fix(file-explorer): commit inline rename on outside click and stop double-click rename flicker (#10867)
Reviewed with an independent reproduction. Both fixes verified; added a flush for a dropped directory toggle and an integration test connecting blur/Escape through to renameFileOnDisk.
This commit is contained in:
parent
c6076a507c
commit
f1d54c123b
|
|
@ -47,6 +47,10 @@ import {
|
|||
buildAddProjectFromFolderModalData,
|
||||
canShowAddAsProjectAction
|
||||
} from './file-explorer-add-project-action'
|
||||
import {
|
||||
isRenameHotspotTarget,
|
||||
resolveDirToggleTiming
|
||||
} from './file-explorer-dir-toggle-timing'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import { useFileExplorerSelection } from './useFileExplorerSelection'
|
||||
import { useFileExplorerVisibleRowProjection } from './useFileExplorerVisibleRowProjection'
|
||||
|
|
@ -489,18 +493,19 @@ function FileExplorerFiles(): React.JSX.Element {
|
|||
getNameFilterCollapsedPathsAfterExpand(current, dirPath)
|
||||
)
|
||||
}, [])
|
||||
const { handleClick, handleDoubleClick, handleWheelCapture } = useFileExplorerHandlers({
|
||||
activeWorktreeId,
|
||||
runtimeEnvironmentId: activeRuntimeEnvironmentId,
|
||||
openFile,
|
||||
makePreviewFilePermanent,
|
||||
toggleDir: hasNameFilter ? handleToggleNameFilterDir : toggleDir,
|
||||
loadDir,
|
||||
statPath,
|
||||
markPathAsDirectory,
|
||||
setSelectedPath: setSingleSelectedPath,
|
||||
scrollRef
|
||||
})
|
||||
const { handleClick, handleDoubleClick, handleWheelCapture, cancelPendingDirToggle } =
|
||||
useFileExplorerHandlers({
|
||||
activeWorktreeId,
|
||||
runtimeEnvironmentId: activeRuntimeEnvironmentId,
|
||||
openFile,
|
||||
makePreviewFilePermanent,
|
||||
toggleDir: hasNameFilter ? handleToggleNameFilterDir : toggleDir,
|
||||
loadDir,
|
||||
statPath,
|
||||
markPathAsDirectory,
|
||||
setSelectedPath: setSingleSelectedPath,
|
||||
scrollRef
|
||||
})
|
||||
|
||||
// Why: pass a stable activator so arrow-key navigation can hand the same
|
||||
// activate-toggles-folder / open-file-preview behavior the click handler
|
||||
|
|
@ -511,6 +516,15 @@ function FileExplorerFiles(): React.JSX.Element {
|
|||
},
|
||||
[handleClick]
|
||||
)
|
||||
// Why: a rename can start while a name click is still holding back its
|
||||
// directory toggle; drop it so the tree doesn't shift under the input.
|
||||
const handleStartRename = useCallback(
|
||||
(node: TreeNode) => {
|
||||
cancelPendingDirToggle()
|
||||
startRename(node)
|
||||
},
|
||||
[cancelPendingDirToggle, startRename]
|
||||
)
|
||||
const scrollToIndex = useCallback(
|
||||
(index: number) => {
|
||||
virtualizer.scrollToIndex(index, { align: 'auto' })
|
||||
|
|
@ -529,7 +543,7 @@ function FileExplorerFiles(): React.JSX.Element {
|
|||
activateNode,
|
||||
moveSelection,
|
||||
toggleDir: hasNameFilter ? handleToggleNameFilterDir : toggleDir,
|
||||
startRename,
|
||||
startRename: handleStartRename,
|
||||
requestDelete,
|
||||
requestDeleteAll,
|
||||
scrollToIndex,
|
||||
|
|
@ -552,8 +566,13 @@ function FileExplorerFiles(): React.JSX.Element {
|
|||
|
||||
const handleDuplicate = useFileDuplicate({ activeWorktreeId, worktreePath, refreshDir })
|
||||
const handleRowClick = useCallback(
|
||||
(node: TreeNode, event: React.MouseEvent<HTMLButtonElement>) =>
|
||||
selectRowWithModifiers(node, event, handleClick),
|
||||
(node: TreeNode, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const dirToggle = resolveDirToggleTiming({
|
||||
fromRenameHotspot: isRenameHotspotTarget(event.target),
|
||||
clickCount: event.detail
|
||||
})
|
||||
selectRowWithModifiers(node, event, (target) => handleClick(target, dirToggle))
|
||||
},
|
||||
[handleClick, selectRowWithModifiers]
|
||||
)
|
||||
const handleCollapseFolderSubtree = useCallback(
|
||||
|
|
@ -762,7 +781,7 @@ function FileExplorerFiles(): React.JSX.Element {
|
|||
onContextMenuSelect={preserveSelectionForContextMenu}
|
||||
onCopyPaths={copyPathsForNode}
|
||||
onStartNew={startNew}
|
||||
onStartRename={startRename}
|
||||
onStartRename={handleStartRename}
|
||||
onDuplicate={handleDuplicate}
|
||||
onAddFolderAsProject={handleAddFolderAsProject}
|
||||
canAddFolderAsProject={(node) => canShowAddAsProjectAction(node, activeRepo)}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
} from '@/lib/workspace-file-drag'
|
||||
import type { GitFileStatus } from '../../../../shared/types'
|
||||
import { STATUS_LABELS } from './status-display'
|
||||
import { RENAME_HOTSPOT_ATTR } from './file-explorer-dir-toggle-timing'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import { useFileExplorerRowDrag } from './useFileExplorerRowDrag'
|
||||
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
|
||||
|
|
@ -229,21 +230,12 @@ export function InlineInputRow({
|
|||
}}
|
||||
onFocus={clearBlurTimeout}
|
||||
onBlur={(e) => {
|
||||
// When a Radix menu (context or dropdown) closes, it restores focus
|
||||
// to its trigger button, which steals focus from this input before
|
||||
// the user can type. Detect this by checking relatedTarget — if focus
|
||||
// moved to any menu trigger, it's Radix cleanup, not a user action.
|
||||
if (
|
||||
e.relatedTarget instanceof HTMLElement &&
|
||||
(e.relatedTarget.closest('[data-slot="context-menu-trigger"]') ||
|
||||
e.relatedTarget.closest('[data-slot="dropdown-menu-trigger"]'))
|
||||
) {
|
||||
scheduleInputRefocus()
|
||||
return
|
||||
}
|
||||
// During the grace period after mount, menu close focus management
|
||||
// may shift focus away (often relatedTarget is null). Re-focus
|
||||
// instead of dismissing the still-empty input.
|
||||
// may shift focus away before the user can type. Re-focus instead of
|
||||
// dismissing the still-empty input. Past that window a blur is the
|
||||
// user leaving, so commit like Finder does rather than clinging to
|
||||
// the edit state — every row is itself a context-menu trigger, so
|
||||
// relatedTarget can't tell an ordinary row click from Radix cleanup.
|
||||
if (!focusSettled.current) {
|
||||
scheduleInputRefocus()
|
||||
return
|
||||
|
|
@ -635,6 +627,9 @@ export function FileExplorerRow({
|
|||
</>
|
||||
)}
|
||||
<span
|
||||
// Why: marks the rename hotspot so the row's click handler can hold
|
||||
// back the directory toggle until the double-click window closes.
|
||||
{...{ [RENAME_HOTSPOT_ATTR]: '' }}
|
||||
className={cn(
|
||||
'truncate',
|
||||
isSelected && !nodeStatus && !isIgnored && 'text-accent-foreground',
|
||||
|
|
@ -651,10 +646,8 @@ export function FileExplorerRow({
|
|||
: undefined
|
||||
}
|
||||
onDoubleClick={(e) => {
|
||||
// Why: the row itself swallows double-click for "pin preview" /
|
||||
// directory toggle. Scope rename to the filename text only so
|
||||
// those behaviors stay intact on the icon and empty row area,
|
||||
// matching VS Code's rename hotspot.
|
||||
// Why: scope rename to the filename text so "pin preview" and the
|
||||
// directory toggle stay reachable on the icon and empty row area.
|
||||
e.stopPropagation()
|
||||
onStartRename(node)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { useFileExplorerHandlers } from './useFileExplorerHandlers'
|
||||
import { DIR_TOGGLE_DOUBLE_CLICK_MS } from './file-explorer-dir-toggle-timing'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
|
||||
const directoryNode: TreeNode = {
|
||||
name: 'components',
|
||||
path: '/repo/src/components',
|
||||
relativePath: 'src/components',
|
||||
isDirectory: true,
|
||||
depth: 1
|
||||
}
|
||||
|
||||
const siblingDirectoryNode: TreeNode = {
|
||||
name: 'lib',
|
||||
path: '/repo/src/lib',
|
||||
relativePath: 'src/lib',
|
||||
isDirectory: true,
|
||||
depth: 1
|
||||
}
|
||||
|
||||
function renderHandlers(toggleDir: (worktreeId: string, dirPath: string) => void) {
|
||||
return renderHook(() =>
|
||||
useFileExplorerHandlers({
|
||||
activeWorktreeId: 'wt-1',
|
||||
openFile: vi.fn(),
|
||||
makePreviewFilePermanent: vi.fn(),
|
||||
toggleDir,
|
||||
loadDir: vi.fn().mockResolvedValue(true),
|
||||
statPath: vi.fn().mockResolvedValue({ isDirectory: true }),
|
||||
markPathAsDirectory: vi.fn(),
|
||||
setSelectedPath: vi.fn(),
|
||||
scrollRef: createRef<HTMLDivElement>()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function flush(ms: number): Promise<void> {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(ms)
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
describe('deferred directory toggle', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('toggles immediately when the click missed the rename hotspot', async () => {
|
||||
const toggleDir = vi.fn()
|
||||
const { result } = renderHandlers(toggleDir)
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleClick(directoryNode, 'immediate')
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(toggleDir).toHaveBeenCalledWith('wt-1', directoryNode.path)
|
||||
})
|
||||
|
||||
it('holds a filename click back until the double-click window closes', async () => {
|
||||
const toggleDir = vi.fn()
|
||||
const { result } = renderHandlers(toggleDir)
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleClick(directoryNode, 'deferred')
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(toggleDir).not.toHaveBeenCalled()
|
||||
|
||||
await flush(DIR_TOGGLE_DOUBLE_CLICK_MS)
|
||||
expect(toggleDir).toHaveBeenCalledWith('wt-1', directoryNode.path)
|
||||
})
|
||||
|
||||
it('never toggles when a second click turns the gesture into a rename', async () => {
|
||||
const toggleDir = vi.fn()
|
||||
const { result } = renderHandlers(toggleDir)
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleClick(directoryNode, 'deferred')
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
result.current.handleClick(directoryNode, 'skip')
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await flush(DIR_TOGGLE_DOUBLE_CLICK_MS * 2)
|
||||
expect(toggleDir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a pending toggle when a rename starts', async () => {
|
||||
const toggleDir = vi.fn()
|
||||
const { result } = renderHandlers(toggleDir)
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleClick(directoryNode, 'deferred')
|
||||
await Promise.resolve()
|
||||
})
|
||||
act(() => result.current.cancelPendingDirToggle())
|
||||
|
||||
await flush(DIR_TOGGLE_DOUBLE_CLICK_MS * 2)
|
||||
expect(toggleDir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flushes a pending toggle when the next click lands on a different row', async () => {
|
||||
const toggleDir = vi.fn()
|
||||
const { result } = renderHandlers(toggleDir)
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleClick(directoryNode, 'deferred')
|
||||
await Promise.resolve()
|
||||
})
|
||||
// Why: clicking another folder must not silently discard the first folder's
|
||||
// expand — only that row's own second click (the rename) may retract it.
|
||||
await act(async () => {
|
||||
result.current.handleClick(siblingDirectoryNode, 'deferred')
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(toggleDir).toHaveBeenCalledWith('wt-1', directoryNode.path)
|
||||
|
||||
await flush(DIR_TOGGLE_DOUBLE_CLICK_MS)
|
||||
expect(toggleDir).toHaveBeenCalledWith('wt-1', siblingDirectoryNode.path)
|
||||
expect(toggleDir).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('drops a pending toggle when the explorer unmounts', async () => {
|
||||
const toggleDir = vi.fn()
|
||||
const { result, unmount } = renderHandlers(toggleDir)
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleClick(directoryNode, 'deferred')
|
||||
await Promise.resolve()
|
||||
})
|
||||
unmount()
|
||||
|
||||
await flush(DIR_TOGGLE_DOUBLE_CLICK_MS * 2)
|
||||
expect(toggleDir).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RENAME_HOTSPOT_ATTR,
|
||||
isRenameHotspotTarget,
|
||||
resolveDirToggleTiming
|
||||
} from './file-explorer-dir-toggle-timing'
|
||||
|
||||
describe('resolveDirToggleTiming', () => {
|
||||
it('toggles immediately when the click misses the rename hotspot', () => {
|
||||
expect(resolveDirToggleTiming({ fromRenameHotspot: false, clickCount: 1 })).toBe('immediate')
|
||||
// Why: the chevron and empty row area must stay instant even on a fast double click.
|
||||
expect(resolveDirToggleTiming({ fromRenameHotspot: false, clickCount: 2 })).toBe('immediate')
|
||||
})
|
||||
|
||||
it('defers the first click on the filename so a double click can cancel it', () => {
|
||||
expect(resolveDirToggleTiming({ fromRenameHotspot: true, clickCount: 1 })).toBe('deferred')
|
||||
})
|
||||
|
||||
it('drops the toggle on the second click, which belongs to the rename', () => {
|
||||
expect(resolveDirToggleTiming({ fromRenameHotspot: true, clickCount: 2 })).toBe('skip')
|
||||
expect(resolveDirToggleTiming({ fromRenameHotspot: true, clickCount: 3 })).toBe('skip')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRenameHotspotTarget', () => {
|
||||
it('matches the filename element and its descendants', () => {
|
||||
const row = document.createElement('button')
|
||||
const name = document.createElement('span')
|
||||
name.setAttribute(RENAME_HOTSPOT_ATTR, '')
|
||||
const inner = document.createElement('em')
|
||||
name.appendChild(inner)
|
||||
row.appendChild(name)
|
||||
|
||||
expect(isRenameHotspotTarget(name)).toBe(true)
|
||||
expect(isRenameHotspotTarget(inner)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects the row chrome and non-element targets', () => {
|
||||
const row = document.createElement('button')
|
||||
const icon = document.createElement('svg')
|
||||
row.appendChild(icon)
|
||||
|
||||
expect(isRenameHotspotTarget(icon)).toBe(false)
|
||||
expect(isRenameHotspotTarget(row)).toBe(false)
|
||||
expect(isRenameHotspotTarget(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/** Marks the filename text, which doubles as the double-click-to-rename hotspot. */
|
||||
export const RENAME_HOTSPOT_ATTR = 'data-file-explorer-row-name'
|
||||
|
||||
/**
|
||||
* Matches Chromium/Electron's double-click window (`kDoubleClickTimeMS`), so a
|
||||
* deferred toggle can't fire before the second click of a slow double-click
|
||||
* arrives and turns the gesture into a rename.
|
||||
*/
|
||||
export const DIR_TOGGLE_DOUBLE_CLICK_MS = 500
|
||||
|
||||
export type DirToggleTiming = 'immediate' | 'deferred' | 'skip'
|
||||
|
||||
export function isRenameHotspotTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && target.closest(`[${RENAME_HOTSPOT_ATTR}]`) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: a double-click on the filename toggles the directory twice before the
|
||||
* rename starts, so the row visibly collapses and re-expands. Clicks on the
|
||||
* rename hotspot wait out the double-click window; the second click drops the
|
||||
* toggle entirely and lets the rename take over.
|
||||
*/
|
||||
export function resolveDirToggleTiming({
|
||||
fromRenameHotspot,
|
||||
clickCount
|
||||
}: {
|
||||
fromRenameHotspot: boolean
|
||||
clickCount: number
|
||||
}): DirToggleTiming {
|
||||
if (!fromRenameHotspot) {
|
||||
return 'immediate'
|
||||
}
|
||||
return clickCount > 1 ? 'skip' : 'deferred'
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { InlineInputRow } from './FileExplorerRow'
|
||||
import type { InlineInput } from './FileExplorerRow'
|
||||
|
||||
const renameInput: InlineInput = {
|
||||
parentPath: '/repo/src',
|
||||
type: 'rename',
|
||||
depth: 1,
|
||||
existingName: 'components',
|
||||
existingPath: '/repo/src/components'
|
||||
}
|
||||
|
||||
const BLUR_COMMIT_MS = 200
|
||||
|
||||
// Why: the input focuses itself a frame after mount, then arms blur-commits once
|
||||
// the 200ms menu-close grace period has elapsed.
|
||||
async function advance(ms: number): Promise<void> {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(ms)
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async function settleInlineInput(): Promise<void> {
|
||||
await advance(0)
|
||||
await advance(250)
|
||||
}
|
||||
|
||||
function renderRenameRow(): {
|
||||
input: HTMLInputElement
|
||||
row: HTMLButtonElement
|
||||
onSubmit: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const onSubmit = vi.fn()
|
||||
const view = render(
|
||||
<div>
|
||||
<InlineInputRow
|
||||
depth={1}
|
||||
inlineInput={renameInput}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
{/* Rows are Radix context-menu triggers, so a genuine click on a neighbour
|
||||
used to be indistinguishable from Radix restoring focus after a close. */}
|
||||
<button type="button" data-slot="context-menu-trigger">
|
||||
another-file.ts
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
return {
|
||||
input: view.container.querySelector('input') as HTMLInputElement,
|
||||
row: view.container.querySelector('button') as HTMLButtonElement,
|
||||
onSubmit
|
||||
}
|
||||
}
|
||||
|
||||
describe('file explorer inline rename input', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('commits when the user clicks another file explorer row', async () => {
|
||||
const { input, row, onSubmit } = renderRenameRow()
|
||||
await settleInlineInput()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'renamed' } })
|
||||
fireEvent.blur(input, { relatedTarget: row })
|
||||
await advance(BLUR_COMMIT_MS)
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('renamed')
|
||||
})
|
||||
|
||||
it('commits when the user tabs to another row without any pointer input', async () => {
|
||||
const { input, row, onSubmit } = renderRenameRow()
|
||||
await settleInlineInput()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'renamed' } })
|
||||
row.focus()
|
||||
fireEvent.blur(input, { relatedTarget: row })
|
||||
await advance(BLUR_COMMIT_MS)
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('renamed')
|
||||
})
|
||||
|
||||
it('commits when focus moves to an unrelated element', async () => {
|
||||
const { input, onSubmit } = renderRenameRow()
|
||||
await settleInlineInput()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'renamed' } })
|
||||
fireEvent.blur(input, { relatedTarget: null })
|
||||
await advance(BLUR_COMMIT_MS)
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('renamed')
|
||||
})
|
||||
|
||||
it('refocuses instead of committing when a menu close steals focus on mount', async () => {
|
||||
const { input, row, onSubmit } = renderRenameRow()
|
||||
// Only let the mount focus frame run — stay inside the grace period.
|
||||
await advance(0)
|
||||
|
||||
fireEvent.blur(input, { relatedTarget: row })
|
||||
await advance(BLUR_COMMIT_MS)
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { useRef } from 'react'
|
||||
import { InlineInputRow } from './FileExplorerRow'
|
||||
import { createFileExplorerRowProjection } from './file-explorer-row-projection'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import { useFileExplorerInlineInput } from './useFileExplorerInlineInput'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openFile: vi.fn(),
|
||||
refreshDir: vi.fn().mockResolvedValue(undefined),
|
||||
renameFileOnDisk: vi.fn().mockResolvedValue(undefined),
|
||||
toggleDir: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({ openFile: mocks.openFile, toggleDir: mocks.toggleDir })
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/rename-file', () => ({
|
||||
extractIpcErrorMessage: vi.fn(),
|
||||
renameFileOnDisk: mocks.renameFileOnDisk
|
||||
}))
|
||||
|
||||
const node: TreeNode = {
|
||||
name: 'components',
|
||||
path: '/repo/src/components',
|
||||
relativePath: 'src/components',
|
||||
isDirectory: true,
|
||||
depth: 1
|
||||
}
|
||||
const rowProjection = createFileExplorerRowProjection([node])
|
||||
|
||||
function RenameHarness(): React.JSX.Element {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const { inlineInput, startRename, dismissInlineInput, handleInlineSubmit } =
|
||||
useFileExplorerInlineInput({
|
||||
activeWorktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
expanded: new Set(),
|
||||
rowProjection,
|
||||
scrollRef,
|
||||
refreshDir: mocks.refreshDir
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} tabIndex={-1}>
|
||||
<button type="button" onClick={() => startRename(node)}>
|
||||
Rename
|
||||
</button>
|
||||
{inlineInput ? (
|
||||
<InlineInputRow
|
||||
depth={inlineInput.depth}
|
||||
inlineInput={inlineInput}
|
||||
onSubmit={handleInlineSubmit}
|
||||
onCancel={dismissInlineInput}
|
||||
/>
|
||||
) : null}
|
||||
<button type="button">Another row</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function advance(ms: number): Promise<void> {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(ms)
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async function startSettledRename(): Promise<{
|
||||
input: HTMLInputElement
|
||||
outsideRow: HTMLButtonElement
|
||||
}> {
|
||||
const view = render(<RenameHarness />)
|
||||
fireEvent.click(view.getByRole('button', { name: 'Rename' }))
|
||||
await advance(0)
|
||||
await advance(250)
|
||||
return {
|
||||
input: view.getByRole('textbox') as HTMLInputElement,
|
||||
outsideRow: view.getByRole('button', { name: 'Another row' }) as HTMLButtonElement
|
||||
}
|
||||
}
|
||||
|
||||
describe('file explorer inline rename flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renames on disk when focus leaves the input', async () => {
|
||||
const { input, outsideRow } = await startSettledRename()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'renamed-components' } })
|
||||
fireEvent.blur(input, { relatedTarget: outsideRow })
|
||||
await advance(150)
|
||||
|
||||
expect(mocks.renameFileOnDisk).toHaveBeenCalledWith({
|
||||
oldPath: node.path,
|
||||
newName: 'renamed-components',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
operationOwner: undefined,
|
||||
refreshDir: mocks.refreshDir
|
||||
})
|
||||
})
|
||||
|
||||
it('discards the rename on Escape without touching disk', async () => {
|
||||
const { input } = await startSettledRename()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'renamed-components' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
await advance(200)
|
||||
|
||||
expect(mocks.renameFileOnDisk).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type React from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { toast } from 'sonner'
|
||||
import type { TreeNode } from './file-explorer-types'
|
||||
import { FILE_EXPLORER_DRAGGABLE_SELECTOR } from './file-explorer-drag-scroll-marker'
|
||||
import { DIR_TOGGLE_DOUBLE_CLICK_MS } from './file-explorer-dir-toggle-timing'
|
||||
import type { DirToggleTiming } from './file-explorer-dir-toggle-timing'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getFileExplorerOwnerUnresolvedMessage,
|
||||
|
|
@ -44,9 +46,10 @@ type UseFileExplorerHandlersParams = {
|
|||
}
|
||||
|
||||
type UseFileExplorerHandlersReturn = {
|
||||
handleClick: (node: TreeNode) => void
|
||||
handleClick: (node: TreeNode, dirToggle?: DirToggleTiming) => void
|
||||
handleDoubleClick: (node: TreeNode) => void
|
||||
handleWheelCapture: (e: React.WheelEvent<HTMLDivElement>) => void
|
||||
cancelPendingDirToggle: () => void
|
||||
}
|
||||
|
||||
type OpenFileParams = Parameters<UseFileExplorerHandlersParams['openFile']>[0]
|
||||
|
|
@ -164,14 +167,64 @@ export function useFileExplorerHandlers({
|
|||
setSelectedPath,
|
||||
scrollRef
|
||||
}: UseFileExplorerHandlersParams): UseFileExplorerHandlersReturn {
|
||||
const pendingDirToggle = useRef<{
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
dirPath: string
|
||||
run: () => void
|
||||
} | null>(null)
|
||||
|
||||
const cancelPendingDirToggle = useCallback((): void => {
|
||||
if (pendingDirToggle.current === null) {
|
||||
return
|
||||
}
|
||||
clearTimeout(pendingDirToggle.current.timer)
|
||||
pendingDirToggle.current = null
|
||||
}, [])
|
||||
|
||||
// Why: only the row that armed the deferral can retract it (its own second
|
||||
// click becomes a rename). Any other gesture leaves that click's intent
|
||||
// standing, so run it now instead of dropping the folder the user opened.
|
||||
const settlePendingDirToggle = useCallback((retractingDirPath: string | null): void => {
|
||||
const pending = pendingDirToggle.current
|
||||
if (pending === null) {
|
||||
return
|
||||
}
|
||||
clearTimeout(pending.timer)
|
||||
pendingDirToggle.current = null
|
||||
if (pending.dirPath !== retractingDirPath) {
|
||||
pending.run()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => cancelPendingDirToggle, [cancelPendingDirToggle])
|
||||
|
||||
const handleClick = useCallback(
|
||||
(node: TreeNode) => {
|
||||
(node: TreeNode, dirToggle: DirToggleTiming = 'immediate') => {
|
||||
settlePendingDirToggle(node.path)
|
||||
if (dirToggle === 'skip' && node.isDirectory) {
|
||||
// Why: the rename about to start owns this gesture; selection still applies.
|
||||
setSelectedPath(node.path)
|
||||
return
|
||||
}
|
||||
void activateFileExplorerNode({
|
||||
node,
|
||||
activeWorktreeId,
|
||||
runtimeEnvironmentId,
|
||||
openFile,
|
||||
toggleDir,
|
||||
toggleDir:
|
||||
dirToggle === 'deferred'
|
||||
? (worktreeId, dirPath) => {
|
||||
const run = (): void => toggleDir(worktreeId, dirPath)
|
||||
pendingDirToggle.current = {
|
||||
dirPath,
|
||||
run,
|
||||
timer: setTimeout(() => {
|
||||
pendingDirToggle.current = null
|
||||
run()
|
||||
}, DIR_TOGGLE_DOUBLE_CLICK_MS)
|
||||
}
|
||||
}
|
||||
: toggleDir,
|
||||
canToggleDirectories,
|
||||
loadDir,
|
||||
statPath,
|
||||
|
|
@ -183,6 +236,7 @@ export function useFileExplorerHandlers({
|
|||
activeWorktreeId,
|
||||
runtimeEnvironmentId,
|
||||
canToggleDirectories,
|
||||
settlePendingDirToggle,
|
||||
loadDir,
|
||||
markPathAsDirectory,
|
||||
openFile,
|
||||
|
|
@ -221,5 +275,5 @@ export function useFileExplorerHandlers({
|
|||
[scrollRef]
|
||||
)
|
||||
|
||||
return { handleClick, handleDoubleClick, handleWheelCapture }
|
||||
return { handleClick, handleDoubleClick, handleWheelCapture, cancelPendingDirToggle }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue