parent
f4c67434f0
commit
43e3a5598b
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue