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:
parent
21fc9383c0
commit
6767157fe0
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue