mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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'
|
||||
@@ -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 ? (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{currentExtension}
|
||||
{pinnedExtension}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex shrink-0 items-center">
|
||||
@@ -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()
|
||||
}}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12906,7 +12906,12 @@
|
||||
"f0fd4174b5": "ファイルタブを開いてリッチ markdown 編集を使用する",
|
||||
"a10d9b8337": "ファイルを開く",
|
||||
"2076ecfc9c": "前の変更",
|
||||
"631dab0df3": "次の変更"
|
||||
"631dab0df3": "次の変更",
|
||||
"revealInFinder": "Finderで表示",
|
||||
"openContainingFolder": "含まれるフォルダを開く",
|
||||
"revealInFileExplorer": "エクスプローラーで表示",
|
||||
"confirmRename": "名前の変更を確定する",
|
||||
"cancelRename": "名前の変更をキャンセルする"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "PDFとしてエクスポート",
|
||||
|
||||
@@ -12972,7 +12972,12 @@
|
||||
"f0fd4174b5": "풍부한 markdown 편집을 사용하려면 파일 탭을 엽니다.",
|
||||
"a10d9b8337": "파일 열기",
|
||||
"2076ecfc9c": "이전 변경",
|
||||
"631dab0df3": "다음 변경"
|
||||
"631dab0df3": "다음 변경",
|
||||
"revealInFinder": "Finder에서 보기",
|
||||
"openContainingFolder": "포함된 폴더 열기",
|
||||
"revealInFileExplorer": "탐색기에서 보기",
|
||||
"confirmRename": "이름 바꾸기 확인",
|
||||
"cancelRename": "이름 바꾸기 취소"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "PDF로 내보내기",
|
||||
|
||||
@@ -12986,7 +12986,12 @@
|
||||
"f0fd4174b5": "打开文件选项卡以使用富文本 Markdown 编辑",
|
||||
"a10d9b8337": "打开文件",
|
||||
"2076ecfc9c": "上一个更改",
|
||||
"631dab0df3": "下一个更改"
|
||||
"631dab0df3": "下一个更改",
|
||||
"revealInFinder": "在 Finder 中显示",
|
||||
"openContainingFolder": "打开所在文件夹",
|
||||
"revealInFileExplorer": "在资源管理器中显示",
|
||||
"confirmRename": "确认重命名",
|
||||
"cancelRename": "取消重命名"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "导出为 PDF",
|
||||
|
||||
Reference in New Issue
Block a user