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)
+ }
+}