From b31ae7ad7bf5de70df83471ea6de06fc762a04c7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:14:43 -0700 Subject: [PATCH] Fix markdown rename: drop blur-commit, handle IME, track active file - Blur no longer commits, preventing accidental renames when focus moves - IME composition keys properly handled for CJK input support - File switches during rename now cancel the operation - Extension display improved to show final filename - Comprehensive test coverage added for edge cases --- .../editor/EditorPanelHeaderPath.test.tsx | 93 ++++++++++++++++++- .../editor/EditorPanelHeaderPath.tsx | 23 +++-- .../editor/editor-header-file-rename.ts | 41 +++++--- .../src/components/tab-bar/EditorFileTab.tsx | 4 +- src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/fr.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- 10 files changed, 164 insertions(+), 36 deletions(-) diff --git a/src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx b/src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx index b4ed008a31a..c68632a5523 100644 --- a/src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx @@ -43,8 +43,8 @@ function baseFile(overrides: Partial = {}): OpenFile { } } -function renderPath(file: OpenFile): void { - render( +function renderPath(file: OpenFile): (next: OpenFile) => void { + const view = render( ) + return (next) => + view.rerender( + + ) } function getRenameInput(label: string): HTMLInputElement { @@ -116,8 +127,8 @@ describe('EditorPanelHeaderPath breadcrumb morph rename', () => { if (!strip) { throw new Error('Missing rename strip') } + expect(strip.className).toContain('w-full') expect(strip.className).toContain('max-w-full') - expect(strip.className).not.toContain('520px') }) it('selects the basename so typing replaces just the name', () => { @@ -146,19 +157,91 @@ describe('EditorPanelHeaderPath breadcrumb morph rename', () => { }) }) - it('respects an explicitly typed extension without duplicating it', () => { + it('respects an explicitly typed extension and drops the pinned suffix', () => { renderPath(baseFile()) openRenameInput() const input = getRenameInput('Rename file notes.md') fireEvent.change(input, { target: { value: 'renamed.mdx' } }) - fireEvent.keyDown(input, { key: 'Enter' }) + expect(screen.queryByText('.md')).toBeNull() + fireEvent.keyDown(input, { key: 'Enter' }) expect(renameFileOnDiskMock).toHaveBeenCalledWith( expect.objectContaining({ newName: 'renamed.mdx' }) ) }) + it('shows the same name it commits for a dotted basename', () => { + renderPath(baseFile()) + openRenameInput() + + const input = getRenameInput('Rename file notes.md') + fireEvent.change(input, { target: { value: 'v1.2' } }) + expect(screen.queryByText('.md')).toBeNull() + + fireEvent.keyDown(input, { key: 'Enter' }) + expect(renameFileOnDiskMock).toHaveBeenCalledWith(expect.objectContaining({ newName: 'v1.2' })) + }) + + it('keeps the pinned suffix for a leading-dot name', () => { + renderPath(baseFile()) + openRenameInput() + + const input = getRenameInput('Rename file notes.md') + fireEvent.change(input, { target: { value: '.notes' } }) + expect(screen.getByText('.md')).toBeDefined() + + fireEvent.keyDown(input, { key: 'Enter' }) + expect(renameFileOnDiskMock).toHaveBeenCalledWith( + expect.objectContaining({ newName: '.notes.md' }) + ) + }) + + it('ignores an Enter that only confirms an IME candidate', () => { + renderPath(baseFile()) + openRenameInput() + + const input = getRenameInput('Rename file notes.md') + fireEvent.change(input, { target: { value: 'renamed' } }) + fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 }) + expect(renameFileOnDiskMock).not.toHaveBeenCalled() + + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 }) + expect(renameFileOnDiskMock).toHaveBeenCalledWith( + expect.objectContaining({ newName: 'renamed.md' }) + ) + }) + + it('does not commit when the field merely loses focus', () => { + renderPath(baseFile()) + openRenameInput() + + const input = getRenameInput('Rename file notes.md') + fireEvent.change(input, { target: { value: 'renamed' } }) + fireEvent.blur(input) + + expect(renameFileOnDiskMock).not.toHaveBeenCalled() + expect(screen.getByLabelText('Rename file notes.md')).toBeDefined() + }) + + it('drops rename mode when the active file changes', () => { + const rerenderPath = renderPath(baseFile()) + openRenameInput() + + fireEvent.change(getRenameInput('Rename file notes.md'), { target: { value: 'renamed' } }) + rerenderPath( + baseFile({ + id: '/repo/other.md', + filePath: '/repo/other.md', + relativePath: 'other.md' + }) + ) + + expect(screen.queryByLabelText('Rename file notes.md')).toBeNull() + expect(screen.queryByLabelText('Rename file other.md')).toBeNull() + expect(renameFileOnDiskMock).not.toHaveBeenCalled() + }) + it('commits via the confirm button', () => { renderPath(baseFile()) openRenameInput() diff --git a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx index b99c804626f..22c44a58d4b 100644 --- a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx @@ -9,6 +9,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' import type { OpenFile } from '@/store/slices/editor' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab' @@ -63,11 +64,12 @@ export function EditorPanelHeaderPath({ canRename, currentFileName, currentBaseName, - currentExtension, + pinnedExtension, breadcrumbSegments, isRenaming, renameInputRef, openRenameInput, + setRenameDraft, commitRename, cancelRename } = useEditorHeaderFileRename(activeFile) @@ -111,7 +113,13 @@ export function EditorPanelHeaderPath({ onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} + onChange={(event) => setRenameDraft(event.target.value)} onKeyDown={(event) => { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() event.stopPropagation() @@ -122,11 +130,10 @@ export function EditorPanelHeaderPath({ cancelRename() } }} - onBlur={commitRename} /> - {currentExtension ? ( + {pinnedExtension ? ( - {currentExtension} + {pinnedExtension} ) : null}
@@ -142,8 +149,8 @@ export function EditorPanelHeaderPath({ )} className="flex size-5 items-center justify-center rounded text-status-success hover:bg-status-success-background" onMouseDown={(event) => { - // Why: preventDefault keeps focus in the input so clicking - // confirm does not blur-commit first and double-rename. + // Why: preventDefault keeps the caret in the input so the + // field is still usable if the click misses the button. event.preventDefault() event.stopPropagation() }} @@ -166,8 +173,8 @@ export function EditorPanelHeaderPath({ )} className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" onMouseDown={(event) => { - // Why: same as confirm — cancel must win over the input's - // blur-commit when the pointer leaves the field. + // Why: same as confirm — the pointer leaving the field must + // not disturb the input it is about to dismiss. event.preventDefault() event.stopPropagation() }} diff --git a/src/renderer/src/components/editor/editor-header-file-rename.ts b/src/renderer/src/components/editor/editor-header-file-rename.ts index 82ab0c08824..c9c55695b08 100644 --- a/src/renderer/src/components/editor/editor-header-file-rename.ts +++ b/src/renderer/src/components/editor/editor-header-file-rename.ts @@ -10,11 +10,12 @@ type EditorHeaderFileRenameState = { canRename: boolean currentFileName: string currentBaseName: string - currentExtension: string + pinnedExtension: string breadcrumbSegments: string[] isRenaming: boolean renameInputRef: RefCallback openRenameInput: () => void + setRenameDraft: (value: string) => void commitRename: () => void cancelRename: () => void } @@ -36,9 +37,17 @@ function getBreadcrumbSegments(relativePath: string, worktreePath: string | null return segments } +// A leading dot is part of a dotfile's name, not an extension — same split the +// hook uses on the current file name. +export function getTypedExtension(rawValue: string): string { + const dotIndex = rawValue.lastIndexOf('.') + return dotIndex > 0 ? rawValue.slice(dotIndex) : '' +} + // The morph input edits the basename while the extension stays pinned as a -// suffix, so a bare name gets the extension re-attached. An explicitly typed -// extension (same or different) is respected verbatim; null means no-op. +// suffix, so a bare name gets the extension re-attached. A typed extension +// (same or different) is respected verbatim and the pinned suffix is hidden so +// the strip shows the name that will be committed; null means no-op. export function resolveRenameTarget( rawValue: string, currentFileName: string, @@ -47,7 +56,7 @@ export function resolveRenameTarget( if (!rawValue || rawValue === currentFileName) { return null } - if (!currentExtension || rawValue.endsWith(currentExtension) || rawValue.includes('.')) { + if (!currentExtension || getTypedExtension(rawValue)) { return rawValue } return `${rawValue}${currentExtension}` @@ -56,14 +65,23 @@ export function resolveRenameTarget( export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFileRenameState { const worktree = useWorktreeById(activeFile.worktreeId) const [isRenaming, setIsRenaming] = useState(false) + const [renameDraft, setRenameDraft] = useState('') + const [renameFilePath, setRenameFilePath] = useState(activeFile.filePath) const renameInputElementRef = useRef(null) - const renameCancelledRef = useRef(false) const renameFocusFrameRef = useRef(null) + // Why: the header renders one unkeyed path strip for every file, so a file + // switch mid-rename would otherwise commit the typed name against the new path. + if (renameFilePath !== activeFile.filePath) { + setRenameFilePath(activeFile.filePath) + setIsRenaming(false) + setRenameDraft('') + } const currentFileName = basename(activeFile.filePath) const lastDotIndex = currentFileName.lastIndexOf('.') const hasExtension = lastDotIndex > 0 const currentBaseName = hasExtension ? currentFileName.slice(0, lastDotIndex) : currentFileName const currentExtension = hasExtension ? currentFileName.slice(lastDotIndex) : '' + const pinnedExtension = getTypedExtension(renameDraft.trim()) ? '' : currentExtension const breadcrumbSegments = getBreadcrumbSegments(activeFile.relativePath, worktree?.path ?? null) // Why: read-only tabs (AI Vault View Log) are never renameable — rename would // rewrite the agent-owned artifact's backing path. @@ -78,24 +96,17 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil if (!canRename) { return } - renameCancelledRef.current = false + setRenameDraft(currentBaseName) setIsRenaming(true) } const commitRename = (): void => { - if (renameCancelledRef.current) { - setIsRenaming(false) - return - } const input = renameInputElementRef.current if (!input) { setIsRenaming(false) return } const rawVal = input.value.trim() - // onBlur follows Enter when the input unmounts; consume that trailing event - // so one user action cannot start a second rename against the old path. - renameCancelledRef.current = true setIsRenaming(false) const newName = resolveRenameTarget(rawVal, currentFileName, currentExtension) if (!newName) { @@ -111,7 +122,6 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil } const cancelRename = (): void => { - renameCancelledRef.current = true setIsRenaming(false) } @@ -151,11 +161,12 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil canRename, currentFileName, currentBaseName, - currentExtension, + pinnedExtension, breadcrumbSegments, isRenaming, renameInputRef, openRenameInput, + setRenameDraft, commitRename, cancelRename } diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 923d9a23341..064ac1fdb0e 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -298,8 +298,8 @@ export default function EditorFileTab({ )} defaultValue={basename(file.filePath)} // Why: keep the inline field compact enough for the titlebar while - // giving filenames more room to edit without premature truncation. - className="mr-1 h-5 w-[18ch] min-w-[96px] max-w-[200px] rounded-sm bg-input/40 px-1 py-0 text-xs text-foreground md:text-xs focus-visible:ring-[1px]" + // giving filenames a little more room than the static tab label. + className="mr-1 h-5 w-[12ch] min-w-[72px] max-w-[132px] rounded-sm bg-input/40 px-1 py-0 text-xs text-foreground md:text-xs focus-visible:ring-[1px]" spellCheck={false} onPointerDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7a8eb085539..acf89db744b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14875,7 +14875,9 @@ "631dab0df3": "Next change", "revealInFinder": "Reveal in Finder", "openContainingFolder": "Open Containing Folder", - "revealInFileExplorer": "Reveal in File Explorer" + "revealInFileExplorer": "Reveal in File Explorer", + "confirmRename": "Confirm rename", + "cancelRename": "Cancel rename" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "Export as PDF", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e2231a6707c..aea98a47a5e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12906,7 +12906,12 @@ "f0fd4174b5": "Abre la pestaña de archivo para usar la edición enriquecida de Markdown", "a10d9b8337": "Abrir archivo", "2076ecfc9c": "Cambio anterior", - "631dab0df3": "Cambio siguiente" + "631dab0df3": "Cambio siguiente", + "revealInFinder": "Mostrar en Finder", + "openContainingFolder": "Abrir carpeta contenedora", + "revealInFileExplorer": "Mostrar en el Explorador", + "confirmRename": "Confirmar cambio de nombre", + "cancelRename": "Cancelar cambio de nombre" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "Exportar como PDF", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 9e4f221980a..f0e1be53e8a 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -14124,7 +14124,12 @@ "f0fd4174b5": "Ouvrez un onglet de fichier pour utiliser l'édition Markdown enrichie", "a10d9b8337": "Ouvrir le fichier", "2076ecfc9c": "Modification précédente", - "631dab0df3": "Modification suivante" + "631dab0df3": "Modification suivante", + "revealInFinder": "Afficher dans le Finder", + "openContainingFolder": "Ouvrir le dossier contenant", + "revealInFileExplorer": "Afficher dans l'Explorateur", + "confirmRename": "Confirmer le renommage", + "cancelRename": "Annuler le renommage" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "Exporter en PDF", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 378fdc8bf75..4ad4ca40273 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12906,7 +12906,12 @@ "f0fd4174b5": "ファイルタブを開いてリッチ markdown 編集を使用する", "a10d9b8337": "ファイルを開く", "2076ecfc9c": "前の変更", - "631dab0df3": "次の変更" + "631dab0df3": "次の変更", + "revealInFinder": "Finderで表示", + "openContainingFolder": "含まれるフォルダを開く", + "revealInFileExplorer": "エクスプローラーで表示", + "confirmRename": "名前の変更を確定する", + "cancelRename": "名前の変更をキャンセルする" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "PDFとしてエクスポート", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c05e94a0eb0..14374b2d77d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12972,7 +12972,12 @@ "f0fd4174b5": "풍부한 markdown 편집을 사용하려면 파일 탭을 엽니다.", "a10d9b8337": "파일 열기", "2076ecfc9c": "이전 변경", - "631dab0df3": "다음 변경" + "631dab0df3": "다음 변경", + "revealInFinder": "Finder에서 보기", + "openContainingFolder": "포함된 폴더 열기", + "revealInFileExplorer": "탐색기에서 보기", + "confirmRename": "이름 바꾸기 확인", + "cancelRename": "이름 바꾸기 취소" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "PDF로 내보내기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7c6370ff46f..30318587481 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12986,7 +12986,12 @@ "f0fd4174b5": "打开文件选项卡以使用富文本 Markdown 编辑", "a10d9b8337": "打开文件", "2076ecfc9c": "上一个更改", - "631dab0df3": "下一个更改" + "631dab0df3": "下一个更改", + "revealInFinder": "在 Finder 中显示", + "openContainingFolder": "打开所在文件夹", + "revealInFileExplorer": "在资源管理器中显示", + "confirmRename": "确认重命名", + "cancelRename": "取消重命名" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "导出为 PDF",