mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user