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:
ye4241 2026-07-28 09:42:32 +08:00 committed by GitHub
parent c6076a507c
commit f1d54c123b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 571 additions and 39 deletions

View File

@ -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)}

View File

@ -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)
}}

View File

@ -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()
})
})

View File

@ -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)
})
})

View File

@ -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'
}

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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 }
}