Refactor editor header file rename to a breadcrumb morph UI

- Display full breadcrumb (repo name + parent dirs) during rename for context
- Separate basename field from extension suffix to clarify what users edit
- Replace blur-to-commit with explicit confirm/cancel buttons
- Auto-attach extension to basename; respect explicitly typed extensions
- Add comprehensive tests for rename scenarios and edge cases
This commit is contained in:
Jinjing
2026-09-21 15:44:48 -07:00
parent da982a4eb0
commit 7ad897cf30
4 changed files with 371 additions and 45 deletions
@@ -0,0 +1,221 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import { EditorPanelHeaderPath } from './EditorPanelHeaderPath'
const renameFileOnDiskMock = vi.hoisted(() => vi.fn())
vi.mock('@/store/selectors', () => ({
useWorktreeById: () => ({ path: '/repo', repoId: 'repo-1' })
}))
vi.mock('@/lib/rename-file', () => ({
renameFileOnDisk: renameFileOnDiskMock
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
useShortcutLabel: () => ''
}))
vi.mock('@/i18n/i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/i18n/i18n')>() // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.mock requires an inline import
return {
...actual,
translate: (_key: string, fallback: string, options?: { value0?: string }) =>
fallback.replace('{{value0}}', options?.value0 ?? '')
}
})
afterEach(cleanup)
function baseFile(overrides: Partial<OpenFile> = {}): OpenFile {
return {
id: '/repo/notes.md',
filePath: '/repo/notes.md',
relativePath: 'notes.md',
worktreeId: 'wt-1',
language: 'markdown',
isDirty: false,
mode: 'edit',
...overrides
}
}
function renderPath(file: OpenFile): void {
render(
<EditorPanelHeaderPath
activeFile={file}
copiedPathVisible={false}
canShowMarkdownPreview={false}
onCopyPath={vi.fn()}
onOpenMarkdownPreview={vi.fn()}
onOpenContainingFolder={vi.fn()}
/>
)
}
function getRenameInput(label: string): HTMLInputElement {
const input = screen.getByLabelText(label)
if (!(input instanceof HTMLInputElement)) {
throw new Error(`Missing rename input: ${label}`)
}
return input
}
function openRenameInput(): void {
const pathRow = document.querySelector('.editor-header-path-row')
if (!pathRow) {
throw new Error('Missing editor header path row')
}
fireEvent.contextMenu(pathRow)
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
}
describe('EditorPanelHeaderPath breadcrumb morph rename', () => {
beforeEach(() => {
renameFileOnDiskMock.mockReset()
Object.assign(window, { api: { ui: { writeClipboardText: vi.fn() } } })
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0)
return 1
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
})
it('morphs into a breadcrumb strip with the basename editable', () => {
renderPath(baseFile())
openRenameInput()
const input = getRenameInput('Rename file notes.md')
expect(input.value).toBe('notes')
expect(screen.getByText('.md')).toBeDefined()
expect(screen.getByText('repo /')).toBeDefined()
})
it('shows the full crumb chain without ellipsis cuts', () => {
renderPath(
baseFile({
id: '/repo/docs/marketing/notes.md',
filePath: '/repo/docs/marketing/notes.md',
relativePath: 'docs/marketing/notes.md'
})
)
openRenameInput()
expect(screen.getByText('repo / docs / marketing /')).toBeDefined()
})
it('lets the strip claim the full header width instead of capping at 520px', () => {
renderPath(baseFile())
openRenameInput()
const input = getRenameInput('Rename file notes.md')
const strip = input.parentElement
if (!strip) {
throw new Error('Missing rename strip')
}
expect(strip.className).toContain('max-w-full')
expect(strip.className).not.toContain('520px')
})
it('selects the basename so typing replaces just the name', () => {
renderPath(baseFile())
openRenameInput()
const input = getRenameInput('Rename file notes.md')
expect(document.activeElement).toBe(input)
expect(input.selectionStart).toBe(0)
expect(input.selectionEnd).toBe('notes'.length)
})
it('re-attaches the pinned extension to a bare basename', () => {
renderPath(baseFile())
openRenameInput()
const input = getRenameInput('Rename file notes.md')
fireEvent.change(input, { target: { value: 'renamed' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(renameFileOnDiskMock).toHaveBeenCalledWith({
oldPath: '/repo/notes.md',
newName: 'renamed.md',
worktreeId: 'wt-1',
worktreePath: '/repo'
})
})
it('respects an explicitly typed extension without duplicating it', () => {
renderPath(baseFile())
openRenameInput()
const input = getRenameInput('Rename file notes.md')
fireEvent.change(input, { target: { value: 'renamed.mdx' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(renameFileOnDiskMock).toHaveBeenCalledWith(
expect.objectContaining({ newName: 'renamed.mdx' })
)
})
it('commits via the confirm button', () => {
renderPath(baseFile())
openRenameInput()
fireEvent.change(getRenameInput('Rename file notes.md'), {
target: { value: 'renamed.md' }
})
fireEvent.mouseDown(screen.getByRole('button', { name: 'Confirm rename' }))
fireEvent.click(screen.getByRole('button', { name: 'Confirm rename' }))
expect(renameFileOnDiskMock).toHaveBeenCalledTimes(1)
expect(renameFileOnDiskMock).toHaveBeenCalledWith(
expect.objectContaining({ newName: 'renamed.md' })
)
})
it('cancels via the cancel button without renaming', () => {
renderPath(baseFile())
openRenameInput()
fireEvent.change(getRenameInput('Rename file notes.md'), {
target: { value: 'renamed.md' }
})
fireEvent.mouseDown(screen.getByRole('button', { name: 'Cancel rename' }))
fireEvent.click(screen.getByRole('button', { name: 'Cancel rename' }))
expect(renameFileOnDiskMock).not.toHaveBeenCalled()
expect(screen.queryByLabelText('Rename file notes.md')).toBeNull()
})
it('cancels on Escape and ignores empty renames', () => {
renderPath(baseFile())
openRenameInput()
const input = getRenameInput('Rename file notes.md')
fireEvent.change(input, { target: { value: 'renamed.md' } })
fireEvent.keyDown(input, { key: 'Escape' })
expect(renameFileOnDiskMock).not.toHaveBeenCalled()
expect(screen.queryByLabelText('Rename file notes.md')).toBeNull()
openRenameInput()
fireEvent.change(getRenameInput('Rename file notes.md'), { target: { value: ' ' } })
fireEvent.keyDown(getRenameInput('Rename file notes.md'), { key: 'Enter' })
expect(renameFileOnDiskMock).not.toHaveBeenCalled()
})
it('selects the whole name when there is no extension', () => {
const file = baseFile({
id: '/repo/Makefile',
filePath: '/repo/Makefile',
relativePath: 'Makefile'
})
renderPath(file)
openRenameInput()
const input = getRenameInput('Rename file Makefile')
expect(input.selectionStart).toBe(0)
expect(input.selectionEnd).toBe('Makefile'.length)
})
})
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Copy, ExternalLink, Eye, Pencil } from 'lucide-react'
import { Check, Copy, ExternalLink, Eye, Pencil, X } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -8,7 +8,6 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import type { OpenFile } from '@/store/slices/editor'
@@ -63,6 +62,9 @@ export function EditorPanelHeaderPath({
const {
canRename,
currentFileName,
currentBaseName,
currentExtension,
breadcrumbSegments,
isRenaming,
renameInputRef,
openRenameInput,
@@ -88,36 +90,96 @@ export function EditorPanelHeaderPath({
}}
>
{isRenaming ? (
<Input
ref={renameInputRef}
data-editor-header-rename-input="true"
aria-label={translate(
'auto.components.editor.EditorPanelHeader.1bb1e226ec',
'Rename file {{value0}}',
{ value0: currentFileName }
)}
defaultValue={currentFileName}
// Why: the header is narrow in floating mode; this keeps the
// edit field aligned with the path label without growing chrome.
className="h-6 w-[16ch] min-w-[104px] max-w-full rounded-sm bg-input/40 px-1.5 py-0 font-mono text-xs text-foreground md:text-xs focus-visible:ring-[1px]"
spellCheck={false}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
commitRename()
} else if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
cancelRename()
}
}}
onBlur={commitRename}
/>
<div className="flex h-6 w-full min-w-0 max-w-full items-center gap-1 rounded-md border border-accent/40 bg-input/40 py-0.5 pl-1.5 pr-1 focus-within:border-accent focus-within:ring-1 focus-within:ring-ring">
{breadcrumbSegments.length > 0 ? (
<span className="min-w-0 shrink truncate font-mono text-xs text-muted-foreground">
{breadcrumbSegments.join(' / ')} /
</span>
) : null}
<input
ref={renameInputRef}
data-editor-header-rename-input="true"
aria-label={translate(
'auto.components.editor.EditorPanelHeader.1bb1e226ec',
'Rename file {{value0}}',
{ value0: currentFileName }
)}
defaultValue={currentBaseName}
className="h-full min-w-0 flex-1 bg-transparent font-mono text-xs font-semibold text-foreground outline-none"
spellCheck={false}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
commitRename()
} else if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
cancelRename()
}
}}
onBlur={commitRename}
/>
{currentExtension ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{currentExtension}
</span>
) : null}
<div className="flex shrink-0 items-center">
<button
type="button"
aria-label={translate(
'auto.components.editor.EditorPanelHeader.confirmRename',
'Confirm rename'
)}
title={translate(
'auto.components.editor.EditorPanelHeader.confirmRename',
'Confirm rename'
)}
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.
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
commitRename()
}}
>
<Check className="size-3" strokeWidth={3} aria-hidden="true" />
</button>
<button
type="button"
aria-label={translate(
'auto.components.editor.EditorPanelHeader.cancelRename',
'Cancel rename'
)}
title={translate(
'auto.components.editor.EditorPanelHeader.cancelRename',
'Cancel rename'
)}
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.
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
cancelRename()
}}
>
<X className="size-3" strokeWidth={3} aria-hidden="true" />
</button>
</div>
</div>
) : (
<button
type="button"
@@ -2,13 +2,16 @@ import { useCallback, useRef, useState } from 'react'
import type { RefCallback } from 'react'
import type { OpenFile } from '@/store/slices/editor'
import { useWorktreeById } from '@/store/selectors'
import { basename } from '@/lib/path'
import { basename, dirname } from '@/lib/path'
import { renameFileOnDisk } from '@/lib/rename-file'
import { getUntitledFileRoot } from './untitled-file-rename-path'
type EditorHeaderFileRenameState = {
canRename: boolean
currentFileName: string
currentBaseName: string
currentExtension: string
breadcrumbSegments: string[]
isRenaming: boolean
renameInputRef: RefCallback<HTMLInputElement>
openRenameInput: () => void
@@ -16,6 +19,40 @@ type EditorHeaderFileRenameState = {
cancelRename: () => void
}
// Breadcrumb for the morph strip: worktree name plus parent dirs, so the
// rename field keeps its location context without shifting header layout.
function getBreadcrumbSegments(relativePath: string, worktreePath: string | null): string[] {
const segments: string[] = []
if (worktreePath) {
segments.push(basename(worktreePath))
}
if (relativePath) {
for (const part of dirname(relativePath).split('/')) {
if (part && part !== '.') {
segments.push(part)
}
}
}
return segments
}
// 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.
export function resolveRenameTarget(
rawValue: string,
currentFileName: string,
currentExtension: string
): string | null {
if (!rawValue || rawValue === currentFileName) {
return null
}
if (!currentExtension || rawValue.endsWith(currentExtension) || rawValue.includes('.')) {
return rawValue
}
return `${rawValue}${currentExtension}`
}
export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFileRenameState {
const worktree = useWorktreeById(activeFile.worktreeId)
const [isRenaming, setIsRenaming] = useState(false)
@@ -23,6 +60,11 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil
const renameCancelledRef = useRef(false)
const renameFocusFrameRef = useRef<number | null>(null)
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 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.
const canRename =
@@ -50,12 +92,13 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil
setIsRenaming(false)
return
}
const newName = input.value.trim()
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)
if (!newName || newName === currentFileName) {
const newName = resolveRenameTarget(rawVal, currentFileName, currentExtension)
if (!newName) {
return
}
const worktreePath = getUntitledFileRoot(activeFile, worktree?.path ?? null)
@@ -89,27 +132,27 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil
}
// Why: focus belongs to the rename input mount; the frame preserves the
// previous timing so header layout settles before selecting text.
// previous timing so header layout settles before selecting text. The
// input holds the basename with the extension pinned alongside, so
// selecting all replaces the name without touching the suffix.
renameFocusFrameRef.current = requestAnimationFrame(() => {
renameFocusFrameRef.current = null
if (renameInputElementRef.current !== el) {
return
}
el.focus()
const dotIndex = currentFileName.lastIndexOf('.')
if (dotIndex > 0) {
el.setSelectionRange(0, dotIndex)
} else {
el.select()
}
el.select()
})
},
[clearRenameFocusFrame, currentFileName, isRenaming]
[clearRenameFocusFrame, isRenaming]
)
return {
canRename,
currentFileName,
currentBaseName,
currentExtension,
breadcrumbSegments,
isRenaming,
renameInputRef,
openRenameInput,
@@ -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 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]"
// 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]"
spellCheck={false}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}