mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 16:02:37 +00:00
feat(file-explorer): keyboard navigation (arrows, Home/End, PageUp/Down) (#4726)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user