From f1d54c123b97ec780ebcb95f531b456bf34543dd Mon Sep 17 00:00:00 2001 From: ye4241 <6803102+ye4241@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:42:32 +0800 Subject: [PATCH] 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. --- .../components/right-sidebar/FileExplorer.tsx | 51 ++++-- .../right-sidebar/FileExplorerRow.tsx | 29 ++-- .../file-explorer-deferred-dir-toggle.test.ts | 148 ++++++++++++++++++ .../file-explorer-dir-toggle-timing.test.ts | 49 ++++++ .../file-explorer-dir-toggle-timing.ts | 34 ++++ ...plorer-inline-input-outside-click.test.tsx | 111 +++++++++++++ .../file-explorer-inline-rename-flow.test.tsx | 124 +++++++++++++++ .../right-sidebar/useFileExplorerHandlers.ts | 64 +++++++- 8 files changed, 571 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-deferred-dir-toggle.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-inline-rename-flow.test.tsx diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 8cdaf62db..045073f81 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -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) => - selectRowWithModifiers(node, event, handleClick), + (node: TreeNode, event: React.MouseEvent) => { + 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)} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 2e1c3e24f..1b621454a 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -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({ )} { - // 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) }} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-deferred-dir-toggle.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-deferred-dir-toggle.test.ts new file mode 100644 index 000000000..5ce6a0bba --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-deferred-dir-toggle.test.ts @@ -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() + }) + ) +} + +async function flush(ms: number): Promise { + 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() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.test.ts new file mode 100644 index 000000000..b3b10fc0f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.ts b/src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.ts new file mode 100644 index 000000000..06cde664c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.ts @@ -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' +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx new file mode 100644 index 000000000..c5543965d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx @@ -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 { + await act(async () => { + vi.advanceTimersByTime(ms) + await Promise.resolve() + }) +} + +async function settleInlineInput(): Promise { + await advance(0) + await advance(250) +} + +function renderRenameRow(): { + input: HTMLInputElement + row: HTMLButtonElement + onSubmit: ReturnType +} { + const onSubmit = vi.fn() + const view = render( +
+ + {/* Rows are Radix context-menu triggers, so a genuine click on a neighbour + used to be indistinguishable from Radix restoring focus after a close. */} + +
+ ) + 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() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-inline-rename-flow.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-inline-rename-flow.test.tsx new file mode 100644 index 000000000..94677ca3f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-inline-rename-flow.test.tsx @@ -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) => 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(null) + const { inlineInput, startRename, dismissInlineInput, handleInlineSubmit } = + useFileExplorerInlineInput({ + activeWorktreeId: 'wt-1', + worktreePath: '/repo', + expanded: new Set(), + rowProjection, + scrollRef, + refreshDir: mocks.refreshDir + }) + + return ( +
+ + {inlineInput ? ( + + ) : null} + +
+ ) +} + +async function advance(ms: number): Promise { + await act(async () => { + vi.advanceTimersByTime(ms) + await Promise.resolve() + }) +} + +async function startSettledRename(): Promise<{ + input: HTMLInputElement + outsideRow: HTMLButtonElement +}> { + const view = render() + 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() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts index 01a5c6d52..b83d60123 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts @@ -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) => void + cancelPendingDirToggle: () => void } type OpenFileParams = Parameters[0] @@ -164,14 +167,64 @@ export function useFileExplorerHandlers({ setSelectedPath, scrollRef }: UseFileExplorerHandlersParams): UseFileExplorerHandlersReturn { + const pendingDirToggle = useRef<{ + timer: ReturnType + 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 } }