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.
This commit is contained in:
Jinjing 2026-04-17 10:16:38 -07:00 committed by GitHub
parent 21fc9383c0
commit 6767157fe0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 278 additions and 105 deletions

View File

@ -302,6 +302,14 @@ export function FileExplorerRow({
<span
className={cn('truncate', isSelected && !nodeStatus && 'text-accent-foreground')}
style={nodeStatus ? { color: statusColor ?? undefined } : undefined}
onDoubleClick={(e) => {
// 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}
</span>

View File

@ -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<void> => {
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 {

View File

@ -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<HTMLInputElement>(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({
/>
)}
<span className="mr-1.5 flex min-w-0 items-baseline gap-1.5">
<span
className={`truncate max-w-[130px]${file.isPreview ? ' italic' : ''}`}
style={tabStatusColor ? { color: tabStatusColor } : undefined}
>
{getEditorDisplayLabel(file)}
</span>
{tabStatus && (
{isRenaming ? (
<input
ref={renameInputRef}
defaultValue={basename(file.filePath)}
// Tiny border to make the edit affordance obvious without
// changing overall tab height. Size matches the label span.
className="truncate max-w-[130px] bg-transparent text-sm text-foreground outline-none border border-ring rounded-sm px-1 py-0"
onPointerDown={(e) => 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}
/>
) : (
<span
className={`truncate max-w-[130px]${file.isPreview ? ' italic' : ''}`}
style={tabStatusColor ? { color: tabStatusColor } : undefined}
onDoubleClick={(e) => {
// 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)}
</span>
)}
{tabStatus && !isRenaming && (
<span
className="shrink-0 text-[10px] leading-none font-semibold tracking-wide"
style={{ color: tabStatusColor }}

View File

@ -0,0 +1,140 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { basename, dirname, joinPath } from '@/lib/path'
import { getConnectionId } from '@/lib/connection-context'
import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave'
import { commitFileExplorerOp } from '@/components/right-sidebar/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.
*/
export 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
}
/**
* Walk every open file whose path is `fromPath` or a descendant of it
* and rehome it to `toPath`. Closes and re-opens each tab to preserve
* drafts and dirty state under the new path. Directory renames remap
* all descendants, which is why we check both `/` and `\` separators.
*/
function remapOpenTabsForRenamedPath(fromPath: string, toPath: string, worktreePath: 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)
}
}
}
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<void>
}
/**
* 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<void> {
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)
}
}