Add multi-select copy paths in explorer

Fixes #1411
This commit is contained in:
Neil 2026-05-15 18:48:16 -07:00 committed by GitHub
parent f4c67434f0
commit 43e3a5598b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 528 additions and 39 deletions

View File

@ -24,6 +24,7 @@ import { useFileExplorerImport } from './useFileExplorerImport'
import { useFileExplorerManualRefresh } from './useFileExplorerManualRefresh'
import { useFileExplorerTree } from './useFileExplorerTree'
import { useFileExplorerWatch } from './useFileExplorerWatch'
import { useFileExplorerSelection } from './useFileExplorerSelection'
function FileExplorerInner(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
@ -64,7 +65,6 @@ function FileExplorerInner(): React.JSX.Element {
} = useFileExplorerTree(worktreePath, expanded, activeWorktreeId)
const manualRefresh = useFileExplorerManualRefresh(refreshTree)
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [flashingPath, setFlashingPath] = useState<string | null>(null)
const [bgMenuOpen, setBgMenuOpen] = useState(false)
const [bgMenuPoint, setBgMenuPoint] = useState({ x: 0, y: 0 })
@ -74,6 +74,15 @@ function FileExplorerInner(): React.JSX.Element {
const flashTimeoutRef = useRef<number | null>(null)
const isMac = useMemo(() => navigator.userAgent.includes('Mac'), [])
const isWindows = useMemo(() => navigator.userAgent.includes('Windows'), [])
const {
selectedPath,
selectedPaths,
setSingleSelectedPath,
resetSelection,
selectRowWithModifiers,
preserveSelectionForContextMenu,
copyPathsForNode
} = useFileExplorerSelection(flatRows, isMac)
const clearFlashTimeout = useCallback(() => {
if (flashTimeoutRef.current !== null) {
@ -95,7 +104,7 @@ function FileExplorerInner(): React.JSX.Element {
closeFile,
refreshDir,
selectedPath,
setSelectedPath,
setSelectedPath: setSingleSelectedPath,
isMac,
isWindows
})
@ -128,10 +137,10 @@ function FileExplorerInner(): React.JSX.Element {
if (!worktreePath) {
return
}
setSelectedPath(null)
resetSelection()
resetAndLoad()
clearFileExplorerUndoHistory()
}, [worktreePath]) // eslint-disable-line react-hooks/exhaustive-deps
}, [worktreePath, resetSelection]) // eslint-disable-line react-hooks/exhaustive-deps
// Why: on app startup the file explorer loads before SSH providers are
// registered, so readDir fails for remote worktrees. When the SSH
@ -183,7 +192,7 @@ function FileExplorerInner(): React.JSX.Element {
dirCache,
setDirCache,
expanded,
setSelectedPath,
setSelectedPath: setSingleSelectedPath,
refreshDir,
refreshTree,
inlineInput,
@ -196,7 +205,7 @@ function FileExplorerInner(): React.JSX.Element {
activeWorktreeId,
refreshDir,
clearNativeDragState,
setSelectedPath
setSelectedPath: setSingleSelectedPath
})
const totalCount = flatRows.length + (inlineInputIndex >= 0 ? 1 : 0)
@ -229,7 +238,7 @@ function FileExplorerInner(): React.JSX.Element {
rowsByPath,
flatRows,
loadDir,
setSelectedPath,
setSelectedPath: setSingleSelectedPath,
setFlashingPath,
flashTimeoutRef,
virtualizer
@ -243,7 +252,7 @@ function FileExplorerInner(): React.JSX.Element {
openFiles,
rowsByPath,
flatRows,
setSelectedPath,
setSelectedPath: setSingleSelectedPath,
virtualizer
})
@ -258,6 +267,7 @@ function FileExplorerInner(): React.JSX.Element {
containerRef: explorerShellRef,
flatRows,
inlineInput,
selectedPaths,
selectedNode,
startRename,
requestDelete
@ -268,11 +278,16 @@ function FileExplorerInner(): React.JSX.Element {
openFile,
pinFile,
toggleDir,
setSelectedPath,
setSelectedPath: setSingleSelectedPath,
scrollRef
})
const handleDuplicate = useFileDuplicate({ activeWorktreeId, worktreePath, refreshDir })
const handleRowClick = useCallback(
(node: (typeof flatRows)[number], event: React.MouseEvent<HTMLButtonElement>) =>
selectRowWithModifiers(node, event, handleClick),
[handleClick, selectRowWithModifiers]
)
if (!worktreePath) {
return (
@ -357,13 +372,14 @@ function FileExplorerInner(): React.JSX.Element {
statusByRelativePath={statusByRelativePath}
expanded={expanded}
dirCache={dirCache}
selectedPath={selectedPath}
selectedPaths={selectedPaths}
activeFileId={activeFileId}
flashingPath={flashingPath}
deleteShortcutLabel={deleteShortcutLabel}
onClick={handleClick}
onClick={handleRowClick}
onDoubleClick={handleDoubleClick}
onSelectPath={setSelectedPath}
onContextMenuSelect={preserveSelectionForContextMenu}
onCopyPaths={copyPathsForNode}
onStartNew={startNew}
onStartRename={startRename}
onDuplicate={handleDuplicate}

View File

@ -201,9 +201,11 @@ type FileExplorerRowProps = {
deleteShortcutLabel: string
targetDir: string
targetDepth: number
onClick: () => void
selectionSize: number
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
onDoubleClick: () => void
onSelect: () => void
onContextMenuSelect: () => void
onCopyPaths: (pathKind: 'absolute' | 'relative') => void
onStartNew: (type: 'file' | 'folder', dir: string, depth: number) => void
onStartRename: (node: TreeNode) => void
onDuplicate: (node: TreeNode) => void
@ -227,9 +229,11 @@ export function FileExplorerRow({
deleteShortcutLabel,
targetDir,
targetDepth,
selectionSize,
onClick,
onDoubleClick,
onSelect,
onContextMenuSelect,
onCopyPaths,
onStartNew,
onStartRename,
onDuplicate,
@ -282,8 +286,7 @@ export function FileExplorerRow({
onDrop={handleDrop}
onClick={onClick}
onDoubleClick={onDoubleClick}
onFocus={onSelect}
onContextMenu={onSelect}
onContextMenu={onContextMenuSelect}
>
{node.isDirectory ? (
<>
@ -344,14 +347,14 @@ export function FileExplorerRow({
New Folder
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => window.api.ui.writeClipboardText(node.path)}>
<ContextMenuItem onSelect={() => onCopyPaths('absolute')}>
<Copy />
Copy Path
{selectionSize > 1 ? 'Copy Paths' : 'Copy Path'}
<ContextMenuShortcut>{isMac ? '⌥⌘C' : 'Shift+Alt+C'}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={() => window.api.ui.writeClipboardText(node.relativePath)}>
<ContextMenuItem onSelect={() => onCopyPaths('relative')}>
<Copy />
Copy Relative Path
{selectionSize > 1 ? 'Copy Relative Paths' : 'Copy Relative Path'}
<ContextMenuShortcut>{isMac ? '⌥⇧⌘C' : 'Ctrl+Shift+Alt+C'}</ContextMenuShortcut>
</ContextMenuItem>
{!node.isDirectory && (

View File

@ -6,6 +6,7 @@ import type { GitFileStatus } from '../../../../shared/types'
import { FileExplorerRow, InlineInputRow, type InlineInput } from './FileExplorerRow'
import { STATUS_COLORS } from './status-display'
import type { DirCache, TreeNode } from './file-explorer-types'
import { countVisibleFileExplorerSelections } from './file-explorer-selection'
type FileExplorerVirtualRowsProps = {
virtualizer: Virtualizer<HTMLDivElement, Element>
@ -18,13 +19,14 @@ type FileExplorerVirtualRowsProps = {
statusByRelativePath: Map<string, GitFileStatus>
expanded: Set<string>
dirCache: Record<string, DirCache>
selectedPath: string | null
selectedPaths: Set<string>
activeFileId: string | null
flashingPath: string | null
deleteShortcutLabel: string
onClick: (node: TreeNode) => void
onClick: (node: TreeNode, event: React.MouseEvent<HTMLButtonElement>) => void
onDoubleClick: (node: TreeNode) => void
onSelectPath: (path: string) => void
onContextMenuSelect: (node: TreeNode) => void
onCopyPaths: (node: TreeNode, pathKind: 'absolute' | 'relative') => void
onStartNew: (type: 'file' | 'folder', parentPath: string, depth: number) => void
onStartRename: (node: TreeNode) => void
onDuplicate: (node: TreeNode) => void
@ -52,13 +54,14 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
statusByRelativePath,
expanded,
dirCache,
selectedPath,
selectedPaths,
activeFileId,
flashingPath,
deleteShortcutLabel,
onClick,
onDoubleClick,
onSelectPath,
onContextMenuSelect,
onCopyPaths,
onStartNew,
onStartRename,
onDuplicate,
@ -74,6 +77,8 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
nativeDropTargetDir
} = props
const visibleSelectionCount = countVisibleFileExplorerSelections(flatRows, selectedPaths)
return (
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((vItem) => {
@ -136,16 +141,18 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
node={n}
isExpanded={expanded.has(n.path)}
isLoading={n.isDirectory && Boolean(dirCache[n.path]?.loading)}
isSelected={selectedPath === n.path || activeFileId === n.path}
isSelected={selectedPaths.has(n.path) || activeFileId === n.path}
isFlashing={flashingPath === n.path}
nodeStatus={nodeStatus}
statusColor={nodeStatus ? STATUS_COLORS[nodeStatus] : null}
deleteShortcutLabel={deleteShortcutLabel}
targetDir={n.isDirectory ? n.path : dirname(n.path)}
targetDepth={n.isDirectory ? n.depth + 1 : n.depth}
onClick={() => onClick(n)}
selectionSize={selectedPaths.has(n.path) ? visibleSelectionCount : 1}
onClick={(event) => onClick(n, event)}
onDoubleClick={() => onDoubleClick(n)}
onSelect={() => onSelectPath(n.path)}
onContextMenuSelect={() => onContextMenuSelect(n)}
onCopyPaths={(pathKind) => onCopyPaths(n, pathKind)}
onStartNew={onStartNew}
onStartRename={onStartRename}
onDuplicate={onDuplicate}

View File

@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import type { TreeNode } from './file-explorer-types'
import {
countVisibleFileExplorerSelections,
createSingleFileExplorerSelection,
formatFileExplorerPathsForClipboard,
getFileExplorerActionNodes,
getFileExplorerSelectionMode,
updateFileExplorerSelection,
updateFileExplorerSelectionPaths
} from './file-explorer-selection'
function node(path: string, relativePath = path): TreeNode {
return {
name: path.split(/[\\/]/).at(-1) ?? path,
path,
relativePath,
isDirectory: false,
depth: 0
}
}
describe('file explorer selection', () => {
it('uses Ctrl for multi-selection on Windows and Linux', () => {
expect(
getFileExplorerSelectionMode({ ctrlKey: true, metaKey: false, shiftKey: false }, false)
).toBe('toggle')
expect(
getFileExplorerSelectionMode({ ctrlKey: true, metaKey: false, shiftKey: true }, false)
).toBe('additive-range')
})
it('uses Command for multi-selection on macOS', () => {
expect(
getFileExplorerSelectionMode({ ctrlKey: true, metaKey: false, shiftKey: false }, true)
).toBe('replace')
expect(
getFileExplorerSelectionMode({ ctrlKey: false, metaKey: true, shiftKey: false }, true)
).toBe('toggle')
})
it('selects a contiguous visible range from the anchor with Shift', () => {
const current = createSingleFileExplorerSelection('/repo/a.ts')
const next = updateFileExplorerSelection(
current,
['/repo/a.ts', '/repo/b.ts', '/repo/c.ts'],
'/repo/c.ts',
'range'
)
expect(Array.from(next.selectedPaths)).toEqual(['/repo/a.ts', '/repo/b.ts', '/repo/c.ts'])
expect(next.anchorPath).toBe('/repo/a.ts')
expect(next.activePath).toBe('/repo/c.ts')
})
it('toggles a selected path without losing the remaining visible selection', () => {
const current = updateFileExplorerSelection(
createSingleFileExplorerSelection('/repo/a.ts'),
['/repo/a.ts', '/repo/b.ts', '/repo/c.ts'],
'/repo/c.ts',
'range'
)
const next = updateFileExplorerSelection(
current,
['/repo/a.ts', '/repo/b.ts', '/repo/c.ts'],
'/repo/b.ts',
'toggle'
)
expect(Array.from(next.selectedPaths)).toEqual(['/repo/a.ts', '/repo/c.ts'])
expect(next.activePath).toBe('/repo/a.ts')
})
it('copies selected nodes in visible tree order', () => {
const rows = [
node('/repo/b.ts', 'b.ts'),
node('/repo/a.ts', 'a.ts'),
node('/repo/c.ts', 'c.ts')
]
const selectedPaths = new Set(['/repo/a.ts', '/repo/b.ts'])
const actionNodes = getFileExplorerActionNodes(rows, selectedPaths, rows[0])
expect(actionNodes.map((entry) => entry.path)).toEqual(['/repo/b.ts', '/repo/a.ts'])
expect(formatFileExplorerPathsForClipboard(actionNodes, 'absolute')).toBe(
'/repo/b.ts\n/repo/a.ts'
)
expect(formatFileExplorerPathsForClipboard(actionNodes, 'relative')).toBe('b.ts\na.ts')
})
it('copies only the context-clicked node when it is outside the current selection', () => {
const rows = [node('/repo/a.ts'), node('/repo/b.ts')]
const actionNodes = getFileExplorerActionNodes(rows, new Set(['/repo/a.ts']), rows[1])
expect(actionNodes.map((entry) => entry.path)).toEqual(['/repo/b.ts'])
})
it('applies legacy path cleanup across the selected set', () => {
const current = updateFileExplorerSelection(
createSingleFileExplorerSelection('/repo/a.ts'),
['/repo/a.ts', '/repo/b.ts', '/repo/c.ts'],
'/repo/c.ts',
'range'
)
const next = updateFileExplorerSelectionPaths(current, (path) =>
path === '/repo/b.ts' ? null : path
)
expect(Array.from(next.selectedPaths)).toEqual(['/repo/a.ts', '/repo/c.ts'])
expect(next.activePath).toBe('/repo/c.ts')
expect(next.anchorPath).toBe('/repo/a.ts')
})
it('does not scan rows for empty or single selection counts', () => {
const rows = new Proxy([node('/repo/a.ts')], {
get(target, prop, receiver) {
if (prop === 'reduce') {
throw new Error('unexpected row scan')
}
return Reflect.get(target, prop, receiver)
}
})
expect(countVisibleFileExplorerSelections(rows, new Set())).toBe(0)
expect(countVisibleFileExplorerSelections(rows, new Set(['/repo/a.ts']))).toBe(1)
})
})

View File

@ -0,0 +1,200 @@
import type { TreeNode } from './file-explorer-types'
export type FileExplorerSelectionState = {
activePath: string | null
anchorPath: string | null
selectedPaths: Set<string>
}
export type FileExplorerSelectionMode = 'replace' | 'toggle' | 'range' | 'additive-range'
export type FileExplorerSelectionModifiers = {
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
}
export function createEmptyFileExplorerSelection(): FileExplorerSelectionState {
return {
activePath: null,
anchorPath: null,
selectedPaths: new Set()
}
}
export function createSingleFileExplorerSelection(path: string | null): FileExplorerSelectionState {
return {
activePath: path,
anchorPath: path,
selectedPaths: path ? new Set([path]) : new Set()
}
}
export function getFileExplorerSelectionMode(
modifiers: FileExplorerSelectionModifiers,
isMac: boolean
): FileExplorerSelectionMode {
const hasToggleModifier = isMac ? modifiers.metaKey : modifiers.ctrlKey
if (modifiers.shiftKey && hasToggleModifier) {
return 'additive-range'
}
if (modifiers.shiftKey) {
return 'range'
}
if (hasToggleModifier) {
return 'toggle'
}
return 'replace'
}
function firstSelectedPathInTreeOrder(
selectedPaths: Set<string>,
orderedPaths: readonly string[]
): string | null {
return orderedPaths.find((path) => selectedPaths.has(path)) ?? null
}
function getRangePaths(
orderedPaths: readonly string[],
anchorPath: string | null,
targetPath: string
): string[] {
const targetIndex = orderedPaths.indexOf(targetPath)
const anchorIndex = anchorPath ? orderedPaths.indexOf(anchorPath) : -1
if (targetIndex === -1 || anchorIndex === -1) {
return [targetPath]
}
const start = Math.min(anchorIndex, targetIndex)
const end = Math.max(anchorIndex, targetIndex)
return orderedPaths.slice(start, end + 1)
}
export function updateFileExplorerSelection(
current: FileExplorerSelectionState,
orderedPaths: readonly string[],
targetPath: string,
mode: FileExplorerSelectionMode
): FileExplorerSelectionState {
if (mode === 'replace') {
return createSingleFileExplorerSelection(targetPath)
}
if (mode === 'toggle') {
const selectedPaths = new Set(current.selectedPaths)
if (selectedPaths.has(targetPath)) {
selectedPaths.delete(targetPath)
} else {
selectedPaths.add(targetPath)
}
const activePath = selectedPaths.has(targetPath)
? targetPath
: firstSelectedPathInTreeOrder(selectedPaths, orderedPaths)
return {
activePath,
anchorPath: activePath,
selectedPaths
}
}
const rangeAnchor =
current.anchorPath && orderedPaths.includes(current.anchorPath)
? current.anchorPath
: targetPath
const rangePaths = getRangePaths(orderedPaths, rangeAnchor, targetPath)
const selectedPaths =
mode === 'additive-range' ? new Set(current.selectedPaths) : new Set<string>()
for (const path of rangePaths) {
selectedPaths.add(path)
}
return {
activePath: targetPath,
anchorPath: rangeAnchor,
selectedPaths
}
}
export function updateFileExplorerSelectionPaths(
current: FileExplorerSelectionState,
updatePath: (path: string) => string | null
): FileExplorerSelectionState {
const updatedPathByPath = new Map<string, string | null>()
const getUpdatedPath = (path: string | null): string | null => {
if (path === null) {
return null
}
if (!updatedPathByPath.has(path)) {
updatedPathByPath.set(path, updatePath(path))
}
return updatedPathByPath.get(path) ?? null
}
let changed = false
const selectedPaths = new Set<string>()
for (const path of current.selectedPaths) {
const nextPath = getUpdatedPath(path)
if (nextPath !== path) {
changed = true
}
if (nextPath !== null) {
selectedPaths.add(nextPath)
}
}
const updatedActivePath = getUpdatedPath(current.activePath)
const activePath =
updatedActivePath && selectedPaths.has(updatedActivePath)
? updatedActivePath
: (selectedPaths.values().next().value ?? null)
const updatedAnchorPath = getUpdatedPath(current.anchorPath)
const anchorPath =
updatedAnchorPath && selectedPaths.has(updatedAnchorPath) ? updatedAnchorPath : activePath
if (
!changed &&
activePath === current.activePath &&
anchorPath === current.anchorPath &&
selectedPaths.size === current.selectedPaths.size
) {
return current
}
return {
activePath,
anchorPath,
selectedPaths
}
}
export function getFileExplorerActionNodes(
flatRows: readonly TreeNode[],
selectedPaths: Set<string>,
fallbackNode: TreeNode
): TreeNode[] {
if (!selectedPaths.has(fallbackNode.path)) {
return [fallbackNode]
}
const selectedNodes = flatRows.filter((node) => selectedPaths.has(node.path))
return selectedNodes.length > 0 ? selectedNodes : [fallbackNode]
}
export function countVisibleFileExplorerSelections(
flatRows: readonly TreeNode[],
selectedPaths: Set<string>
): number {
if (selectedPaths.size <= 1) {
return selectedPaths.size
}
return flatRows.reduce((count, node) => count + (selectedPaths.has(node.path) ? 1 : 0), 0)
}
export function formatFileExplorerPathsForClipboard(
nodes: readonly TreeNode[],
pathKind: 'absolute' | 'relative'
): string {
return nodes.map((node) => (pathKind === 'absolute' ? node.path : node.relativePath)).join('\n')
}

View File

@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { useAppStore } from '@/store'
import type { InlineInput } from './FileExplorerRow'
import type { TreeNode } from './file-explorer-types'
import { formatFileExplorerPathsForClipboard } from './file-explorer-selection'
import {
fileExplorerHasRedo,
fileExplorerHasUndo,
@ -34,6 +35,18 @@ function isCmdZUndo(e: KeyboardEvent): boolean {
return e.code === 'KeyZ' || e.key.toLowerCase() === 'z'
}
function isCopyRelativePathShortcut(e: KeyboardEvent): boolean {
return e.code === 'KeyC' && e.altKey && e.shiftKey && (isMac ? e.metaKey : e.ctrlKey)
}
function isCopyPathShortcut(e: KeyboardEvent): boolean {
return (
e.code === 'KeyC' &&
e.altKey &&
((isMac && e.metaKey && !e.shiftKey) || (!isMac && e.shiftKey && !e.ctrlKey))
)
}
/**
* Keyboard shortcuts for the file explorer.
*
@ -44,6 +57,7 @@ export function useFileExplorerKeys(opts: {
containerRef: React.RefObject<HTMLDivElement | null>
flatRows: TreeNode[]
inlineInput: InlineInput | null
selectedPaths: Set<string>
selectedNode: TreeNode | null
startRename: (node: TreeNode) => void
requestDelete: (node: TreeNode) => void
@ -55,6 +69,8 @@ export function useFileExplorerKeys(opts: {
flatRowsRef.current = opts.flatRows
const inlineInputRef = useRef(opts.inlineInput)
inlineInputRef.current = opts.inlineInput
const selectedPathsRef = useRef(opts.selectedPaths)
selectedPathsRef.current = opts.selectedPaths
const selectedNodeRef = useRef(opts.selectedNode)
selectedNodeRef.current = opts.selectedNode
const startRenameRef = useRef(opts.startRename)
@ -144,24 +160,34 @@ export function useFileExplorerKeys(opts: {
if (!focusInExplorer()) {
return
}
const node = selectedNodeRef.current
if (!node) {
const wantsCopyRelativePath = isCopyRelativePathShortcut(e)
const wantsCopyPath = isCopyPathShortcut(e)
if (!wantsCopyRelativePath && !wantsCopyPath) {
return
}
const node = selectedNodeRef.current ?? findFocusedNode()
const selectedNodes = flatRowsRef.current.filter((row) =>
selectedPathsRef.current.has(row.path)
)
const fallbackNodes = selectedNodes.length > 0 ? selectedNodes : node ? [node] : []
if (fallbackNodes.length === 0) {
return
}
// ⌥⇧⌘C (Mac) / Ctrl+Shift+Alt+C (Win) — Copy Relative Path
if (e.code === 'KeyC' && e.altKey && e.shiftKey && (isMac ? e.metaKey : e.ctrlKey)) {
if (wantsCopyRelativePath) {
e.preventDefault()
window.api.ui.writeClipboardText(node.relativePath)
window.api.ui.writeClipboardText(
formatFileExplorerPathsForClipboard(fallbackNodes, 'relative')
)
return
}
// ⌥⌘C (Mac) / Shift+Alt+C (Win) — Copy Path
if (
e.code === 'KeyC' &&
e.altKey &&
((isMac && e.metaKey && !e.shiftKey) || (!isMac && e.shiftKey && !e.ctrlKey))
) {
if (wantsCopyPath) {
e.preventDefault()
window.api.ui.writeClipboardText(node.path)
window.api.ui.writeClipboardText(
formatFileExplorerPathsForClipboard(fallbackNodes, 'absolute')
)
}
}

View File

@ -0,0 +1,111 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import type React from 'react'
import type { TreeNode } from './file-explorer-types'
import {
createEmptyFileExplorerSelection,
createSingleFileExplorerSelection,
formatFileExplorerPathsForClipboard,
getFileExplorerActionNodes,
getFileExplorerSelectionMode,
updateFileExplorerSelection,
updateFileExplorerSelectionPaths
} from './file-explorer-selection'
type UseFileExplorerSelectionResult = {
selectedPath: string | null
selectedPaths: Set<string>
setSingleSelectedPath: React.Dispatch<React.SetStateAction<string | null>>
resetSelection: () => void
selectRowWithModifiers: (
node: TreeNode,
event: React.MouseEvent<HTMLButtonElement>,
onReplaceClick: (node: TreeNode) => void
) => void
preserveSelectionForContextMenu: (node: TreeNode) => void
copyPathsForNode: (node: TreeNode, pathKind: 'absolute' | 'relative') => void
}
export function useFileExplorerSelection(
flatRows: TreeNode[],
isMac: boolean
): UseFileExplorerSelectionResult {
const [selectionState, setSelectionState] = useState(createEmptyFileExplorerSelection)
const selectionStateRef = useRef(selectionState)
const flatRowsRef = useRef(flatRows)
selectionStateRef.current = selectionState
flatRowsRef.current = flatRows
const orderedPaths = useMemo(() => flatRows.map((row) => row.path), [flatRows])
const setSingleSelectedPath = useCallback((value: React.SetStateAction<string | null>) => {
setSelectionState((prev) => {
if (typeof value === 'function') {
// Why: legacy watcher cleanup still speaks in single-path updater terms;
// apply it across the whole selected set so stale multi-selections converge.
return updateFileExplorerSelectionPaths(prev, value)
}
const nextPath = value
return createSingleFileExplorerSelection(nextPath)
})
}, [])
const resetSelection = useCallback(() => {
setSelectionState(createEmptyFileExplorerSelection())
}, [])
const selectRowWithModifiers = useCallback(
(
node: TreeNode,
event: React.MouseEvent<HTMLButtonElement>,
onReplaceClick: (node: TreeNode) => void
) => {
const selectionMode = getFileExplorerSelectionMode(
{
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey
},
isMac
)
if (selectionMode === 'replace') {
onReplaceClick(node)
return
}
setSelectionState((prev) =>
updateFileExplorerSelection(prev, orderedPaths, node.path, selectionMode)
)
},
[isMac, orderedPaths]
)
const preserveSelectionForContextMenu = useCallback((node: TreeNode) => {
// Why: right-clicking an existing multi-selection should keep the copy
// target set; right-clicking outside it should behave like a single item.
setSelectionState((prev) =>
prev.selectedPaths.has(node.path) ? prev : createSingleFileExplorerSelection(node.path)
)
}, [])
const copyPathsForNode = useCallback((node: TreeNode, pathKind: 'absolute' | 'relative') => {
const actionNodes = getFileExplorerActionNodes(
flatRowsRef.current,
selectionStateRef.current.selectedPaths,
node
)
void window.api.ui.writeClipboardText(
formatFileExplorerPathsForClipboard(actionNodes, pathKind)
)
}, [])
return {
selectedPath: selectionState.activePath,
selectedPaths: selectionState.selectedPaths,
setSingleSelectedPath,
resetSelection,
selectRowWithModifiers,
preserveSelectionForContextMenu,
copyPathsForNode
}
}