From 6767157fe0b620c6bc7a204fb548674ad6e21ee5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:16:38 -0700 Subject: [PATCH] feat: add double-click-to-rename for editor tabs and file explorer (#760) - fix: prevent Escape-cancelled rename from committing via trailing blur Users can now double-click a filename in editor tabs or the file explorer to rename it inline. Both flows share a common renameFileOnDisk() function that handles: - Quiescing in-flight autosaves before rename to prevent recreating old paths - Remapping all open editor tabs to the renamed file/directory - Undo/redo support via the file explorer undo stack The shared Escape-cancel logic prevents a race condition where blur could fire and commit the rename after user cancelled with Escape. --- .../right-sidebar/FileExplorerRow.tsx | 8 + .../useFileExplorerInlineInput.ts | 105 ++----------- .../src/components/tab-bar/EditorFileTab.tsx | 130 ++++++++++++++-- src/renderer/src/lib/rename-file.ts | 140 ++++++++++++++++++ 4 files changed, 278 insertions(+), 105 deletions(-) create mode 100644 src/renderer/src/lib/rename-file.ts diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 48c3e42b4..314b43b3f 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -302,6 +302,14 @@ export function FileExplorerRow({ { + // Why: the row itself swallows double-click for "pin preview" / + // directory toggle. Scope rename to the filename text only so + // those behaviors stay intact on the icon and empty row area, + // matching VS Code's rename hotspot. + e.stopPropagation() + onStartRename(node) + }} > {node.name} diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts index 9b431f2c2..65d495437 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts @@ -3,26 +3,13 @@ import type React from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' import { detectLanguage } from '@/lib/language-detect' -import { basename, dirname, joinPath } from '@/lib/path' +import { dirname, joinPath } from '@/lib/path' import { getConnectionId } from '@/lib/connection-context' +import { extractIpcErrorMessage, renameFileOnDisk } from '@/lib/rename-file' import type { InlineInput } from './FileExplorerRow' import type { TreeNode } from './file-explorer-types' -import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { commitFileExplorerOp } from './fileExplorerUndoRedo' -/** - * Electron's ipcRenderer.invoke wraps errors as: - * "Error invoking remote method 'channel': Error: actual message" - * Strip the wrapper so users see only the meaningful part. - */ -function extractIpcErrorMessage(err: unknown, fallback: string): string { - if (!(err instanceof Error)) { - return fallback - } - const match = err.message.match(/Error invoking remote method '[^']*': (?:Error: )?(.+)/) - return match ? match[1] : err.message -} - type UseFileExplorerInlineInputParams = { activeWorktreeId: string | null worktreePath: string | null @@ -122,89 +109,15 @@ export function useFileExplorerInlineInput({ return } const run = async (): Promise => { - const remapOpenTabsForRenamedPath = (fromPath: string, toPath: string): void => { - const state = useAppStore.getState() - const filesToMove = state.openFiles.filter((file) => { - if (file.filePath === fromPath) { - return true - } - return ( - file.filePath.startsWith(`${fromPath}/`) || file.filePath.startsWith(`${fromPath}\\`) - ) - }) - - for (const file of filesToMove) { - const oldFilePath = file.filePath - const suffix = oldFilePath.slice(fromPath.length) - const updatedPath = toPath + suffix - const updatedRelative = updatedPath.slice(worktreePath.length + 1) - const draft = state.editorDrafts[file.id] - const wasDirty = file.isDirty - - state.closeFile(oldFilePath) - if (file.mode !== 'edit') { - continue - } - - state.openFile({ - filePath: updatedPath, - relativePath: updatedRelative, - worktreeId: file.worktreeId, - language: detectLanguage(basename(updatedPath)), - mode: 'edit' - }) - - if (draft !== undefined) { - state.setEditorDraft(updatedPath, draft) - } - if (wasDirty) { - state.markFileDirty(updatedPath, true) - } - } - } - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined if (inlineInput.type === 'rename' && inlineInput.existingPath) { - const parentDir = dirname(inlineInput.existingPath) - const oldPath = inlineInput.existingPath - const newPath = joinPath(parentDir, name) - // Why: a rename changes the file's path. Let any in-flight autosave - // finish first so a trailing write to the old path cannot recreate it. - const state = useAppStore.getState() - const filesToQuiesce = state.openFiles.filter( - (file) => - file.filePath === oldPath || - file.filePath.startsWith(`${oldPath}/`) || - file.filePath.startsWith(`${oldPath}\\`) - ) - await Promise.all( - filesToQuiesce.map((file) => requestEditorSaveQuiesce({ fileId: file.id })) - ) - try { - await window.api.fs.rename({ - oldPath, - newPath, - connectionId - }) - remapOpenTabsForRenamedPath(oldPath, newPath) - commitFileExplorerOp({ - undo: async () => { - await window.api.fs.rename({ oldPath: newPath, newPath: oldPath, connectionId }) - await refreshDir(parentDir) - remapOpenTabsForRenamedPath(newPath, oldPath) - }, - redo: async () => { - await window.api.fs.rename({ oldPath: oldPath, newPath: newPath, connectionId }) - await refreshDir(parentDir) - remapOpenTabsForRenamedPath(oldPath, newPath) - } - }) - } catch (err) { - toast.error( - extractIpcErrorMessage(err, `Failed to rename '${inlineInput.existingName}'.`) - ) - } - await refreshDir(parentDir) + await renameFileOnDisk({ + oldPath: inlineInput.existingPath, + newName: name, + worktreeId: activeWorktreeId, + worktreePath, + refreshDir + }) } else { const fullPath = joinPath(inlineInput.parentPath, name) try { diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 5035685e2..a8646af51 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { @@ -18,8 +18,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { normalizeRelativePath } from '@/lib/path' +import { basename, normalizeRelativePath } from '@/lib/path' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' +import { renameFileOnDisk } from '@/lib/rename-file' +import { useAppStore } from '@/store' import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display' import type { GitFileStatus } from '../../../../shared/types' import type { OpenFile } from '../../store/slices/editor' @@ -80,6 +82,79 @@ export default function EditorFileTab({ const isConflictReview = file.mode === 'conflict-review' const [menuOpen, setMenuOpen] = useState(false) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) + const [isRenaming, setIsRenaming] = useState(false) + const renameInputRef = useRef(null) + // Escape fires setIsRenaming(false), which unmounts the input. The browser + // still fires focusout as the focused node is removed, so onBlur can invoke + // commitRename *after* cancel — committing the typed value against the + // user's intent. This flag suppresses the trailing blur-commit. + const renameCancelledRef = useRef(false) + // Only real on-disk files in edit mode are renameable. Diff, conflict-review, + // untitled drafts, and combined/virtual views don't point at a single concrete + // file we can safely rename. + const canRename = file.mode === 'edit' && !file.isUntitled && !file.diffSource && !file.conflict + + const commitRename = (): void => { + if (renameCancelledRef.current) { + renameCancelledRef.current = false + setIsRenaming(false) + return + } + const input = renameInputRef.current + if (!input) { + setIsRenaming(false) + return + } + const newName = input.value.trim() + setIsRenaming(false) + if (!newName) { + return + } + const oldName = basename(file.filePath) + if (newName === oldName) { + return + } + const worktreePath = (() => { + const state = useAppStore.getState() + for (const worktrees of Object.values(state.worktreesByRepo)) { + const wt = worktrees.find((w) => w.id === file.worktreeId) + if (wt) { + return wt.path + } + } + return null + })() + if (!worktreePath) { + return + } + void renameFileOnDisk({ + oldPath: file.filePath, + newName, + worktreeId: file.worktreeId, + worktreePath + }) + } + + useEffect(() => { + if (!isRenaming) { + return + } + const raf = requestAnimationFrame(() => { + const el = renameInputRef.current + if (!el) { + return + } + el.focus() + const name = basename(file.filePath) + const dotIndex = name.lastIndexOf('.') + if (dotIndex > 0) { + el.setSelectionRange(0, dotIndex) + } else { + el.select() + } + }) + return () => cancelAnimationFrame(raf) + }, [isRenaming, file.filePath]) const tabStatus = file.relativePath === 'All Changes' @@ -152,13 +227,50 @@ export default function EditorFileTab({ /> )} - - {getEditorDisplayLabel(file)} - - {tabStatus && ( + {isRenaming ? ( + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + commitRename() + } else if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + renameCancelledRef.current = true + setIsRenaming(false) + } + }} + onBlur={commitRename} + /> + ) : ( + { + // Why: the outer tab's onDoubleClick pins preview tabs. Scope + // rename to the filename text only so pin-on-dblclick still + // works anywhere else on the tab chrome (matching VS Code). + if (!canRename) { + return + } + e.stopPropagation() + setIsRenaming(true) + }} + > + {getEditorDisplayLabel(file)} + + )} + {tabStatus && !isRenaming && ( { + if (file.filePath === fromPath) { + return true + } + return file.filePath.startsWith(`${fromPath}/`) || file.filePath.startsWith(`${fromPath}\\`) + }) + + for (const file of filesToMove) { + const oldFilePath = file.filePath + const suffix = oldFilePath.slice(fromPath.length) + const updatedPath = toPath + suffix + const updatedRelative = updatedPath.slice(worktreePath.length + 1) + const draft = state.editorDrafts[file.id] + const wasDirty = file.isDirty + + state.closeFile(oldFilePath) + if (file.mode !== 'edit') { + continue + } + + state.openFile({ + filePath: updatedPath, + relativePath: updatedRelative, + worktreeId: file.worktreeId, + language: detectLanguage(basename(updatedPath)), + mode: 'edit' + }) + + if (draft !== undefined) { + state.setEditorDraft(updatedPath, draft) + } + if (wasDirty) { + state.markFileDirty(updatedPath, true) + } + } +} + +type RenameFileArgs = { + oldPath: string + /** just the new filename (no directory) */ + newName: string + worktreeId: string + worktreePath: string + /** refresh the parent directory in the explorer tree, if caller tracks one */ + refreshDir?: (dirPath: string) => Promise +} + +/** + * Rename a file or directory on disk. Handles: + * - no-op when the name is unchanged + * - quiescing any in-flight autosave on open tabs under `oldPath` + * (so a trailing write can't recreate the old path post-rename) + * - remapping every affected open editor tab to the new path + * - committing an undo/redo pair via the file-explorer undo stack + * - unwrapped toast on IPC failure + * + * Used by the file-explorer inline rename and by double-click-rename + * from an editor tab. Both entry points should go through here so + * the tab-remap + quiesce behavior stays consistent. + */ +export async function renameFileOnDisk(args: RenameFileArgs): Promise { + const { oldPath, newName, worktreeId, worktreePath, refreshDir } = args + const trimmed = newName.trim() + if (!trimmed) { + return + } + const existingName = basename(oldPath) + if (trimmed === existingName) { + return + } + const parentDir = dirname(oldPath) + const newPath = joinPath(parentDir, trimmed) + const connectionId = getConnectionId(worktreeId) ?? undefined + + // Let any in-flight autosave under `oldPath` finish first — a trailing + // write to the old path after rename would silently recreate it. + const state = useAppStore.getState() + const filesToQuiesce = state.openFiles.filter( + (file) => + file.filePath === oldPath || + file.filePath.startsWith(`${oldPath}/`) || + file.filePath.startsWith(`${oldPath}\\`) + ) + await Promise.all(filesToQuiesce.map((file) => requestEditorSaveQuiesce({ fileId: file.id }))) + + try { + await window.api.fs.rename({ oldPath, newPath, connectionId }) + remapOpenTabsForRenamedPath(oldPath, newPath, worktreePath) + commitFileExplorerOp({ + undo: async () => { + await window.api.fs.rename({ oldPath: newPath, newPath: oldPath, connectionId }) + if (refreshDir) { + await refreshDir(parentDir) + } + remapOpenTabsForRenamedPath(newPath, oldPath, worktreePath) + }, + redo: async () => { + await window.api.fs.rename({ oldPath, newPath, connectionId }) + if (refreshDir) { + await refreshDir(parentDir) + } + remapOpenTabsForRenamedPath(oldPath, newPath, worktreePath) + } + }) + } catch (err) { + toast.error(extractIpcErrorMessage(err, `Failed to rename '${existingName}'.`)) + } + if (refreshDir) { + await refreshDir(parentDir) + } +}