From 43e3a5598bbac173ace57bf2000b99674824ed76 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 15 May 2026 18:48:16 -0700 Subject: [PATCH] Add multi-select copy paths in explorer Fixes #1411 --- .../components/right-sidebar/FileExplorer.tsx | 40 ++-- .../right-sidebar/FileExplorerRow.tsx | 21 +- .../right-sidebar/FileExplorerVirtualRows.tsx | 23 +- .../file-explorer-selection.test.ts | 126 +++++++++++ .../right-sidebar/file-explorer-selection.ts | 200 ++++++++++++++++++ .../right-sidebar/useFileExplorerKeys.ts | 46 +++- .../right-sidebar/useFileExplorerSelection.ts | 111 ++++++++++ 7 files changed, 528 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-selection.ts create mode 100644 src/renderer/src/components/right-sidebar/useFileExplorerSelection.ts diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index ecda45f33..ef6a70b5f 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -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(null) const [flashingPath, setFlashingPath] = useState(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(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) => + 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} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 910b0fcef..d407575b9 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -201,9 +201,11 @@ type FileExplorerRowProps = { deleteShortcutLabel: string targetDir: string targetDepth: number - onClick: () => void + selectionSize: number + onClick: (event: React.MouseEvent) => 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 - window.api.ui.writeClipboardText(node.path)}> + onCopyPaths('absolute')}> - Copy Path + {selectionSize > 1 ? 'Copy Paths' : 'Copy Path'} {isMac ? '⌥⌘C' : 'Shift+Alt+C'} - window.api.ui.writeClipboardText(node.relativePath)}> + onCopyPaths('relative')}> - Copy Relative Path + {selectionSize > 1 ? 'Copy Relative Paths' : 'Copy Relative Path'} {isMac ? '⌥⇧⌘C' : 'Ctrl+Shift+Alt+C'} {!node.isDirectory && ( diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx index 0b98d9886..3c73e961f 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx @@ -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 @@ -18,13 +19,14 @@ type FileExplorerVirtualRowsProps = { statusByRelativePath: Map expanded: Set dirCache: Record - selectedPath: string | null + selectedPaths: Set activeFileId: string | null flashingPath: string | null deleteShortcutLabel: string - onClick: (node: TreeNode) => void + onClick: (node: TreeNode, event: React.MouseEvent) => 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 (
{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} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts new file mode 100644 index 000000000..ba187d4e2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-selection.ts b/src/renderer/src/components/right-sidebar/file-explorer-selection.ts new file mode 100644 index 000000000..dfb002a69 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-selection.ts @@ -0,0 +1,200 @@ +import type { TreeNode } from './file-explorer-types' + +export type FileExplorerSelectionState = { + activePath: string | null + anchorPath: string | null + selectedPaths: Set +} + +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, + 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() + 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() + 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() + 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, + 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 +): 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') +} diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts b/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts index 29d89e388..1a583a852 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts @@ -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 flatRows: TreeNode[] inlineInput: InlineInput | null + selectedPaths: Set 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') + ) } } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerSelection.ts b/src/renderer/src/components/right-sidebar/useFileExplorerSelection.ts new file mode 100644 index 000000000..f56341f44 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerSelection.ts @@ -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 + setSingleSelectedPath: React.Dispatch> + resetSelection: () => void + selectRowWithModifiers: ( + node: TreeNode, + event: React.MouseEvent, + 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) => { + 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, + 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 + } +}