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
This commit is contained in:
Jinjing
2026-09-17 18:14:43 -07:00
parent 8cda0f9c38
commit 6fa032ce09
10 changed files with 164 additions and 36 deletions
@@ -43,8 +43,8 @@ function baseFile(overrides: Partial<OpenFile> = {}): OpenFile {
}
}
function renderPath(file: OpenFile): void {
render(
function renderPath(file: OpenFile): (next: OpenFile) => void {
const view = render(
<EditorPanelHeaderPath
activeFile={file}
copiedPathVisible={false}
@@ -54,6 +54,17 @@ function renderPath(file: OpenFile): void {
onOpenContainingFolder={vi.fn()}
/>
)
return (next) =>
view.rerender(
<EditorPanelHeaderPath
activeFile={next}
copiedPathVisible={false}
canShowMarkdownPreview={false}
onCopyPath={vi.fn()}
onOpenMarkdownPreview={vi.fn()}
onOpenContainingFolder={vi.fn()}
/>
)
}
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()
@@ -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'
@@ -61,11 +62,12 @@ export function EditorPanelHeaderPath({
canRename,
currentFileName,
currentBaseName,
currentExtension,
pinnedExtension,
breadcrumbSegments,
isRenaming,
renameInputRef,
openRenameInput,
setRenameDraft,
commitRename,
cancelRename
} = useEditorHeaderFileRename(activeFile)
@@ -109,7 +111,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()
@@ -120,11 +128,10 @@ export function EditorPanelHeaderPath({
cancelRename()
}
}}
onBlur={commitRename}
/>
{currentExtension ? (
{pinnedExtension ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{currentExtension}
{pinnedExtension}
</span>
) : null}
<div className="flex shrink-0 items-center">
@@ -140,8 +147,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()
}}
@@ -164,8 +171,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()
}}
@@ -10,11 +10,12 @@ type EditorHeaderFileRenameState = {
canRename: boolean
currentFileName: string
currentBaseName: string
currentExtension: string
pinnedExtension: string
breadcrumbSegments: string[]
isRenaming: boolean
renameInputRef: RefCallback<HTMLInputElement>
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<HTMLInputElement | null>(null)
const renameCancelledRef = useRef(false)
const renameFocusFrameRef = useRef<number | null>(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
}
@@ -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()}
+3 -1
View File
@@ -14754,7 +14754,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",
+6 -1
View File
@@ -12884,7 +12884,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",
+6 -1
View File
@@ -14127,7 +14127,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",
+6 -1
View File
@@ -12884,7 +12884,12 @@
"f0fd4174b5": "ファイルタブを開いてリッチ markdown 編集を使用する",
"a10d9b8337": "ファイルを開く",
"2076ecfc9c": "前の変更",
"631dab0df3": "次の変更"
"631dab0df3": "次の変更",
"revealInFinder": "Finderで表示",
"openContainingFolder": "含まれるフォルダを開く",
"revealInFileExplorer": "エクスプローラーで表示",
"confirmRename": "名前の変更を確定する",
"cancelRename": "名前の変更をキャンセルする"
},
"EditorPanelMarkdownActionsMenu": {
"3e0ce48c24": "PDFとしてエクスポート",
+6 -1
View File
@@ -12950,7 +12950,12 @@
"f0fd4174b5": "풍부한 markdown 편집을 사용하려면 파일 탭을 엽니다.",
"a10d9b8337": "파일 열기",
"2076ecfc9c": "이전 변경",
"631dab0df3": "다음 변경"
"631dab0df3": "다음 변경",
"revealInFinder": "Finder에서 보기",
"openContainingFolder": "포함된 폴더 열기",
"revealInFileExplorer": "탐색기에서 보기",
"confirmRename": "이름 바꾸기 확인",
"cancelRename": "이름 바꾸기 취소"
},
"EditorPanelMarkdownActionsMenu": {
"3e0ce48c24": "PDF로 내보내기",
+6 -1
View File
@@ -12964,7 +12964,12 @@
"f0fd4174b5": "打开文件选项卡以使用富文本 Markdown 编辑",
"a10d9b8337": "打开文件",
"2076ecfc9c": "上一个更改",
"631dab0df3": "下一个更改"
"631dab0df3": "下一个更改",
"revealInFinder": "在 Finder 中显示",
"openContainingFolder": "打开所在文件夹",
"revealInFileExplorer": "在资源管理器中显示",
"confirmRename": "确认重命名",
"cancelRename": "取消重命名"
},
"EditorPanelMarkdownActionsMenu": {
"3e0ce48c24": "导出为 PDF",