feat(file-explorer): keyboard navigation (arrows, Home/End, PageUp/Down) (#4726)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-06-05 15:59:56 -07:00
committed by GitHub
co-authored by Orca
parent b65fcf6966
commit a1eaa4592f
7 changed files with 640 additions and 20 deletions
@@ -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.
@@ -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' })
})
})
})
@@ -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<NavigationKey, true> = {
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
}
@@ -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()
})
})
@@ -11,6 +11,8 @@ export type FileExplorerRowProjection = {
getRowsByPaths: (paths: Set<string>) => TreeNode[]
countVisiblePaths: (paths: Set<string>) => 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<string, TreeNode>,
@@ -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<string>
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<HTMLElement>(
`[data-index="${index}"]`
)
const button = wrapper?.querySelector<HTMLButtonElement>('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) {
@@ -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<HTMLButtonElement>,
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
}