mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Simplify markdown rename to inline field without breadcrumb or buttons
Replaces breadcrumb-morph rename with confirm/cancel buttons with a simple inline field accepting full filenames. Commits on Enter, blur, or Escape to cancel, matching tab bar and file explorer behavior. Adds renameCancelledRef to prevent blur-commit after Escape. Removes unused i18n strings for buttons and simplifies state by dropping extension pinning and breadcrumb display.
This commit is contained in:
@@ -72,7 +72,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
|
||||
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
|
||||
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
|
||||
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
|
||||
- **Windows terminal shells**: `--shell` picks the shell a terminal *is*; `--command` is typed into whatever shell the host spawned, so a shell choice routed through `command` silently becomes a child process. See [`docs/reference/windows-terminal-shell-selection.md`](./docs/reference/windows-terminal-shell-selection.md).
|
||||
- **Windows terminal shells**: `--shell` picks the shell a terminal _is_; `--command` is typed into whatever shell the host spawned, so a shell choice routed through `command` silently becomes a child process. See [`docs/reference/windows-terminal-shell-selection.md`](./docs/reference/windows-terminal-shell-selection.md).
|
||||
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
|
||||
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely; see [`docs/reference/windows-cmd-shim-resolution.md`](./docs/reference/windows-cmd-shim-resolution.md) before adding a shim shape or debugging one.
|
||||
- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md).
|
||||
|
||||
@@ -102,7 +102,7 @@ gate, and a conflicted worktree that looks clean is granted a hosted-review crea
|
||||
should not have. Withholding an affordance is a degrade; removing the evidence a gate
|
||||
reads is not.
|
||||
|
||||
A fallback is only ever allowed to shape a *reading*. If the member is sent back to the
|
||||
A fallback is only ever allowed to shape a _reading_. If the member is sent back to the
|
||||
host — a token the client echoes into a later call's params — pass it through as
|
||||
`z.string()` and let the send site keep it verbatim. `hostedReview`'s `provider` is the
|
||||
case: the eligibility reply names it and the create call returns it, so an
|
||||
|
||||
@@ -33,7 +33,12 @@ const EXPECTED_EVENTS = FILE_COUNT * EVENTS_PER_FILE
|
||||
const TOKENS_PER_EVENT = 200
|
||||
|
||||
const WORKTREES: UsageScanWorktreeRef[] = [
|
||||
{ repoId: 'repo-1', worktreeId: 'wt-1', path: '/tmp/orca-usage-oracle-project', displayName: 'demo' }
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
path: '/tmp/orca-usage-oracle-project',
|
||||
displayName: 'demo'
|
||||
}
|
||||
]
|
||||
|
||||
let corpusRoot = ''
|
||||
|
||||
@@ -84,7 +84,7 @@ function openRenameInput(): void {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
}
|
||||
|
||||
describe('EditorPanelHeaderPath breadcrumb morph rename', () => {
|
||||
describe('EditorPanelHeaderPath inline rename', () => {
|
||||
beforeEach(() => {
|
||||
renameFileOnDiskMock.mockReset()
|
||||
Object.assign(window, { api: { ui: { writeClipboardText: vi.fn() } } })
|
||||
@@ -95,40 +95,21 @@ describe('EditorPanelHeaderPath breadcrumb morph rename', () => {
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
})
|
||||
|
||||
it('morphs into a breadcrumb strip with the basename editable', () => {
|
||||
it('opens a field holding the whole name', () => {
|
||||
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()
|
||||
expect(input.value).toBe('notes.md')
|
||||
})
|
||||
|
||||
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', () => {
|
||||
it('lets the field claim the full header width', () => {
|
||||
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('w-full')
|
||||
expect(strip.className).toContain('max-w-full')
|
||||
expect(input.className).toContain('w-full')
|
||||
expect(input.className).toContain('max-w-full')
|
||||
})
|
||||
|
||||
it('selects the basename so typing replaces just the name', () => {
|
||||
@@ -141,60 +122,42 @@ describe('EditorPanelHeaderPath breadcrumb morph rename', () => {
|
||||
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 and drops the pinned suffix', () => {
|
||||
it('renames to the typed name verbatim', () => {
|
||||
renderPath(baseFile())
|
||||
openRenameInput()
|
||||
|
||||
const input = getRenameInput('Rename file notes.md')
|
||||
fireEvent.change(input, { target: { value: 'renamed.mdx' } })
|
||||
expect(screen.queryByText('.md')).toBeNull()
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(renameFileOnDiskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ newName: 'renamed.mdx' })
|
||||
)
|
||||
|
||||
expect(renameFileOnDiskMock).toHaveBeenCalledWith({
|
||||
oldPath: '/repo/notes.md',
|
||||
newName: 'renamed.mdx',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the same name it commits for a dotted basename', () => {
|
||||
it('commits on blur like the tab bar and file explorer', () => {
|
||||
renderPath(baseFile())
|
||||
openRenameInput()
|
||||
|
||||
const input = getRenameInput('Rename file notes.md')
|
||||
fireEvent.change(input, { target: { value: 'v1.2' } })
|
||||
expect(screen.queryByText('.md')).toBeNull()
|
||||
fireEvent.change(input, { target: { value: 'renamed.md' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(renameFileOnDiskMock).toHaveBeenCalledWith(expect.objectContaining({ newName: 'v1.2' }))
|
||||
expect(renameFileOnDiskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ newName: 'renamed.md' })
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the pinned suffix for a leading-dot name', () => {
|
||||
it('does not request a rename when the name was not edited', () => {
|
||||
renderPath(baseFile())
|
||||
openRenameInput()
|
||||
|
||||
const input = getRenameInput('Rename file notes.md')
|
||||
fireEvent.change(input, { target: { value: '.notes' } })
|
||||
expect(screen.getByText('.md')).toBeDefined()
|
||||
fireEvent.keyDown(getRenameInput('Rename file notes.md'), { key: 'Enter' })
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(renameFileOnDiskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ newName: '.notes.md' })
|
||||
)
|
||||
expect(renameFileOnDiskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores an Enter that only confirms an IME candidate', () => {
|
||||
@@ -202,7 +165,7 @@ describe('EditorPanelHeaderPath breadcrumb morph rename', () => {
|
||||
openRenameInput()
|
||||
|
||||
const input = getRenameInput('Rename file notes.md')
|
||||
fireEvent.change(input, { target: { value: 'renamed' } })
|
||||
fireEvent.change(input, { target: { value: 'renamed.md' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 })
|
||||
expect(renameFileOnDiskMock).not.toHaveBeenCalled()
|
||||
|
||||
@@ -212,23 +175,28 @@ describe('EditorPanelHeaderPath breadcrumb morph rename', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not commit when the field merely loses focus', () => {
|
||||
it('cancels on Escape without a trailing blur-commit, and ignores empty renames', () => {
|
||||
renderPath(baseFile())
|
||||
openRenameInput()
|
||||
|
||||
const input = getRenameInput('Rename file notes.md')
|
||||
fireEvent.change(input, { target: { value: 'renamed' } })
|
||||
fireEvent.change(input, { target: { value: 'renamed.md' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(renameFileOnDiskMock).not.toHaveBeenCalled()
|
||||
expect(screen.getByLabelText('Rename file notes.md')).toBeDefined()
|
||||
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('drops rename mode when the active file changes', () => {
|
||||
const rerenderPath = renderPath(baseFile())
|
||||
openRenameInput()
|
||||
|
||||
fireEvent.change(getRenameInput('Rename file notes.md'), { target: { value: 'renamed' } })
|
||||
fireEvent.change(getRenameInput('Rename file notes.md'), { target: { value: 'renamed.md' } })
|
||||
rerenderPath(
|
||||
baseFile({
|
||||
id: '/repo/other.md',
|
||||
@@ -242,52 +210,6 @@ describe('EditorPanelHeaderPath breadcrumb morph rename', () => {
|
||||
expect(renameFileOnDiskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, Copy, ExternalLink, Eye, Pencil, X } from 'lucide-react'
|
||||
import { Copy, ExternalLink, Eye, Pencil } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -61,13 +61,9 @@ export function EditorPanelHeaderPath({
|
||||
const {
|
||||
canRename,
|
||||
currentFileName,
|
||||
currentBaseName,
|
||||
pinnedExtension,
|
||||
breadcrumbSegments,
|
||||
isRenaming,
|
||||
renameInputRef,
|
||||
openRenameInput,
|
||||
setRenameDraft,
|
||||
commitRename,
|
||||
cancelRename
|
||||
} = useEditorHeaderFileRename(activeFile)
|
||||
@@ -90,101 +86,41 @@ export function EditorPanelHeaderPath({
|
||||
}}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<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()}
|
||||
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()
|
||||
commitRename()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
cancelRename()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{pinnedExtension ? (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{pinnedExtension}
|
||||
</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 the caret in the input so the
|
||||
// field is still usable if the click misses the button.
|
||||
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 — the pointer leaving the field must
|
||||
// not disturb the input it is about to dismiss.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
cancelRename()
|
||||
}}
|
||||
>
|
||||
<X className="size-3" strokeWidth={3} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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 field spans the header rather than sizing to the name —
|
||||
// a long path is exactly when the rename field needs the room.
|
||||
className="h-6 w-full min-w-0 max-w-full rounded-md border border-accent/40 bg-input/40 px-1.5 font-mono text-xs text-foreground outline-none focus:border-accent focus:ring-1 focus:ring-ring"
|
||||
spellCheck={false}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
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()
|
||||
commitRename()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
cancelRename()
|
||||
}
|
||||
}}
|
||||
onBlur={commitRename}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -196,12 +132,17 @@ export function EditorPanelHeaderPath({
|
||||
{headerCopyState.pathLabel}
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`editor-header-copy-toast${copiedPathVisible ? ' is-visible' : ''}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{headerCopyState.copyToastLabel}
|
||||
</span>
|
||||
{/* Why: the toast is opacity-0 rather than display-none, so leaving it
|
||||
mounted reserves ~100px of the row from the rename field for a
|
||||
message that cannot fire while renaming. */}
|
||||
{!isRenaming && (
|
||||
<span
|
||||
className={`editor-header-copy-toast${copiedPathVisible ? ' is-visible' : ''}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{headerCopyState.copyToastLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu open={pathMenuOpen} onOpenChange={setPathMenuOpen} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -2,87 +2,38 @@ 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, dirname } from '@/lib/path'
|
||||
import { basename } from '@/lib/path'
|
||||
import { renameFileOnDisk } from '@/lib/rename-file'
|
||||
import { getUntitledFileRoot } from './untitled-file-rename-path'
|
||||
|
||||
type EditorHeaderFileRenameState = {
|
||||
canRename: boolean
|
||||
currentFileName: string
|
||||
currentBaseName: string
|
||||
pinnedExtension: string
|
||||
breadcrumbSegments: string[]
|
||||
isRenaming: boolean
|
||||
renameInputRef: RefCallback<HTMLInputElement>
|
||||
openRenameInput: () => void
|
||||
setRenameDraft: (value: string) => void
|
||||
commitRename: () => void
|
||||
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
|
||||
}
|
||||
|
||||
// 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. 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,
|
||||
currentExtension: string
|
||||
): string | null {
|
||||
if (!rawValue || rawValue === currentFileName) {
|
||||
return null
|
||||
}
|
||||
if (!currentExtension || getTypedExtension(rawValue)) {
|
||||
return rawValue
|
||||
}
|
||||
return `${rawValue}${currentExtension}`
|
||||
}
|
||||
|
||||
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 renameFocusFrameRef = useRef<number | null>(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)
|
||||
// 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.
|
||||
const canRename =
|
||||
@@ -96,20 +47,26 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil
|
||||
if (!canRename) {
|
||||
return
|
||||
}
|
||||
setRenameDraft(currentBaseName)
|
||||
renameCancelledRef.current = false
|
||||
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()
|
||||
const newName = 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) {
|
||||
if (!newName || newName === currentFileName) {
|
||||
return
|
||||
}
|
||||
const worktreePath = getUntitledFileRoot(activeFile, worktree?.path ?? null)
|
||||
@@ -122,6 +79,7 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil
|
||||
}
|
||||
|
||||
const cancelRename = (): void => {
|
||||
renameCancelledRef.current = true
|
||||
setIsRenaming(false)
|
||||
}
|
||||
|
||||
@@ -141,32 +99,33 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil
|
||||
return
|
||||
}
|
||||
|
||||
// Why: focus belongs to the rename input mount; the frame preserves the
|
||||
// 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.
|
||||
// Why: focus belongs to the rename input mount; the frame lets the header
|
||||
// layout settle before selecting text. Preselect the basename only, so
|
||||
// typing replaces the name and leaves the extension — same as the tab bar
|
||||
// and the file explorer.
|
||||
renameFocusFrameRef.current = requestAnimationFrame(() => {
|
||||
renameFocusFrameRef.current = null
|
||||
if (renameInputElementRef.current !== el) {
|
||||
return
|
||||
}
|
||||
el.focus()
|
||||
el.select()
|
||||
const dotIndex = currentFileName.lastIndexOf('.')
|
||||
if (dotIndex > 0) {
|
||||
el.setSelectionRange(0, dotIndex)
|
||||
} else {
|
||||
el.select()
|
||||
}
|
||||
})
|
||||
},
|
||||
[clearRenameFocusFrame, isRenaming]
|
||||
[clearRenameFocusFrame, currentFileName, isRenaming]
|
||||
)
|
||||
|
||||
return {
|
||||
canRename,
|
||||
currentFileName,
|
||||
currentBaseName,
|
||||
pinnedExtension,
|
||||
breadcrumbSegments,
|
||||
isRenaming,
|
||||
renameInputRef,
|
||||
openRenameInput,
|
||||
setRenameDraft,
|
||||
commitRename,
|
||||
cancelRename
|
||||
}
|
||||
|
||||
@@ -14754,9 +14754,7 @@
|
||||
"631dab0df3": "Next change",
|
||||
"revealInFinder": "Reveal in Finder",
|
||||
"openContainingFolder": "Open Containing Folder",
|
||||
"revealInFileExplorer": "Reveal in File Explorer",
|
||||
"confirmRename": "Confirm rename",
|
||||
"cancelRename": "Cancel rename"
|
||||
"revealInFileExplorer": "Reveal in File Explorer"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "Export as PDF",
|
||||
|
||||
@@ -12884,12 +12884,7 @@
|
||||
"f0fd4174b5": "Abre la pestaña de archivo para usar la edición enriquecida de Markdown",
|
||||
"a10d9b8337": "Abrir archivo",
|
||||
"2076ecfc9c": "Cambio anterior",
|
||||
"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"
|
||||
"631dab0df3": "Cambio siguiente"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "Exportar como PDF",
|
||||
|
||||
@@ -14127,12 +14127,7 @@
|
||||
"f0fd4174b5": "Ouvrez un onglet de fichier pour utiliser l'édition Markdown enrichie",
|
||||
"a10d9b8337": "Ouvrir le fichier",
|
||||
"2076ecfc9c": "Modification précédente",
|
||||
"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"
|
||||
"631dab0df3": "Modification suivante"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "Exporter en PDF",
|
||||
|
||||
@@ -12884,12 +12884,7 @@
|
||||
"f0fd4174b5": "ファイルタブを開いてリッチ markdown 編集を使用する",
|
||||
"a10d9b8337": "ファイルを開く",
|
||||
"2076ecfc9c": "前の変更",
|
||||
"631dab0df3": "次の変更",
|
||||
"revealInFinder": "Finderで表示",
|
||||
"openContainingFolder": "含まれるフォルダを開く",
|
||||
"revealInFileExplorer": "エクスプローラーで表示",
|
||||
"confirmRename": "名前の変更を確定する",
|
||||
"cancelRename": "名前の変更をキャンセルする"
|
||||
"631dab0df3": "次の変更"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "PDFとしてエクスポート",
|
||||
|
||||
@@ -12950,12 +12950,7 @@
|
||||
"f0fd4174b5": "풍부한 markdown 편집을 사용하려면 파일 탭을 엽니다.",
|
||||
"a10d9b8337": "파일 열기",
|
||||
"2076ecfc9c": "이전 변경",
|
||||
"631dab0df3": "다음 변경",
|
||||
"revealInFinder": "Finder에서 보기",
|
||||
"openContainingFolder": "포함된 폴더 열기",
|
||||
"revealInFileExplorer": "탐색기에서 보기",
|
||||
"confirmRename": "이름 바꾸기 확인",
|
||||
"cancelRename": "이름 바꾸기 취소"
|
||||
"631dab0df3": "다음 변경"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "PDF로 내보내기",
|
||||
|
||||
@@ -12964,12 +12964,7 @@
|
||||
"f0fd4174b5": "打开文件选项卡以使用富文本 Markdown 编辑",
|
||||
"a10d9b8337": "打开文件",
|
||||
"2076ecfc9c": "上一个更改",
|
||||
"631dab0df3": "下一个更改",
|
||||
"revealInFinder": "在 Finder 中显示",
|
||||
"openContainingFolder": "打开所在文件夹",
|
||||
"revealInFileExplorer": "在资源管理器中显示",
|
||||
"confirmRename": "确认重命名",
|
||||
"cancelRename": "取消重命名"
|
||||
"631dab0df3": "下一个更改"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "导出为 PDF",
|
||||
|
||||
Reference in New Issue
Block a user