Skip to content

Commit 268732f

Browse files
sirozhacursoragent
andcommitted
feat(file-manager): add subtree selection, open gesture, and parent-dir drop forwarding
- Folder rows now surface a tri-state checkbox derived from descendant selection; clicking / shift-clicking / toggling a folder flips the entire subtree in one gesture, including descendants of a collapsed folder. - Files get a new onOpen prop, fired on double-click and Enter; directories keep expanding/collapsing. The resources page wires it to the existing download flow. - Drop on a file row forwards to its parent folder so the whole folder acts as a single drop zone (Finder/Explorer semantics), with a shared drag-enter counter so highlight stays stable when moving the cursor between sibling rows. - Extract pure data layer into useFileManagerData and pure selection reducers (computeRowClickSelection / computeToggleSelection / computeToggleSelectAll / computeDirSelectionState) into file-manager-utils; bundle per-tree row props into display / handlers so memoized rows stay reference-stable across parent re-renders, keyboard expansion, and selection changes. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ba14913 commit 268732f

12 files changed

Lines changed: 1690 additions & 233 deletions

frontend/src/components/file-manager/file-manager-row.tsx

Lines changed: 116 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,49 +19,95 @@ import {
1919
} from '@/components/ui/dropdown-menu';
2020
import { cn } from '@/lib/utils';
2121

22-
import type { FileManagerAction, FileManagerInternalNode } from './file-manager-types';
22+
import type { FileManagerAction, FileManagerInternalNode, FileNode } from './file-manager-types';
2323
import type { FileManagerNodeDndHandlers } from './use-file-manager-dnd';
2424

2525
import { FileManagerHighlightedName } from './file-manager-highlighted-name';
2626
import { getFileTypeIcon } from './file-manager-icons';
2727
import { formatModified as defaultFormatModified, formatFileSize } from './file-manager-utils';
2828

2929
/**
30-
* Marker on every interactive child of the row that should NOT bubble into a row click.
31-
* Detected via `closest()` in the row's click handler — descendants don't need to call
32-
* `event.stopPropagation()` themselves.
30+
* Marker on every interactive child of the row that should NOT bubble into a row
31+
* click or double-click. Detected via `closest()` in the row's handlers — descendants
32+
* don't need to call `event.stopPropagation()` themselves.
3333
*/
3434
const SKIP_ROW_CLICK_ATTR = 'data-fm-skip-row-click';
3535
const skipRowClickProps = { [SKIP_ROW_CLICK_ATTR]: '' };
3636

37-
interface FileManagerRowProps {
38-
actions: readonly FileManagerAction[];
39-
activeRowPath: null | string;
40-
/** Drag/drop handlers for this row. `null` when intra-tree DnD is disabled. */
41-
dnd: FileManagerNodeDndHandlers | null;
42-
file: FileManagerInternalNode;
37+
/**
38+
* Layout/visibility/i18n props that are identical for every row in the tree.
39+
* `FileManager` builds this object once with `useMemo` so memoized rows do not
40+
* have to compare seven separate primitives on every parent re-render — a single
41+
* reference check is enough.
42+
*/
43+
export interface FileManagerRowDisplay {
4344
formatModified?: (modifiedAt: Date | string | undefined) => string;
4445
gridTemplate: string;
4546
hasActions: boolean;
4647
isCheckboxVisible: boolean;
47-
isExpanded: boolean;
4848
isModifiedVisible: boolean;
49-
isSelected: boolean;
5049
isSizeVisible: boolean;
51-
onClick: (event: ReactMouseEvent, path: string) => void;
50+
searchQuery?: string;
51+
}
52+
53+
/**
54+
* Stable callback bundle shared by every row. All handlers are produced by
55+
* hooks that go through the latest-ref pattern, so this object is built once
56+
* and never invalidates the row memo.
57+
*/
58+
export interface FileManagerRowHandlers {
59+
onClick: (event: ReactMouseEvent, path: string, subtreePaths?: readonly string[]) => void;
5260
onFocusRow: (path: string) => void;
53-
onToggleCheckbox: (path: string) => void;
61+
onOpen?: (file: FileNode) => void;
5462
onToggleExpand: (path: string, wasExpanded: boolean) => void;
63+
/**
64+
* Polymorphic selection toggle: file rows pass just `path`, directory rows
65+
* pass the precomputed subtree so the whole branch flips in one gesture.
66+
*/
67+
onToggleSelection: (path: string, subtreePaths?: readonly string[]) => void;
68+
}
69+
70+
interface FileManagerRowProps {
71+
actions: readonly FileManagerAction[];
72+
activeRowPath: null | string;
73+
/**
74+
* Tri-state checkbox value for directory rows (`true`, `false`, `'indeterminate'`).
75+
* `undefined` for file rows — files fall back to `isSelected`.
76+
*/
77+
dirCheckboxState?: 'indeterminate' | boolean;
78+
/**
79+
* Pre-computed list of every selectable path in the directory's subtree
80+
* (the directory itself plus all descendants). `undefined` for files.
81+
* Captured by the directory-checkbox click handler so a single gesture
82+
* flips the entire branch.
83+
*/
84+
dirSubtreePaths?: readonly string[];
85+
/** Per-tree shared layout / i18n bundle (one stable reference). */
86+
display: FileManagerRowDisplay;
87+
/** Drag/drop handlers for this row. `null` when intra-tree DnD is disabled. */
88+
dnd: FileManagerNodeDndHandlers | null;
89+
file: FileManagerInternalNode;
90+
/** Per-tree shared callback bundle (one stable reference). */
91+
handlers: FileManagerRowHandlers;
92+
isExpanded: boolean;
93+
isSelected: boolean;
5594
/** 1-based position of the row inside its parent's child list (for `aria-posinset`). */
5695
posInSet: number;
57-
searchQuery?: string;
5896
/** Total number of siblings the row is part of (for `aria-setsize`). */
5997
setSize: number;
6098
}
6199

62-
/** Returns `true` when the click originated from an element opted-out of row activation. */
100+
/**
101+
* Returns `true` when the click originated from an element opted-out of row activation.
102+
*
103+
* `Element` (not `HTMLElement`) is the correct guard: `<svg>` and its children
104+
* (`<path>` etc.) are `SVGElement`s, which do NOT extend `HTMLElement` even
105+
* though they share the `Element.closest()` API. Using `HTMLElement` here would
106+
* make a click on the actual painted pixels of an icon (chevron, action button
107+
* icon, …) bypass the skip-marker check and re-trigger row selection.
108+
*/
63109
const isClickInsideSkipZone = (target: EventTarget | null): boolean =>
64-
target instanceof HTMLElement && !!target.closest(`[${SKIP_ROW_CLICK_ATTR}]`);
110+
target instanceof Element && !!target.closest(`[${SKIP_ROW_CLICK_ATTR}]`);
65111

66112
const buildVisibleActions = (
67113
actions: readonly FileManagerAction[],
@@ -71,24 +117,28 @@ const buildVisibleActions = (
71117
const FileManagerRowImpl = ({
72118
actions,
73119
activeRowPath,
120+
dirCheckboxState,
121+
dirSubtreePaths,
122+
display,
74123
dnd,
75124
file,
76-
formatModified = defaultFormatModified,
77-
gridTemplate,
78-
hasActions,
79-
isCheckboxVisible,
125+
handlers,
80126
isExpanded,
81-
isModifiedVisible,
82127
isSelected,
83-
isSizeVisible,
84-
onClick,
85-
onFocusRow,
86-
onToggleCheckbox,
87-
onToggleExpand,
88128
posInSet,
89-
searchQuery,
90129
setSize,
91130
}: FileManagerRowProps) => {
131+
const {
132+
formatModified = defaultFormatModified,
133+
gridTemplate,
134+
hasActions,
135+
isCheckboxVisible,
136+
isModifiedVisible,
137+
isSizeVisible,
138+
searchQuery,
139+
} = display;
140+
const { onClick, onFocusRow, onOpen, onToggleExpand, onToggleSelection } = handlers;
141+
92142
const { icon: Icon, tone } = useMemo(
93143
() =>
94144
file.groupIcon
@@ -104,11 +154,34 @@ const FileManagerRowImpl = ({
104154
return;
105155
}
106156

157+
// Hand the precomputed subtree paths to the selection hook for directory
158+
// rows: a plain or `Cmd`/`Ctrl`+click on a folder then operates on the
159+
// entire branch — including descendants of a collapsed folder — instead
160+
// of just the folder's own path.
161+
onClick(event, file.path, file.isDir ? dirSubtreePaths : undefined);
162+
};
163+
164+
// Double-click is the row's "open" gesture. For directories it expands or
165+
// collapses (decoupling expansion from the single click keeps `Shift`/`Cmd`+click
166+
// pure selection gestures and matches Finder/Explorer). For files it forwards
167+
// to `onOpen` — typically wired to download / preview / open-in-tab. The
168+
// chevron icon and arrow keys remain alternative ways to expand without selecting.
169+
const handleRowDoubleClick = (event: ReactMouseEvent) => {
170+
if (isClickInsideSkipZone(event.target)) {
171+
return;
172+
}
173+
107174
if (file.isDir) {
175+
event.preventDefault();
108176
onToggleExpand(file.path, isExpanded);
177+
178+
return;
109179
}
110180

111-
onClick(event, file.path);
181+
if (onOpen) {
182+
event.preventDefault();
183+
onOpen(file);
184+
}
112185
};
113186

114187
const renderActionItem = (
@@ -189,6 +262,9 @@ const FileManagerRowImpl = ({
189262
className={cn(
190263
'group hover:bg-accent grid cursor-pointer items-center gap-3 px-3 py-1.5 transition-colors outline-none',
191264
'focus-visible:bg-muted/70 focus-visible:ring-ring focus-visible:ring-1',
265+
// `select-none` keeps double-click reserved for expand/collapse
266+
// — without it the browser would highlight the row's text on dblclick.
267+
'select-none',
192268
isSelected && 'bg-muted',
193269
isDropTarget && 'bg-primary/10 ring-primary/40 ring-1 ring-inset',
194270
// Ghost every row that's part of the in-flight drag (the grabbed row
@@ -205,6 +281,7 @@ const FileManagerRowImpl = ({
205281
data-path={file.path}
206282
draggable={isDraggable}
207283
onClick={handleRowClick}
284+
onDoubleClick={handleRowDoubleClick}
208285
onDragEnd={dnd?.onDragEnd}
209286
onDragEnter={dnd?.onDragEnter}
210287
onDragLeave={dnd?.onDragLeave}
@@ -223,8 +300,15 @@ const FileManagerRowImpl = ({
223300
>
224301
<Checkbox
225302
aria-label={`Select ${file.name}`}
226-
checked={isSelected}
227-
onCheckedChange={() => onToggleCheckbox(file.path)}
303+
// Directories surface a tri-state value derived from their
304+
// descendants; files (and edge cases without a precomputed
305+
// value) fall back to the row's own selection flag.
306+
checked={file.isDir ? (dirCheckboxState ?? isSelected) : isSelected}
307+
// For folders we hand the precomputed subtree to the
308+
// selection hook so one gesture flips the entire branch
309+
// (the directory itself + every descendant); files just
310+
// toggle their own path.
311+
onCheckedChange={() => onToggleSelection(file.path, file.isDir ? dirSubtreePaths : undefined)}
228312
/>
229313
</span>
230314
) : (
@@ -238,7 +322,7 @@ const FileManagerRowImpl = ({
238322
{Array.from({ length: file.depth }, (_, i) => (
239323
<span
240324
aria-hidden="true"
241-
className="bg-border pointer-events-none absolute -inset-y-1.5 w-px"
325+
className="bg-border pointer-events-none absolute -inset-y-1.75 w-px"
242326
key={i}
243327
style={{ left: `${i * 16 + 6}px` }}
244328
/>

frontend/src/components/file-manager/file-manager-tree-node.tsx

Lines changed: 31 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Fragment, type MouseEvent as ReactMouseEvent } from 'react';
1+
import { Fragment } from 'react';
22

3+
import type { FileManagerRowDisplay, FileManagerRowHandlers } from './file-manager-row';
34
import type { FileManagerAction, FileManagerInternalNode } from './file-manager-types';
45
import type { FileManagerNodeDndHandlers } from './use-file-manager-dnd';
56

@@ -10,22 +11,19 @@ interface FileManagerTreeNodeProps {
1011
activeRowPath: null | string;
1112
/** Returns drag/drop handlers for a node, or `null` when DnD is disabled. */
1213
bindNodeDnd: (node: FileManagerInternalNode) => FileManagerNodeDndHandlers | null;
14+
/** Pre-computed tri-state value per directory path; missing entries default to `false`. */
15+
dirSelectionStates: ReadonlyMap<string, 'indeterminate' | boolean>;
16+
/** Pre-computed subtree paths per directory path (the dir itself + descendants). */
17+
dirSubtreePaths: ReadonlyMap<string, readonly string[]>;
18+
/** Per-tree shared layout / i18n bundle. Forwarded as-is to every row. */
19+
display: FileManagerRowDisplay;
1320
expandedPaths: Set<string>;
14-
formatModified?: (modifiedAt: Date | string | undefined) => string;
15-
gridTemplate: string;
16-
hasActions: boolean;
17-
isCheckboxVisible: boolean;
18-
isModifiedVisible: boolean;
19-
isSizeVisible: boolean;
21+
/** Per-tree shared callback bundle. Forwarded as-is to every row. */
22+
handlers: FileManagerRowHandlers;
2023
node: FileManagerInternalNode;
21-
onClick: (event: ReactMouseEvent, path: string) => void;
22-
onFocusRow: (path: string) => void;
23-
onToggleCheckbox: (path: string) => void;
24-
onToggleExpand: (path: string, wasExpanded: boolean) => void;
2524
/** 1-based position of the node inside its parent's child list (for `aria-posinset`). */
2625
posInSet: number;
27-
searchQuery?: string;
28-
selectedPaths: Set<string>;
26+
selectedPaths: ReadonlySet<string>;
2927
/** Total number of siblings the node is part of (for `aria-setsize`). */
3028
setSize: number;
3129
}
@@ -40,20 +38,13 @@ export const FileManagerTreeNode = ({
4038
actions,
4139
activeRowPath,
4240
bindNodeDnd,
41+
dirSelectionStates,
42+
dirSubtreePaths,
43+
display,
4344
expandedPaths,
44-
formatModified,
45-
gridTemplate,
46-
hasActions,
47-
isCheckboxVisible,
48-
isModifiedVisible,
49-
isSizeVisible,
45+
handlers,
5046
node,
51-
onClick,
52-
onFocusRow,
53-
onToggleCheckbox,
54-
onToggleExpand,
5547
posInSet,
56-
searchQuery,
5748
selectedPaths,
5849
setSize,
5950
}: FileManagerTreeNodeProps) => {
@@ -62,27 +53,28 @@ export const FileManagerTreeNode = ({
6253
const renderChildren = node.isDir && isExpanded && node.children.length > 0;
6354
const dnd = bindNodeDnd(node);
6455

56+
// Pre-resolve tri-state + subtree paths per row: keeping the lookup outside
57+
// the memoized `FileManagerRow` lets each row receive primitive/stable props
58+
// (`'indeterminate' | true | false | undefined` plus a `Map`-stored array
59+
// reference) so a selection change only re-renders the rows whose computed
60+
// state actually flipped.
61+
const dirCheckboxState = node.isDir ? (dirSelectionStates.get(node.path) ?? false) : undefined;
62+
const subtreePaths = node.isDir ? dirSubtreePaths.get(node.path) : undefined;
63+
6564
return (
6665
<Fragment>
6766
<FileManagerRow
6867
actions={actions}
6968
activeRowPath={activeRowPath}
69+
dirCheckboxState={dirCheckboxState}
70+
dirSubtreePaths={subtreePaths}
71+
display={display}
7072
dnd={dnd}
7173
file={node}
72-
formatModified={formatModified}
73-
gridTemplate={gridTemplate}
74-
hasActions={hasActions}
75-
isCheckboxVisible={isCheckboxVisible && !node.isGroupRoot}
74+
handlers={handlers}
7675
isExpanded={isExpanded}
77-
isModifiedVisible={isModifiedVisible}
7876
isSelected={isSelected}
79-
isSizeVisible={isSizeVisible}
80-
onClick={onClick}
81-
onFocusRow={onFocusRow}
82-
onToggleCheckbox={onToggleCheckbox}
83-
onToggleExpand={onToggleExpand}
8477
posInSet={posInSet}
85-
searchQuery={searchQuery}
8678
setSize={setSize}
8779
/>
8880
{renderChildren &&
@@ -91,21 +83,14 @@ export const FileManagerTreeNode = ({
9183
actions={actions}
9284
activeRowPath={activeRowPath}
9385
bindNodeDnd={bindNodeDnd}
86+
dirSelectionStates={dirSelectionStates}
87+
dirSubtreePaths={dirSubtreePaths}
88+
display={display}
9489
expandedPaths={expandedPaths}
95-
formatModified={formatModified}
96-
gridTemplate={gridTemplate}
97-
hasActions={hasActions}
98-
isCheckboxVisible={isCheckboxVisible}
99-
isModifiedVisible={isModifiedVisible}
100-
isSizeVisible={isSizeVisible}
90+
handlers={handlers}
10191
key={child.id}
10292
node={child}
103-
onClick={onClick}
104-
onFocusRow={onFocusRow}
105-
onToggleCheckbox={onToggleCheckbox}
106-
onToggleExpand={onToggleExpand}
10793
posInSet={index + 1}
108-
searchQuery={searchQuery}
10994
selectedPaths={selectedPaths}
11095
setSize={node.children.length}
11196
/>

frontend/src/components/file-manager/file-manager-types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,26 @@ export interface FileManagerProps {
100100
* the data (or rely on subscriptions) so the new positions become visible.
101101
*/
102102
onMoveItems?: (sources: FileNode[], destinationDir: string) => Promise<void> | void;
103+
/**
104+
* Fired when the user "opens" a *file* row via double-click or `Enter`. Directories
105+
* are not passed through this callback; they always toggle expand/collapse on
106+
* activation, mirroring Finder/Explorer semantics.
107+
*
108+
* Use it to wire downloads, in-app previews, or open-in-tab behavior.
109+
*/
110+
onOpen?: (file: FileNode) => void;
103111
/**
104112
* Fires whenever the multi-selection changes. Use it from selection-only
105113
* flows (e.g. resource pickers) where the parent owns its own confirm button
106114
* and needs to know which items are currently checked.
107115
*
108116
* The supplied callback is read through a ref, so it does not need to be
109117
* memoized — only meaningful selection changes will trigger it.
118+
*
119+
* The Set is owned by the manager and must be treated as read-only —
120+
* mutating it will desync the manager's internal state.
110121
*/
111-
onSelectionChange?: (selectedPaths: Set<string>) => void;
122+
onSelectionChange?: (selectedPaths: ReadonlySet<string>) => void;
112123
/** Synthetic top-level groups (e.g. Uploads / Container). When omitted, root is flat. */
113124
rootGroups?: FileManagerRootGroup[];
114125
/** Search query and matching empty state. Provide `query` to enable filtering. */

0 commit comments

Comments
 (0)