@@ -19,49 +19,95 @@ import {
1919} from '@/components/ui/dropdown-menu' ;
2020import { cn } from '@/lib/utils' ;
2121
22- import type { FileManagerAction , FileManagerInternalNode } from './file-manager-types' ;
22+ import type { FileManagerAction , FileManagerInternalNode , FileNode } from './file-manager-types' ;
2323import type { FileManagerNodeDndHandlers } from './use-file-manager-dnd' ;
2424
2525import { FileManagerHighlightedName } from './file-manager-highlighted-name' ;
2626import { getFileTypeIcon } from './file-manager-icons' ;
2727import { 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 */
3434const SKIP_ROW_CLICK_ATTR = 'data-fm-skip-row-click' ;
3535const 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+ */
63109const 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
66112const buildVisibleActions = (
67113 actions : readonly FileManagerAction [ ] ,
@@ -71,24 +117,28 @@ const buildVisibleActions = (
71117const 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 />
0 commit comments