cursedbelt 3.0.2 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/react/components/ListToolbar.d.ts +94 -0
- package/dist/react/components/ListToolbar.d.ts.map +1 -0
- package/dist/react/components/ListToolbar.js +66 -0
- package/dist/react/components/ListToolbar.js.map +1 -0
- package/dist/react/file-tree/FileTree.d.ts +282 -3
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +799 -131
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/fileTree.css +151 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
- package/dist/react/file-tree/fileTreeSelection.js +264 -0
- package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
- package/dist/react/file-tree/index.d.ts +1 -0
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +8 -0
- package/dist/react/file-tree/index.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/form.d.ts +3 -0
- package/dist/react/lib/form.d.ts.map +1 -1
- package/dist/react/lib/form.js +43 -5
- package/dist/react/lib/form.js.map +1 -1
- package/dist/react/media/DenseGalleryShell.d.ts +19 -3
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +55 -15
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +14 -14
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +23 -0
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +67 -1
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/MediaResize.d.ts +51 -19
- package/dist/react/media/MediaResize.d.ts.map +1 -1
- package/dist/react/media/MediaResize.js +20 -13
- package/dist/react/media/MediaResize.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +67 -0
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
- package/dist/react/media/VideoChapterEditor.js +148 -0
- package/dist/react/media/VideoChapterEditor.js.map +1 -0
- package/dist/react/media/VideoPlayer.d.ts +46 -4
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +124 -11
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts +15 -2
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js +29 -2
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media/denseGallery.css +104 -46
- package/dist/react/media/hlsSource.d.ts +57 -0
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +177 -1
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/index.d.ts +2 -0
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +6 -0
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/media/mediaResize.css +127 -54
- package/dist/react/media/pictureEditor.css +52 -6
- package/dist/react/media/videoChapterEditor.css +203 -0
- package/dist/react/media/videoChapters.d.ts +117 -0
- package/dist/react/media/videoChapters.d.ts.map +1 -0
- package/dist/react/media/videoChapters.js +95 -0
- package/dist/react/media/videoChapters.js.map +1 -0
- package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
- package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +40 -9
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +484 -104
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
- package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
- package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
- package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
- package/dist/react/media-gallery/index.d.ts +2 -0
- package/dist/react/media-gallery/index.d.ts.map +1 -1
- package/dist/react/media-gallery/index.js +5 -0
- package/dist/react/media-gallery/index.js.map +1 -1
- package/dist/react/media-gallery/mediaGallery.css +180 -4
- package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
- package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
- package/dist/react/media-gallery/mediaMeta.js +56 -0
- package/dist/react/media-gallery/mediaMeta.js.map +1 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
- package/dist/react/media-gallery/playbackPreferences.js +42 -0
- package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +34 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +29 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
- package/dist/styles-areas/activity-bar.css +30 -0
- package/dist/styles-areas/analytics.css +672 -0
- package/dist/styles-areas/auth.css +154 -0
- package/dist/styles-areas/calendar.css +416 -0
- package/dist/styles-areas/chart-table.css +41 -0
- package/dist/styles-areas/charts.css +89 -0
- package/dist/styles-areas/chat.css +364 -0
- package/dist/styles-areas/clipboard.css +26 -0
- package/dist/styles-areas/code-editor.css +130 -0
- package/dist/styles-areas/companion-link.css +70 -0
- package/dist/styles-areas/core.css +2503 -0
- package/dist/styles-areas/dashboard-grid.css +84 -0
- package/dist/styles-areas/data-table.css +657 -0
- package/dist/styles-areas/deep-link.css +26 -0
- package/dist/styles-areas/diff-viewer.css +30 -0
- package/dist/styles-areas/disk-usage.css +262 -0
- package/dist/styles-areas/emoji.css +154 -0
- package/dist/styles-areas/fields.css +582 -0
- package/dist/styles-areas/file-tree.css +33 -0
- package/dist/styles-areas/filter-rail.css +235 -0
- package/dist/styles-areas/folder-tree.css +219 -0
- package/dist/styles-areas/keep-awake.css +26 -0
- package/dist/styles-areas/layout-engine.css +239 -0
- package/dist/styles-areas/markdown.css +87 -0
- package/dist/styles-areas/master-detail.css +239 -0
- package/dist/styles-areas/master-lock.css +26 -0
- package/dist/styles-areas/media-gallery.css +351 -0
- package/dist/styles-areas/media.css +658 -0
- package/dist/styles-areas/nav.css +488 -0
- package/dist/styles-areas/notifications.css +245 -0
- package/dist/styles-areas/overlays.css +133 -0
- package/dist/styles-areas/palette.css +140 -0
- package/dist/styles-areas/pdf-viewer.css +47 -0
- package/dist/styles-areas/problem-detail.css +97 -0
- package/dist/styles-areas/rich-text.css +167 -0
- package/dist/styles-areas/sharing.css +153 -0
- package/dist/styles-areas/spreadsheet.css +37 -0
- package/dist/styles-areas/stats.css +281 -0
- package/dist/styles-areas/test-report.css +133 -0
- package/dist/styles-areas/virtual.css +30 -0
- package/dist/styles-areas/wizard.css +381 -0
- package/dist/styles-areas/workbench.css +543 -0
- package/dist/styles-areas/workbook-viewer.css +44 -0
- package/dist/styles-static.css +34 -23
- package/dist/styles.css +34 -23
- package/package.json +130 -32
- package/scripts/checkDistExports.ts +67 -0
- package/scripts/cssRules.ts +125 -0
- package/scripts/fixtureAppCss.ts +344 -0
- package/scripts/generateAreaStyles.ts +253 -0
- package/scripts/guardrailsEnforce.spec.ts +62 -0
- package/scripts/guardrailsEnforce.ts +67 -3
- package/scripts/paths.ts +106 -0
- package/scripts/styleAreas.ts +585 -0
- package/scripts/verify.ts +5 -0
- package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
- package/src/docsMatchTheSplit.spec.ts +98 -0
- package/src/fixtureAppCss.spec.ts +177 -0
- package/src/namedSubpathsResolve.spec.ts +35 -0
- package/src/publishShape.spec.ts +67 -0
- package/src/react/components/ListToolbar.spec.tsx +172 -0
- package/src/react/components/ListToolbar.tsx +151 -0
- package/src/react/file-tree/FileTree.spec.tsx +1009 -0
- package/src/react/file-tree/FileTree.tsx +1459 -309
- package/src/react/file-tree/fileTree.css +151 -0
- package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
- package/src/react/file-tree/fileTreeSelection.ts +321 -0
- package/src/react/file-tree/index.ts +21 -0
- package/src/react/index.ts +1 -0
- package/src/react/lib/form.ts +51 -8
- package/src/react/markdown/MarkdownDoc.spec.tsx +1 -1
- package/src/react/media/DenseGalleryShell.tsx +108 -36
- package/src/react/media/FocusViewer.tsx +15 -8
- package/src/react/media/FrameGrabber.spec.tsx +175 -0
- package/src/react/media/FrameGrabber.tsx +67 -0
- package/src/react/media/MediaResize.spec.tsx +84 -14
- package/src/react/media/MediaResize.tsx +51 -19
- package/src/react/media/VideoChapterEditor.tsx +428 -0
- package/src/react/media/VideoPlayer.spec.tsx +111 -1
- package/src/react/media/VideoPlayer.tsx +158 -11
- package/src/react/media/VideoTurner.spec.tsx +67 -0
- package/src/react/media/VideoTurner.tsx +70 -15
- package/src/react/media/denseGallery.css +104 -46
- package/src/react/media/hlsSource.recovery.spec.ts +213 -0
- package/src/react/media/hlsSource.spec.ts +8 -0
- package/src/react/media/hlsSource.ts +189 -1
- package/src/react/media/index.ts +14 -0
- package/src/react/media/mediaResize.css +127 -54
- package/src/react/media/pictureEditor.css +52 -6
- package/src/react/media/videoChapterEditor.css +203 -0
- package/src/react/media/videoChapters.spec.ts +176 -0
- package/src/react/media/videoChapters.ts +181 -0
- package/src/react/media-gallery/GalleryTable.tsx +93 -3
- package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
- package/src/react/media-gallery/MediaGallery.tsx +813 -95
- package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
- package/src/react/media-gallery/galleryTable.spec.ts +41 -1
- package/src/react/media-gallery/index.ts +7 -0
- package/src/react/media-gallery/mediaGallery.css +180 -4
- package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
- package/src/react/media-gallery/mediaMeta.ts +101 -0
- package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
- package/src/react/media-gallery/playbackPreferences.ts +100 -0
- package/src/react/media-gallery/types.ts +34 -0
- package/src/shippedFilesAreTracked.spec.ts +69 -0
- package/src/styles-areas/activity-bar.css +30 -0
- package/src/styles-areas/analytics.css +672 -0
- package/src/styles-areas/auth.css +154 -0
- package/src/styles-areas/calendar.css +416 -0
- package/src/styles-areas/chart-table.css +41 -0
- package/src/styles-areas/charts.css +89 -0
- package/src/styles-areas/chat.css +364 -0
- package/src/styles-areas/clipboard.css +26 -0
- package/src/styles-areas/code-editor.css +130 -0
- package/src/styles-areas/companion-link.css +70 -0
- package/src/styles-areas/core.css +2503 -0
- package/src/styles-areas/dashboard-grid.css +84 -0
- package/src/styles-areas/data-table.css +657 -0
- package/src/styles-areas/deep-link.css +26 -0
- package/src/styles-areas/diff-viewer.css +30 -0
- package/src/styles-areas/disk-usage.css +262 -0
- package/src/styles-areas/emoji.css +154 -0
- package/src/styles-areas/fields.css +582 -0
- package/src/styles-areas/file-tree.css +33 -0
- package/src/styles-areas/filter-rail.css +235 -0
- package/src/styles-areas/folder-tree.css +219 -0
- package/src/styles-areas/keep-awake.css +26 -0
- package/src/styles-areas/layout-engine.css +239 -0
- package/src/styles-areas/markdown.css +87 -0
- package/src/styles-areas/master-detail.css +239 -0
- package/src/styles-areas/master-lock.css +26 -0
- package/src/styles-areas/media-gallery.css +351 -0
- package/src/styles-areas/media.css +658 -0
- package/src/styles-areas/nav.css +488 -0
- package/src/styles-areas/notifications.css +245 -0
- package/src/styles-areas/overlays.css +133 -0
- package/src/styles-areas/palette.css +140 -0
- package/src/styles-areas/pdf-viewer.css +47 -0
- package/src/styles-areas/problem-detail.css +97 -0
- package/src/styles-areas/rich-text.css +167 -0
- package/src/styles-areas/sharing.css +153 -0
- package/src/styles-areas/spreadsheet.css +37 -0
- package/src/styles-areas/stats.css +281 -0
- package/src/styles-areas/test-report.css +133 -0
- package/src/styles-areas/virtual.css +30 -0
- package/src/styles-areas/wizard.css +381 -0
- package/src/styles-areas/workbench.css +543 -0
- package/src/styles-areas/workbook-viewer.css +44 -0
- package/src/styles-static.css +34 -23
- package/src/styles.css +34 -23
- package/src/stylesAreas.spec.ts +247 -0
- package/src/stylesUtilitiesMatches.spec.ts +6 -77
- package/src/testFilesRunInParallel.spec.ts +79 -0
- package/src/typecheckCachesAreSeparate.spec.ts +128 -0
- package/src/verifyGraph.spec.ts +6 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createElement as _createElement } from "react";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
-
import { accessMarkCopy, describeDrop,
|
|
4
|
+
import { accessMarkCopy, describeDrop, filterFileTree, findFileTreeNode, flattenFileTree, matchTypeahead, resolveKeyboardMove, } from 'cursedbelt-core/file-tree/file-tree-model';
|
|
5
5
|
import { Input } from '../components/Input';
|
|
6
6
|
import { Tooltip } from '../components/Tooltip';
|
|
7
|
+
import { draggedIdsOf, nodesByIds, planWrap, refuseCopy, refuseWrap, resolveMultiDrop, selectionRange, toggleSelected, topmostSelected, } from './fileTreeSelection';
|
|
7
8
|
import './fileTree.css';
|
|
8
9
|
/** How far a press travels before it stops being a click and becomes a drag. */
|
|
9
10
|
const DRAG_THRESHOLD = 4;
|
|
@@ -22,6 +23,11 @@ const SPRING_OPEN_MS = 600;
|
|
|
22
23
|
/** How long a typeahead word survives a pause. One second is what every file explorer uses. */
|
|
23
24
|
const TYPEAHEAD_RESET_MS = 1000;
|
|
24
25
|
const NO_OFFER = { target: null, refused: false, reason: null };
|
|
26
|
+
/**
|
|
27
|
+
* One frozen empty list for "nothing is pinned", so a consumer that passes no `pinnedIds`
|
|
28
|
+
* hands the memos below a stable reference instead of a new `[]` every render.
|
|
29
|
+
*/
|
|
30
|
+
const NO_PINS = [];
|
|
25
31
|
/**
|
|
26
32
|
* The default marks — a consumer that wants kind-specific icons passes `renderIcon`.
|
|
27
33
|
*
|
|
@@ -62,9 +68,19 @@ function RowGlyph({ node, open, renderIcon, }) {
|
|
|
62
68
|
return icon;
|
|
63
69
|
return (_jsx("img", { className: 'cbft-thumb', src: src, alt: '', "aria-hidden": 'true', loading: 'lazy', decoding: 'async', draggable: false, onError: () => setFailed(src) }, src));
|
|
64
70
|
}
|
|
65
|
-
export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: collapsedProp, onCollapsedChange, onMove, allowReorder = false, lockedReason, canDrop, unavailableReason, renamingId, onRenameStart, onRename, onRenameCancel, onDelete, draft, onDraftCommit, onDraftCancel, onRowMenu, renderTools, renderIcon, renderAccessory, onDropExternal, acceptsExternalDrag, accessCopy, filter, empty = 'Nothing here yet.', ariaLabel = 'Files', className, }) {
|
|
71
|
+
export function FileTree({ nodes, selectedId, onSelect, selectedIds, onSelectionChange, onOpen, collapsed: collapsedProp, onCollapsedChange, expandOnReveal = false, onRevealExpand, pinnedIds, onPinnedChange, pinnedLabel = 'Pinned', onMove, onMoveMany, allowReorder = false, lockedReason, canDrop, unavailableReason, renamingId, onRenameStart, onRename, onRenameCancel, onDelete, onDeleteMany, onCopy, canCopy, draft, onDraftCommit, onDraftCancel, onWrapStart, canWrap, onRowMenu, renderTools, renderIcon, renderAccessory, onDropExternal, acceptsExternalDrag, accessCopy, filter, empty = 'Nothing here yet.', ariaLabel = 'Files', className, }) {
|
|
66
72
|
const scrollRef = useRef(null);
|
|
67
73
|
const rowRefs = useRef(new Map());
|
|
74
|
+
/**
|
|
75
|
+
* The PINNED copies' elements, in a map of their own.
|
|
76
|
+
*
|
|
77
|
+
* 🔴 The second half of "one identity, two row keys" (see the header). A pinned row is the
|
|
78
|
+
* same node drawn a second time, so registering both copies under `node.id` would make
|
|
79
|
+
* every `rowRefs.get(id)` a coin toss — the reveal would scroll to whichever won, the drag's
|
|
80
|
+
* hit test would measure the wrong box, and focus would land in the wrong section. `node.id`
|
|
81
|
+
* stays the identity; the COPY is addressed here.
|
|
82
|
+
*/
|
|
83
|
+
const pinnedRefs = useRef(new Map());
|
|
68
84
|
const autoScroll = useRef(null);
|
|
69
85
|
const spring = useRef(null);
|
|
70
86
|
/** Folders this drag opened by hovering, to shut again if it never drops. */
|
|
@@ -81,15 +97,77 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
81
97
|
* gesture; the click is debris from it.
|
|
82
98
|
*/
|
|
83
99
|
const draggedJustNow = useRef(false);
|
|
100
|
+
/**
|
|
101
|
+
* Where a shift-click measures FROM — the last row plain-clicked or ⌘-clicked.
|
|
102
|
+
*
|
|
103
|
+
* 🔴 A shift-click deliberately does not move it. That is what lets a second shift-click
|
|
104
|
+
* re-cover the range from the same start instead of growing it one row at a time, which is
|
|
105
|
+
* the behaviour every file explorer has and the reason the anchor is not just "the last
|
|
106
|
+
* thing selected".
|
|
107
|
+
*/
|
|
108
|
+
const anchor = useRef(null);
|
|
84
109
|
const [ownCollapsed, setOwnCollapsed] = useState([]);
|
|
85
|
-
|
|
110
|
+
/**
|
|
111
|
+
* What the CONSUMER holds — the value it persists, and the only one ever written back.
|
|
112
|
+
*
|
|
113
|
+
* 🔴 Everything below reads `collapsed` instead, which is this list minus whatever an
|
|
114
|
+
* expanding reveal opened. The two are the same list for every consumer that does not wire
|
|
115
|
+
* {@link FileTreeProps.expandOnReveal}, and keeping them apart is what lets the reveal open
|
|
116
|
+
* a folder without the open ever reaching storage — see `revealOpened`.
|
|
117
|
+
*/
|
|
118
|
+
const persistedCollapsed = collapsedProp ?? ownCollapsed;
|
|
86
119
|
const setCollapsed = useCallback((next) => {
|
|
87
120
|
if (collapsedProp === undefined)
|
|
88
121
|
setOwnCollapsed(next);
|
|
89
122
|
onCollapsedChange?.(next);
|
|
90
123
|
}, [collapsedProp, onCollapsedChange]);
|
|
124
|
+
/**
|
|
125
|
+
* The folders an expanding reveal opened — a TRANSIENT overlay, never written out.
|
|
126
|
+
*
|
|
127
|
+
* 🔴 This is the answer to the objection limit 1 of the reveal records, and it is a
|
|
128
|
+
* correctness answer rather than a taste one: `collapsed` is a CONTROLLED value the
|
|
129
|
+
* consumer persists, so a reveal that opened a folder by calling `onCollapsedChange` would
|
|
130
|
+
* write a collapse the person chose away for good — every later mount would find it open
|
|
131
|
+
* and nothing in the app would ever put it back. An overlay cannot do that. The consumer's
|
|
132
|
+
* list is untouched, the open lasts as long as this tree is mounted, and shutting the
|
|
133
|
+
* folder by hand drops the overlay entry and lets the persisted value decide again.
|
|
134
|
+
*
|
|
135
|
+
* The other half is where the writes go: every one of them below is a DELTA on
|
|
136
|
+
* `persistedCollapsed` (`shutFolders`/`openFolders`) rather than the effective list handed
|
|
137
|
+
* back whole. A write computed from the effective list would drop every other overlay id
|
|
138
|
+
* out of the consumer's value — the same corruption by a longer route.
|
|
139
|
+
*/
|
|
140
|
+
const [revealOpened, setRevealOpened] = useState([]);
|
|
141
|
+
/** Which folders are shut ON SCREEN: the consumer's list, minus what a reveal opened. */
|
|
142
|
+
const collapsed = useMemo(() => revealOpened.length === 0
|
|
143
|
+
? persistedCollapsed
|
|
144
|
+
: persistedCollapsed.filter((id) => !revealOpened.includes(id)), [persistedCollapsed, revealOpened]);
|
|
145
|
+
/**
|
|
146
|
+
* Shut folders — added to the consumer's list, and dropped from the reveal's overlay so the
|
|
147
|
+
* two cannot disagree about a folder somebody has just shut by hand.
|
|
148
|
+
*/
|
|
149
|
+
const shutFolders = useCallback((ids) => {
|
|
150
|
+
setRevealOpened((prev) => prev.some((id) => ids.includes(id)) ? prev.filter((id) => !ids.includes(id)) : prev);
|
|
151
|
+
const add = ids.filter((id) => !persistedCollapsed.includes(id));
|
|
152
|
+
if (add.length > 0)
|
|
153
|
+
setCollapsed([...persistedCollapsed, ...add]);
|
|
154
|
+
}, [persistedCollapsed, setCollapsed]);
|
|
155
|
+
/** Open folders — the same delta in the other direction. An explicit open IS persisted. */
|
|
156
|
+
const openFolders = useCallback((ids) => {
|
|
157
|
+
setRevealOpened((prev) => prev.some((id) => ids.includes(id)) ? prev.filter((id) => !ids.includes(id)) : prev);
|
|
158
|
+
if (!persistedCollapsed.some((id) => ids.includes(id)))
|
|
159
|
+
return;
|
|
160
|
+
setCollapsed(persistedCollapsed.filter((id) => !ids.includes(id)));
|
|
161
|
+
}, [persistedCollapsed, setCollapsed]);
|
|
91
162
|
const [drag, setDrag] = useState(null);
|
|
92
|
-
|
|
163
|
+
/**
|
|
164
|
+
* The row the keyboard is on, and WHICH COPY of it — the roving tabindex's answer.
|
|
165
|
+
*
|
|
166
|
+
* 🔴 The section is part of the answer, not decoration. A pinned row and its row in the
|
|
167
|
+
* folder share one `node.id`, so an id alone would make both copies claim `tabIndex={0}` and
|
|
168
|
+
* the tree would have two tab stops for one row. Two trees have two stops; one row does not.
|
|
169
|
+
*/
|
|
170
|
+
const [focused, setFocused] = useState(null);
|
|
93
171
|
/** The row an EXTERNAL drag is over, drawn with the same marker an internal one uses. */
|
|
94
172
|
const [externalOverId, setExternalOverId] = useState(null);
|
|
95
173
|
/**
|
|
@@ -101,12 +179,127 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
101
179
|
* reading of "broken" this tree's whole drop design exists to avoid.
|
|
102
180
|
*/
|
|
103
181
|
const [notice, setNotice] = useState('');
|
|
182
|
+
/**
|
|
183
|
+
* What ⌘X or ⌘C is holding, and which of the two it was.
|
|
184
|
+
*
|
|
185
|
+
* 🔴 The tree owns this, not the consumer. A clipboard is UI state with a VISIBLE
|
|
186
|
+
* consequence — the cut rows are drawn lifted (`data-cut`) until they land — and pushing it
|
|
187
|
+
* out as a prop would make the mark a thing every consumer has to remember to draw. What
|
|
188
|
+
* the consumer owns is the verb: `onMoveMany`/`onMove` for a cut, `onCopy` for a copy,
|
|
189
|
+
* reached only through the same drop rules a drag obeys.
|
|
190
|
+
*
|
|
191
|
+
* Cleared by a paste of a CUT (the rows have moved; a second paste would move them again
|
|
192
|
+
* from wherever they now are) and kept after a copy, which is what Finder does and what
|
|
193
|
+
* *"copying in another folder or the same folder"* needs.
|
|
194
|
+
*/
|
|
195
|
+
const [clipboard, setClipboard] = useState(null);
|
|
104
196
|
const shown = useMemo(() => (filter ? filterFileTree(nodes, filter) : nodes), [nodes, filter]);
|
|
105
197
|
/*
|
|
106
198
|
* A search shows everything it kept — a match hidden inside a collapsed folder is a match
|
|
107
199
|
* the person cannot see, which reads as "not found".
|
|
108
200
|
*/
|
|
109
201
|
const rows = useMemo(() => flattenFileTree(shown, filter ? [] : collapsed), [shown, collapsed, filter]);
|
|
202
|
+
const pinned = pinnedIds ?? NO_PINS;
|
|
203
|
+
/**
|
|
204
|
+
* The pinned section's rows — the same nodes, drawn a second time and drawn FLAT.
|
|
205
|
+
*
|
|
206
|
+
* Built off `flattenFileTree(shown, [])` rather than off `rows`, because a pinned row is
|
|
207
|
+
* very often inside a folder that is shut: the whole point of pinning it is to reach it
|
|
208
|
+
* without opening anything. So the section is derived from the tree rather than from what
|
|
209
|
+
* the tree is currently showing, and a pin the tree no longer holds — deleted elsewhere,
|
|
210
|
+
* narrowed away by `filter` — simply is not drawn. A ghost row that cannot be clicked is
|
|
211
|
+
* worse than an absent one, and the pin itself is the consumer's to prune if it wants.
|
|
212
|
+
*
|
|
213
|
+
* 🔴 `depth: 0` and `expandable: false`, but the REAL `parentId` and `index`. The section is
|
|
214
|
+
* a flat list of shortcuts — indenting it would redraw a hierarchy beside the one below it,
|
|
215
|
+
* and a twisty there would open a branch inside a section that has no branches. What it
|
|
216
|
+
* keeps is where the row actually LIVES, because that is what a drop aimed at it resolves
|
|
217
|
+
* against: dropping onto the pinned copy of `Projects` files things into `Projects`.
|
|
218
|
+
*/
|
|
219
|
+
const pinnedRows = useMemo(() => {
|
|
220
|
+
if (pinned.length === 0)
|
|
221
|
+
return [];
|
|
222
|
+
const all = new Map(flattenFileTree(shown, []).map((row) => [row.node.id, row]));
|
|
223
|
+
const out = [];
|
|
224
|
+
for (const id of pinned) {
|
|
225
|
+
const real = all.get(id);
|
|
226
|
+
if (real)
|
|
227
|
+
out.push({ ...real, depth: 0, expandable: false, expanded: false });
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}, [pinned, shown]);
|
|
231
|
+
/**
|
|
232
|
+
* The whole selection, singular or plural — `[selectedId]` for a consumer that never
|
|
233
|
+
* passed {@link FileTreeProps.selectedIds}, which is every consumer that existed before
|
|
234
|
+
* this and the reason nothing below has a "multi-select mode" branch.
|
|
235
|
+
*/
|
|
236
|
+
const selection = useMemo(() => selectedIds ?? (selectedId === null ? [] : [selectedId]), [selectedIds, selectedId]);
|
|
237
|
+
const selected = useMemo(() => new Set(selection), [selection]);
|
|
238
|
+
const cut = useMemo(() => (clipboard?.mode === 'cut' ? new Set(clipboard.ids) : null), [clipboard]);
|
|
239
|
+
/**
|
|
240
|
+
* Which rows a gesture aimed at `node` should act on: the selection when `node` is part of
|
|
241
|
+
* one and the consumer wired the plural verb, and `node` alone otherwise.
|
|
242
|
+
*
|
|
243
|
+
* 🔴 `batched` is the whole compatibility story in one argument. Every plural callback is
|
|
244
|
+
* optional, so a consumer that wired only the singular one keeps the singular behaviour on
|
|
245
|
+
* every gesture — the tree never invents a batch nobody can receive.
|
|
246
|
+
*/
|
|
247
|
+
const actOn = useCallback((node, plural) => {
|
|
248
|
+
if (!plural || selection.length < 2 || !selected.has(node.id))
|
|
249
|
+
return { ids: [node.id], batched: false };
|
|
250
|
+
/*
|
|
251
|
+
* 🔴 `batched` is NOT `ids.length > 1`, and the difference is a real bug that was in
|
|
252
|
+
* here first. Select a folder AND a file inside it, then press ⌫: `topmostSelected`
|
|
253
|
+
* correctly prunes the pair to one id, and a plural test written on the COUNT then
|
|
254
|
+
* falls through to the singular callback — so a consumer that only wired
|
|
255
|
+
* `onDeleteMany` gets nothing at all and the key does nothing. The gesture is plural
|
|
256
|
+
* because it was aimed at a SELECTION, whatever pruning leaves.
|
|
257
|
+
*/
|
|
258
|
+
return { ids: topmostSelected(shown, selection), batched: true };
|
|
259
|
+
}, [selected, selection, shown]);
|
|
260
|
+
/** Whether the tree can be rearranged at all — either verb counts. */
|
|
261
|
+
const canRearrange = Boolean(onMove || onMoveMany);
|
|
262
|
+
/**
|
|
263
|
+
* Commit a move through whichever verb the consumer wired, never through both.
|
|
264
|
+
*
|
|
265
|
+
* More than one row is `onMoveMany`'s alone (see its header); one row prefers `onMove`, so
|
|
266
|
+
* a consumer that wired only the singular one sees exactly the call it always saw.
|
|
267
|
+
*/
|
|
268
|
+
const commitMove = useCallback((ids, target) => {
|
|
269
|
+
if (ids.length === 0)
|
|
270
|
+
return;
|
|
271
|
+
if (ids.length > 1) {
|
|
272
|
+
onMoveMany?.(ids, target);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (onMove)
|
|
276
|
+
onMove(ids[0], target);
|
|
277
|
+
else
|
|
278
|
+
onMoveMany?.(ids, target);
|
|
279
|
+
}, [onMove, onMoveMany]);
|
|
280
|
+
// ── The draft, and the rows it may be closing around ──────────────────────────────────
|
|
281
|
+
/**
|
|
282
|
+
* What the open draft would wrap, re-derived from the tree it is actually drawn against.
|
|
283
|
+
*
|
|
284
|
+
* 🔴 Planned HERE rather than trusted as given, even though {@link FileTreeProps.onWrapStart}
|
|
285
|
+
* hands the consumer the same answer. A draft is open for as long as somebody is typing,
|
|
286
|
+
* and the tree underneath it is re-derived while they do — a row can be gone by the time
|
|
287
|
+
* Enter is pressed. Re-planning means the ids that reach `onDraftCommit` are ids the tree
|
|
288
|
+
* still holds, in tree order, with descendants pruned, whatever the consumer stored.
|
|
289
|
+
*/
|
|
290
|
+
const wrapping = useMemo(() => (draft?.wrap && draft.wrap.length > 0 ? planWrap(shown, draft.wrap) : null), [draft, shown]);
|
|
291
|
+
/**
|
|
292
|
+
* The name field committed — the folder and its contents in ONE call. See
|
|
293
|
+
* {@link FileTreeProps.onDraftCommit} for why it cannot be two.
|
|
294
|
+
*/
|
|
295
|
+
const commitDraft = useCallback((name) => {
|
|
296
|
+
// A wrap whose rows have all gone away is a plain new folder, and is called as one:
|
|
297
|
+
// `wrap` is ABSENT rather than empty, which is the argument an old consumer never sees.
|
|
298
|
+
if (wrapping)
|
|
299
|
+
onDraftCommit?.(name, wrapping.ids);
|
|
300
|
+
else
|
|
301
|
+
onDraftCommit?.(name);
|
|
302
|
+
}, [onDraftCommit, wrapping]);
|
|
110
303
|
/**
|
|
111
304
|
* The folders between the root and the selected row, and the shut one HOLDING it.
|
|
112
305
|
*
|
|
@@ -154,20 +347,81 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
154
347
|
}
|
|
155
348
|
return counts;
|
|
156
349
|
}, [rows]);
|
|
350
|
+
/*
|
|
351
|
+
* 🔴 On what the row is DRAWN as, not on what the consumer stored. A folder an expanding
|
|
352
|
+
* reveal opened is in `persistedCollapsed` and open on screen, so a toggle read off the
|
|
353
|
+
* stored list would "open" a folder somebody is looking at and the twisty would do nothing.
|
|
354
|
+
*/
|
|
157
355
|
const toggle = useCallback((id) => {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
set.delete(id);
|
|
356
|
+
if (collapsed.includes(id))
|
|
357
|
+
openFolders([id]);
|
|
161
358
|
else
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}, [collapsed, setCollapsed]);
|
|
359
|
+
shutFolders([id]);
|
|
360
|
+
}, [collapsed, openFolders, shutFolders]);
|
|
165
361
|
const openFolder = useCallback((id) => {
|
|
166
362
|
if (!collapsed.includes(id))
|
|
167
363
|
return false;
|
|
168
|
-
|
|
364
|
+
openFolders([id]);
|
|
169
365
|
return true;
|
|
170
|
-
}, [collapsed,
|
|
366
|
+
}, [collapsed, openFolders]);
|
|
367
|
+
/**
|
|
368
|
+
* Pin a row, or take the pin off — the one verb the section has, from either copy.
|
|
369
|
+
*
|
|
370
|
+
* `toggleSelected` is reused rather than re-derived: "in if it was out, out if it was in,
|
|
371
|
+
* and the ORDER of the rest is kept" is the same rule a ⌘-click follows, and the order is
|
|
372
|
+
* load-bearing here too — a new pin joins the END of the section instead of resorting a list
|
|
373
|
+
* somebody has arranged.
|
|
374
|
+
*
|
|
375
|
+
* 🔴 It says so out loud. The pinned section may be scrolled off, or the pin may have been
|
|
376
|
+
* pressed on the top copy where the change is a row VANISHING from under the pointer — so
|
|
377
|
+
* the live region names what happened, the same way every keyboard move here does.
|
|
378
|
+
*/
|
|
379
|
+
const togglePin = useCallback((node) => {
|
|
380
|
+
if (!onPinnedChange)
|
|
381
|
+
return;
|
|
382
|
+
const next = toggleSelected(pinned, node.id);
|
|
383
|
+
onPinnedChange(next);
|
|
384
|
+
setNotice(next.includes(node.id) ? `Pinned ${node.name}` : `Unpinned ${node.name}`);
|
|
385
|
+
}, [onPinnedChange, pinned]);
|
|
386
|
+
/**
|
|
387
|
+
* The shut folders between the root and the selection — what an expanding reveal opens.
|
|
388
|
+
*
|
|
389
|
+
* Empty unless {@link FileTreeProps.expandOnReveal} is wired, and empty while a `filter` is
|
|
390
|
+
* running: a filter draws everything it kept whatever `collapsed` says, so there is nothing
|
|
391
|
+
* shut to open and an overlay built there would outlive the search that caused it.
|
|
392
|
+
*
|
|
393
|
+
* 🔴 `searching` exists so that no line in this file — code OR comment, both are scanned —
|
|
394
|
+
* is a bang immediately followed by the word `filter`. Tailwind v4 reads SOURCE for
|
|
395
|
+
* candidates, `filter` is a real utility, and that pair is its important variant: written
|
|
396
|
+
* literally, `expandOnReveal && <bang>filter && …` emits an escaped `filter` rule with
|
|
397
|
+
* `!important` into every stylesheet this component's area ships, which fails the CSS budget
|
|
398
|
+
* in `src/fixtureAppCss.spec.ts` — a red hundreds of lines away from anything that looks
|
|
399
|
+
* like CSS. Measured: it cost this change a whole gate run, and then a second one when the
|
|
400
|
+
* comment explaining it quoted the token it was warning about. `LogViewer.tsx` gets away
|
|
401
|
+
* with the same pair only because the `.` of `.trim()` breaks the candidate; do not rely on
|
|
402
|
+
* that. Negate a plain boolean of your own instead — no utility is named `searching`.
|
|
403
|
+
*/
|
|
404
|
+
const searching = Boolean(filter);
|
|
405
|
+
const revealShut = useMemo(() => expandOnReveal && !searching && openPath.path.size > 0
|
|
406
|
+
? [...openPath.path].filter((id) => collapsed.includes(id))
|
|
407
|
+
: [], [collapsed, expandOnReveal, searching, openPath]);
|
|
408
|
+
/**
|
|
409
|
+
* The same list, readable from the reveal without becoming one of its triggers.
|
|
410
|
+
*
|
|
411
|
+
* 🔴 The effect below must fire on a SELECTION change and on nothing else (limits 2 and 3),
|
|
412
|
+
* and `revealShut` moves whenever the tree does — a rename, a new row, a folder opened by
|
|
413
|
+
* hand. Depending on it would scroll the list on all three. The mirror is written by an
|
|
414
|
+
* effect declared FIRST, so it is already fresh when the reveal runs in the same commit.
|
|
415
|
+
*/
|
|
416
|
+
const revealTargets = useRef(revealShut);
|
|
417
|
+
/** The same, for the callback: a reveal two selections later must not call a stale one. */
|
|
418
|
+
const announceReveal = useRef(onRevealExpand);
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
revealTargets.current = revealShut;
|
|
421
|
+
announceReveal.current = onRevealExpand;
|
|
422
|
+
}, [revealShut, onRevealExpand]);
|
|
423
|
+
/** The selection an expanding reveal has already fired for — see limit 1 below. */
|
|
424
|
+
const revealedFor = useRef(null);
|
|
171
425
|
/**
|
|
172
426
|
* Bring the selected row into view — the explorer's *reveal*.
|
|
173
427
|
*
|
|
@@ -180,10 +434,31 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
180
434
|
*
|
|
181
435
|
* Three deliberate limits, each of which VS Code gets right or wrong depending on a setting:
|
|
182
436
|
*
|
|
183
|
-
* 1.
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
437
|
+
* 1. **A shut folder stays shut** — *unless* {@link FileTreeProps.expandOnReveal} is wired,
|
|
438
|
+
* which is the opt-in half of this limit rather than an exception to it. Left unwired,
|
|
439
|
+
* the shut folder's `data-holds-open` marker is scrolled to and nothing opens.
|
|
440
|
+
*
|
|
441
|
+
* 🔴 **The argument, and what changed about it, 2026-09-16.** This limit was written as
|
|
442
|
+
* a flat refusal: auto-expanding is what `explorer.autoReveal` does and it is the
|
|
443
|
+
* behaviour people turn off, because it silently undoes a collapse they chose — and
|
|
444
|
+
* `collapsed` here is often a CONTROLLED value the consumer persists, so expanding would
|
|
445
|
+
* write it open for good. Then the owner asked for it BY NAME — *"show that file in the
|
|
446
|
+
* file tree by like vscode does when you select a tab with a file in it"* — and named the
|
|
447
|
+
* application whose default is `true`. His ask wins over the default this file chose for
|
|
448
|
+
* him, so the behaviour is here.
|
|
449
|
+
*
|
|
450
|
+
* What survives is the half of the objection that was never taste: **the persisted value
|
|
451
|
+
* is not rewritten.** The expansion goes into `revealOpened`, an overlay of this tree's
|
|
452
|
+
* own, and `onCollapsedChange` is never called for it — so the consumer's collapse is
|
|
453
|
+
* read back unchanged on the next mount, and a folder shut by hand goes back to obeying
|
|
454
|
+
* it. The two candidate designs were that and "call `onCollapsedChange` and let the
|
|
455
|
+
* consumer refuse to keep it"; the second makes every consumer that wires the prop
|
|
456
|
+
* responsible for not persisting a write it did not ask for, and the one that forgets
|
|
457
|
+
* has the bug this paragraph exists to prevent. Defaults, not diligence.
|
|
458
|
+
*
|
|
459
|
+
* It fires once per SELECTION, not continuously: shut an expanded ancestor by hand while
|
|
460
|
+
* the same file is open and it stays shut, which is what VS Code does and the only thing
|
|
461
|
+
* that leaves the twisty working at all.
|
|
187
462
|
* 2. **Only when the row is not already visible.** A list that jumps a few pixels every
|
|
188
463
|
* time you click a row you can already see reads as a bug.
|
|
189
464
|
* 3. **Never mid-drag.** Scrolling the list under a drag moves the drop target out from
|
|
@@ -196,12 +471,32 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
196
471
|
* `data-holds-open` is the answer precisely when the selected row has none — but it is the
|
|
197
472
|
* one thing whose CHANGE should reveal. And `drag` IS read, as a guard rather than a
|
|
198
473
|
* trigger: adding it would reveal the moment a drag ends, which is limit 3 above.
|
|
474
|
+
*
|
|
475
|
+
* `revealOpened` is the SECOND PASS of an expanding reveal, and nothing else: the row it was
|
|
476
|
+
* asked to scroll to does not exist in the DOM until the folders above it are open, so the
|
|
477
|
+
* expansion returns without scrolling and the re-render it causes is what lands the scroll.
|
|
478
|
+
* What it must NOT be is the trigger for expanding again — that is `revealedFor`, below.
|
|
199
479
|
*/
|
|
200
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: `selectedId` is the trigger and not a read; `drag` is a guard and not a trigger — see just above.
|
|
480
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `selectedId` is the trigger and not a read; `drag` is a guard and not a trigger; `revealOpened` is the second pass — see just above.
|
|
201
481
|
useEffect(() => {
|
|
202
482
|
const scroller = scrollRef.current;
|
|
203
483
|
if (!scroller || drag?.active)
|
|
204
484
|
return;
|
|
485
|
+
/*
|
|
486
|
+
* Once per selection, whatever it finds — the ref moves even when there is nothing shut to
|
|
487
|
+
* open, so a later hand-shut of one of these folders cannot be undone by this effect.
|
|
488
|
+
*/
|
|
489
|
+
if (revealedFor.current !== selectedId) {
|
|
490
|
+
revealedFor.current = selectedId;
|
|
491
|
+
const toOpen = revealTargets.current;
|
|
492
|
+
if (toOpen.length > 0) {
|
|
493
|
+
setRevealOpened((prev) => [...prev, ...toOpen.filter((id) => !prev.includes(id))]);
|
|
494
|
+
// 🔴 Said out loud, because a consumer that FETCHES a folder's children on open has
|
|
495
|
+
// no other way to learn of it — see {@link FileTreeProps.onRevealExpand}.
|
|
496
|
+
announceReveal.current?.(toOpen);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
205
500
|
const row = scroller.querySelector('.cbft-row[data-selected]') ??
|
|
206
501
|
scroller.querySelector('.cbft-row[data-holds-open]');
|
|
207
502
|
if (!row?.scrollIntoView)
|
|
@@ -213,7 +508,7 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
213
508
|
// `nearest` on both axes: the least movement that makes the row visible, and no sideways
|
|
214
509
|
// travel in a list that does not scroll sideways.
|
|
215
510
|
row.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
216
|
-
}, [selectedId]);
|
|
511
|
+
}, [selectedId, revealOpened]);
|
|
217
512
|
// ── The drag ──────────────────────────────────────────────────────────────────────────
|
|
218
513
|
const stopAutoScroll = useCallback(() => {
|
|
219
514
|
if (!autoScroll.current)
|
|
@@ -229,16 +524,36 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
229
524
|
}, []);
|
|
230
525
|
/** The row under a client point, and how far down it the pointer is. */
|
|
231
526
|
const rowAt = useCallback((x, y) => {
|
|
527
|
+
/*
|
|
528
|
+
* The PINNED copies first — they are drawn in their own box above the scroller, so a
|
|
529
|
+
* point inside one is never inside a tree row and the two loops cannot both answer.
|
|
530
|
+
*
|
|
531
|
+
* 🔴 The middle of the row, and `pinned` so the caller turns REORDERING off for it. A
|
|
532
|
+
* pinned row's edges have no "between these two" to express: the section's order is the
|
|
533
|
+
* pin order, which is not an order in the tree at all — so a `before`/`after` aimed
|
|
534
|
+
* there would draw a marker in one list and re-file the row among siblings it is not
|
|
535
|
+
* drawn beside in another. What is left is the whole requirement and nothing else: the
|
|
536
|
+
* row carries its REAL `parentId`, so "into" is the real folder, and a drop on the
|
|
537
|
+
* pinned copy of `Projects` files into `Projects`.
|
|
538
|
+
*/
|
|
539
|
+
for (const row of pinnedRows) {
|
|
540
|
+
const el = pinnedRefs.current.get(row.node.id);
|
|
541
|
+
if (!el)
|
|
542
|
+
continue;
|
|
543
|
+
const box = el.getBoundingClientRect();
|
|
544
|
+
if (y >= box.top && y <= box.bottom && x >= box.left && x <= box.right)
|
|
545
|
+
return { row, ratio: 0.5, pinned: true };
|
|
546
|
+
}
|
|
232
547
|
for (const row of rows) {
|
|
233
548
|
const el = rowRefs.current.get(row.node.id);
|
|
234
549
|
if (!el)
|
|
235
550
|
continue;
|
|
236
551
|
const box = el.getBoundingClientRect();
|
|
237
552
|
if (y >= box.top && y <= box.bottom && x >= box.left && x <= box.right)
|
|
238
|
-
return { row, ratio: box.height > 0 ? (y - box.top) / box.height : 0.5 };
|
|
553
|
+
return { row, ratio: box.height > 0 ? (y - box.top) / box.height : 0.5, pinned: false };
|
|
239
554
|
}
|
|
240
555
|
return null;
|
|
241
|
-
}, [rows]);
|
|
556
|
+
}, [pinnedRows, rows]);
|
|
242
557
|
const endDrag = useCallback((commit, state) => {
|
|
243
558
|
stopAutoScroll();
|
|
244
559
|
stopSpring();
|
|
@@ -247,30 +562,47 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
247
562
|
// way, and Escape landing you on a different row would be its own surprise.
|
|
248
563
|
if (state?.active)
|
|
249
564
|
draggedJustNow.current = true;
|
|
250
|
-
if (commit && state?.active && state.offer.target &&
|
|
251
|
-
|
|
565
|
+
if (commit && state?.active && state.offer.target && canRearrange) {
|
|
566
|
+
commitMove(state.ids, state.offer.target);
|
|
252
567
|
sprung.current = [];
|
|
253
568
|
}
|
|
254
569
|
else if (sprung.current.length > 0) {
|
|
255
570
|
// A drag that opened folders on its way and then went nowhere puts them back — the
|
|
256
571
|
// tree it leaves behind must be the tree it found.
|
|
257
|
-
const
|
|
258
|
-
for (const id of sprung.current)
|
|
259
|
-
shut.add(id);
|
|
572
|
+
const back = sprung.current;
|
|
260
573
|
sprung.current = [];
|
|
261
|
-
|
|
574
|
+
shutFolders(back);
|
|
262
575
|
}
|
|
263
576
|
setDrag(null);
|
|
264
|
-
}, [
|
|
577
|
+
}, [canRearrange, commitMove, shutFolders, stopAutoScroll, stopSpring]);
|
|
265
578
|
const beginDrag = useCallback((event, node) => {
|
|
266
579
|
// A row the consumer marked undraggable never starts a gesture — see
|
|
267
580
|
// `FileTreeNode.draggable`. A drag that refuses everywhere reads as a broken tree.
|
|
268
|
-
if (!
|
|
581
|
+
if (!canRearrange || event.button !== 0 || node.draggable === false)
|
|
582
|
+
return;
|
|
583
|
+
/*
|
|
584
|
+
* Press a row that is part of a selection and the whole selection comes with you —
|
|
585
|
+
* *"with all of those selected I want to be able to click and hold on any of them to
|
|
586
|
+
* drag and drop them to another folder"*. Press one that is not, and the selection is
|
|
587
|
+
* left where it is: Finder's rule, and the only one that lets you move a single row out
|
|
588
|
+
* of a group you have built without dismantling it first.
|
|
589
|
+
*/
|
|
590
|
+
const { ids } = actOn(node, Boolean(onMoveMany));
|
|
591
|
+
/*
|
|
592
|
+
* 🔴 All or nothing. One undraggable row in the set stops the whole gesture rather than
|
|
593
|
+
* quietly leaving it behind — a drag that moved four of five rows and said nothing is
|
|
594
|
+
* the failure the plural drop rule exists to prevent (see `resolveMultiDrop`), and it
|
|
595
|
+
* would be no better for arriving at the START of the gesture instead of the end.
|
|
596
|
+
* `apps/collections` marks its albums `draggable: false`, so this is reachable there by
|
|
597
|
+
* ⌘-clicking one into a selection of folders.
|
|
598
|
+
*/
|
|
599
|
+
if (ids.some((id) => findFileTreeNode(shown, id)?.draggable === false))
|
|
269
600
|
return;
|
|
270
601
|
const startX = event.clientX;
|
|
271
602
|
const startY = event.clientY;
|
|
272
603
|
let state = {
|
|
273
604
|
id: node.id,
|
|
605
|
+
ids,
|
|
274
606
|
startX,
|
|
275
607
|
startY,
|
|
276
608
|
x: startX,
|
|
@@ -285,12 +617,18 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
285
617
|
if (!state.active && travelled < DRAG_THRESHOLD)
|
|
286
618
|
return;
|
|
287
619
|
const hit = rowAt(moveEvent.clientX, moveEvent.clientY);
|
|
288
|
-
|
|
620
|
+
// One row or twenty, the same call: `resolveMultiDrop` is `resolveDrop` asked once
|
|
621
|
+
// per carried row, and the strictest answer wins. See its header for why a single
|
|
622
|
+
// refusal refuses the whole drop.
|
|
623
|
+
const offer = resolveMultiDrop({
|
|
289
624
|
nodes: shown,
|
|
290
625
|
row: hit?.row ?? null,
|
|
291
626
|
offsetRatio: hit?.ratio ?? 0.5,
|
|
292
|
-
|
|
293
|
-
|
|
627
|
+
dragIds: ids,
|
|
628
|
+
// 🔴 Off over the pinned section, whatever the consumer stores. See `rowAt`: those
|
|
629
|
+
// rows are drawn in pin order, so there is no "between these two" for an edge to
|
|
630
|
+
// mean there — only "into this", which is the real folder.
|
|
631
|
+
allowReorder: allowReorder && !hit?.pinned,
|
|
294
632
|
...(lockedReason ? { lockedReason } : {}),
|
|
295
633
|
...(canDrop ? { canDrop } : {}),
|
|
296
634
|
});
|
|
@@ -366,12 +704,14 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
366
704
|
target.addEventListener('pointercancel', cancel);
|
|
367
705
|
window.addEventListener('keydown', abort);
|
|
368
706
|
}, [
|
|
707
|
+
actOn,
|
|
369
708
|
allowReorder,
|
|
370
709
|
canDrop,
|
|
710
|
+
canRearrange,
|
|
371
711
|
collapsed,
|
|
372
712
|
endDrag,
|
|
373
713
|
lockedReason,
|
|
374
|
-
|
|
714
|
+
onMoveMany,
|
|
375
715
|
openFolder,
|
|
376
716
|
rowAt,
|
|
377
717
|
shown,
|
|
@@ -380,20 +720,39 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
380
720
|
]);
|
|
381
721
|
// A drag abandoned by a re-render — the folder deleted in another tab, the list replaced
|
|
382
722
|
// under the pointer — must not leave a marker behind.
|
|
723
|
+
//
|
|
724
|
+
// 🔴 The pinned section counts as "still drawn". A drag started from the top copy of a row
|
|
725
|
+
// that lives inside a SHUT folder has no row in `rows` at all — which is the ordinary case
|
|
726
|
+
// for a pin, not an edge one — so this would cancel the gesture on its first re-render.
|
|
383
727
|
useEffect(() => {
|
|
384
|
-
if (drag &&
|
|
728
|
+
if (drag &&
|
|
729
|
+
!rows.some((r) => r.node.id === drag.id) &&
|
|
730
|
+
!pinnedRows.some((r) => r.node.id === drag.id))
|
|
385
731
|
setDrag(null);
|
|
386
|
-
}, [drag, rows]);
|
|
732
|
+
}, [drag, pinnedRows, rows]);
|
|
387
733
|
useEffect(() => () => {
|
|
388
734
|
stopAutoScroll();
|
|
389
735
|
stopSpring();
|
|
390
736
|
}, [stopAutoScroll, stopSpring]);
|
|
391
737
|
// ── The keyboard ──────────────────────────────────────────────────────────────────────
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
738
|
+
/**
|
|
739
|
+
* The element drawing `id`, preferring the section asked for and falling back to the other.
|
|
740
|
+
*
|
|
741
|
+
* 🔴 The fallback is what makes the REAL row the default answer everywhere: a row in the
|
|
742
|
+
* tree is the one that has a depth, a twisty and ancestors marked above it, so focus and the
|
|
743
|
+
* rename field belong there whenever it is drawn. The pinned copy answers only for a row the
|
|
744
|
+
* tree is not currently showing — which, for a pin, is the common case.
|
|
745
|
+
*/
|
|
746
|
+
const rowEl = useCallback((id, pinnedCopy) => {
|
|
747
|
+
const first = pinnedCopy ? pinnedRefs : rowRefs;
|
|
748
|
+
const second = pinnedCopy ? rowRefs : pinnedRefs;
|
|
749
|
+
return first.current.get(id) ?? second.current.get(id);
|
|
396
750
|
}, []);
|
|
751
|
+
const focusRow = useCallback((id, pinnedCopy = false) => {
|
|
752
|
+
setFocused(id === null ? null : { id, pinned: pinnedCopy });
|
|
753
|
+
if (id)
|
|
754
|
+
rowEl(id, pinnedCopy)?.focus();
|
|
755
|
+
}, [rowEl]);
|
|
397
756
|
/*
|
|
398
757
|
* ── The keyboard comes back from a rename ─────────────────────────────────────────────
|
|
399
758
|
*
|
|
@@ -422,14 +781,21 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
422
781
|
return;
|
|
423
782
|
focusRow(previous);
|
|
424
783
|
}, [renamingId, focusRow]);
|
|
425
|
-
const onKeyDown = useCallback((event, row
|
|
784
|
+
const onKeyDown = useCallback((event, row,
|
|
785
|
+
/*
|
|
786
|
+
* The rows the ARROWS walk, and which section drew this one. A pinned copy's neighbours
|
|
787
|
+
* are the other pins, not the tree rows around where it lives — ↓ from the last pin
|
|
788
|
+
* jumping into the middle of a folder would be a cursor that teleports. Every verb below
|
|
789
|
+
* still acts on `row.node`, which is the one identity both copies share.
|
|
790
|
+
*/
|
|
791
|
+
list, pinnedCopy) => {
|
|
426
792
|
if (renamingId)
|
|
427
793
|
return;
|
|
428
|
-
const index =
|
|
794
|
+
const index = list.findIndex((r) => r.node.id === row.node.id);
|
|
429
795
|
const step = (delta) => {
|
|
430
|
-
const next =
|
|
796
|
+
const next = list[Math.min(list.length - 1, Math.max(0, index + delta))];
|
|
431
797
|
if (next)
|
|
432
|
-
focusRow(next.node.id);
|
|
798
|
+
focusRow(next.node.id, pinnedCopy);
|
|
433
799
|
};
|
|
434
800
|
/*
|
|
435
801
|
* The keyboard's route to the row's own menu — see {@link FileTreeProps.onRowMenu}.
|
|
@@ -438,10 +804,166 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
438
804
|
*/
|
|
439
805
|
if (onRowMenu && (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10'))) {
|
|
440
806
|
event.preventDefault();
|
|
441
|
-
|
|
807
|
+
// The corner of the copy the keys are actually on — a menu anchored to the row in the
|
|
808
|
+
// folder while the focus ring is on the pinned one would open somewhere else entirely.
|
|
809
|
+
const box = rowEl(row.node.id, pinnedCopy)?.getBoundingClientRect();
|
|
442
810
|
onRowMenu(row.node, { x: box?.right ?? 0, y: box?.bottom ?? 0 });
|
|
443
811
|
return;
|
|
444
812
|
}
|
|
813
|
+
/*
|
|
814
|
+
* ── ⌃⌘T — pin, and unpin ────────────────────────────────────────────────────────
|
|
815
|
+
*
|
|
816
|
+
* Owner, 2026-09-13: *"need … ability to pin open files where they stay at the top of
|
|
817
|
+
* the file tree … Pinned will mean I want to keep them there until I unpin them."* One
|
|
818
|
+
* chord for both directions, because it is one state with two values and a person who
|
|
819
|
+
* can pin can already see whether this row is pinned.
|
|
820
|
+
*
|
|
821
|
+
* ⌃⌘T is Finder's own **Add to Sidebar**, so the gesture is not one anybody has to be
|
|
822
|
+
* told about — and, exactly like ⌃⌘N above, it is anchored on **Ctrl** with ⌘ optional,
|
|
823
|
+
* for the Windows and Linux hosts this same tree runs on where there is no ⌘ to hold.
|
|
824
|
+
*
|
|
825
|
+
* Only when {@link FileTreeProps.onPinnedChange} is wired: with no consumer to receive
|
|
826
|
+
* the list this is not a chord this component has, and the event falls through to the
|
|
827
|
+
* browser rather than being swallowed by a control that would do nothing.
|
|
828
|
+
*/
|
|
829
|
+
if (onPinnedChange &&
|
|
830
|
+
event.ctrlKey &&
|
|
831
|
+
!event.altKey &&
|
|
832
|
+
!event.shiftKey &&
|
|
833
|
+
event.key.toLowerCase() === 't') {
|
|
834
|
+
event.preventDefault();
|
|
835
|
+
togglePin(row.node);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
/*
|
|
839
|
+
* ── ⌃⌘N — New Folder with Selection ─────────────────────────────────────────────
|
|
840
|
+
*
|
|
841
|
+
* Owner, 2026-09-13: *"allow make dir from file where dir wraps file location and gets
|
|
842
|
+
* name of file"*. Finder calls it **New Folder with Selection** and VS Code binds it to
|
|
843
|
+
* ⌃⌘N, so the shape is settled and this invents nothing: the folder is made where the
|
|
844
|
+
* row already is, it is named after the row, and the row goes into it.
|
|
845
|
+
*
|
|
846
|
+
* 🔴 Ctrl is what the chord is anchored on, ⌘ optional — ⌃⌘N on a Mac, ⌃N on the
|
|
847
|
+
* Windows and Linux hosts this same tree runs on, where there is no ⌘ to hold. Bare
|
|
848
|
+
* ⌥/⇧ are excluded so the rest of the modifier space stays the app's.
|
|
849
|
+
*
|
|
850
|
+
* Like every other verb here it exists only when the consumer wired it: no
|
|
851
|
+
* `onWrapStart` and this is not a chord this component has, and the event falls through
|
|
852
|
+
* to the browser rather than being swallowed by a control that would do nothing.
|
|
853
|
+
*/
|
|
854
|
+
if (onWrapStart &&
|
|
855
|
+
event.ctrlKey &&
|
|
856
|
+
!event.altKey &&
|
|
857
|
+
!event.shiftKey &&
|
|
858
|
+
event.key.toLowerCase() === 'n') {
|
|
859
|
+
event.preventDefault();
|
|
860
|
+
const { ids } = actOn(row.node, true);
|
|
861
|
+
const plan = planWrap(shown, ids);
|
|
862
|
+
if (!plan)
|
|
863
|
+
return;
|
|
864
|
+
/*
|
|
865
|
+
* Both vetoes BEFORE the draft opens, never after a name is typed — the consumer's
|
|
866
|
+
* words first, then the tree's own rule about a row that cannot be moved at all.
|
|
867
|
+
* Either way it is said out loud: a chord that silently did nothing is the failure
|
|
868
|
+
* `notice` exists for.
|
|
869
|
+
*/
|
|
870
|
+
const refused = refuseWrap(shown, plan.ids, canWrap);
|
|
871
|
+
if (refused) {
|
|
872
|
+
setNotice(refused);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const stuck = plan.ids.find((id) => findFileTreeNode(shown, id)?.draggable === false);
|
|
876
|
+
if (stuck) {
|
|
877
|
+
setNotice(`${nameOf(shown, stuck)} cannot be moved`);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
onWrapStart(nodesByIds(shown, plan.ids), plan.parentId);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
/*
|
|
884
|
+
* ── ⌘X / ⌘C / ⌘V — the clipboard ────────────────────────────────────────────────
|
|
885
|
+
*
|
|
886
|
+
* Owner, 2026-09-14: *"I also want to be able to cut and paste to move them with
|
|
887
|
+
* command + x and command + v, and copy them with command + c and command + v. Copying
|
|
888
|
+
* should allow copying in another folder or the same folder. These are similar to how
|
|
889
|
+
* finder works except cutting and pasting will be easy with just command + x."*
|
|
890
|
+
*
|
|
891
|
+
* 🔴 Each key exists only when its verb does. No `onMove`/`onMoveMany` and ⌘X is not a
|
|
892
|
+
* key this tree has; no `onCopy` and neither is ⌘C — the event falls through to the
|
|
893
|
+
* browser rather than being swallowed by a control that would have done nothing with
|
|
894
|
+
* it. `preventDefault` is only ever called on a chord that is about to act.
|
|
895
|
+
*
|
|
896
|
+
* ⌃ as well as ⌘, because the same tree is used on Windows and Linux, where the
|
|
897
|
+
* clipboard chord is the same three letters with the other modifier.
|
|
898
|
+
*
|
|
899
|
+
* The paste DESTINATION is the focused row, resolved by exactly the rules a pointer
|
|
900
|
+
* drag obeys (`resolveMultiDrop`): a folder row means into it, a file row means the
|
|
901
|
+
* folder that row is in, and a top-level file row means the top level. So a paste can
|
|
902
|
+
* never reach somewhere the drag refuses, which is the "one opinion about what is
|
|
903
|
+
* legal" rule the keyboard MOVE already answers to.
|
|
904
|
+
*/
|
|
905
|
+
if ((event.metaKey || event.ctrlKey) &&
|
|
906
|
+
!event.altKey &&
|
|
907
|
+
(event.key === 'x' || event.key === 'c' || event.key === 'v')) {
|
|
908
|
+
if (event.key === 'x' && canRearrange) {
|
|
909
|
+
event.preventDefault();
|
|
910
|
+
const { ids } = actOn(row.node, Boolean(onMoveMany));
|
|
911
|
+
setClipboard({ mode: 'cut', ids });
|
|
912
|
+
setNotice(ids.length > 1 ? `Cut ${ids.length} items` : `Cut ${row.node.name}`);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (event.key === 'c' && onCopy) {
|
|
916
|
+
event.preventDefault();
|
|
917
|
+
const { ids } = actOn(row.node, true);
|
|
918
|
+
// The consumer's veto, asked BEFORE the clipboard takes — a copy that is going to
|
|
919
|
+
// be refused must be refused while there is still something to say it about.
|
|
920
|
+
const refused = refuseCopy(shown, ids, canCopy);
|
|
921
|
+
if (refused) {
|
|
922
|
+
setNotice(refused);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
setClipboard({ mode: 'copy', ids });
|
|
926
|
+
setNotice(ids.length > 1 ? `Copied ${ids.length} items` : `Copied ${row.node.name}`);
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (event.key === 'v' && clipboard) {
|
|
930
|
+
event.preventDefault();
|
|
931
|
+
const offer = resolveMultiDrop({
|
|
932
|
+
nodes: shown,
|
|
933
|
+
row,
|
|
934
|
+
// The middle of the row: "into this", never "between these two". Reordering is a
|
|
935
|
+
// pointer gesture — a paste has no edge to aim at.
|
|
936
|
+
offsetRatio: 0.5,
|
|
937
|
+
allowReorder: false,
|
|
938
|
+
/*
|
|
939
|
+
* 🔴 The carried ids are declared for a COPY too, so the descendant rule applies
|
|
940
|
+
* to it: a folder cannot be pasted inside itself any more than it can be dragged
|
|
941
|
+
* there. What that rule does NOT refuse is the case the owner named — *"copying
|
|
942
|
+
* in … the same folder"* — because a row's own parent is not a descendant of it.
|
|
943
|
+
*/
|
|
944
|
+
dragIds: clipboard.ids,
|
|
945
|
+
...(lockedReason ? { lockedReason } : {}),
|
|
946
|
+
...(canDrop ? { canDrop } : {}),
|
|
947
|
+
});
|
|
948
|
+
if (!offer.target) {
|
|
949
|
+
setNotice(offer.reason ?? '');
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
setNotice(describeDrop(shown, offer.target));
|
|
953
|
+
if (clipboard.mode === 'copy') {
|
|
954
|
+
onCopy?.(clipboard.ids, offer.target);
|
|
955
|
+
// Kept, not cleared: Finder pastes the same copy again, and *"copying in another
|
|
956
|
+
// folder or the same folder"* is two pastes of one ⌘C.
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
commitMove(clipboard.ids, offer.target);
|
|
960
|
+
// The rows have moved. A second paste would move them again from wherever they
|
|
961
|
+
// now are, which is not what the person cut them for.
|
|
962
|
+
setClipboard(null);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
445
967
|
/*
|
|
446
968
|
* ⌥→ and ⌥← MOVE the focused row — the keyboard's half of the drag, and the only half
|
|
447
969
|
* that exists on touch (a press-and-drag there scrolls the list, so the gesture is
|
|
@@ -451,7 +973,7 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
451
973
|
* 🔴 Before the arrow cases below, so one key never means two things. Everything else
|
|
452
974
|
* in this handler is bare-key, which is what keeps ⇧⏎/⌘F and the rest free for the app.
|
|
453
975
|
*/
|
|
454
|
-
if (
|
|
976
|
+
if (canRearrange &&
|
|
455
977
|
event.altKey &&
|
|
456
978
|
!event.metaKey &&
|
|
457
979
|
!event.ctrlKey &&
|
|
@@ -467,7 +989,11 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
467
989
|
});
|
|
468
990
|
if (offer.target) {
|
|
469
991
|
setNotice(describeDrop(shown, offer.target));
|
|
470
|
-
|
|
992
|
+
// Deliberately the focused row alone, never the selection: `resolveKeyboardMove`
|
|
993
|
+
// answers for ONE row ("the nearest folder above IT", "the folder ITS folder is
|
|
994
|
+
// in"), and there is no one answer to that question for a set spread across
|
|
995
|
+
// several levels. The plural route is the drag and the clipboard.
|
|
996
|
+
commitMove([row.node.id], offer.target);
|
|
471
997
|
}
|
|
472
998
|
else {
|
|
473
999
|
setNotice(offer.reason ?? '');
|
|
@@ -496,19 +1022,22 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
496
1022
|
event.preventDefault();
|
|
497
1023
|
if (row.expandable && row.expanded)
|
|
498
1024
|
toggle(row.node.id);
|
|
499
|
-
|
|
1025
|
+
// Never out of the pinned section: its rows are drawn flat, so "go to my parent"
|
|
1026
|
+
// would leap the focus into the tree — and often onto a row that is not drawn,
|
|
1027
|
+
// because the folder a pinned row lives in is very often shut.
|
|
1028
|
+
else if (!pinnedCopy && row.parentId)
|
|
500
1029
|
focusRow(row.parentId);
|
|
501
1030
|
return;
|
|
502
1031
|
}
|
|
503
1032
|
case 'Home':
|
|
504
1033
|
event.preventDefault();
|
|
505
|
-
if (
|
|
506
|
-
focusRow(
|
|
1034
|
+
if (list[0])
|
|
1035
|
+
focusRow(list[0].node.id, pinnedCopy);
|
|
507
1036
|
return;
|
|
508
1037
|
case 'End':
|
|
509
1038
|
event.preventDefault();
|
|
510
|
-
if (
|
|
511
|
-
focusRow(
|
|
1039
|
+
if (list.at(-1))
|
|
1040
|
+
focusRow(list.at(-1).node.id, pinnedCopy);
|
|
512
1041
|
return;
|
|
513
1042
|
/*
|
|
514
1043
|
── The keys VS Code taught everybody ───────────────────────────────────────────
|
|
@@ -553,12 +1082,41 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
553
1082
|
// ⌫, Delete, and ⌘⌫ — the macOS chord arrives as `Backspace` + `metaKey`, so it is
|
|
554
1083
|
// this branch and not a fourth one. See {@link FileTreeProps.onDelete}.
|
|
555
1084
|
case 'Backspace':
|
|
556
|
-
case 'Delete':
|
|
1085
|
+
case 'Delete': {
|
|
1086
|
+
/*
|
|
1087
|
+
* 🔴 On a SELECTION it deletes all of it — *"for both of these new selection
|
|
1088
|
+
* functionalities I also want to be able to hold command and click delete to delete
|
|
1089
|
+
* all the selected files"*. `onDeleteMany` is what makes that reachable; without it
|
|
1090
|
+
* the key still means the focused row alone, which is what it has always meant.
|
|
1091
|
+
*
|
|
1092
|
+
* `actOn` prunes descendants of other selected rows, so deleting a folder and a
|
|
1093
|
+
* file inside it asks for one deletion, not two — the second of which would be of
|
|
1094
|
+
* something the first already took.
|
|
1095
|
+
*/
|
|
1096
|
+
const { ids, batched } = actOn(row.node, Boolean(onDeleteMany));
|
|
1097
|
+
if (batched && onDeleteMany) {
|
|
1098
|
+
event.preventDefault();
|
|
1099
|
+
onDeleteMany(nodesByIds(shown, ids));
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
557
1102
|
if (!onDelete)
|
|
558
1103
|
return;
|
|
559
1104
|
event.preventDefault();
|
|
560
1105
|
onDelete(row.node);
|
|
561
1106
|
return;
|
|
1107
|
+
}
|
|
1108
|
+
/*
|
|
1109
|
+
* Escape puts the clipboard down. A cut that is never pasted leaves every row it
|
|
1110
|
+
* holds drawn as lifted (`data-cut`), and a mark with no way to take it off is a
|
|
1111
|
+
* tree that looks permanently mid-gesture.
|
|
1112
|
+
*/
|
|
1113
|
+
case 'Escape':
|
|
1114
|
+
if (!clipboard)
|
|
1115
|
+
return;
|
|
1116
|
+
event.preventDefault();
|
|
1117
|
+
setClipboard(null);
|
|
1118
|
+
setNotice('');
|
|
1119
|
+
return;
|
|
562
1120
|
default:
|
|
563
1121
|
break;
|
|
564
1122
|
}
|
|
@@ -570,25 +1128,37 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
570
1128
|
? event.key
|
|
571
1129
|
: typeahead.current.word + event.key;
|
|
572
1130
|
typeahead.current = { word, at: now };
|
|
573
|
-
const found = matchTypeahead(
|
|
1131
|
+
const found = matchTypeahead(list, word, index);
|
|
574
1132
|
if (found >= 0) {
|
|
575
1133
|
event.preventDefault();
|
|
576
|
-
focusRow(
|
|
1134
|
+
focusRow(list[found].node.id, pinnedCopy);
|
|
577
1135
|
}
|
|
578
1136
|
}, [
|
|
1137
|
+
actOn,
|
|
1138
|
+
canCopy,
|
|
579
1139
|
canDrop,
|
|
1140
|
+
canRearrange,
|
|
1141
|
+
canWrap,
|
|
1142
|
+
clipboard,
|
|
1143
|
+
commitMove,
|
|
580
1144
|
focusRow,
|
|
581
1145
|
lockedReason,
|
|
1146
|
+
onCopy,
|
|
582
1147
|
onDelete,
|
|
583
|
-
|
|
1148
|
+
onDeleteMany,
|
|
1149
|
+
onMoveMany,
|
|
584
1150
|
onOpen,
|
|
1151
|
+
onPinnedChange,
|
|
585
1152
|
onRenameStart,
|
|
586
1153
|
onRowMenu,
|
|
587
1154
|
onSelect,
|
|
1155
|
+
onWrapStart,
|
|
588
1156
|
renamingId,
|
|
1157
|
+
rowEl,
|
|
589
1158
|
rows,
|
|
590
1159
|
shown,
|
|
591
1160
|
toggle,
|
|
1161
|
+
togglePin,
|
|
592
1162
|
]);
|
|
593
1163
|
// ── External drags (Finder files, gallery tiles) ──────────────────────────────────────
|
|
594
1164
|
const externalWanted = useCallback((event) => Boolean(onDropExternal) && (acceptsExternalDrag?.(event) ?? true), [acceptsExternalDrag, onDropExternal]);
|
|
@@ -623,12 +1193,10 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
623
1193
|
sprung.current = [];
|
|
624
1194
|
return;
|
|
625
1195
|
}
|
|
626
|
-
const
|
|
627
|
-
for (const id of sprung.current)
|
|
628
|
-
shut.add(id);
|
|
1196
|
+
const back = sprung.current;
|
|
629
1197
|
sprung.current = [];
|
|
630
|
-
|
|
631
|
-
}, [
|
|
1198
|
+
shutFolders(back);
|
|
1199
|
+
}, [shutFolders, stopSpring]);
|
|
632
1200
|
const externalHandlers = (node) => onDropExternal
|
|
633
1201
|
? {
|
|
634
1202
|
onDragOver: (event) => {
|
|
@@ -679,91 +1247,191 @@ export function FileTree({ nodes, selectedId, onSelect, onOpen, collapsed: colla
|
|
|
679
1247
|
: {};
|
|
680
1248
|
// ── Drawing ───────────────────────────────────────────────────────────────────────────
|
|
681
1249
|
const dropId = drag?.active && drag.offer.target?.kind === 'into' ? drag.offer.target.parentId : null;
|
|
682
|
-
const dragging = drag?.active ?
|
|
1250
|
+
const dragging = drag?.active ? draggedIdsOf(shown, drag.ids) : null;
|
|
1251
|
+
/**
|
|
1252
|
+
* What a click on `node` means, given its modifiers — the whole of shift-click and
|
|
1253
|
+
* ⌘-click.
|
|
1254
|
+
*
|
|
1255
|
+
* Returns whether the click was CONSUMED by the selection: a shift- or ⌘-click extends the
|
|
1256
|
+
* selection and stops there, because extending a selection is not also navigating and in a
|
|
1257
|
+
* consumer that opens on select it would be. A plain click reports `[id]` and falls
|
|
1258
|
+
* through to `onSelect`, so the ordinary act is unchanged.
|
|
1259
|
+
*/
|
|
1260
|
+
const clickSelected = (node, event,
|
|
1261
|
+
/* The rows a SHIFT-range is measured over — the section the click landed in, for the
|
|
1262
|
+
reason the arrows walk one section too: a range is what you can see yourself cover. */
|
|
1263
|
+
list) => {
|
|
1264
|
+
if (!onSelectionChange)
|
|
1265
|
+
return false;
|
|
1266
|
+
if (event.shiftKey) {
|
|
1267
|
+
// From the anchor, over the rows ON SCREEN — see `selectionRange`. `selectedId` is the
|
|
1268
|
+
// fallback so the very first shift-click after a deep link still has a start.
|
|
1269
|
+
onSelectionChange(selectionRange(list, anchor.current ?? selectedId, node.id));
|
|
1270
|
+
return true;
|
|
1271
|
+
}
|
|
1272
|
+
if (event.metaKey || event.ctrlKey) {
|
|
1273
|
+
anchor.current = node.id;
|
|
1274
|
+
onSelectionChange(toggleSelected(selection, node.id));
|
|
1275
|
+
return true;
|
|
1276
|
+
}
|
|
1277
|
+
anchor.current = node.id;
|
|
1278
|
+
onSelectionChange([node.id]);
|
|
1279
|
+
return false;
|
|
1280
|
+
};
|
|
683
1281
|
/**
|
|
684
1282
|
* The unnamed row, at whatever depth it is being made at.
|
|
685
1283
|
*
|
|
686
1284
|
* A function rather than an element so the two placements below — nested under a parent,
|
|
687
1285
|
* or at the root — cannot drift into drawing two different things.
|
|
1286
|
+
*
|
|
1287
|
+
* 🔴 A WRAP opens pre-filled and selected, with the first row's name minus its extension:
|
|
1288
|
+
* one keystroke replaces it, Enter accepts it, and the suggestion is the answer often
|
|
1289
|
+
* enough that it has to be the one already in the field. A plain new folder opens empty, as
|
|
1290
|
+
* it always has — there is nothing for it to be named after.
|
|
1291
|
+
*/
|
|
1292
|
+
const draftRow = (depth) => (_jsxs("div", { className: 'cbft-row cbft-row-draft', style: { '--cbft-depth': depth }, children: [_jsx("span", { className: 'cbft-slot', "aria-hidden": 'true', children: renderIcon ? (renderIcon({ id: '', name: '', kind: 'folder' })) : (_jsx(DefaultIcon, { node: { id: '', name: '', kind: 'folder' }, open: false })) }), nameField(wrapping?.name ?? '', (value) => commitDraft(value), () => onDraftCancel?.(),
|
|
1293
|
+
// A suggested name that comes back unchanged is the person AGREEING with it. Cancelling
|
|
1294
|
+
// there would make Enter mean "no" on the one field where it obviously means "yes".
|
|
1295
|
+
wrapping ? 'commit' : 'cancel')] }));
|
|
1296
|
+
const nameField = (initial, commit, cancel,
|
|
1297
|
+
/**
|
|
1298
|
+
* What a name that came back UNCHANGED means. A rename to the name a row already has is a
|
|
1299
|
+
* no-op, so it cancels and nothing is written; a pre-filled draft is the opposite — see
|
|
1300
|
+
* just above. An EMPTY field always cancels, whichever this is.
|
|
688
1301
|
*/
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
const
|
|
692
|
-
if (!
|
|
1302
|
+
unchanged = 'cancel') => {
|
|
1303
|
+
const done = (value) => {
|
|
1304
|
+
const name = value.trim();
|
|
1305
|
+
if (!name || (name === initial && unchanged === 'cancel'))
|
|
693
1306
|
cancel();
|
|
694
1307
|
else
|
|
695
|
-
commit(
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
event.
|
|
700
|
-
|
|
701
|
-
}
|
|
702
|
-
else if (event.key === 'Enter') {
|
|
703
|
-
event.preventDefault();
|
|
704
|
-
const value = event.currentTarget.value.trim();
|
|
705
|
-
if (!value || value === initial)
|
|
1308
|
+
commit(name);
|
|
1309
|
+
};
|
|
1310
|
+
return (_jsx(Input, { className: 'cbft-name-input', defaultValue: initial, autoFocus: true, "aria-label": 'Name', onFocus: (event) => event.currentTarget.select(), onClick: (event) => event.stopPropagation(), onPointerDown: (event) => event.stopPropagation(), onBlur: (event) => done(event.currentTarget.value), onKeyDown: (event) => {
|
|
1311
|
+
event.stopPropagation();
|
|
1312
|
+
if (event.key === 'Escape') {
|
|
1313
|
+
event.preventDefault();
|
|
706
1314
|
cancel();
|
|
1315
|
+
}
|
|
1316
|
+
else if (event.key === 'Enter') {
|
|
1317
|
+
event.preventDefault();
|
|
1318
|
+
done(event.currentTarget.value);
|
|
1319
|
+
}
|
|
1320
|
+
} }));
|
|
1321
|
+
};
|
|
1322
|
+
/**
|
|
1323
|
+
* One row — drawn in the tree, or drawn a second time in the pinned section above it.
|
|
1324
|
+
*
|
|
1325
|
+
* 🔴 ONE function for both, and that is the point rather than a tidiness. A pinned row is
|
|
1326
|
+
* the same row: it selects the same way, drags the same way, shows the same thumbnail, the
|
|
1327
|
+
* same padlock, the same count and the same tool shelf. Two copies of this markup would be
|
|
1328
|
+
* two rows that drift apart on the first change to either — the lesson `ListToolbar` was
|
|
1329
|
+
* extracted for one repo over. What `pinnedCopy` changes is only the five things that are
|
|
1330
|
+
* genuinely different, each marked below: which ref map holds it, which list its arrows
|
|
1331
|
+
* walk, which tab stop it competes for, that it is drawn FLAT, and that the rename field
|
|
1332
|
+
* stays on the real row while the real row is on screen.
|
|
1333
|
+
*/
|
|
1334
|
+
const renderRow = (row, pinnedCopy) => {
|
|
1335
|
+
const { node } = row;
|
|
1336
|
+
const list = pinnedCopy ? pinnedRows : rows;
|
|
1337
|
+
// One id or many, asked the same way: `selected` is `[selectedId]` for every
|
|
1338
|
+
// consumer that never passed `selectedIds`, so this is the old test verbatim.
|
|
1339
|
+
const isSelected = selected.has(node.id);
|
|
1340
|
+
/*
|
|
1341
|
+
* 🔴 Exactly ONE copy ever draws the name field, and it is the visible one.
|
|
1342
|
+
*
|
|
1343
|
+
* `renamingId` names a NODE, and a pinned node is two rows — so the naive test opens two
|
|
1344
|
+
* `<input autoFocus>`s with one `aria-label='Name'` between them, which is both an
|
|
1345
|
+
* ambiguous query for a screen reader and a race for the caret. The tree's copy wins
|
|
1346
|
+
* whenever it exists, because that is the row with the depth and the ancestors marked
|
|
1347
|
+
* above it; the pinned copy answers only when the row is inside a shut folder and the
|
|
1348
|
+
* tree is not drawing it at all — where the alternative is a rename that opens nowhere.
|
|
1349
|
+
*/
|
|
1350
|
+
const isRenaming = renamingId === node.id &&
|
|
1351
|
+
(!pinnedCopy || !rows.some((other) => other.node.id === node.id));
|
|
1352
|
+
const isDragged = dragging?.has(node.id) === true;
|
|
1353
|
+
const isDropTarget = dropId === node.id || externalOverId === node.id;
|
|
1354
|
+
const isPinned = pinned.includes(node.id);
|
|
1355
|
+
const marker = drag?.active && drag.offer.target && 'siblingId' in drag.offer.target
|
|
1356
|
+
? drag.offer.target.siblingId === node.id
|
|
1357
|
+
? drag.offer.target.kind
|
|
1358
|
+
: null
|
|
1359
|
+
: null;
|
|
1360
|
+
const rowBody = (_createElement("div", { role: 'treeitem', "aria-selected": isSelected, "aria-level": pinnedCopy ? 1 : row.depth + 1, "aria-posinset": pinnedCopy ? list.indexOf(row) + 1 : row.index + 1, "aria-setsize": pinnedCopy ? list.length : (setSizes.get(row.parentId ?? '') ?? 1), ...(row.expandable ? { 'aria-expanded': row.expanded } : {}),
|
|
1361
|
+
/* One stop per SECTION, not per row and not per node: `focused` carries which copy
|
|
1362
|
+
the keyboard is on, so the two trees do not both answer for one id. */
|
|
1363
|
+
tabIndex: node.id ===
|
|
1364
|
+
(pinnedCopy
|
|
1365
|
+
? focused?.pinned
|
|
1366
|
+
? focused.id
|
|
1367
|
+
: pinnedRows[0]?.node.id
|
|
1368
|
+
: focused && !focused.pinned
|
|
1369
|
+
? focused.id
|
|
1370
|
+
: (selectedId ?? rows[0]?.node.id))
|
|
1371
|
+
? 0
|
|
1372
|
+
: -1, ref: (el) => {
|
|
1373
|
+
const refs = pinnedCopy ? pinnedRefs : rowRefs;
|
|
1374
|
+
if (el)
|
|
1375
|
+
refs.current.set(node.id, el);
|
|
707
1376
|
else
|
|
708
|
-
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
|
|
1377
|
+
refs.current.delete(node.id);
|
|
1378
|
+
}, key: node.id, className: 'cbft-row', "data-selected": isSelected || undefined, "data-cut": cut?.has(node.id) || undefined, "data-dragged": isDragged || undefined, "data-into": isDropTarget || undefined, "data-marker": marker ?? undefined, "data-pinned": isPinned || undefined, "data-pinned-copy": pinnedCopy || undefined, "data-ancestor": openPath.path.has(node.id) || undefined, "data-holds-open": (row.expandable && !row.expanded && openPath.holder === node.id) || undefined, "data-access": node.access && node.access !== 'normal' ? node.access : undefined, style: { '--cbft-depth': row.depth }, onPointerDown: (event) => {
|
|
1379
|
+
if (inTools(event.target))
|
|
1380
|
+
return;
|
|
1381
|
+
beginDrag(event, node);
|
|
1382
|
+
}, onClick: (event) => {
|
|
1383
|
+
if (inTools(event.target))
|
|
1384
|
+
return;
|
|
1385
|
+
// The click a finished drag left behind — see `draggedJustNow`.
|
|
1386
|
+
if (draggedJustNow.current) {
|
|
1387
|
+
draggedJustNow.current = false;
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
if (clickSelected(node, event, list))
|
|
1391
|
+
return;
|
|
1392
|
+
onSelect(node);
|
|
1393
|
+
}, onDoubleClick: (event) => {
|
|
1394
|
+
if (inTools(event.target))
|
|
1395
|
+
return;
|
|
1396
|
+
(onOpen ?? onSelect)(node);
|
|
1397
|
+
}, onKeyDown: (event) => onKeyDown(event, row, list, pinnedCopy), onFocus: () => setFocused({ id: node.id, pinned: pinnedCopy }), ...externalHandlers(node) },
|
|
1398
|
+
row.expandable ? (_jsx("span", { className: 'cbft-twisty', "data-open": row.expanded || undefined, "aria-hidden": 'true', onPointerDown: (event) => event.stopPropagation(), onClick: (event) => {
|
|
1399
|
+
event.stopPropagation();
|
|
1400
|
+
toggle(node.id);
|
|
1401
|
+
}, children: _jsx("svg", { viewBox: '0 0 24 24', width: '12', height: '12', fill: 'none', stroke: 'currentColor', strokeWidth: '2.2', strokeLinecap: 'round', strokeLinejoin: 'round', focusable: 'false', "aria-hidden": 'true', children: _jsx("path", { d: 'm9 6 6 6-6 6' }) }) })) : (_jsx("span", { className: 'cbft-slot', "aria-hidden": 'true', children: _jsx(RowGlyph, { node: node, open: row.expanded, ...(renderIcon ? { renderIcon } : {}) }) })),
|
|
1402
|
+
isRenaming && onRename ? (nameField(node.name, (value) => onRename(node, value), () => onRenameCancel?.())) : (_jsx(Tooltip, { content: node.name, children: _jsx("span", { className: 'cbft-name', children: node.name }) })),
|
|
1403
|
+
node.access === 'private' || node.access === 'locked' ? (_jsx(Tooltip, { content: accessMarkCopy(node.access, accessCopy), children: _jsx("span", { className: 'cbft-access', role: 'img', "aria-label": 'Private', children: "\uD83D\uDD12" }) })) : null,
|
|
1404
|
+
renderAccessory ? renderAccessory(node) : null,
|
|
1405
|
+
node.count !== undefined ? _jsx("span", { className: 'cbft-count', children: node.count }) : null,
|
|
1406
|
+
onPinnedChange ? (_jsx("span", { className: 'cbft-tools cbft-pin-shelf', children: _jsx(Tooltip, { content: isPinned ? 'Unpin from the top' : 'Pin to the top', children: _jsx("button", {
|
|
1407
|
+
// guardrails-ignore no-raw-action-button: a 16px glyph inside a 22px tree row,
|
|
1408
|
+
// sized by `--cbft-glyph` exactly as the twisty beside it is. cursedbelt's
|
|
1409
|
+
// Button/IconButton carry the shared 36px control height and Tailwind classes
|
|
1410
|
+
// this stylesheet deliberately does not rely on (see fileTree.css's header for
|
|
1411
|
+
// why the tree ships plain CSS), so a control-class primitive here would make
|
|
1412
|
+
// one row taller than every other — the geometry `file-tree-geometry.json`
|
|
1413
|
+
// pins. The native element is used precisely so Enter/Space, focus and
|
|
1414
|
+
// disabled semantics stay the browser's.
|
|
1415
|
+
type: 'button', className: 'cbft-pin', "aria-pressed": isPinned, "aria-label": isPinned ? `Unpin ${node.name}` : `Pin ${node.name}`, onPointerDown: (event) => event.stopPropagation(), onClick: (event) => {
|
|
1416
|
+
event.stopPropagation();
|
|
1417
|
+
togglePin(node);
|
|
1418
|
+
}, children: _jsxs("svg", { viewBox: '0 0 24 24', width: '12', height: '12', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round', focusable: 'false', "aria-hidden": 'true', children: [_jsx("path", { d: 'M12 17v5' }), _jsx("path", { d: 'M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z' })] }) }) }) })) : null,
|
|
1419
|
+
renderTools ? _jsx("span", { className: 'cbft-tools', children: renderTools(node) }) : null));
|
|
1420
|
+
// The "why not" only exists while the tree genuinely cannot be rearranged;
|
|
1421
|
+
// wrapping every row in a tooltip otherwise would put a hint on a working
|
|
1422
|
+
// control saying nothing.
|
|
1423
|
+
const body = unavailableReason && !canRearrange ? (_jsx(Tooltip, { content: unavailableReason, children: rowBody }, node.id)) : (rowBody);
|
|
1424
|
+
// The draft belongs INSIDE the folder it is being made in, one level deeper —
|
|
1425
|
+
// see the note above the parentless case. Never under a PINNED copy: the draft is
|
|
1426
|
+
// being made in a place, and the place is where the folder actually lives.
|
|
1427
|
+
return !pinnedCopy && draft && draft.parentId === node.id ? (_jsxs(Fragment, { children: [body, draftRow(row.depth + 1)] }, node.id)) : (body);
|
|
1428
|
+
};
|
|
1429
|
+
return (_jsxs("div", { className: `cbft${className ? ` ${className}` : ''}`, children: [pinnedRows.length > 0 ? (_jsxs("div", { className: 'cbft-pinned', children: [_jsx("div", { className: 'cbft-pinned-head', children: pinnedLabel }), _jsx("div", { role: 'tree', "aria-label": pinnedLabel, ...(onSelectionChange ? { 'aria-multiselectable': true } : {}), className: `cbft-pinned-list${drag?.active ? ' cbft-list-dragging' : ''}`, children: pinnedRows.map((row) => renderRow(row, true)) })] })) : null, _jsx("div", { className: 'cbft-scroll', ref: scrollRef, ...externalHandlers(null), onPointerUp: () => {
|
|
712
1430
|
// Releasing over the blank space means the top level; the pointer capture keeps
|
|
713
1431
|
// the row's own listeners running, so this only has to not swallow the event.
|
|
714
|
-
}, children: _jsxs("div", { role: 'tree', "aria-label": ariaLabel, className: `cbft-list${drag?.active ? ' cbft-list-dragging' : ''}`, children: [rows.length === 0 && !draft ? _jsx("div", { className: 'cbft-empty', children: empty }) : null, draft && draft.parentId === null ? draftRow(0) : null, rows.map((row) =>
|
|
715
|
-
const { node } = row;
|
|
716
|
-
const isSelected = node.id === selectedId;
|
|
717
|
-
const isRenaming = renamingId === node.id;
|
|
718
|
-
const isDragged = dragging?.has(node.id) === true;
|
|
719
|
-
const isDropTarget = dropId === node.id || externalOverId === node.id;
|
|
720
|
-
const marker = drag?.active && drag.offer.target && 'siblingId' in drag.offer.target
|
|
721
|
-
? drag.offer.target.siblingId === node.id
|
|
722
|
-
? drag.offer.target.kind
|
|
723
|
-
: null
|
|
724
|
-
: null;
|
|
725
|
-
const rowBody = (_createElement("div", { role: 'treeitem', "aria-selected": isSelected, "aria-level": row.depth + 1, "aria-posinset": row.index + 1, "aria-setsize": setSizes.get(row.parentId ?? '') ?? 1, ...(row.expandable ? { 'aria-expanded': row.expanded } : {}), tabIndex: node.id === (focusedId ?? selectedId ?? rows[0]?.node.id) ? 0 : -1, ref: (el) => {
|
|
726
|
-
if (el)
|
|
727
|
-
rowRefs.current.set(node.id, el);
|
|
728
|
-
else
|
|
729
|
-
rowRefs.current.delete(node.id);
|
|
730
|
-
}, key: node.id, className: 'cbft-row', "data-selected": isSelected || undefined, "data-dragged": isDragged || undefined, "data-into": isDropTarget || undefined, "data-marker": marker ?? undefined, "data-ancestor": openPath.path.has(node.id) || undefined, "data-holds-open": (row.expandable && !row.expanded && openPath.holder === node.id) || undefined, "data-access": node.access && node.access !== 'normal' ? node.access : undefined, style: { '--cbft-depth': row.depth }, onPointerDown: (event) => {
|
|
731
|
-
if (inTools(event.target))
|
|
732
|
-
return;
|
|
733
|
-
beginDrag(event, node);
|
|
734
|
-
}, onClick: (event) => {
|
|
735
|
-
if (inTools(event.target))
|
|
736
|
-
return;
|
|
737
|
-
// The click a finished drag left behind — see `draggedJustNow`.
|
|
738
|
-
if (draggedJustNow.current) {
|
|
739
|
-
draggedJustNow.current = false;
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
onSelect(node);
|
|
743
|
-
}, onDoubleClick: (event) => {
|
|
744
|
-
if (inTools(event.target))
|
|
745
|
-
return;
|
|
746
|
-
(onOpen ?? onSelect)(node);
|
|
747
|
-
}, onKeyDown: (event) => onKeyDown(event, row), onFocus: () => setFocusedId(node.id), ...externalHandlers(node) },
|
|
748
|
-
row.expandable ? (_jsx("span", { className: 'cbft-twisty', "data-open": row.expanded || undefined, "aria-hidden": 'true', onPointerDown: (event) => event.stopPropagation(), onClick: (event) => {
|
|
749
|
-
event.stopPropagation();
|
|
750
|
-
toggle(node.id);
|
|
751
|
-
}, children: _jsx("svg", { viewBox: '0 0 24 24', width: '12', height: '12', fill: 'none', stroke: 'currentColor', strokeWidth: '2.2', strokeLinecap: 'round', strokeLinejoin: 'round', focusable: 'false', "aria-hidden": 'true', children: _jsx("path", { d: 'm9 6 6 6-6 6' }) }) })) : (_jsx("span", { className: 'cbft-slot', "aria-hidden": 'true', children: _jsx(RowGlyph, { node: node, open: row.expanded, ...(renderIcon ? { renderIcon } : {}) }) })),
|
|
752
|
-
isRenaming && onRename ? (nameField(node.name, (value) => onRename(node, value), () => onRenameCancel?.())) : (_jsx(Tooltip, { content: node.name, children: _jsx("span", { className: 'cbft-name', children: node.name }) })),
|
|
753
|
-
node.access === 'private' || node.access === 'locked' ? (_jsx(Tooltip, { content: accessMarkCopy(node.access, accessCopy), children: _jsx("span", { className: 'cbft-access', role: 'img', "aria-label": 'Private', children: "\uD83D\uDD12" }) })) : null,
|
|
754
|
-
renderAccessory ? renderAccessory(node) : null,
|
|
755
|
-
node.count !== undefined ? (_jsx("span", { className: 'cbft-count', children: node.count })) : null,
|
|
756
|
-
renderTools ? _jsx("span", { className: 'cbft-tools', children: renderTools(node) }) : null));
|
|
757
|
-
// The "why not" only exists while the tree genuinely cannot be rearranged;
|
|
758
|
-
// wrapping every row in a tooltip otherwise would put a hint on a working
|
|
759
|
-
// control saying nothing.
|
|
760
|
-
const body = unavailableReason && !onMove ? (_jsx(Tooltip, { content: unavailableReason, children: rowBody }, node.id)) : (rowBody);
|
|
761
|
-
// The draft belongs INSIDE the folder it is being made in, one level deeper —
|
|
762
|
-
// see the note above the parentless case.
|
|
763
|
-
return draft && draft.parentId === node.id ? (_jsxs(Fragment, { children: [body, draftRow(row.depth + 1)] }, node.id)) : (body);
|
|
764
|
-
}), draft && draft.parentId !== null && !rows.some((r) => r.node.id === draft.parentId)
|
|
1432
|
+
}, children: _jsxs("div", { role: 'tree', "aria-label": ariaLabel, ...(onSelectionChange ? { 'aria-multiselectable': true } : {}), className: `cbft-list${drag?.active ? ' cbft-list-dragging' : ''}`, children: [rows.length === 0 && !draft ? _jsx("div", { className: 'cbft-empty', children: empty }) : null, draft && draft.parentId === null ? draftRow(0) : null, rows.map((row) => renderRow(row, false)), draft && draft.parentId !== null && !rows.some((r) => r.node.id === draft.parentId)
|
|
765
1433
|
? draftRow(0)
|
|
766
|
-
: null] }) }), drag?.active ? (_jsxs("div", { className: 'cbft-chip', "data-refused": drag.offer.refused || undefined, style: { left: `${drag.x}px`, top: `${drag.y}px` }, "aria-hidden": 'true', children: [_jsx("span", { className: 'cbft-chip-name', children: nameOf(shown, drag.id) }), _jsx("span", { className: 'cbft-chip-where', children: drag.offer.reason ?? describeDrop(shown, drag.offer.target) })] })) : null, _jsx("span", { className: 'cbft-sr', role: 'status', "aria-live": 'polite', children: drag?.active ? (drag.offer.reason ?? describeDrop(shown, drag.offer.target)) : notice })] }));
|
|
1434
|
+
: null] }) }), drag?.active ? (_jsxs("div", { className: 'cbft-chip', "data-refused": drag.offer.refused || undefined, style: { left: `${drag.x}px`, top: `${drag.y}px` }, "aria-hidden": 'true', children: [_jsx("span", { className: 'cbft-chip-name', children: drag.ids.length > 1 ? `${drag.ids.length} items` : nameOf(shown, drag.id) }), _jsx("span", { className: 'cbft-chip-where', children: drag.offer.reason ?? describeDrop(shown, drag.offer.target) })] })) : null, _jsx("span", { className: 'cbft-sr', role: 'status', "aria-live": 'polite', children: drag?.active ? (drag.offer.reason ?? describeDrop(shown, drag.offer.target)) : notice })] }));
|
|
767
1435
|
}
|
|
768
1436
|
/**
|
|
769
1437
|
* Whether an event landed on the consumer's own row controls.
|