From a1eaa4592fa6d00fd85c00f3cffd23977360931a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:59:56 -0700 Subject: [PATCH] feat(file-explorer): keyboard navigation (arrows, Home/End, PageUp/Down) (#4726) Co-authored-by: Orca --- .../components/right-sidebar/FileExplorer.tsx | 44 +++- .../file-explorer-keyboard-navigation.test.ts | 248 ++++++++++++++++++ .../file-explorer-keyboard-navigation.ts | 188 +++++++++++++ .../file-explorer-row-projection.test.ts | 37 +++ .../file-explorer-row-projection.ts | 35 ++- .../right-sidebar/useFileExplorerKeys.ts | 98 ++++++- .../right-sidebar/useFileExplorerSelection.ts | 10 +- 7 files changed, 640 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.ts diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 4a55f5e4a8d..4507a313ba2 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -123,6 +123,7 @@ function FileExplorerInner(): React.JSX.Element { setSelectedPaths, resetSelection, selectRowWithModifiers, + moveSelection, preserveSelectionForContextMenu, copyPathsForNode } = useFileExplorerSelection(rowProjection, isMac) @@ -322,17 +323,6 @@ function FileExplorerInner(): React.JSX.Element { () => rowProjection.getRowsByPaths(selectedPaths), [rowProjection, selectedPaths] ) - useFileExplorerKeys({ - containerRef: explorerShellRef, - rowProjection, - inlineInput, - selectedPaths, - selectedNode, - startRename, - requestDelete, - requestDeleteAll - }) - const { handleClick, handleDoubleClick, handleWheelCapture } = useFileExplorerHandlers({ activeWorktreeId, openFile, @@ -345,6 +335,38 @@ function FileExplorerInner(): React.JSX.Element { scrollRef }) + // Why: pass a stable activator so arrow-key navigation can hand the same + // activate-toggles-folder / open-file-preview behavior the click handler + // already uses, without the keyboard path re-implementing symlink handling. + const activateNode = useCallback( + (node: TreeNode) => { + void handleClick(node) + }, + [handleClick] + ) + const scrollToIndex = useCallback( + (index: number) => { + virtualizer.scrollToIndex(index, { align: 'auto' }) + }, + [virtualizer] + ) + + useFileExplorerKeys({ + containerRef: explorerShellRef, + rowProjection, + inlineInput, + selectedPaths, + selectedNode, + activateNode, + moveSelection, + toggleDir, + startRename, + requestDelete, + requestDeleteAll, + scrollToIndex, + activeWorktreeId + }) + // Why: context-menu Delete should respect the multi-selection — if the // right-clicked node is already part of a multi-selection, delete the whole // set; otherwise fall through to single-node delete. diff --git a/src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.test.ts new file mode 100644 index 00000000000..a38ea851e7d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest' +import type { TreeNode } from './file-explorer-types' +import { createFileExplorerRowProjection } from './file-explorer-row-projection' +import { resolveFileExplorerNavigationTarget } from './file-explorer-keyboard-navigation' + +function row(path: string, depth: number, isDirectory = false): TreeNode { + return { + name: path.split(/[\\/]/).at(-1) ?? path, + path, + relativePath: path.replace('/repo/', ''), + isDirectory, + depth + } +} + +function makeProjection(rows: TreeNode[]) { + return createFileExplorerRowProjection(rows) +} + +const SAMPLE_ROWS = [ + row('/repo/src', 0, true), + row('/repo/src/a.ts', 1), + row('/repo/src/nested', 1, true), + row('/repo/src/nested/b.ts', 2), + row('/repo/root.ts', 0) +] + +const isCollapsed = (): false => false +function isExpandedSet(paths: string[]): (path: string) => boolean { + const set = new Set(paths) + return (path) => set.has(path) +} + +describe('resolveFileExplorerNavigationTarget', () => { + describe('flat list movement', () => { + const projection = makeProjection(SAMPLE_ROWS) + + it('moves to the next row on ArrowDown', () => { + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowDown', + currentIndex: 0, + rowProjection: projection, + total: 5, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'move', targetIndex: 1 }) + }) + + it('moves to the previous row on ArrowUp', () => { + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowUp', + currentIndex: 3, + rowProjection: projection, + total: 5, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'move', targetIndex: 2 }) + }) + + it('clamps ArrowDown to the last row', () => { + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowDown', + currentIndex: 4, + rowProjection: projection, + total: 5, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'move', targetIndex: 4 }) + }) + + it('clamps ArrowUp to the first row', () => { + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowUp', + currentIndex: 0, + rowProjection: projection, + total: 5, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'move', targetIndex: 0 }) + }) + + it('jumps to the first row on Home', () => { + expect( + resolveFileExplorerNavigationTarget({ + key: 'Home', + currentIndex: 3, + rowProjection: projection, + total: 5, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'move', targetIndex: 0 }) + }) + + it('jumps to the last row on End', () => { + expect( + resolveFileExplorerNavigationTarget({ + key: 'End', + currentIndex: 1, + rowProjection: projection, + total: 5, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'move', targetIndex: 4 }) + }) + }) + + describe('initial movement with no current row', () => { + const projection = makeProjection(SAMPLE_ROWS) + + it('selects the first row on ArrowDown when nothing is focused', () => { + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowDown', + currentIndex: null, + rowProjection: projection, + total: 5, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'move', targetIndex: 0 }) + }) + + it('selects the last row on ArrowUp when nothing is focused', () => { + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowUp', + currentIndex: null, + rowProjection: projection, + total: 5, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'move', targetIndex: 4 }) + }) + + it('leaves ArrowLeft/Right unhandled when no current row is anchored', () => { + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowLeft', + currentIndex: null, + rowProjection: projection, + total: 5, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'unhandled' }) + }) + }) + + describe('arrow-right on a folder', () => { + it('toggles to expand when the folder is collapsed', () => { + const projection = makeProjection(SAMPLE_ROWS) + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowRight', + currentIndex: 0, + rowProjection: projection, + total: 5, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'toggle-expand', currentIndex: 0, dirPath: '/repo/src' }) + }) + + it('moves into the first child when the folder is already expanded', () => { + const projection = makeProjection(SAMPLE_ROWS) + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowRight', + currentIndex: 0, + rowProjection: projection, + total: 5, + isExpanded: isExpandedSet(['/repo/src']) + }) + ).toEqual({ type: 'move', targetIndex: 1 }) + }) + + it('stays on the row when the target is a file', () => { + const projection = makeProjection(SAMPLE_ROWS) + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowRight', + currentIndex: 1, + rowProjection: projection, + total: 5, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'move', targetIndex: 1 }) + }) + }) + + describe('arrow-left on a folder', () => { + it('toggles to collapse when the folder is expanded', () => { + const projection = makeProjection(SAMPLE_ROWS) + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowLeft', + currentIndex: 0, + rowProjection: projection, + total: 5, + isExpanded: isExpandedSet(['/repo/src']) + }) + ).toEqual({ type: 'toggle-collapse', currentIndex: 0, dirPath: '/repo/src' }) + }) + + it('moves to the parent row when the folder is collapsed', () => { + const projection = makeProjection(SAMPLE_ROWS) + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowLeft', + currentIndex: 1, + rowProjection: projection, + total: 5, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'move', targetIndex: 0 }) + }) + + it('no-ops at the top of the tree', () => { + const projection = makeProjection(SAMPLE_ROWS) + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowLeft', + currentIndex: 0, + rowProjection: projection, + total: 5, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'no-op' }) + }) + }) + + describe('empty tree', () => { + const projection = makeProjection([]) + + it('returns no-op for any navigation key', () => { + expect( + resolveFileExplorerNavigationTarget({ + key: 'ArrowDown', + currentIndex: null, + rowProjection: projection, + total: 0, + isExpanded: isCollapsed + }) + ).toEqual({ type: 'no-op' }) + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.ts b/src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.ts new file mode 100644 index 00000000000..0a512aac83d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.ts @@ -0,0 +1,188 @@ +import type { FileExplorerRowProjection } from './file-explorer-row-projection' +import type { TreeNode } from './file-explorer-types' + +export type NavigationKey = + | 'ArrowDown' + | 'ArrowUp' + | 'ArrowLeft' + | 'ArrowRight' + | 'Home' + | 'End' + | 'PageUp' + | 'PageDown' + +export type ResolvedNavigation = + | { type: 'move'; targetIndex: number } + | { type: 'toggle-expand'; currentIndex: number; dirPath: string } + | { type: 'toggle-collapse'; currentIndex: number; dirPath: string } + | { type: 'no-op' } + | { type: 'unhandled' } + +export type SelectionMode = 'replace' | 'toggle' | 'range' | 'additive-range' + +/** + * Resolve a tree-navigation key to a target row index, mirroring the VS Code + * Explorer tree: arrow keys move within the flat visible order, Left/Right + * collapse/expand folders or step across parent/child boundaries, and + * Home/End/PageUp/PageDown jump along the visible list. + */ +export function resolveFileExplorerNavigationTarget(args: { + key: NavigationKey + currentIndex: number | null + rowProjection: FileExplorerRowProjection + total: number + isExpanded: (path: string) => boolean +}): ResolvedNavigation { + const { key, currentIndex, rowProjection, total, isExpanded } = args + if (total === 0) { + return { type: 'no-op' } + } + + if (currentIndex === null) { + if (key === 'ArrowDown' || key === 'End' || key === 'PageDown') { + return { type: 'move', targetIndex: 0 } + } + if (key === 'ArrowUp' || key === 'Home' || key === 'PageUp') { + return { type: 'move', targetIndex: total - 1 } + } + return { type: 'unhandled' } + } + + switch (key) { + case 'ArrowDown': + return { type: 'move', targetIndex: Math.min(total - 1, currentIndex + 1) } + case 'ArrowUp': + return { type: 'move', targetIndex: Math.max(0, currentIndex - 1) } + case 'Home': + return { type: 'move', targetIndex: 0 } + case 'End': + return { type: 'move', targetIndex: total - 1 } + case 'PageDown': { + const pageSize = Math.max(1, Math.floor(total / 10)) + return { type: 'move', targetIndex: Math.min(total - 1, currentIndex + pageSize) } + } + case 'PageUp': { + const pageSize = Math.max(1, Math.floor(total / 10)) + return { type: 'move', targetIndex: Math.max(0, currentIndex - pageSize) } + } + case 'ArrowRight': { + const node = rowProjection.getRowAtIndex(currentIndex) + if (!node || !node.isDirectory) { + return { type: 'move', targetIndex: currentIndex } + } + if (!isExpanded(node.path)) { + return { type: 'toggle-expand', currentIndex, dirPath: node.path } + } + const firstChild = rowProjection.getFirstChildIndex(currentIndex) + return { type: 'move', targetIndex: firstChild ?? currentIndex } + } + case 'ArrowLeft': { + const node = rowProjection.getRowAtIndex(currentIndex) + if (!node) { + return { type: 'no-op' } + } + if (node.isDirectory && isExpanded(node.path)) { + return { type: 'toggle-collapse', currentIndex, dirPath: node.path } + } + const parent = rowProjection.getParentIndex(currentIndex) + if (parent === null) { + return { type: 'no-op' } + } + return { type: 'move', targetIndex: parent } + } + } +} + +const NAVIGATION_KEY_SET: Record = { + ArrowDown: true, + ArrowUp: true, + ArrowLeft: true, + ArrowRight: true, + Home: true, + End: true, + PageUp: true, + PageDown: true +} + +export function isNavigationKey(key: string): key is NavigationKey { + return key in NAVIGATION_KEY_SET +} + +export type NavigationHandlers = { + moveSelection: (targetPath: string, mode: SelectionMode) => void + toggleDir: (worktreeId: string, dirPath: string) => void + scrollToIndex: (index: number) => void + focusRowAtIndex: (index: number) => void +} + +export type NavigationContext = { + rowProjection: FileExplorerRowProjection + activeWorktreeId: string | null + selectedNode: TreeNode | null + isExpanded: (path: string) => boolean + findFocusedIndex: () => number | null + handlers: NavigationHandlers +} + +/** + * Apply a tree-navigation key to the explorer: resolve the target, then + * move the selection (or toggle a directory) and bring the new row into + * view. Returns true if the key was handled. + */ +export function applyFileExplorerNavigation(ctx: NavigationContext, e: KeyboardEvent): boolean { + if (e.altKey || e.metaKey || e.ctrlKey) { + return false + } + if (!isNavigationKey(e.key)) { + return false + } + const total = ctx.rowProjection.getVisibleCount() + const focusedIndex = ctx.findFocusedIndex() + const activePath = ctx.selectedNode?.path ?? null + const activeIndex = activePath ? (ctx.rowProjection.getIndexByPath(activePath) ?? null) : null + const currentIndex = focusedIndex ?? activeIndex + + const resolved = resolveFileExplorerNavigationTarget({ + key: e.key, + currentIndex, + rowProjection: ctx.rowProjection, + total, + isExpanded: ctx.isExpanded + }) + + if (resolved.type === 'unhandled' || resolved.type === 'no-op') { + return false + } + + if (resolved.type === 'toggle-expand' || resolved.type === 'toggle-collapse') { + e.preventDefault() + e.stopPropagation() + if (ctx.activeWorktreeId) { + ctx.handlers.toggleDir(ctx.activeWorktreeId, resolved.dirPath) + } + return true + } + + const targetNode = ctx.rowProjection.getRowAtIndex(resolved.targetIndex) + if (!targetNode) { + return false + } + + e.preventDefault() + e.stopPropagation() + + // Why: VS Code replaces the selection on bare arrow keys and extends + // it (from the anchor) on Shift+arrow. We translate the modifier into + // the same selection modes the click handler uses. + const mode: SelectionMode = e.shiftKey && currentIndex !== null ? 'range' : 'replace' + ctx.handlers.moveSelection(targetNode.path, mode) + + // Why: focusing the row button keeps subsequent arrow keys anchored to + // the new row and lets the existing Enter/Delete shortcuts pick it up + // without a separate focus call from the caller. + requestAnimationFrame(() => { + ctx.handlers.focusRowAtIndex(resolved.targetIndex) + ctx.handlers.scrollToIndex(resolved.targetIndex) + }) + return true +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-row-projection.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-row-projection.test.ts index 2edc03c7020..dc697464cda 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-row-projection.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-row-projection.test.ts @@ -63,4 +63,41 @@ describe('file explorer row projection', () => { expect(projection.getInsertIndexAfterSubtree('/repo/src', '/repo')).toBe(1) }) + + it('walks backward to find the immediate parent row in the visible list', () => { + const projection = createFileExplorerRowProjection([ + row('/repo/src', 0, true), + row('/repo/src/nested', 1, true), + row('/repo/src/nested/deep', 2, true), + row('/repo/src/nested/deep/x.ts', 3), + row('/repo/root.ts', 0) + ]) + + expect(projection.getParentIndex(3)).toBe(2) + expect(projection.getParentIndex(2)).toBe(1) + expect(projection.getParentIndex(1)).toBe(0) + // Why: the root directory itself is never a row, so its children have + // no visible parent to walk up to. + expect(projection.getParentIndex(0)).toBeNull() + expect(projection.getParentIndex(4)).toBeNull() + expect(projection.getParentIndex(99)).toBeNull() + }) + + it('finds the first child row only when the folder is expanded', () => { + const projection = createFileExplorerRowProjection([ + row('/repo/src', 0, true), + row('/repo/src/a.ts', 1), + row('/repo/src/nested', 1, true), + row('/repo/src/nested/b.ts', 2), + row('/repo/root.ts', 0) + ]) + + expect(projection.getFirstChildIndex(0)).toBe(1) + expect(projection.getFirstChildIndex(2)).toBe(3) + // Why: files have no children; collapsed folders are not in the visible + // list at all, so neither surface a first child. + expect(projection.getFirstChildIndex(1)).toBeNull() + expect(projection.getFirstChildIndex(4)).toBeNull() + expect(projection.getFirstChildIndex(99)).toBeNull() + }) }) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-row-projection.ts b/src/renderer/src/components/right-sidebar/file-explorer-row-projection.ts index 93bf5af1b99..0b8e8998340 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-row-projection.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-row-projection.ts @@ -11,6 +11,8 @@ export type FileExplorerRowProjection = { getRowsByPaths: (paths: Set) => TreeNode[] countVisiblePaths: (paths: Set) => number getInsertIndexAfterSubtree: (parentPath: string, worktreePath: string | null) => number + getParentIndex: (index: number) => number | null + getFirstChildIndex: (index: number) => number | null } export function createFileExplorerRowProjection( @@ -61,10 +63,41 @@ export function createFileExplorerRowProjectionFromParts( getRowsByPathsInProjectionOrder(visibleFlatRows, rowsByPath, getIndexByPathMap, paths), countVisiblePaths: (paths) => countVisiblePaths(rowsByPath, paths), getInsertIndexAfterSubtree: (parentPath, worktreePath) => - getInsertIndexAfterSubtree(visibleFlatRows, getIndexByPathMap, parentPath, worktreePath) + getInsertIndexAfterSubtree(visibleFlatRows, getIndexByPathMap, parentPath, worktreePath), + getParentIndex: (index) => getParentIndex(visibleFlatRows, index), + getFirstChildIndex: (index) => getFirstChildIndex(visibleFlatRows, index) } } +function getParentIndex(visibleFlatRows: readonly TreeNode[], index: number): number | null { + const current = visibleFlatRows[index] + if (!current) { + return null + } + if (current.depth <= 0) { + return null + } + for (let i = index - 1; i >= 0; i -= 1) { + const node = visibleFlatRows[i] + if (node && node.depth < current.depth) { + return i + } + } + return null +} + +function getFirstChildIndex(visibleFlatRows: readonly TreeNode[], index: number): number | null { + const current = visibleFlatRows[index] + if (!current || !current.isDirectory) { + return null + } + const next = visibleFlatRows[index + 1] + if (next && next.depth === current.depth + 1) { + return index + 1 + } + return null +} + function getRowsByPathsInProjectionOrder( visibleFlatRows: readonly TreeNode[], rowsByPath: ReadonlyMap, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts b/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts index 6161209826f..2fe6858fad3 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts @@ -13,6 +13,10 @@ import { redoFileExplorer, undoFileExplorer } from './fileExplorerUndoRedo' +import { + applyFileExplorerNavigation, + type SelectionMode +} from './file-explorer-keyboard-navigation' import { keybindingMatchesAction } from '../../../../shared/keybindings' /** @@ -27,9 +31,14 @@ export function useFileExplorerKeys(opts: { inlineInput: InlineInput | null selectedPaths: Set selectedNode: TreeNode | null + activateNode: (node: TreeNode) => void + moveSelection: (targetPath: string, mode: SelectionMode) => void + toggleDir: (worktreeId: string, dirPath: string) => void startRename: (node: TreeNode) => void requestDelete: (node: TreeNode) => void requestDeleteAll: (nodes: TreeNode[]) => void + scrollToIndex: (index: number) => void + activeWorktreeId: string | null }): void { const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) @@ -49,11 +58,22 @@ export function useFileExplorerKeys(opts: { requestDeleteRef.current = opts.requestDelete const requestDeleteAllRef = useRef(opts.requestDeleteAll) requestDeleteAllRef.current = opts.requestDeleteAll + const activateNodeRef = useRef(opts.activateNode) + activateNodeRef.current = opts.activateNode + const moveSelectionRef = useRef(opts.moveSelection) + moveSelectionRef.current = opts.moveSelection + const toggleDirRef = useRef(opts.toggleDir) + toggleDirRef.current = opts.toggleDir + const scrollToIndexRef = useRef(opts.scrollToIndex) + scrollToIndexRef.current = opts.scrollToIndex + const activeWorktreeIdRef = useRef(opts.activeWorktreeId) + activeWorktreeIdRef.current = opts.activeWorktreeId useEffect(() => { - // Find the node that the focused button represents (for bare-key shortcuts). - // Each row button's closest [data-index] gives us the virtualizer index. - const findFocusedNode = (): TreeNode | null => { + // Find the row index whose button is currently focused. Each virtualized + // row's wrapper carries data-index; the inline-rename slot is the only + // wrapper without a real TreeNode, so it falls back to the row above. + const findFocusedIndex = (): number | null => { const el = document.activeElement as HTMLElement | null if (!el || !opts.containerRef.current?.contains(el)) { return null @@ -62,8 +82,15 @@ export function useFileExplorerKeys(opts: { if (!wrapper) { return null } - const idx = Number(wrapper.dataset.index) - return rowProjectionRef.current.getRowAtIndex(idx) + const raw = wrapper.dataset.index + if (raw === undefined) { + return null + } + const idx = Number(raw) + if (rowProjectionRef.current.getRowAtIndex(idx) === null) { + return idx > 0 ? idx - 1 : null + } + return idx } const focusInExplorer = (): boolean => { @@ -81,6 +108,23 @@ export function useFileExplorerKeys(opts: { ) } + const focusRowAtIndex = (index: number): void => { + const wrapper = opts.containerRef.current?.querySelector( + `[data-index="${index}"]` + ) + const button = wrapper?.querySelector('button') + button?.focus() + } + + const isDirExpanded = (path: string): boolean => { + const worktreeId = activeWorktreeIdRef.current + if (!worktreeId) { + return false + } + const expanded = useAppStore.getState().expandedDirs[worktreeId] + return expanded ? expanded.has(path) : false + } + const onKeyDown = (e: KeyboardEvent): void => { if (!rightSidebarOpen || rightSidebarTab !== 'explorer') { return @@ -111,7 +155,44 @@ export function useFileExplorerKeys(opts: { // ── Bare-key shortcuts: only when explorer has focus ── if (focusInExplorer()) { - const node = findFocusedNode() ?? selectedNodeRef.current + if ( + applyFileExplorerNavigation( + { + rowProjection: rowProjectionRef.current, + activeWorktreeId: activeWorktreeIdRef.current, + selectedNode: selectedNodeRef.current, + isExpanded: isDirExpanded, + findFocusedIndex, + handlers: { + moveSelection: moveSelectionRef.current, + toggleDir: toggleDirRef.current, + scrollToIndex: scrollToIndexRef.current, + focusRowAtIndex + } + }, + e + ) + ) { + return + } + + // ── Space activates the focused row (open file / toggle folder). ── + if (e.key === ' ' && !e.shiftKey) { + const focused = findFocusedIndex() + const node = + (focused !== null ? rowProjectionRef.current.getRowAtIndex(focused) : null) ?? + selectedNodeRef.current + if (node) { + e.preventDefault() + activateNodeRef.current(node) + return + } + } + + const focused = findFocusedIndex() + const node = + (focused !== null ? rowProjectionRef.current.getRowAtIndex(focused) : null) ?? + selectedNodeRef.current if (node) { if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { e.preventDefault() @@ -154,7 +235,10 @@ export function useFileExplorerKeys(opts: { return } - const node = selectedNodeRef.current ?? findFocusedNode() + const focused = findFocusedIndex() + const node = + (focused !== null ? rowProjectionRef.current.getRowAtIndex(focused) : null) ?? + selectedNodeRef.current const selectedNodes = rowProjectionRef.current.getRowsByPaths(selectedPathsRef.current) const fallbackNodes = selectedNodes.length > 0 ? selectedNodes : node ? [node] : [] if (fallbackNodes.length === 0) { diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerSelection.ts b/src/renderer/src/components/right-sidebar/useFileExplorerSelection.ts index 7a6138a3b7d..9b10db615ec 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerSelection.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerSelection.ts @@ -8,7 +8,8 @@ import { formatFileExplorerPathsForClipboard, getFileExplorerSelectionMode, updateFileExplorerSelection, - updateFileExplorerSelectionPaths + updateFileExplorerSelectionPaths, + type FileExplorerSelectionMode } from './file-explorer-selection' type UseFileExplorerSelectionResult = { @@ -22,6 +23,7 @@ type UseFileExplorerSelectionResult = { event: React.MouseEvent, onReplaceClick: (node: TreeNode) => void ) => void + moveSelection: (targetPath: string, mode: FileExplorerSelectionMode) => void preserveSelectionForContextMenu: (node: TreeNode) => void copyPathsForNode: (node: TreeNode, pathKind: 'absolute' | 'relative') => void } @@ -63,6 +65,11 @@ export function useFileExplorerSelection( }) }, []) + const moveSelection = useCallback((targetPath: string, mode: FileExplorerSelectionMode) => { + const orderedPaths = rowProjectionRef.current.getOrderedPaths() + setSelectionState((prev) => updateFileExplorerSelection(prev, orderedPaths, targetPath, mode)) + }, []) + const selectRowWithModifiers = useCallback( ( node: TreeNode, @@ -119,6 +126,7 @@ export function useFileExplorerSelection( setSelectedPaths, resetSelection, selectRowWithModifiers, + moveSelection, preserveSelectionForContextMenu, copyPathsForNode }