cursedbelt 3.0.2 → 4.0.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 +111 -31
- 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/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/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
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
* graduation. They are here now and each says so where it lives:
|
|
27
27
|
*
|
|
28
28
|
* 1. **Reveal** — the selected row is scrolled into view, and a shut folder holding it is
|
|
29
|
-
* marked and scrolled to
|
|
29
|
+
* marked and scrolled to rather than forced open unless `expandOnReveal` says otherwise
|
|
30
|
+
* (see the effect below, and the section on it).
|
|
30
31
|
* 2. **The path is marked** — `data-ancestor` on the folders between the root and the
|
|
31
32
|
* selection, `data-holds-open` on the shut one holding it (`openPath`).
|
|
32
33
|
* 3. **Escape cancels a drag** (`beginDrag`), which `pointercancel` never did.
|
|
@@ -45,6 +46,100 @@
|
|
|
45
46
|
* needs and a design system should not have is an argument for a prop, not for a copy: the
|
|
46
47
|
* three claimants collapse into one `selectedId`, and a kind-specific glyph is `renderIcon`.
|
|
47
48
|
*
|
|
49
|
+
* ── 🔴 Multi-select, 2026-09-15 — and why `selectedId` did not move ─────────────────────
|
|
50
|
+
* Owner, `ideas/file-tree.md` item 2: shift-click a range, ⌘-click to add one, drag the whole
|
|
51
|
+
* selection to another folder or to the root, ⌘⌫ the lot, and ⌘X/⌘C/⌘V to move or copy them.
|
|
52
|
+
* Three apps were waiting on it and none of them forked the component to get it.
|
|
53
|
+
*
|
|
54
|
+
* Every one of those is a change to this SHARED contract, and the trap in it is that
|
|
55
|
+
* `selectedId: string | null` is load-bearing in all three: it is the row the main pane is
|
|
56
|
+
* showing in `apps/collections`, and the folder the library is filtered by in `apps/music`.
|
|
57
|
+
* So it did not move. {@link FileTreeProps.selectedIds} sits BESIDE it, the selection is
|
|
58
|
+
* `[selectedId]` for a consumer that passes neither, and every plural callback is its own
|
|
59
|
+
* optional prop — `onMoveMany`, `onDeleteMany`, `onCopy` — with one rule holding it together:
|
|
60
|
+
* **N > 1 goes through the plural verb, and without it the gesture acts on the one row it
|
|
61
|
+
* started on**. A tree that silently became multi-select would have broken "what am I looking
|
|
62
|
+
* at" in two shipped apps on the day it published.
|
|
63
|
+
*
|
|
64
|
+
* The arithmetic is `fileTreeSelection.ts`, DOM-free and unit-tested, for the same reason
|
|
65
|
+
* `core/file-tree/fileTreeModel.ts` is: happy-dom computes no layout, so the pointer half of
|
|
66
|
+
* a multi-drag cannot be asserted anywhere and the RULES have to be reachable without it.
|
|
67
|
+
*
|
|
68
|
+
* ── 🔴 New Folder with Selection, 2026-09-16 ────────────────────────────────────────────
|
|
69
|
+
* Owner, 2026-09-13: *"allow make dir from file where dir wraps file location and gets name
|
|
70
|
+
* of file"*. Half of it was already here — the {@link FileTreeProps.draft} row, which names a
|
|
71
|
+
* folder before anything is created — and the other half is `onMove`/`onMoveMany`, so this is
|
|
72
|
+
* one gesture out of two things that existed rather than a new mechanism: the draft grew a
|
|
73
|
+
* `wrap` list, and committing hands the name AND the ids back in a single call. Two calls is
|
|
74
|
+
* how a folder ends up made and empty when the second one fails.
|
|
75
|
+
*
|
|
76
|
+
* It is Finder's *New Folder with Selection* and VS Code's ⌃⌘N, invented no further: the
|
|
77
|
+
* folder is made where the row already is, named after it with the extension dropped, and the
|
|
78
|
+
* row moves in. A selection of twenty is the same code path with N > 1, named after the first
|
|
79
|
+
* in TREE order — see `planWrap`, which is where all of that is decided without a DOM.
|
|
80
|
+
*
|
|
81
|
+
* ── 🔴 Pinned rows, 2026-09-16 ──────────────────────────────────────────────────────────
|
|
82
|
+
* Owner, 2026-09-13: *"need ces ability to pin open files where they stay at the top of the
|
|
83
|
+
* file tree in a new section above the folders. Pinned will mean I want to keep them there
|
|
84
|
+
* until I unpin them. This makes it easier to keep track of important files and folders. They
|
|
85
|
+
* still will exist in the folder they were stored in and unpinning takes it back to normal.
|
|
86
|
+
* Pinning or unpinning one place doesn't change pinning in the other place."*
|
|
87
|
+
*
|
|
88
|
+
* Five things in that paragraph, and the last two are the ones a sort order loses:
|
|
89
|
+
*
|
|
90
|
+
* 1. **A SECTION, not an order.** A pinned row is drawn TWICE — once at the top and once
|
|
91
|
+
* where it lives, because *"they still will exist in the folder they were stored in"*.
|
|
92
|
+
* Lifting the row out would make pinning a move, and unpinning an undo of one.
|
|
93
|
+
* 2. **Files and folders both** — *"important files and folders"*.
|
|
94
|
+
* 3. **Sticky until unpinned.** It is not a recents list and it does not age out, which is
|
|
95
|
+
* why the list is the consumer's to persist rather than the tree's to remember.
|
|
96
|
+
* 4. **Unpinning leaves nothing behind**: the top copy goes and the row in the folder is
|
|
97
|
+
* the row it always was.
|
|
98
|
+
* 5. 🔴 **Per place.** *"Pinning or unpinning one place doesn't change pinning in the other
|
|
99
|
+
* place."* That is free here and only because the state is CONTROLLED — the consumer
|
|
100
|
+
* holds one list per tree, exactly as `media/stageResize.ts` holds one height per
|
|
101
|
+
* consumer key (*"one shared key would make sizing either of them silently resize the
|
|
102
|
+
* other"* — the same defect, one surface along).
|
|
103
|
+
*
|
|
104
|
+
* 🔴 **One identity, two row keys.** Every selection, drag, reveal and aria path in here is
|
|
105
|
+
* keyed on `node.id`, and drawing a row twice would break each of them if both copies claimed
|
|
106
|
+
* the same key: `openPath`/`data-holds-open` walks for the selected row and would find the
|
|
107
|
+
* pinned copy, marking the wrong ancestors. So the node's id stays its identity and the COPY
|
|
108
|
+
* is addressed separately — two ref maps (`rowRefs`, `pinnedRefs`), and `focused` says which
|
|
109
|
+
* section it is in. `apps/collections` makes the same split with `itemRowId`/`nodeRowId`.
|
|
110
|
+
*
|
|
111
|
+
* 🔴 **Its own `role='tree'`, outside the scroller.** `aria-setsize`/`aria-posinset` below are
|
|
112
|
+
* computed from the rows ON SCREEN, on the stated assumption that every sibling of a visible
|
|
113
|
+
* row is itself visible — folding pins into that row list would make the counts wrong for
|
|
114
|
+
* every row in the tree. The section is a separate widget with counts of its own, and it sits
|
|
115
|
+
* ABOVE `.cbft-scroll` rather than sticky inside it, so the reveal below, the autoscroll and
|
|
116
|
+
* the blank-space drop target all still mean exactly what they meant.
|
|
117
|
+
*
|
|
118
|
+
* The pinned copy is a SHORTCUT, not a second editing surface: it is flat (no twisty, no
|
|
119
|
+
* depth), a drop aimed at it lands in the real folder, and the rename field draws on the real
|
|
120
|
+
* row whenever the real row is on screen.
|
|
121
|
+
*
|
|
122
|
+
* ── 🔴 The reveal OPENS the folder now, if it is asked to, 2026-09-16 ───────────────────
|
|
123
|
+
* Owner, `ideas/collections-09-14.md`: *"clicking a file in media gallery content window
|
|
124
|
+
* should show that file in the file tree by like vscode does when you select a tab with a
|
|
125
|
+
* file in it."* Almost all of that was already here — `selectedId`, `openPath`,
|
|
126
|
+
* `data-ancestor`/`data-holds-open` and the scroll — and the one thing missing was the one
|
|
127
|
+
* thing this file had DECIDED against: limit 1 of the reveal, *a shut folder stays shut*.
|
|
128
|
+
*
|
|
129
|
+
* He named VS Code, whose `explorer.autoReveal` is `true` and expands, and he named the exact
|
|
130
|
+
* gesture. So the ask wins over the default, and the decision is recorded in full on the
|
|
131
|
+
* effect itself rather than here: {@link FileTreeProps.expandOnReveal} is OFF unless a
|
|
132
|
+
* consumer wires it, and what it opens goes into an overlay of this component's own
|
|
133
|
+
* (`revealOpened`) which is **never handed to `onCollapsedChange`**. The persisted collapse
|
|
134
|
+
* is read back unchanged on the next mount, which is the half of limit 1's objection that was
|
|
135
|
+
* never a matter of taste.
|
|
136
|
+
*
|
|
137
|
+
* 🔴 Silent is not the same as invisible, and {@link FileTreeProps.onRevealExpand} is the
|
|
138
|
+
* difference. A tree that FETCHES a folder's children when it opens — `apps/collections` asks
|
|
139
|
+
* the server per open folder — would otherwise draw the folder the reveal opened with nothing
|
|
140
|
+
* in it, because the consumer never learned the expansion happened. So it is told, in a
|
|
141
|
+
* callback that exists to feed a fetch plan and explicitly not to be stored.
|
|
142
|
+
*
|
|
48
143
|
* ── Pointer events, not HTML5 drag-and-drop, for INTERNAL drags ─────────────────────────
|
|
49
144
|
* The ghost image is a translucent screenshot nobody asked for, `dragover` fires at the
|
|
50
145
|
* mercy of the browser, and touch does not have HTML5 dnd at all. The drag only *starts*
|
|
@@ -71,19 +166,29 @@ import {
|
|
|
71
166
|
type AccessMarkCopy,
|
|
72
167
|
accessMarkCopy,
|
|
73
168
|
describeDrop,
|
|
74
|
-
descendantIdsOf,
|
|
75
169
|
type DropOffer,
|
|
76
170
|
type FileTreeDrop,
|
|
77
171
|
type FileTreeNode,
|
|
78
172
|
type FileTreeRow,
|
|
79
173
|
filterFileTree,
|
|
174
|
+
findFileTreeNode,
|
|
80
175
|
flattenFileTree,
|
|
81
176
|
matchTypeahead,
|
|
82
|
-
resolveDrop,
|
|
83
177
|
resolveKeyboardMove,
|
|
84
178
|
} from 'cursedbelt-core/file-tree/file-tree-model';
|
|
85
179
|
import { Input } from '../components/Input';
|
|
86
180
|
import { Tooltip } from '../components/Tooltip';
|
|
181
|
+
import {
|
|
182
|
+
draggedIdsOf,
|
|
183
|
+
nodesByIds,
|
|
184
|
+
planWrap,
|
|
185
|
+
refuseCopy,
|
|
186
|
+
refuseWrap,
|
|
187
|
+
resolveMultiDrop,
|
|
188
|
+
selectionRange,
|
|
189
|
+
toggleSelected,
|
|
190
|
+
topmostSelected,
|
|
191
|
+
} from './fileTreeSelection';
|
|
87
192
|
import './fileTree.css';
|
|
88
193
|
|
|
89
194
|
/** How far a press travels before it stops being a click and becomes a drag. */
|
|
@@ -108,7 +213,16 @@ const SPRING_OPEN_MS = 600;
|
|
|
108
213
|
const TYPEAHEAD_RESET_MS = 1000;
|
|
109
214
|
|
|
110
215
|
interface DragState {
|
|
216
|
+
/** The row the press landed on — the chip's name, and the single-row `onMove` argument. */
|
|
111
217
|
id: string;
|
|
218
|
+
/**
|
|
219
|
+
* EVERYTHING in the air, `id` included.
|
|
220
|
+
*
|
|
221
|
+
* 🔴 One id was the whole of this until 2026-09-15, and it is why a selection could not be
|
|
222
|
+
* dragged at all: the gesture had nowhere to carry the other rows. It is always at least
|
|
223
|
+
* `[id]`, so the single-row drag is the plural one with N = 1 rather than a second path.
|
|
224
|
+
*/
|
|
225
|
+
ids: readonly string[];
|
|
112
226
|
startX: number;
|
|
113
227
|
startY: number;
|
|
114
228
|
/**
|
|
@@ -127,11 +241,41 @@ interface DragState {
|
|
|
127
241
|
|
|
128
242
|
const NO_OFFER: DropOffer = { target: null, refused: false, reason: null };
|
|
129
243
|
|
|
244
|
+
/**
|
|
245
|
+
* One frozen empty list for "nothing is pinned", so a consumer that passes no `pinnedIds`
|
|
246
|
+
* hands the memos below a stable reference instead of a new `[]` every render.
|
|
247
|
+
*/
|
|
248
|
+
const NO_PINS: readonly string[] = [];
|
|
249
|
+
|
|
130
250
|
export interface FileTreeProps {
|
|
131
251
|
nodes: readonly FileTreeNode[];
|
|
132
252
|
/** The row drawn as selected — usually "what the main pane is showing". */
|
|
133
253
|
selectedId: string | null;
|
|
134
254
|
onSelect: (node: FileTreeNode) => void;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The rows drawn as selected when there is more than one — shift-click and ⌘-click.
|
|
258
|
+
*
|
|
259
|
+
* 🔴 BESIDE {@link selectedId}, never instead of it, and that is the whole shape of this
|
|
260
|
+
* feature. `selectedId` is load-bearing in every consumer this tree has: in
|
|
261
|
+
* `apps/collections` it is the row the main pane is showing, in `apps/music` it is the
|
|
262
|
+
* folder the library is filtered by. Those are singular questions with singular answers,
|
|
263
|
+
* and a tree that silently became multi-select would break "what am I looking at" in two
|
|
264
|
+
* shipped apps on the day it published. So a consumer that passes neither this nor
|
|
265
|
+
* {@link onSelectionChange} gets EXACTLY today's behaviour, byte for byte.
|
|
266
|
+
*
|
|
267
|
+
* Omit it and the selection is `[selectedId]`, which is what makes ⌘-click work for a
|
|
268
|
+
* consumer that stores only the one id and grows into the array afterwards.
|
|
269
|
+
*/
|
|
270
|
+
selectedIds?: readonly string[];
|
|
271
|
+
/**
|
|
272
|
+
* The selection changed by a gesture. **Wiring it is what turns multi-select on.**
|
|
273
|
+
*
|
|
274
|
+
* A plain click reports `[id]` and still calls {@link onSelect}; shift-click and ⌘-click
|
|
275
|
+
* report the new set and do NOT — in every file explorer, extending a selection is not
|
|
276
|
+
* also navigating, and in a consumer that opens on select it would be.
|
|
277
|
+
*/
|
|
278
|
+
onSelectionChange?: (ids: string[]) => void;
|
|
135
279
|
/**
|
|
136
280
|
* Double-click, or Space on a focused row. Falls back to `onSelect` when omitted.
|
|
137
281
|
*
|
|
@@ -142,12 +286,97 @@ export interface FileTreeProps {
|
|
|
142
286
|
/** Shut folders, as a controlled value. Omit both for the tree's own memory. */
|
|
143
287
|
collapsed?: readonly string[];
|
|
144
288
|
onCollapsedChange?: (next: string[]) => void;
|
|
289
|
+
/**
|
|
290
|
+
* **Open the shut folders on the way to the selection**, instead of marking the shut one
|
|
291
|
+
* and stopping there — VS Code's `explorer.autoReveal`, which is on by default over there.
|
|
292
|
+
*
|
|
293
|
+
* Owner, `ideas/collections-09-14.md`: *"clicking a file in media gallery content window
|
|
294
|
+
* should show that file in the file tree by like vscode does when you select a tab with a
|
|
295
|
+
* file in it."* He named the application and he named the gesture, so this is what the
|
|
296
|
+
* reveal does when it is asked for by name — see limit 1 on the reveal effect for the
|
|
297
|
+
* argument this prop is the opt-in half of, and why it stays the default.
|
|
298
|
+
*
|
|
299
|
+
* 🔴 **It never touches {@link collapsed}.** The folders it opens are held in an overlay of
|
|
300
|
+
* the tree's own and {@link onCollapsedChange} is not called, so the collapse the consumer
|
|
301
|
+
* persisted is still the collapse it persisted — on the next mount the tree reads it back
|
|
302
|
+
* unchanged. Shutting one of them by hand drops it from the overlay and the persisted value
|
|
303
|
+
* decides again; nothing about it is ever written out. That is the whole of the objection
|
|
304
|
+
* limit 1 records, answered without giving up the behaviour he asked for.
|
|
305
|
+
*
|
|
306
|
+
* Off unless it is wired, like every other verb here: a consumer that passes nothing gets
|
|
307
|
+
* byte for byte today's reveal.
|
|
308
|
+
*/
|
|
309
|
+
expandOnReveal?: boolean;
|
|
310
|
+
/**
|
|
311
|
+
* The folders {@link expandOnReveal} just opened, told to the consumer once per reveal.
|
|
312
|
+
*
|
|
313
|
+
* 🔴 **Not a request to persist them** — that is the one thing this must never be read as,
|
|
314
|
+
* and the whole reason the overlay exists. It is for a tree whose children are FETCHED when
|
|
315
|
+
* a folder opens: `apps/collections` asks the server for a folder's files only for the
|
|
316
|
+
* folders it believes are open (`ExplorerSidebar.tsx` → `nodesToFetch`), so an expansion it
|
|
317
|
+
* cannot see would draw the folder open and EMPTY, with the file the reveal was asked for
|
|
318
|
+
* still missing. A silent overlay is the right default and an unobservable one is a trap.
|
|
319
|
+
*
|
|
320
|
+
* Wire it to whatever decides the fetch plan, never to the stored collapse.
|
|
321
|
+
*/
|
|
322
|
+
onRevealExpand?: (ids: readonly string[]) => void;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The rows kept in a section of their own ABOVE the folders — *"pin open files where they
|
|
326
|
+
* stay at the top of the file tree in a new section above the folders"*.
|
|
327
|
+
*
|
|
328
|
+
* Files and folders both, in the order they were pinned, and a pin the tree no longer holds
|
|
329
|
+
* is simply not drawn rather than drawn as a ghost. Each one appears TWICE: at the top and
|
|
330
|
+
* in the folder it lives in, which is the requirement — *"they still will exist in the
|
|
331
|
+
* folder they were stored in"*.
|
|
332
|
+
*
|
|
333
|
+
* 🔴 CONTROLLED, with no fallback to a memory of the tree's own — the one place this
|
|
334
|
+
* differs from {@link collapsed}, and deliberately. *"Pinned will mean I want to keep them
|
|
335
|
+
* there until I unpin them"*: a list this component remembered would be a list that
|
|
336
|
+
* evaporated on the next mount, which is the opposite of what the word means. Holding it
|
|
337
|
+
* outside is also the whole of *"pinning or unpinning one place doesn't change pinning in
|
|
338
|
+
* the other place"* — one list per tree, per app, wherever the consumer persists it, the
|
|
339
|
+
* same reasoning `media/stageResize.ts` records for its per-consumer height key.
|
|
340
|
+
*
|
|
341
|
+
* Omit it and there is no section, no pin control and no chord: byte for byte today's tree.
|
|
342
|
+
*/
|
|
343
|
+
pinnedIds?: readonly string[];
|
|
344
|
+
/**
|
|
345
|
+
* Pin or unpin a row. **Wiring it is what puts the pin control on every row and ⌃⌘T on the
|
|
346
|
+
* keyboard**, the same rule every other verb here follows.
|
|
347
|
+
*
|
|
348
|
+
* The next list arrives whole (a new pin is appended, so the section keeps the order it was
|
|
349
|
+
* built in) and the consumer stores it. Pass {@link pinnedIds} without this to draw a
|
|
350
|
+
* read-only section and put the unpin on your own ⋯ menu.
|
|
351
|
+
*/
|
|
352
|
+
onPinnedChange?: (next: string[]) => void;
|
|
353
|
+
/**
|
|
354
|
+
* What the pinned section is called — its heading, and its `aria-label`. Default `Pinned`.
|
|
355
|
+
*
|
|
356
|
+
* An app whose vocabulary differs ("Favourites", "Starred") passes its own, for the same
|
|
357
|
+
* reason {@link accessCopy} exists: the mechanism is general and the words are not.
|
|
358
|
+
*/
|
|
359
|
+
pinnedLabel?: string;
|
|
145
360
|
|
|
146
361
|
/**
|
|
147
362
|
* Commit a drag. Omit it and the tree cannot be rearranged at all — no press starts a
|
|
148
363
|
* drag, and {@link unavailableReason} says why on hover.
|
|
149
364
|
*/
|
|
150
365
|
onMove?: (nodeId: string, drop: FileTreeDrop) => void;
|
|
366
|
+
/**
|
|
367
|
+
* Commit a move of MORE THAN ONE row — a dragged selection, or ⌘X then ⌘V.
|
|
368
|
+
*
|
|
369
|
+
* 🔴 The single rule that keeps every plural gesture from surprising an old consumer:
|
|
370
|
+
* **N > 1 always goes through this prop, and without it every gesture degrades to the one
|
|
371
|
+
* row it started on.** A tree that fell back to calling `onMove` N times would hand a
|
|
372
|
+
* consumer N separate writes against a tree that is re-derived between each of them, which
|
|
373
|
+
* is how a batch move half-lands.
|
|
374
|
+
*
|
|
375
|
+
* The ids arrive with descendants of other selected rows already removed — see
|
|
376
|
+
* `topmostSelected`. Moving a folder moves what is in it, so a selected child is the same
|
|
377
|
+
* move named twice, and acting on both flattens the child out of its own parent.
|
|
378
|
+
*/
|
|
379
|
+
onMoveMany?: (nodeIds: readonly string[], drop: FileTreeDrop) => void;
|
|
151
380
|
/** See `ResolveDropOptions.allowReorder`. Off unless the consumer stores an order. */
|
|
152
381
|
allowReorder?: boolean;
|
|
153
382
|
/** Words for refusing a drop into a node whose `acceptsDrop` is false. */
|
|
@@ -182,6 +411,34 @@ export interface FileTreeProps {
|
|
|
182
411
|
* consumer owns the confirmation — this only reports which row the key was pressed on.
|
|
183
412
|
*/
|
|
184
413
|
onDelete?: (node: FileTreeNode) => void;
|
|
414
|
+
/**
|
|
415
|
+
* The same keys on a SELECTION of more than one — *"hold command and click delete to
|
|
416
|
+
* delete all the selected files"*.
|
|
417
|
+
*
|
|
418
|
+
* Without it, ⌫ on a multi-selection deletes the focused row alone, which is today's
|
|
419
|
+
* behaviour and a consumer that never asked for the plural keeps it. The nodes are handed
|
|
420
|
+
* over rather than the ids, because a confirmation dialog needs their names.
|
|
421
|
+
*/
|
|
422
|
+
onDeleteMany?: (nodes: readonly FileTreeNode[]) => void;
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* ⌘C then ⌘V — *"Copying should allow copying in another folder or the same folder."*
|
|
426
|
+
*
|
|
427
|
+
* Wiring it is what puts ⌘C on the keyboard at all. `drop` is where the paste landed,
|
|
428
|
+
* resolved by exactly the rules a drag obeys, so a copy can never reach a destination the
|
|
429
|
+
* pointer refuses.
|
|
430
|
+
*/
|
|
431
|
+
onCopy?: (nodeIds: readonly string[], drop: FileTreeDrop) => void;
|
|
432
|
+
/**
|
|
433
|
+
* The consumer's veto on copying a row — words to refuse with, `null` to allow. The same
|
|
434
|
+
* shape as {@link canDrop}, and consulted BEFORE ⌘C takes, never after ⌘V.
|
|
435
|
+
*
|
|
436
|
+
* 🔴 Copy is the one clipboard verb that is not universal. A folder in `apps/music` is
|
|
437
|
+
* derived from the `folder` column of the tracks in it, so copying one would mean
|
|
438
|
+
* duplicating audio there is exactly one copy of — the tree must not assume every consumer
|
|
439
|
+
* can paste. See `refuseCopy`.
|
|
440
|
+
*/
|
|
441
|
+
canCopy?: (node: FileTreeNode) => string | null;
|
|
185
442
|
|
|
186
443
|
/**
|
|
187
444
|
* A row being named that does not exist yet — the "New folder" gesture.
|
|
@@ -189,10 +446,59 @@ export interface FileTreeProps {
|
|
|
189
446
|
* 🔴 Nothing is created until the name is committed. A folder made first and renamed
|
|
190
447
|
* afterwards is a row that spent a moment called "untitled", and a row in the way if the
|
|
191
448
|
* field is cancelled.
|
|
449
|
+
*
|
|
450
|
+
* `wrap` is the same draft asked to close AROUND rows that already exist — *"allow make
|
|
451
|
+
* dir from file where dir wraps file location and gets name of file"*, which is Finder's
|
|
452
|
+
* **New Folder with Selection** and VS Code's ⌃⌘N. It is the ids to re-file into the
|
|
453
|
+
* folder the moment it is made; the field opens pre-filled with the first one's name minus
|
|
454
|
+
* its extension, and {@link onWrapStart} is how the tree ASKS for one of these.
|
|
192
455
|
*/
|
|
193
|
-
draft?: { parentId: string | null } | null;
|
|
194
|
-
|
|
456
|
+
draft?: { parentId: string | null; wrap?: readonly string[] } | null;
|
|
457
|
+
/**
|
|
458
|
+
* The name was committed: make the folder.
|
|
459
|
+
*
|
|
460
|
+
* 🔴 `wrap` arrives in the SAME call, never as a second one, and that is the whole reason
|
|
461
|
+
* this prop grew an argument instead of a sibling callback being added beside it. "Create
|
|
462
|
+
* the folder, then move four things into it" is two round trips, and the failure mode of
|
|
463
|
+
* the second one is a folder that exists, is empty, is named after a file that is still
|
|
464
|
+
* sitting outside it, and that nobody asked for. One call is one transaction on the
|
|
465
|
+
* consumer's side, which is where the only transaction can be.
|
|
466
|
+
*
|
|
467
|
+
* It is absent — not empty — for an ordinary new folder, so a consumer written before this
|
|
468
|
+
* existed is called exactly as it always was. The ids are pruned of rows inside other
|
|
469
|
+
* wrapped rows and are in tree order; see `planWrap`.
|
|
470
|
+
*/
|
|
471
|
+
onDraftCommit?: (name: string, wrap?: readonly string[]) => void;
|
|
195
472
|
onDraftCancel?: () => void;
|
|
473
|
+
/**
|
|
474
|
+
* **⌃⌘N** on a focused row — *make a folder around this*. Wiring it is what puts the chord
|
|
475
|
+
* on the keyboard at all, and a consumer that does not is byte-for-byte unchanged.
|
|
476
|
+
*
|
|
477
|
+
* The tree does not own `draft`, so it cannot open one itself: this is the same shape
|
|
478
|
+
* {@link onRenameStart} has, and the answer to it is to set
|
|
479
|
+
* `draft = { parentId, wrap: nodes.map(n => n.id) }`. Both arguments are already worked
|
|
480
|
+
* out — the nodes in tree order with descendants of other chosen rows dropped, and the
|
|
481
|
+
* parent of the first of them, which is *where the file already is*. Wire a row menu's own
|
|
482
|
+
* "New folder with selection" to the SAME call so the two cannot drift.
|
|
483
|
+
*
|
|
484
|
+
* On a selection of more than one it is the whole selection (Finder's rule); on a row
|
|
485
|
+
* outside the selection it is that row alone, which is the rule every other plural gesture
|
|
486
|
+
* here already follows.
|
|
487
|
+
*/
|
|
488
|
+
onWrapStart?: (nodes: readonly FileTreeNode[], parentId: string | null) => void;
|
|
489
|
+
/**
|
|
490
|
+
* The consumer's veto on wrapping a row — words to refuse with, `null` to allow. The twin
|
|
491
|
+
* of {@link canCopy}, consulted BEFORE the draft opens.
|
|
492
|
+
*
|
|
493
|
+
* 🔴 A tree whose folders are DERIVED has nothing to wrap with. `apps/music` builds a
|
|
494
|
+
* folder out of the `folder` column of the tracks inside it, so there is no folder for this
|
|
495
|
+
* gesture to make — and offering a name field that cannot be honoured is the "control that
|
|
496
|
+
* fails on commit" this component refuses everywhere else. See `refuseWrap`.
|
|
497
|
+
*
|
|
498
|
+
* A row the consumer marked `draggable: false` is refused without asking, because wrapping
|
|
499
|
+
* it MOVES it — the same all-or-nothing rule `beginDrag` holds.
|
|
500
|
+
*/
|
|
501
|
+
canWrap?: (node: FileTreeNode) => string | null;
|
|
196
502
|
|
|
197
503
|
/**
|
|
198
504
|
* The focused row's own menu, reached by **Shift+F10** or the **Menu** key — the keyboard's
|
|
@@ -324,10 +630,18 @@ export function FileTree({
|
|
|
324
630
|
nodes,
|
|
325
631
|
selectedId,
|
|
326
632
|
onSelect,
|
|
633
|
+
selectedIds,
|
|
634
|
+
onSelectionChange,
|
|
327
635
|
onOpen,
|
|
328
636
|
collapsed: collapsedProp,
|
|
329
637
|
onCollapsedChange,
|
|
638
|
+
expandOnReveal = false,
|
|
639
|
+
onRevealExpand,
|
|
640
|
+
pinnedIds,
|
|
641
|
+
onPinnedChange,
|
|
642
|
+
pinnedLabel = 'Pinned',
|
|
330
643
|
onMove,
|
|
644
|
+
onMoveMany,
|
|
331
645
|
allowReorder = false,
|
|
332
646
|
lockedReason,
|
|
333
647
|
canDrop,
|
|
@@ -337,9 +651,14 @@ export function FileTree({
|
|
|
337
651
|
onRename,
|
|
338
652
|
onRenameCancel,
|
|
339
653
|
onDelete,
|
|
654
|
+
onDeleteMany,
|
|
655
|
+
onCopy,
|
|
656
|
+
canCopy,
|
|
340
657
|
draft,
|
|
341
658
|
onDraftCommit,
|
|
342
659
|
onDraftCancel,
|
|
660
|
+
onWrapStart,
|
|
661
|
+
canWrap,
|
|
343
662
|
onRowMenu,
|
|
344
663
|
renderTools,
|
|
345
664
|
renderIcon,
|
|
@@ -354,6 +673,16 @@ export function FileTree({
|
|
|
354
673
|
}: FileTreeProps): JSX.Element {
|
|
355
674
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
356
675
|
const rowRefs = useRef(new Map<string, HTMLElement>());
|
|
676
|
+
/**
|
|
677
|
+
* The PINNED copies' elements, in a map of their own.
|
|
678
|
+
*
|
|
679
|
+
* 🔴 The second half of "one identity, two row keys" (see the header). A pinned row is the
|
|
680
|
+
* same node drawn a second time, so registering both copies under `node.id` would make
|
|
681
|
+
* every `rowRefs.get(id)` a coin toss — the reveal would scroll to whichever won, the drag's
|
|
682
|
+
* hit test would measure the wrong box, and focus would land in the wrong section. `node.id`
|
|
683
|
+
* stays the identity; the COPY is addressed here.
|
|
684
|
+
*/
|
|
685
|
+
const pinnedRefs = useRef(new Map<string, HTMLElement>());
|
|
357
686
|
const autoScroll = useRef<{ direction: number; timer: ReturnType<typeof setInterval> } | null>(
|
|
358
687
|
null,
|
|
359
688
|
);
|
|
@@ -372,9 +701,26 @@ export function FileTree({
|
|
|
372
701
|
* gesture; the click is debris from it.
|
|
373
702
|
*/
|
|
374
703
|
const draggedJustNow = useRef(false);
|
|
704
|
+
/**
|
|
705
|
+
* Where a shift-click measures FROM — the last row plain-clicked or ⌘-clicked.
|
|
706
|
+
*
|
|
707
|
+
* 🔴 A shift-click deliberately does not move it. That is what lets a second shift-click
|
|
708
|
+
* re-cover the range from the same start instead of growing it one row at a time, which is
|
|
709
|
+
* the behaviour every file explorer has and the reason the anchor is not just "the last
|
|
710
|
+
* thing selected".
|
|
711
|
+
*/
|
|
712
|
+
const anchor = useRef<string | null>(null);
|
|
375
713
|
|
|
376
714
|
const [ownCollapsed, setOwnCollapsed] = useState<readonly string[]>([]);
|
|
377
|
-
|
|
715
|
+
/**
|
|
716
|
+
* What the CONSUMER holds — the value it persists, and the only one ever written back.
|
|
717
|
+
*
|
|
718
|
+
* 🔴 Everything below reads `collapsed` instead, which is this list minus whatever an
|
|
719
|
+
* expanding reveal opened. The two are the same list for every consumer that does not wire
|
|
720
|
+
* {@link FileTreeProps.expandOnReveal}, and keeping them apart is what lets the reveal open
|
|
721
|
+
* a folder without the open ever reaching storage — see `revealOpened`.
|
|
722
|
+
*/
|
|
723
|
+
const persistedCollapsed = collapsedProp ?? ownCollapsed;
|
|
378
724
|
const setCollapsed = useCallback(
|
|
379
725
|
(next: string[]) => {
|
|
380
726
|
if (collapsedProp === undefined) setOwnCollapsed(next);
|
|
@@ -382,9 +728,66 @@ export function FileTree({
|
|
|
382
728
|
},
|
|
383
729
|
[collapsedProp, onCollapsedChange],
|
|
384
730
|
);
|
|
731
|
+
/**
|
|
732
|
+
* The folders an expanding reveal opened — a TRANSIENT overlay, never written out.
|
|
733
|
+
*
|
|
734
|
+
* 🔴 This is the answer to the objection limit 1 of the reveal records, and it is a
|
|
735
|
+
* correctness answer rather than a taste one: `collapsed` is a CONTROLLED value the
|
|
736
|
+
* consumer persists, so a reveal that opened a folder by calling `onCollapsedChange` would
|
|
737
|
+
* write a collapse the person chose away for good — every later mount would find it open
|
|
738
|
+
* and nothing in the app would ever put it back. An overlay cannot do that. The consumer's
|
|
739
|
+
* list is untouched, the open lasts as long as this tree is mounted, and shutting the
|
|
740
|
+
* folder by hand drops the overlay entry and lets the persisted value decide again.
|
|
741
|
+
*
|
|
742
|
+
* The other half is where the writes go: every one of them below is a DELTA on
|
|
743
|
+
* `persistedCollapsed` (`shutFolders`/`openFolders`) rather than the effective list handed
|
|
744
|
+
* back whole. A write computed from the effective list would drop every other overlay id
|
|
745
|
+
* out of the consumer's value — the same corruption by a longer route.
|
|
746
|
+
*/
|
|
747
|
+
const [revealOpened, setRevealOpened] = useState<readonly string[]>([]);
|
|
748
|
+
/** Which folders are shut ON SCREEN: the consumer's list, minus what a reveal opened. */
|
|
749
|
+
const collapsed = useMemo(
|
|
750
|
+
() =>
|
|
751
|
+
revealOpened.length === 0
|
|
752
|
+
? persistedCollapsed
|
|
753
|
+
: persistedCollapsed.filter((id) => !revealOpened.includes(id)),
|
|
754
|
+
[persistedCollapsed, revealOpened],
|
|
755
|
+
);
|
|
756
|
+
/**
|
|
757
|
+
* Shut folders — added to the consumer's list, and dropped from the reveal's overlay so the
|
|
758
|
+
* two cannot disagree about a folder somebody has just shut by hand.
|
|
759
|
+
*/
|
|
760
|
+
const shutFolders = useCallback(
|
|
761
|
+
(ids: readonly string[]) => {
|
|
762
|
+
setRevealOpened((prev) =>
|
|
763
|
+
prev.some((id) => ids.includes(id)) ? prev.filter((id) => !ids.includes(id)) : prev,
|
|
764
|
+
);
|
|
765
|
+
const add = ids.filter((id) => !persistedCollapsed.includes(id));
|
|
766
|
+
if (add.length > 0) setCollapsed([...persistedCollapsed, ...add]);
|
|
767
|
+
},
|
|
768
|
+
[persistedCollapsed, setCollapsed],
|
|
769
|
+
);
|
|
770
|
+
/** Open folders — the same delta in the other direction. An explicit open IS persisted. */
|
|
771
|
+
const openFolders = useCallback(
|
|
772
|
+
(ids: readonly string[]) => {
|
|
773
|
+
setRevealOpened((prev) =>
|
|
774
|
+
prev.some((id) => ids.includes(id)) ? prev.filter((id) => !ids.includes(id)) : prev,
|
|
775
|
+
);
|
|
776
|
+
if (!persistedCollapsed.some((id) => ids.includes(id))) return;
|
|
777
|
+
setCollapsed(persistedCollapsed.filter((id) => !ids.includes(id)));
|
|
778
|
+
},
|
|
779
|
+
[persistedCollapsed, setCollapsed],
|
|
780
|
+
);
|
|
385
781
|
|
|
386
782
|
const [drag, setDrag] = useState<DragState | null>(null);
|
|
387
|
-
|
|
783
|
+
/**
|
|
784
|
+
* The row the keyboard is on, and WHICH COPY of it — the roving tabindex's answer.
|
|
785
|
+
*
|
|
786
|
+
* 🔴 The section is part of the answer, not decoration. A pinned row and its row in the
|
|
787
|
+
* folder share one `node.id`, so an id alone would make both copies claim `tabIndex={0}` and
|
|
788
|
+
* the tree would have two tab stops for one row. Two trees have two stops; one row does not.
|
|
789
|
+
*/
|
|
790
|
+
const [focused, setFocused] = useState<{ id: string; pinned: boolean } | null>(null);
|
|
388
791
|
/** The row an EXTERNAL drag is over, drawn with the same marker an internal one uses. */
|
|
389
792
|
const [externalOverId, setExternalOverId] = useState<string | null>(null);
|
|
390
793
|
/**
|
|
@@ -396,6 +799,23 @@ export function FileTree({
|
|
|
396
799
|
* reading of "broken" this tree's whole drop design exists to avoid.
|
|
397
800
|
*/
|
|
398
801
|
const [notice, setNotice] = useState('');
|
|
802
|
+
/**
|
|
803
|
+
* What ⌘X or ⌘C is holding, and which of the two it was.
|
|
804
|
+
*
|
|
805
|
+
* 🔴 The tree owns this, not the consumer. A clipboard is UI state with a VISIBLE
|
|
806
|
+
* consequence — the cut rows are drawn lifted (`data-cut`) until they land — and pushing it
|
|
807
|
+
* out as a prop would make the mark a thing every consumer has to remember to draw. What
|
|
808
|
+
* the consumer owns is the verb: `onMoveMany`/`onMove` for a cut, `onCopy` for a copy,
|
|
809
|
+
* reached only through the same drop rules a drag obeys.
|
|
810
|
+
*
|
|
811
|
+
* Cleared by a paste of a CUT (the rows have moved; a second paste would move them again
|
|
812
|
+
* from wherever they now are) and kept after a copy, which is what Finder does and what
|
|
813
|
+
* *"copying in another folder or the same folder"* needs.
|
|
814
|
+
*/
|
|
815
|
+
const [clipboard, setClipboard] = useState<{
|
|
816
|
+
mode: 'cut' | 'copy';
|
|
817
|
+
ids: readonly string[];
|
|
818
|
+
} | null>(null);
|
|
399
819
|
|
|
400
820
|
const shown = useMemo(() => (filter ? filterFileTree(nodes, filter) : nodes), [nodes, filter]);
|
|
401
821
|
/*
|
|
@@ -407,6 +827,127 @@ export function FileTree({
|
|
|
407
827
|
[shown, collapsed, filter],
|
|
408
828
|
);
|
|
409
829
|
|
|
830
|
+
const pinned = pinnedIds ?? NO_PINS;
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* The pinned section's rows — the same nodes, drawn a second time and drawn FLAT.
|
|
834
|
+
*
|
|
835
|
+
* Built off `flattenFileTree(shown, [])` rather than off `rows`, because a pinned row is
|
|
836
|
+
* very often inside a folder that is shut: the whole point of pinning it is to reach it
|
|
837
|
+
* without opening anything. So the section is derived from the tree rather than from what
|
|
838
|
+
* the tree is currently showing, and a pin the tree no longer holds — deleted elsewhere,
|
|
839
|
+
* narrowed away by `filter` — simply is not drawn. A ghost row that cannot be clicked is
|
|
840
|
+
* worse than an absent one, and the pin itself is the consumer's to prune if it wants.
|
|
841
|
+
*
|
|
842
|
+
* 🔴 `depth: 0` and `expandable: false`, but the REAL `parentId` and `index`. The section is
|
|
843
|
+
* a flat list of shortcuts — indenting it would redraw a hierarchy beside the one below it,
|
|
844
|
+
* and a twisty there would open a branch inside a section that has no branches. What it
|
|
845
|
+
* keeps is where the row actually LIVES, because that is what a drop aimed at it resolves
|
|
846
|
+
* against: dropping onto the pinned copy of `Projects` files things into `Projects`.
|
|
847
|
+
*/
|
|
848
|
+
const pinnedRows = useMemo<FileTreeRow[]>(() => {
|
|
849
|
+
if (pinned.length === 0) return [];
|
|
850
|
+
const all = new Map(flattenFileTree(shown, []).map((row) => [row.node.id, row]));
|
|
851
|
+
const out: FileTreeRow[] = [];
|
|
852
|
+
for (const id of pinned) {
|
|
853
|
+
const real = all.get(id);
|
|
854
|
+
if (real) out.push({ ...real, depth: 0, expandable: false, expanded: false });
|
|
855
|
+
}
|
|
856
|
+
return out;
|
|
857
|
+
}, [pinned, shown]);
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* The whole selection, singular or plural — `[selectedId]` for a consumer that never
|
|
861
|
+
* passed {@link FileTreeProps.selectedIds}, which is every consumer that existed before
|
|
862
|
+
* this and the reason nothing below has a "multi-select mode" branch.
|
|
863
|
+
*/
|
|
864
|
+
const selection = useMemo<readonly string[]>(
|
|
865
|
+
() => selectedIds ?? (selectedId === null ? [] : [selectedId]),
|
|
866
|
+
[selectedIds, selectedId],
|
|
867
|
+
);
|
|
868
|
+
const selected = useMemo(() => new Set(selection), [selection]);
|
|
869
|
+
const cut = useMemo(
|
|
870
|
+
() => (clipboard?.mode === 'cut' ? new Set(clipboard.ids) : null),
|
|
871
|
+
[clipboard],
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Which rows a gesture aimed at `node` should act on: the selection when `node` is part of
|
|
876
|
+
* one and the consumer wired the plural verb, and `node` alone otherwise.
|
|
877
|
+
*
|
|
878
|
+
* 🔴 `batched` is the whole compatibility story in one argument. Every plural callback is
|
|
879
|
+
* optional, so a consumer that wired only the singular one keeps the singular behaviour on
|
|
880
|
+
* every gesture — the tree never invents a batch nobody can receive.
|
|
881
|
+
*/
|
|
882
|
+
const actOn = useCallback(
|
|
883
|
+
(node: FileTreeNode, plural: boolean): { ids: string[]; batched: boolean } => {
|
|
884
|
+
if (!plural || selection.length < 2 || !selected.has(node.id))
|
|
885
|
+
return { ids: [node.id], batched: false };
|
|
886
|
+
/*
|
|
887
|
+
* 🔴 `batched` is NOT `ids.length > 1`, and the difference is a real bug that was in
|
|
888
|
+
* here first. Select a folder AND a file inside it, then press ⌫: `topmostSelected`
|
|
889
|
+
* correctly prunes the pair to one id, and a plural test written on the COUNT then
|
|
890
|
+
* falls through to the singular callback — so a consumer that only wired
|
|
891
|
+
* `onDeleteMany` gets nothing at all and the key does nothing. The gesture is plural
|
|
892
|
+
* because it was aimed at a SELECTION, whatever pruning leaves.
|
|
893
|
+
*/
|
|
894
|
+
return { ids: topmostSelected(shown, selection), batched: true };
|
|
895
|
+
},
|
|
896
|
+
[selected, selection, shown],
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
/** Whether the tree can be rearranged at all — either verb counts. */
|
|
900
|
+
const canRearrange = Boolean(onMove || onMoveMany);
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Commit a move through whichever verb the consumer wired, never through both.
|
|
904
|
+
*
|
|
905
|
+
* More than one row is `onMoveMany`'s alone (see its header); one row prefers `onMove`, so
|
|
906
|
+
* a consumer that wired only the singular one sees exactly the call it always saw.
|
|
907
|
+
*/
|
|
908
|
+
const commitMove = useCallback(
|
|
909
|
+
(ids: readonly string[], target: FileTreeDrop) => {
|
|
910
|
+
if (ids.length === 0) return;
|
|
911
|
+
if (ids.length > 1) {
|
|
912
|
+
onMoveMany?.(ids, target);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (onMove) onMove(ids[0] as string, target);
|
|
916
|
+
else onMoveMany?.(ids, target);
|
|
917
|
+
},
|
|
918
|
+
[onMove, onMoveMany],
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
// ── The draft, and the rows it may be closing around ──────────────────────────────────
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* What the open draft would wrap, re-derived from the tree it is actually drawn against.
|
|
925
|
+
*
|
|
926
|
+
* 🔴 Planned HERE rather than trusted as given, even though {@link FileTreeProps.onWrapStart}
|
|
927
|
+
* hands the consumer the same answer. A draft is open for as long as somebody is typing,
|
|
928
|
+
* and the tree underneath it is re-derived while they do — a row can be gone by the time
|
|
929
|
+
* Enter is pressed. Re-planning means the ids that reach `onDraftCommit` are ids the tree
|
|
930
|
+
* still holds, in tree order, with descendants pruned, whatever the consumer stored.
|
|
931
|
+
*/
|
|
932
|
+
const wrapping = useMemo(
|
|
933
|
+
() => (draft?.wrap && draft.wrap.length > 0 ? planWrap(shown, draft.wrap) : null),
|
|
934
|
+
[draft, shown],
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* The name field committed — the folder and its contents in ONE call. See
|
|
939
|
+
* {@link FileTreeProps.onDraftCommit} for why it cannot be two.
|
|
940
|
+
*/
|
|
941
|
+
const commitDraft = useCallback(
|
|
942
|
+
(name: string) => {
|
|
943
|
+
// A wrap whose rows have all gone away is a plain new folder, and is called as one:
|
|
944
|
+
// `wrap` is ABSENT rather than empty, which is the argument an old consumer never sees.
|
|
945
|
+
if (wrapping) onDraftCommit?.(name, wrapping.ids);
|
|
946
|
+
else onDraftCommit?.(name);
|
|
947
|
+
},
|
|
948
|
+
[onDraftCommit, wrapping],
|
|
949
|
+
);
|
|
950
|
+
|
|
410
951
|
/**
|
|
411
952
|
* The folders between the root and the selected row, and the shut one HOLDING it.
|
|
412
953
|
*
|
|
@@ -453,24 +994,93 @@ export function FileTree({
|
|
|
453
994
|
return counts;
|
|
454
995
|
}, [rows]);
|
|
455
996
|
|
|
997
|
+
/*
|
|
998
|
+
* 🔴 On what the row is DRAWN as, not on what the consumer stored. A folder an expanding
|
|
999
|
+
* reveal opened is in `persistedCollapsed` and open on screen, so a toggle read off the
|
|
1000
|
+
* stored list would "open" a folder somebody is looking at and the twisty would do nothing.
|
|
1001
|
+
*/
|
|
456
1002
|
const toggle = useCallback(
|
|
457
1003
|
(id: string) => {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
else set.add(id);
|
|
461
|
-
setCollapsed([...set]);
|
|
1004
|
+
if (collapsed.includes(id)) openFolders([id]);
|
|
1005
|
+
else shutFolders([id]);
|
|
462
1006
|
},
|
|
463
|
-
[collapsed,
|
|
1007
|
+
[collapsed, openFolders, shutFolders],
|
|
464
1008
|
);
|
|
465
1009
|
|
|
466
1010
|
const openFolder = useCallback(
|
|
467
1011
|
(id: string) => {
|
|
468
1012
|
if (!collapsed.includes(id)) return false;
|
|
469
|
-
|
|
1013
|
+
openFolders([id]);
|
|
470
1014
|
return true;
|
|
471
1015
|
},
|
|
472
|
-
[collapsed,
|
|
1016
|
+
[collapsed, openFolders],
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Pin a row, or take the pin off — the one verb the section has, from either copy.
|
|
1021
|
+
*
|
|
1022
|
+
* `toggleSelected` is reused rather than re-derived: "in if it was out, out if it was in,
|
|
1023
|
+
* and the ORDER of the rest is kept" is the same rule a ⌘-click follows, and the order is
|
|
1024
|
+
* load-bearing here too — a new pin joins the END of the section instead of resorting a list
|
|
1025
|
+
* somebody has arranged.
|
|
1026
|
+
*
|
|
1027
|
+
* 🔴 It says so out loud. The pinned section may be scrolled off, or the pin may have been
|
|
1028
|
+
* pressed on the top copy where the change is a row VANISHING from under the pointer — so
|
|
1029
|
+
* the live region names what happened, the same way every keyboard move here does.
|
|
1030
|
+
*/
|
|
1031
|
+
const togglePin = useCallback(
|
|
1032
|
+
(node: FileTreeNode) => {
|
|
1033
|
+
if (!onPinnedChange) return;
|
|
1034
|
+
const next = toggleSelected(pinned, node.id);
|
|
1035
|
+
onPinnedChange(next);
|
|
1036
|
+
setNotice(next.includes(node.id) ? `Pinned ${node.name}` : `Unpinned ${node.name}`);
|
|
1037
|
+
},
|
|
1038
|
+
[onPinnedChange, pinned],
|
|
1039
|
+
);
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* The shut folders between the root and the selection — what an expanding reveal opens.
|
|
1043
|
+
*
|
|
1044
|
+
* Empty unless {@link FileTreeProps.expandOnReveal} is wired, and empty while a `filter` is
|
|
1045
|
+
* running: a filter draws everything it kept whatever `collapsed` says, so there is nothing
|
|
1046
|
+
* shut to open and an overlay built there would outlive the search that caused it.
|
|
1047
|
+
*
|
|
1048
|
+
* 🔴 `searching` exists so that no line in this file — code OR comment, both are scanned —
|
|
1049
|
+
* is a bang immediately followed by the word `filter`. Tailwind v4 reads SOURCE for
|
|
1050
|
+
* candidates, `filter` is a real utility, and that pair is its important variant: written
|
|
1051
|
+
* literally, `expandOnReveal && <bang>filter && …` emits an escaped `filter` rule with
|
|
1052
|
+
* `!important` into every stylesheet this component's area ships, which fails the CSS budget
|
|
1053
|
+
* in `src/fixtureAppCss.spec.ts` — a red hundreds of lines away from anything that looks
|
|
1054
|
+
* like CSS. Measured: it cost this change a whole gate run, and then a second one when the
|
|
1055
|
+
* comment explaining it quoted the token it was warning about. `LogViewer.tsx` gets away
|
|
1056
|
+
* with the same pair only because the `.` of `.trim()` breaks the candidate; do not rely on
|
|
1057
|
+
* that. Negate a plain boolean of your own instead — no utility is named `searching`.
|
|
1058
|
+
*/
|
|
1059
|
+
const searching = Boolean(filter);
|
|
1060
|
+
const revealShut = useMemo(
|
|
1061
|
+
() =>
|
|
1062
|
+
expandOnReveal && !searching && openPath.path.size > 0
|
|
1063
|
+
? [...openPath.path].filter((id) => collapsed.includes(id))
|
|
1064
|
+
: [],
|
|
1065
|
+
[collapsed, expandOnReveal, searching, openPath],
|
|
473
1066
|
);
|
|
1067
|
+
/**
|
|
1068
|
+
* The same list, readable from the reveal without becoming one of its triggers.
|
|
1069
|
+
*
|
|
1070
|
+
* 🔴 The effect below must fire on a SELECTION change and on nothing else (limits 2 and 3),
|
|
1071
|
+
* and `revealShut` moves whenever the tree does — a rename, a new row, a folder opened by
|
|
1072
|
+
* hand. Depending on it would scroll the list on all three. The mirror is written by an
|
|
1073
|
+
* effect declared FIRST, so it is already fresh when the reveal runs in the same commit.
|
|
1074
|
+
*/
|
|
1075
|
+
const revealTargets = useRef<readonly string[]>(revealShut);
|
|
1076
|
+
/** The same, for the callback: a reveal two selections later must not call a stale one. */
|
|
1077
|
+
const announceReveal = useRef(onRevealExpand);
|
|
1078
|
+
useEffect(() => {
|
|
1079
|
+
revealTargets.current = revealShut;
|
|
1080
|
+
announceReveal.current = onRevealExpand;
|
|
1081
|
+
}, [revealShut, onRevealExpand]);
|
|
1082
|
+
/** The selection an expanding reveal has already fired for — see limit 1 below. */
|
|
1083
|
+
const revealedFor = useRef<string | null>(null);
|
|
474
1084
|
|
|
475
1085
|
/**
|
|
476
1086
|
* Bring the selected row into view — the explorer's *reveal*.
|
|
@@ -484,10 +1094,31 @@ export function FileTree({
|
|
|
484
1094
|
*
|
|
485
1095
|
* Three deliberate limits, each of which VS Code gets right or wrong depending on a setting:
|
|
486
1096
|
*
|
|
487
|
-
* 1.
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
1097
|
+
* 1. **A shut folder stays shut** — *unless* {@link FileTreeProps.expandOnReveal} is wired,
|
|
1098
|
+
* which is the opt-in half of this limit rather than an exception to it. Left unwired,
|
|
1099
|
+
* the shut folder's `data-holds-open` marker is scrolled to and nothing opens.
|
|
1100
|
+
*
|
|
1101
|
+
* 🔴 **The argument, and what changed about it, 2026-09-16.** This limit was written as
|
|
1102
|
+
* a flat refusal: auto-expanding is what `explorer.autoReveal` does and it is the
|
|
1103
|
+
* behaviour people turn off, because it silently undoes a collapse they chose — and
|
|
1104
|
+
* `collapsed` here is often a CONTROLLED value the consumer persists, so expanding would
|
|
1105
|
+
* write it open for good. Then the owner asked for it BY NAME — *"show that file in the
|
|
1106
|
+
* file tree by like vscode does when you select a tab with a file in it"* — and named the
|
|
1107
|
+
* application whose default is `true`. His ask wins over the default this file chose for
|
|
1108
|
+
* him, so the behaviour is here.
|
|
1109
|
+
*
|
|
1110
|
+
* What survives is the half of the objection that was never taste: **the persisted value
|
|
1111
|
+
* is not rewritten.** The expansion goes into `revealOpened`, an overlay of this tree's
|
|
1112
|
+
* own, and `onCollapsedChange` is never called for it — so the consumer's collapse is
|
|
1113
|
+
* read back unchanged on the next mount, and a folder shut by hand goes back to obeying
|
|
1114
|
+
* it. The two candidate designs were that and "call `onCollapsedChange` and let the
|
|
1115
|
+
* consumer refuse to keep it"; the second makes every consumer that wires the prop
|
|
1116
|
+
* responsible for not persisting a write it did not ask for, and the one that forgets
|
|
1117
|
+
* has the bug this paragraph exists to prevent. Defaults, not diligence.
|
|
1118
|
+
*
|
|
1119
|
+
* It fires once per SELECTION, not continuously: shut an expanded ancestor by hand while
|
|
1120
|
+
* the same file is open and it stays shut, which is what VS Code does and the only thing
|
|
1121
|
+
* that leaves the twisty working at all.
|
|
491
1122
|
* 2. **Only when the row is not already visible.** A list that jumps a few pixels every
|
|
492
1123
|
* time you click a row you can already see reads as a bug.
|
|
493
1124
|
* 3. **Never mid-drag.** Scrolling the list under a drag moves the drop target out from
|
|
@@ -500,11 +1131,31 @@ export function FileTree({
|
|
|
500
1131
|
* `data-holds-open` is the answer precisely when the selected row has none — but it is the
|
|
501
1132
|
* one thing whose CHANGE should reveal. And `drag` IS read, as a guard rather than a
|
|
502
1133
|
* trigger: adding it would reveal the moment a drag ends, which is limit 3 above.
|
|
1134
|
+
*
|
|
1135
|
+
* `revealOpened` is the SECOND PASS of an expanding reveal, and nothing else: the row it was
|
|
1136
|
+
* asked to scroll to does not exist in the DOM until the folders above it are open, so the
|
|
1137
|
+
* expansion returns without scrolling and the re-render it causes is what lands the scroll.
|
|
1138
|
+
* What it must NOT be is the trigger for expanding again — that is `revealedFor`, below.
|
|
503
1139
|
*/
|
|
504
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: `selectedId` is the trigger and not a read; `drag` is a guard and not a trigger — see just above.
|
|
1140
|
+
// 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.
|
|
505
1141
|
useEffect(() => {
|
|
506
1142
|
const scroller = scrollRef.current;
|
|
507
1143
|
if (!scroller || drag?.active) return;
|
|
1144
|
+
/*
|
|
1145
|
+
* Once per selection, whatever it finds — the ref moves even when there is nothing shut to
|
|
1146
|
+
* open, so a later hand-shut of one of these folders cannot be undone by this effect.
|
|
1147
|
+
*/
|
|
1148
|
+
if (revealedFor.current !== selectedId) {
|
|
1149
|
+
revealedFor.current = selectedId;
|
|
1150
|
+
const toOpen = revealTargets.current;
|
|
1151
|
+
if (toOpen.length > 0) {
|
|
1152
|
+
setRevealOpened((prev) => [...prev, ...toOpen.filter((id) => !prev.includes(id))]);
|
|
1153
|
+
// 🔴 Said out loud, because a consumer that FETCHES a folder's children on open has
|
|
1154
|
+
// no other way to learn of it — see {@link FileTreeProps.onRevealExpand}.
|
|
1155
|
+
announceReveal.current?.(toOpen);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
508
1159
|
const row =
|
|
509
1160
|
scroller.querySelector<HTMLElement>('.cbft-row[data-selected]') ??
|
|
510
1161
|
scroller.querySelector<HTMLElement>('.cbft-row[data-holds-open]');
|
|
@@ -515,7 +1166,7 @@ export function FileTree({
|
|
|
515
1166
|
// `nearest` on both axes: the least movement that makes the row visible, and no sideways
|
|
516
1167
|
// travel in a list that does not scroll sideways.
|
|
517
1168
|
row.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
518
|
-
}, [selectedId]);
|
|
1169
|
+
}, [selectedId, revealOpened]);
|
|
519
1170
|
|
|
520
1171
|
// ── The drag ──────────────────────────────────────────────────────────────────────────
|
|
521
1172
|
|
|
@@ -533,17 +1184,39 @@ export function FileTree({
|
|
|
533
1184
|
|
|
534
1185
|
/** The row under a client point, and how far down it the pointer is. */
|
|
535
1186
|
const rowAt = useCallback(
|
|
536
|
-
(
|
|
1187
|
+
(
|
|
1188
|
+
x: number,
|
|
1189
|
+
y: number,
|
|
1190
|
+
): { row: FileTreeRow; ratio: number; pinned: boolean } | null => {
|
|
1191
|
+
/*
|
|
1192
|
+
* The PINNED copies first — they are drawn in their own box above the scroller, so a
|
|
1193
|
+
* point inside one is never inside a tree row and the two loops cannot both answer.
|
|
1194
|
+
*
|
|
1195
|
+
* 🔴 The middle of the row, and `pinned` so the caller turns REORDERING off for it. A
|
|
1196
|
+
* pinned row's edges have no "between these two" to express: the section's order is the
|
|
1197
|
+
* pin order, which is not an order in the tree at all — so a `before`/`after` aimed
|
|
1198
|
+
* there would draw a marker in one list and re-file the row among siblings it is not
|
|
1199
|
+
* drawn beside in another. What is left is the whole requirement and nothing else: the
|
|
1200
|
+
* row carries its REAL `parentId`, so "into" is the real folder, and a drop on the
|
|
1201
|
+
* pinned copy of `Projects` files into `Projects`.
|
|
1202
|
+
*/
|
|
1203
|
+
for (const row of pinnedRows) {
|
|
1204
|
+
const el = pinnedRefs.current.get(row.node.id);
|
|
1205
|
+
if (!el) continue;
|
|
1206
|
+
const box = el.getBoundingClientRect();
|
|
1207
|
+
if (y >= box.top && y <= box.bottom && x >= box.left && x <= box.right)
|
|
1208
|
+
return { row, ratio: 0.5, pinned: true };
|
|
1209
|
+
}
|
|
537
1210
|
for (const row of rows) {
|
|
538
1211
|
const el = rowRefs.current.get(row.node.id);
|
|
539
1212
|
if (!el) continue;
|
|
540
1213
|
const box = el.getBoundingClientRect();
|
|
541
1214
|
if (y >= box.top && y <= box.bottom && x >= box.left && x <= box.right)
|
|
542
|
-
return { row, ratio: box.height > 0 ? (y - box.top) / box.height : 0.5 };
|
|
1215
|
+
return { row, ratio: box.height > 0 ? (y - box.top) / box.height : 0.5, pinned: false };
|
|
543
1216
|
}
|
|
544
1217
|
return null;
|
|
545
1218
|
},
|
|
546
|
-
[rows],
|
|
1219
|
+
[pinnedRows, rows],
|
|
547
1220
|
);
|
|
548
1221
|
|
|
549
1222
|
const endDrag = useCallback(
|
|
@@ -554,31 +1227,50 @@ export function FileTree({
|
|
|
554
1227
|
// click — see `draggedJustNow`. Set for a cancelled drag too: the click arrives either
|
|
555
1228
|
// way, and Escape landing you on a different row would be its own surprise.
|
|
556
1229
|
if (state?.active) draggedJustNow.current = true;
|
|
557
|
-
if (commit && state?.active && state.offer.target &&
|
|
558
|
-
|
|
1230
|
+
if (commit && state?.active && state.offer.target && canRearrange) {
|
|
1231
|
+
commitMove(state.ids, state.offer.target);
|
|
559
1232
|
sprung.current = [];
|
|
560
1233
|
} else if (sprung.current.length > 0) {
|
|
561
1234
|
// A drag that opened folders on its way and then went nowhere puts them back — the
|
|
562
1235
|
// tree it leaves behind must be the tree it found.
|
|
563
|
-
const
|
|
564
|
-
for (const id of sprung.current) shut.add(id);
|
|
1236
|
+
const back = sprung.current;
|
|
565
1237
|
sprung.current = [];
|
|
566
|
-
|
|
1238
|
+
shutFolders(back);
|
|
567
1239
|
}
|
|
568
1240
|
setDrag(null);
|
|
569
1241
|
},
|
|
570
|
-
[
|
|
1242
|
+
[canRearrange, commitMove, shutFolders, stopAutoScroll, stopSpring],
|
|
571
1243
|
);
|
|
572
1244
|
|
|
573
1245
|
const beginDrag = useCallback(
|
|
574
1246
|
(event: ReactPointerEvent<HTMLElement>, node: FileTreeNode) => {
|
|
575
1247
|
// A row the consumer marked undraggable never starts a gesture — see
|
|
576
1248
|
// `FileTreeNode.draggable`. A drag that refuses everywhere reads as a broken tree.
|
|
577
|
-
if (!
|
|
1249
|
+
if (!canRearrange || event.button !== 0 || node.draggable === false) return;
|
|
1250
|
+
|
|
1251
|
+
/*
|
|
1252
|
+
* Press a row that is part of a selection and the whole selection comes with you —
|
|
1253
|
+
* *"with all of those selected I want to be able to click and hold on any of them to
|
|
1254
|
+
* drag and drop them to another folder"*. Press one that is not, and the selection is
|
|
1255
|
+
* left where it is: Finder's rule, and the only one that lets you move a single row out
|
|
1256
|
+
* of a group you have built without dismantling it first.
|
|
1257
|
+
*/
|
|
1258
|
+
const { ids } = actOn(node, Boolean(onMoveMany));
|
|
1259
|
+
/*
|
|
1260
|
+
* 🔴 All or nothing. One undraggable row in the set stops the whole gesture rather than
|
|
1261
|
+
* quietly leaving it behind — a drag that moved four of five rows and said nothing is
|
|
1262
|
+
* the failure the plural drop rule exists to prevent (see `resolveMultiDrop`), and it
|
|
1263
|
+
* would be no better for arriving at the START of the gesture instead of the end.
|
|
1264
|
+
* `apps/collections` marks its albums `draggable: false`, so this is reachable there by
|
|
1265
|
+
* ⌘-clicking one into a selection of folders.
|
|
1266
|
+
*/
|
|
1267
|
+
if (ids.some((id) => findFileTreeNode(shown, id)?.draggable === false)) return;
|
|
1268
|
+
|
|
578
1269
|
const startX = event.clientX;
|
|
579
1270
|
const startY = event.clientY;
|
|
580
1271
|
let state: DragState = {
|
|
581
1272
|
id: node.id,
|
|
1273
|
+
ids,
|
|
582
1274
|
startX,
|
|
583
1275
|
startY,
|
|
584
1276
|
x: startX,
|
|
@@ -595,12 +1287,18 @@ export function FileTree({
|
|
|
595
1287
|
if (!state.active && travelled < DRAG_THRESHOLD) return;
|
|
596
1288
|
|
|
597
1289
|
const hit = rowAt(moveEvent.clientX, moveEvent.clientY);
|
|
598
|
-
|
|
1290
|
+
// One row or twenty, the same call: `resolveMultiDrop` is `resolveDrop` asked once
|
|
1291
|
+
// per carried row, and the strictest answer wins. See its header for why a single
|
|
1292
|
+
// refusal refuses the whole drop.
|
|
1293
|
+
const offer = resolveMultiDrop({
|
|
599
1294
|
nodes: shown,
|
|
600
1295
|
row: hit?.row ?? null,
|
|
601
1296
|
offsetRatio: hit?.ratio ?? 0.5,
|
|
602
|
-
|
|
603
|
-
|
|
1297
|
+
dragIds: ids,
|
|
1298
|
+
// 🔴 Off over the pinned section, whatever the consumer stores. See `rowAt`: those
|
|
1299
|
+
// rows are drawn in pin order, so there is no "between these two" for an edge to
|
|
1300
|
+
// mean there — only "into this", which is the real folder.
|
|
1301
|
+
allowReorder: allowReorder && !hit?.pinned,
|
|
604
1302
|
...(lockedReason ? { lockedReason } : {}),
|
|
605
1303
|
...(canDrop ? { canDrop } : {}),
|
|
606
1304
|
});
|
|
@@ -677,12 +1375,14 @@ export function FileTree({
|
|
|
677
1375
|
window.addEventListener('keydown', abort);
|
|
678
1376
|
},
|
|
679
1377
|
[
|
|
1378
|
+
actOn,
|
|
680
1379
|
allowReorder,
|
|
681
1380
|
canDrop,
|
|
1381
|
+
canRearrange,
|
|
682
1382
|
collapsed,
|
|
683
1383
|
endDrag,
|
|
684
1384
|
lockedReason,
|
|
685
|
-
|
|
1385
|
+
onMoveMany,
|
|
686
1386
|
openFolder,
|
|
687
1387
|
rowAt,
|
|
688
1388
|
shown,
|
|
@@ -693,9 +1393,18 @@ export function FileTree({
|
|
|
693
1393
|
|
|
694
1394
|
// A drag abandoned by a re-render — the folder deleted in another tab, the list replaced
|
|
695
1395
|
// under the pointer — must not leave a marker behind.
|
|
1396
|
+
//
|
|
1397
|
+
// 🔴 The pinned section counts as "still drawn". A drag started from the top copy of a row
|
|
1398
|
+
// that lives inside a SHUT folder has no row in `rows` at all — which is the ordinary case
|
|
1399
|
+
// for a pin, not an edge one — so this would cancel the gesture on its first re-render.
|
|
696
1400
|
useEffect(() => {
|
|
697
|
-
if (
|
|
698
|
-
|
|
1401
|
+
if (
|
|
1402
|
+
drag &&
|
|
1403
|
+
!rows.some((r) => r.node.id === drag.id) &&
|
|
1404
|
+
!pinnedRows.some((r) => r.node.id === drag.id)
|
|
1405
|
+
)
|
|
1406
|
+
setDrag(null);
|
|
1407
|
+
}, [drag, pinnedRows, rows]);
|
|
699
1408
|
|
|
700
1409
|
useEffect(
|
|
701
1410
|
() => () => {
|
|
@@ -707,11 +1416,28 @@ export function FileTree({
|
|
|
707
1416
|
|
|
708
1417
|
// ── The keyboard ──────────────────────────────────────────────────────────────────────
|
|
709
1418
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1419
|
+
/**
|
|
1420
|
+
* The element drawing `id`, preferring the section asked for and falling back to the other.
|
|
1421
|
+
*
|
|
1422
|
+
* 🔴 The fallback is what makes the REAL row the default answer everywhere: a row in the
|
|
1423
|
+
* tree is the one that has a depth, a twisty and ancestors marked above it, so focus and the
|
|
1424
|
+
* rename field belong there whenever it is drawn. The pinned copy answers only for a row the
|
|
1425
|
+
* tree is not currently showing — which, for a pin, is the common case.
|
|
1426
|
+
*/
|
|
1427
|
+
const rowEl = useCallback((id: string, pinnedCopy: boolean): HTMLElement | undefined => {
|
|
1428
|
+
const first = pinnedCopy ? pinnedRefs : rowRefs;
|
|
1429
|
+
const second = pinnedCopy ? rowRefs : pinnedRefs;
|
|
1430
|
+
return first.current.get(id) ?? second.current.get(id);
|
|
713
1431
|
}, []);
|
|
714
1432
|
|
|
1433
|
+
const focusRow = useCallback(
|
|
1434
|
+
(id: string | null, pinnedCopy = false) => {
|
|
1435
|
+
setFocused(id === null ? null : { id, pinned: pinnedCopy });
|
|
1436
|
+
if (id) rowEl(id, pinnedCopy)?.focus();
|
|
1437
|
+
},
|
|
1438
|
+
[rowEl],
|
|
1439
|
+
);
|
|
1440
|
+
|
|
715
1441
|
/*
|
|
716
1442
|
* ── The keyboard comes back from a rename ─────────────────────────────────────────────
|
|
717
1443
|
*
|
|
@@ -740,12 +1466,23 @@ export function FileTree({
|
|
|
740
1466
|
}, [renamingId, focusRow]);
|
|
741
1467
|
|
|
742
1468
|
const onKeyDown = useCallback(
|
|
743
|
-
(
|
|
1469
|
+
(
|
|
1470
|
+
event: React.KeyboardEvent<HTMLElement>,
|
|
1471
|
+
row: FileTreeRow,
|
|
1472
|
+
/*
|
|
1473
|
+
* The rows the ARROWS walk, and which section drew this one. A pinned copy's neighbours
|
|
1474
|
+
* are the other pins, not the tree rows around where it lives — ↓ from the last pin
|
|
1475
|
+
* jumping into the middle of a folder would be a cursor that teleports. Every verb below
|
|
1476
|
+
* still acts on `row.node`, which is the one identity both copies share.
|
|
1477
|
+
*/
|
|
1478
|
+
list: readonly FileTreeRow[],
|
|
1479
|
+
pinnedCopy: boolean,
|
|
1480
|
+
) => {
|
|
744
1481
|
if (renamingId) return;
|
|
745
|
-
const index =
|
|
1482
|
+
const index = list.findIndex((r) => r.node.id === row.node.id);
|
|
746
1483
|
const step = (delta: number) => {
|
|
747
|
-
const next =
|
|
748
|
-
if (next) focusRow(next.node.id);
|
|
1484
|
+
const next = list[Math.min(list.length - 1, Math.max(0, index + delta))];
|
|
1485
|
+
if (next) focusRow(next.node.id, pinnedCopy);
|
|
749
1486
|
};
|
|
750
1487
|
|
|
751
1488
|
/*
|
|
@@ -755,11 +1492,175 @@ export function FileTree({
|
|
|
755
1492
|
*/
|
|
756
1493
|
if (onRowMenu && (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10'))) {
|
|
757
1494
|
event.preventDefault();
|
|
758
|
-
|
|
1495
|
+
// The corner of the copy the keys are actually on — a menu anchored to the row in the
|
|
1496
|
+
// folder while the focus ring is on the pinned one would open somewhere else entirely.
|
|
1497
|
+
const box = rowEl(row.node.id, pinnedCopy)?.getBoundingClientRect();
|
|
759
1498
|
onRowMenu(row.node, { x: box?.right ?? 0, y: box?.bottom ?? 0 });
|
|
760
1499
|
return;
|
|
761
1500
|
}
|
|
762
1501
|
|
|
1502
|
+
/*
|
|
1503
|
+
* ── ⌃⌘T — pin, and unpin ────────────────────────────────────────────────────────
|
|
1504
|
+
*
|
|
1505
|
+
* Owner, 2026-09-13: *"need … ability to pin open files where they stay at the top of
|
|
1506
|
+
* the file tree … Pinned will mean I want to keep them there until I unpin them."* One
|
|
1507
|
+
* chord for both directions, because it is one state with two values and a person who
|
|
1508
|
+
* can pin can already see whether this row is pinned.
|
|
1509
|
+
*
|
|
1510
|
+
* ⌃⌘T is Finder's own **Add to Sidebar**, so the gesture is not one anybody has to be
|
|
1511
|
+
* told about — and, exactly like ⌃⌘N above, it is anchored on **Ctrl** with ⌘ optional,
|
|
1512
|
+
* for the Windows and Linux hosts this same tree runs on where there is no ⌘ to hold.
|
|
1513
|
+
*
|
|
1514
|
+
* Only when {@link FileTreeProps.onPinnedChange} is wired: with no consumer to receive
|
|
1515
|
+
* the list this is not a chord this component has, and the event falls through to the
|
|
1516
|
+
* browser rather than being swallowed by a control that would do nothing.
|
|
1517
|
+
*/
|
|
1518
|
+
if (
|
|
1519
|
+
onPinnedChange &&
|
|
1520
|
+
event.ctrlKey &&
|
|
1521
|
+
!event.altKey &&
|
|
1522
|
+
!event.shiftKey &&
|
|
1523
|
+
event.key.toLowerCase() === 't'
|
|
1524
|
+
) {
|
|
1525
|
+
event.preventDefault();
|
|
1526
|
+
togglePin(row.node);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/*
|
|
1531
|
+
* ── ⌃⌘N — New Folder with Selection ─────────────────────────────────────────────
|
|
1532
|
+
*
|
|
1533
|
+
* Owner, 2026-09-13: *"allow make dir from file where dir wraps file location and gets
|
|
1534
|
+
* name of file"*. Finder calls it **New Folder with Selection** and VS Code binds it to
|
|
1535
|
+
* ⌃⌘N, so the shape is settled and this invents nothing: the folder is made where the
|
|
1536
|
+
* row already is, it is named after the row, and the row goes into it.
|
|
1537
|
+
*
|
|
1538
|
+
* 🔴 Ctrl is what the chord is anchored on, ⌘ optional — ⌃⌘N on a Mac, ⌃N on the
|
|
1539
|
+
* Windows and Linux hosts this same tree runs on, where there is no ⌘ to hold. Bare
|
|
1540
|
+
* ⌥/⇧ are excluded so the rest of the modifier space stays the app's.
|
|
1541
|
+
*
|
|
1542
|
+
* Like every other verb here it exists only when the consumer wired it: no
|
|
1543
|
+
* `onWrapStart` and this is not a chord this component has, and the event falls through
|
|
1544
|
+
* to the browser rather than being swallowed by a control that would do nothing.
|
|
1545
|
+
*/
|
|
1546
|
+
if (
|
|
1547
|
+
onWrapStart &&
|
|
1548
|
+
event.ctrlKey &&
|
|
1549
|
+
!event.altKey &&
|
|
1550
|
+
!event.shiftKey &&
|
|
1551
|
+
event.key.toLowerCase() === 'n'
|
|
1552
|
+
) {
|
|
1553
|
+
event.preventDefault();
|
|
1554
|
+
const { ids } = actOn(row.node, true);
|
|
1555
|
+
const plan = planWrap(shown, ids);
|
|
1556
|
+
if (!plan) return;
|
|
1557
|
+
/*
|
|
1558
|
+
* Both vetoes BEFORE the draft opens, never after a name is typed — the consumer's
|
|
1559
|
+
* words first, then the tree's own rule about a row that cannot be moved at all.
|
|
1560
|
+
* Either way it is said out loud: a chord that silently did nothing is the failure
|
|
1561
|
+
* `notice` exists for.
|
|
1562
|
+
*/
|
|
1563
|
+
const refused = refuseWrap(shown, plan.ids, canWrap);
|
|
1564
|
+
if (refused) {
|
|
1565
|
+
setNotice(refused);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
const stuck = plan.ids.find((id) => findFileTreeNode(shown, id)?.draggable === false);
|
|
1569
|
+
if (stuck) {
|
|
1570
|
+
setNotice(`${nameOf(shown, stuck)} cannot be moved`);
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
onWrapStart(nodesByIds(shown, plan.ids), plan.parentId);
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/*
|
|
1578
|
+
* ── ⌘X / ⌘C / ⌘V — the clipboard ────────────────────────────────────────────────
|
|
1579
|
+
*
|
|
1580
|
+
* Owner, 2026-09-14: *"I also want to be able to cut and paste to move them with
|
|
1581
|
+
* command + x and command + v, and copy them with command + c and command + v. Copying
|
|
1582
|
+
* should allow copying in another folder or the same folder. These are similar to how
|
|
1583
|
+
* finder works except cutting and pasting will be easy with just command + x."*
|
|
1584
|
+
*
|
|
1585
|
+
* 🔴 Each key exists only when its verb does. No `onMove`/`onMoveMany` and ⌘X is not a
|
|
1586
|
+
* key this tree has; no `onCopy` and neither is ⌘C — the event falls through to the
|
|
1587
|
+
* browser rather than being swallowed by a control that would have done nothing with
|
|
1588
|
+
* it. `preventDefault` is only ever called on a chord that is about to act.
|
|
1589
|
+
*
|
|
1590
|
+
* ⌃ as well as ⌘, because the same tree is used on Windows and Linux, where the
|
|
1591
|
+
* clipboard chord is the same three letters with the other modifier.
|
|
1592
|
+
*
|
|
1593
|
+
* The paste DESTINATION is the focused row, resolved by exactly the rules a pointer
|
|
1594
|
+
* drag obeys (`resolveMultiDrop`): a folder row means into it, a file row means the
|
|
1595
|
+
* folder that row is in, and a top-level file row means the top level. So a paste can
|
|
1596
|
+
* never reach somewhere the drag refuses, which is the "one opinion about what is
|
|
1597
|
+
* legal" rule the keyboard MOVE already answers to.
|
|
1598
|
+
*/
|
|
1599
|
+
if (
|
|
1600
|
+
(event.metaKey || event.ctrlKey) &&
|
|
1601
|
+
!event.altKey &&
|
|
1602
|
+
(event.key === 'x' || event.key === 'c' || event.key === 'v')
|
|
1603
|
+
) {
|
|
1604
|
+
if (event.key === 'x' && canRearrange) {
|
|
1605
|
+
event.preventDefault();
|
|
1606
|
+
const { ids } = actOn(row.node, Boolean(onMoveMany));
|
|
1607
|
+
setClipboard({ mode: 'cut', ids });
|
|
1608
|
+
setNotice(ids.length > 1 ? `Cut ${ids.length} items` : `Cut ${row.node.name}`);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
if (event.key === 'c' && onCopy) {
|
|
1612
|
+
event.preventDefault();
|
|
1613
|
+
const { ids } = actOn(row.node, true);
|
|
1614
|
+
// The consumer's veto, asked BEFORE the clipboard takes — a copy that is going to
|
|
1615
|
+
// be refused must be refused while there is still something to say it about.
|
|
1616
|
+
const refused = refuseCopy(shown, ids, canCopy);
|
|
1617
|
+
if (refused) {
|
|
1618
|
+
setNotice(refused);
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
setClipboard({ mode: 'copy', ids });
|
|
1622
|
+
setNotice(ids.length > 1 ? `Copied ${ids.length} items` : `Copied ${row.node.name}`);
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
if (event.key === 'v' && clipboard) {
|
|
1626
|
+
event.preventDefault();
|
|
1627
|
+
const offer = resolveMultiDrop({
|
|
1628
|
+
nodes: shown,
|
|
1629
|
+
row,
|
|
1630
|
+
// The middle of the row: "into this", never "between these two". Reordering is a
|
|
1631
|
+
// pointer gesture — a paste has no edge to aim at.
|
|
1632
|
+
offsetRatio: 0.5,
|
|
1633
|
+
allowReorder: false,
|
|
1634
|
+
/*
|
|
1635
|
+
* 🔴 The carried ids are declared for a COPY too, so the descendant rule applies
|
|
1636
|
+
* to it: a folder cannot be pasted inside itself any more than it can be dragged
|
|
1637
|
+
* there. What that rule does NOT refuse is the case the owner named — *"copying
|
|
1638
|
+
* in … the same folder"* — because a row's own parent is not a descendant of it.
|
|
1639
|
+
*/
|
|
1640
|
+
dragIds: clipboard.ids,
|
|
1641
|
+
...(lockedReason ? { lockedReason } : {}),
|
|
1642
|
+
...(canDrop ? { canDrop } : {}),
|
|
1643
|
+
});
|
|
1644
|
+
if (!offer.target) {
|
|
1645
|
+
setNotice(offer.reason ?? '');
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
setNotice(describeDrop(shown, offer.target));
|
|
1649
|
+
if (clipboard.mode === 'copy') {
|
|
1650
|
+
onCopy?.(clipboard.ids, offer.target);
|
|
1651
|
+
// Kept, not cleared: Finder pastes the same copy again, and *"copying in another
|
|
1652
|
+
// folder or the same folder"* is two pastes of one ⌘C.
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
commitMove(clipboard.ids, offer.target);
|
|
1656
|
+
// The rows have moved. A second paste would move them again from wherever they
|
|
1657
|
+
// now are, which is not what the person cut them for.
|
|
1658
|
+
setClipboard(null);
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
763
1664
|
/*
|
|
764
1665
|
* ⌥→ and ⌥← MOVE the focused row — the keyboard's half of the drag, and the only half
|
|
765
1666
|
* that exists on touch (a press-and-drag there scrolls the list, so the gesture is
|
|
@@ -770,7 +1671,7 @@ export function FileTree({
|
|
|
770
1671
|
* in this handler is bare-key, which is what keeps ⇧⏎/⌘F and the rest free for the app.
|
|
771
1672
|
*/
|
|
772
1673
|
if (
|
|
773
|
-
|
|
1674
|
+
canRearrange &&
|
|
774
1675
|
event.altKey &&
|
|
775
1676
|
!event.metaKey &&
|
|
776
1677
|
!event.ctrlKey &&
|
|
@@ -787,7 +1688,11 @@ export function FileTree({
|
|
|
787
1688
|
});
|
|
788
1689
|
if (offer.target) {
|
|
789
1690
|
setNotice(describeDrop(shown, offer.target));
|
|
790
|
-
|
|
1691
|
+
// Deliberately the focused row alone, never the selection: `resolveKeyboardMove`
|
|
1692
|
+
// answers for ONE row ("the nearest folder above IT", "the folder ITS folder is
|
|
1693
|
+
// in"), and there is no one answer to that question for a set spread across
|
|
1694
|
+
// several levels. The plural route is the drag and the clipboard.
|
|
1695
|
+
commitMove([row.node.id], offer.target);
|
|
791
1696
|
} else {
|
|
792
1697
|
setNotice(offer.reason ?? '');
|
|
793
1698
|
}
|
|
@@ -813,16 +1718,19 @@ export function FileTree({
|
|
|
813
1718
|
case 'ArrowLeft': {
|
|
814
1719
|
event.preventDefault();
|
|
815
1720
|
if (row.expandable && row.expanded) toggle(row.node.id);
|
|
816
|
-
|
|
1721
|
+
// Never out of the pinned section: its rows are drawn flat, so "go to my parent"
|
|
1722
|
+
// would leap the focus into the tree — and often onto a row that is not drawn,
|
|
1723
|
+
// because the folder a pinned row lives in is very often shut.
|
|
1724
|
+
else if (!pinnedCopy && row.parentId) focusRow(row.parentId);
|
|
817
1725
|
return;
|
|
818
1726
|
}
|
|
819
1727
|
case 'Home':
|
|
820
1728
|
event.preventDefault();
|
|
821
|
-
if (
|
|
1729
|
+
if (list[0]) focusRow(list[0].node.id, pinnedCopy);
|
|
822
1730
|
return;
|
|
823
1731
|
case 'End':
|
|
824
1732
|
event.preventDefault();
|
|
825
|
-
if (
|
|
1733
|
+
if (list.at(-1)) focusRow((list.at(-1) as FileTreeRow).node.id, pinnedCopy);
|
|
826
1734
|
return;
|
|
827
1735
|
/*
|
|
828
1736
|
── The keys VS Code taught everybody ───────────────────────────────────────────
|
|
@@ -869,11 +1777,39 @@ export function FileTree({
|
|
|
869
1777
|
// ⌫, Delete, and ⌘⌫ — the macOS chord arrives as `Backspace` + `metaKey`, so it is
|
|
870
1778
|
// this branch and not a fourth one. See {@link FileTreeProps.onDelete}.
|
|
871
1779
|
case 'Backspace':
|
|
872
|
-
case 'Delete':
|
|
1780
|
+
case 'Delete': {
|
|
1781
|
+
/*
|
|
1782
|
+
* 🔴 On a SELECTION it deletes all of it — *"for both of these new selection
|
|
1783
|
+
* functionalities I also want to be able to hold command and click delete to delete
|
|
1784
|
+
* all the selected files"*. `onDeleteMany` is what makes that reachable; without it
|
|
1785
|
+
* the key still means the focused row alone, which is what it has always meant.
|
|
1786
|
+
*
|
|
1787
|
+
* `actOn` prunes descendants of other selected rows, so deleting a folder and a
|
|
1788
|
+
* file inside it asks for one deletion, not two — the second of which would be of
|
|
1789
|
+
* something the first already took.
|
|
1790
|
+
*/
|
|
1791
|
+
const { ids, batched } = actOn(row.node, Boolean(onDeleteMany));
|
|
1792
|
+
if (batched && onDeleteMany) {
|
|
1793
|
+
event.preventDefault();
|
|
1794
|
+
onDeleteMany(nodesByIds(shown, ids));
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
873
1797
|
if (!onDelete) return;
|
|
874
1798
|
event.preventDefault();
|
|
875
1799
|
onDelete(row.node);
|
|
876
1800
|
return;
|
|
1801
|
+
}
|
|
1802
|
+
/*
|
|
1803
|
+
* Escape puts the clipboard down. A cut that is never pasted leaves every row it
|
|
1804
|
+
* holds drawn as lifted (`data-cut`), and a mark with no way to take it off is a
|
|
1805
|
+
* tree that looks permanently mid-gesture.
|
|
1806
|
+
*/
|
|
1807
|
+
case 'Escape':
|
|
1808
|
+
if (!clipboard) return;
|
|
1809
|
+
event.preventDefault();
|
|
1810
|
+
setClipboard(null);
|
|
1811
|
+
setNotice('');
|
|
1812
|
+
return;
|
|
877
1813
|
default:
|
|
878
1814
|
break;
|
|
879
1815
|
}
|
|
@@ -885,26 +1821,38 @@ export function FileTree({
|
|
|
885
1821
|
? event.key
|
|
886
1822
|
: typeahead.current.word + event.key;
|
|
887
1823
|
typeahead.current = { word, at: now };
|
|
888
|
-
const found = matchTypeahead(
|
|
1824
|
+
const found = matchTypeahead(list, word, index);
|
|
889
1825
|
if (found >= 0) {
|
|
890
1826
|
event.preventDefault();
|
|
891
|
-
focusRow((
|
|
1827
|
+
focusRow((list[found] as FileTreeRow).node.id, pinnedCopy);
|
|
892
1828
|
}
|
|
893
1829
|
},
|
|
894
1830
|
[
|
|
1831
|
+
actOn,
|
|
1832
|
+
canCopy,
|
|
895
1833
|
canDrop,
|
|
1834
|
+
canRearrange,
|
|
1835
|
+
canWrap,
|
|
1836
|
+
clipboard,
|
|
1837
|
+
commitMove,
|
|
896
1838
|
focusRow,
|
|
897
1839
|
lockedReason,
|
|
1840
|
+
onCopy,
|
|
898
1841
|
onDelete,
|
|
899
|
-
|
|
1842
|
+
onDeleteMany,
|
|
1843
|
+
onMoveMany,
|
|
900
1844
|
onOpen,
|
|
1845
|
+
onPinnedChange,
|
|
901
1846
|
onRenameStart,
|
|
902
1847
|
onRowMenu,
|
|
903
1848
|
onSelect,
|
|
1849
|
+
onWrapStart,
|
|
904
1850
|
renamingId,
|
|
1851
|
+
rowEl,
|
|
905
1852
|
rows,
|
|
906
1853
|
shown,
|
|
907
1854
|
toggle,
|
|
1855
|
+
togglePin,
|
|
908
1856
|
],
|
|
909
1857
|
);
|
|
910
1858
|
|
|
@@ -946,12 +1894,11 @@ export function FileTree({
|
|
|
946
1894
|
sprung.current = [];
|
|
947
1895
|
return;
|
|
948
1896
|
}
|
|
949
|
-
const
|
|
950
|
-
for (const id of sprung.current) shut.add(id);
|
|
1897
|
+
const back = sprung.current;
|
|
951
1898
|
sprung.current = [];
|
|
952
|
-
|
|
1899
|
+
shutFolders(back);
|
|
953
1900
|
},
|
|
954
|
-
[
|
|
1901
|
+
[shutFolders, stopSpring],
|
|
955
1902
|
);
|
|
956
1903
|
|
|
957
1904
|
const externalHandlers = (node: FileTreeNode | null) =>
|
|
@@ -1004,13 +1951,51 @@ export function FileTree({
|
|
|
1004
1951
|
|
|
1005
1952
|
const dropId =
|
|
1006
1953
|
drag?.active && drag.offer.target?.kind === 'into' ? drag.offer.target.parentId : null;
|
|
1007
|
-
const dragging = drag?.active ?
|
|
1954
|
+
const dragging = drag?.active ? draggedIdsOf(shown, drag.ids) : null;
|
|
1955
|
+
|
|
1956
|
+
/**
|
|
1957
|
+
* What a click on `node` means, given its modifiers — the whole of shift-click and
|
|
1958
|
+
* ⌘-click.
|
|
1959
|
+
*
|
|
1960
|
+
* Returns whether the click was CONSUMED by the selection: a shift- or ⌘-click extends the
|
|
1961
|
+
* selection and stops there, because extending a selection is not also navigating and in a
|
|
1962
|
+
* consumer that opens on select it would be. A plain click reports `[id]` and falls
|
|
1963
|
+
* through to `onSelect`, so the ordinary act is unchanged.
|
|
1964
|
+
*/
|
|
1965
|
+
const clickSelected = (
|
|
1966
|
+
node: FileTreeNode,
|
|
1967
|
+
event: React.MouseEvent,
|
|
1968
|
+
/* The rows a SHIFT-range is measured over — the section the click landed in, for the
|
|
1969
|
+
reason the arrows walk one section too: a range is what you can see yourself cover. */
|
|
1970
|
+
list: readonly FileTreeRow[],
|
|
1971
|
+
): boolean => {
|
|
1972
|
+
if (!onSelectionChange) return false;
|
|
1973
|
+
if (event.shiftKey) {
|
|
1974
|
+
// From the anchor, over the rows ON SCREEN — see `selectionRange`. `selectedId` is the
|
|
1975
|
+
// fallback so the very first shift-click after a deep link still has a start.
|
|
1976
|
+
onSelectionChange(selectionRange(list, anchor.current ?? selectedId, node.id));
|
|
1977
|
+
return true;
|
|
1978
|
+
}
|
|
1979
|
+
if (event.metaKey || event.ctrlKey) {
|
|
1980
|
+
anchor.current = node.id;
|
|
1981
|
+
onSelectionChange(toggleSelected(selection, node.id));
|
|
1982
|
+
return true;
|
|
1983
|
+
}
|
|
1984
|
+
anchor.current = node.id;
|
|
1985
|
+
onSelectionChange([node.id]);
|
|
1986
|
+
return false;
|
|
1987
|
+
};
|
|
1008
1988
|
|
|
1009
1989
|
/**
|
|
1010
1990
|
* The unnamed row, at whatever depth it is being made at.
|
|
1011
1991
|
*
|
|
1012
1992
|
* A function rather than an element so the two placements below — nested under a parent,
|
|
1013
1993
|
* or at the root — cannot drift into drawing two different things.
|
|
1994
|
+
*
|
|
1995
|
+
* 🔴 A WRAP opens pre-filled and selected, with the first row's name minus its extension:
|
|
1996
|
+
* one keystroke replaces it, Enter accepts it, and the suggestion is the answer often
|
|
1997
|
+
* enough that it has to be the one already in the field. A plain new folder opens empty, as
|
|
1998
|
+
* it always has — there is nothing for it to be named after.
|
|
1014
1999
|
*/
|
|
1015
2000
|
const draftRow = (depth: number) => (
|
|
1016
2001
|
<div
|
|
@@ -1026,44 +2011,421 @@ export function FileTree({
|
|
|
1026
2011
|
)}
|
|
1027
2012
|
</span>
|
|
1028
2013
|
{nameField(
|
|
1029
|
-
'',
|
|
1030
|
-
(value) =>
|
|
2014
|
+
wrapping?.name ?? '',
|
|
2015
|
+
(value) => commitDraft(value),
|
|
1031
2016
|
() => onDraftCancel?.(),
|
|
2017
|
+
// A suggested name that comes back unchanged is the person AGREEING with it. Cancelling
|
|
2018
|
+
// there would make Enter mean "no" on the one field where it obviously means "yes".
|
|
2019
|
+
wrapping ? 'commit' : 'cancel',
|
|
1032
2020
|
)}
|
|
1033
2021
|
</div>
|
|
1034
2022
|
);
|
|
1035
2023
|
|
|
1036
|
-
const nameField = (
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
2024
|
+
const nameField = (
|
|
2025
|
+
initial: string,
|
|
2026
|
+
commit: (value: string) => void,
|
|
2027
|
+
cancel: () => void,
|
|
2028
|
+
/**
|
|
2029
|
+
* What a name that came back UNCHANGED means. A rename to the name a row already has is a
|
|
2030
|
+
* no-op, so it cancels and nothing is written; a pre-filled draft is the opposite — see
|
|
2031
|
+
* just above. An EMPTY field always cancels, whichever this is.
|
|
2032
|
+
*/
|
|
2033
|
+
unchanged: 'cancel' | 'commit' = 'cancel',
|
|
2034
|
+
) => {
|
|
2035
|
+
const done = (value: string) => {
|
|
2036
|
+
const name = value.trim();
|
|
2037
|
+
if (!name || (name === initial && unchanged === 'cancel')) cancel();
|
|
2038
|
+
else commit(name);
|
|
2039
|
+
};
|
|
2040
|
+
return (
|
|
2041
|
+
<Input
|
|
2042
|
+
className='cbft-name-input'
|
|
2043
|
+
defaultValue={initial}
|
|
2044
|
+
autoFocus
|
|
2045
|
+
aria-label='Name'
|
|
2046
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
2047
|
+
onClick={(event) => event.stopPropagation()}
|
|
2048
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
2049
|
+
onBlur={(event) => done(event.currentTarget.value)}
|
|
2050
|
+
onKeyDown={(event) => {
|
|
2051
|
+
event.stopPropagation();
|
|
2052
|
+
if (event.key === 'Escape') {
|
|
2053
|
+
event.preventDefault();
|
|
2054
|
+
cancel();
|
|
2055
|
+
} else if (event.key === 'Enter') {
|
|
2056
|
+
event.preventDefault();
|
|
2057
|
+
done(event.currentTarget.value);
|
|
2058
|
+
}
|
|
2059
|
+
}}
|
|
2060
|
+
/>
|
|
2061
|
+
);
|
|
2062
|
+
};
|
|
2063
|
+
|
|
2064
|
+
/**
|
|
2065
|
+
* One row — drawn in the tree, or drawn a second time in the pinned section above it.
|
|
2066
|
+
*
|
|
2067
|
+
* 🔴 ONE function for both, and that is the point rather than a tidiness. A pinned row is
|
|
2068
|
+
* the same row: it selects the same way, drags the same way, shows the same thumbnail, the
|
|
2069
|
+
* same padlock, the same count and the same tool shelf. Two copies of this markup would be
|
|
2070
|
+
* two rows that drift apart on the first change to either — the lesson `ListToolbar` was
|
|
2071
|
+
* extracted for one repo over. What `pinnedCopy` changes is only the five things that are
|
|
2072
|
+
* genuinely different, each marked below: which ref map holds it, which list its arrows
|
|
2073
|
+
* walk, which tab stop it competes for, that it is drawn FLAT, and that the rename field
|
|
2074
|
+
* stays on the real row while the real row is on screen.
|
|
2075
|
+
*/
|
|
2076
|
+
const renderRow = (row: FileTreeRow, pinnedCopy: boolean): ReactNode => {
|
|
2077
|
+
const { node } = row;
|
|
2078
|
+
const list = pinnedCopy ? pinnedRows : rows;
|
|
2079
|
+
// One id or many, asked the same way: `selected` is `[selectedId]` for every
|
|
2080
|
+
// consumer that never passed `selectedIds`, so this is the old test verbatim.
|
|
2081
|
+
const isSelected = selected.has(node.id);
|
|
2082
|
+
/*
|
|
2083
|
+
* 🔴 Exactly ONE copy ever draws the name field, and it is the visible one.
|
|
2084
|
+
*
|
|
2085
|
+
* `renamingId` names a NODE, and a pinned node is two rows — so the naive test opens two
|
|
2086
|
+
* `<input autoFocus>`s with one `aria-label='Name'` between them, which is both an
|
|
2087
|
+
* ambiguous query for a screen reader and a race for the caret. The tree's copy wins
|
|
2088
|
+
* whenever it exists, because that is the row with the depth and the ancestors marked
|
|
2089
|
+
* above it; the pinned copy answers only when the row is inside a shut folder and the
|
|
2090
|
+
* tree is not drawing it at all — where the alternative is a rename that opens nowhere.
|
|
2091
|
+
*/
|
|
2092
|
+
const isRenaming =
|
|
2093
|
+
renamingId === node.id &&
|
|
2094
|
+
(!pinnedCopy || !rows.some((other) => other.node.id === node.id));
|
|
2095
|
+
const isDragged = dragging?.has(node.id) === true;
|
|
2096
|
+
const isDropTarget = dropId === node.id || externalOverId === node.id;
|
|
2097
|
+
const isPinned = pinned.includes(node.id);
|
|
2098
|
+
const marker =
|
|
2099
|
+
drag?.active && drag.offer.target && 'siblingId' in drag.offer.target
|
|
2100
|
+
? drag.offer.target.siblingId === node.id
|
|
2101
|
+
? drag.offer.target.kind
|
|
2102
|
+
: null
|
|
2103
|
+
: null;
|
|
2104
|
+
|
|
2105
|
+
const rowBody = (
|
|
2106
|
+
// biome-ignore lint/a11y/useFocusableInteractive: it IS focusable — `tabIndex`
|
|
2107
|
+
// is set below by the roving-focus rule (one row at 0, the rest at -1).
|
|
2108
|
+
<div
|
|
2109
|
+
role='treeitem'
|
|
2110
|
+
aria-selected={isSelected}
|
|
2111
|
+
/* Flat in the pinned section: it is a list of shortcuts, not a second hierarchy. */
|
|
2112
|
+
aria-level={pinnedCopy ? 1 : row.depth + 1}
|
|
2113
|
+
/* A screen reader says "3 of 7" only if it is told the level's size. Both
|
|
2114
|
+
come from `shown`, so a filtered tree counts what it drew rather than
|
|
2115
|
+
what it hid.
|
|
2116
|
+
|
|
2117
|
+
🔴 The pinned section counts ITSELF. `setSizes` is built from the rows on screen on
|
|
2118
|
+
the stated assumption that every sibling of a visible row is visible too — true of
|
|
2119
|
+
the tree, false the moment a pin is folded into it, and the cost would be wrong
|
|
2120
|
+
counts on every row in the tree rather than only on the pinned ones. Its own
|
|
2121
|
+
`role='tree'` below is the other half of the same answer. */
|
|
2122
|
+
aria-posinset={pinnedCopy ? list.indexOf(row) + 1 : row.index + 1}
|
|
2123
|
+
aria-setsize={pinnedCopy ? list.length : (setSizes.get(row.parentId ?? '') ?? 1)}
|
|
2124
|
+
{...(row.expandable ? { 'aria-expanded': row.expanded } : {})}
|
|
2125
|
+
/* One stop per SECTION, not per row and not per node: `focused` carries which copy
|
|
2126
|
+
the keyboard is on, so the two trees do not both answer for one id. */
|
|
2127
|
+
tabIndex={
|
|
2128
|
+
node.id ===
|
|
2129
|
+
(pinnedCopy
|
|
2130
|
+
? focused?.pinned
|
|
2131
|
+
? focused.id
|
|
2132
|
+
: pinnedRows[0]?.node.id
|
|
2133
|
+
: focused && !focused.pinned
|
|
2134
|
+
? focused.id
|
|
2135
|
+
: (selectedId ?? rows[0]?.node.id))
|
|
2136
|
+
? 0
|
|
2137
|
+
: -1
|
|
1060
2138
|
}
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
2139
|
+
ref={(el) => {
|
|
2140
|
+
const refs = pinnedCopy ? pinnedRefs : rowRefs;
|
|
2141
|
+
if (el) refs.current.set(node.id, el);
|
|
2142
|
+
else refs.current.delete(node.id);
|
|
2143
|
+
}}
|
|
2144
|
+
key={node.id}
|
|
2145
|
+
className='cbft-row'
|
|
2146
|
+
data-selected={isSelected || undefined}
|
|
2147
|
+
/* Held by a ⌘X that has not been pasted yet — drawn lifted, the way Finder
|
|
2148
|
+
draws a cut. Escape puts it down. */
|
|
2149
|
+
data-cut={cut?.has(node.id) || undefined}
|
|
2150
|
+
data-dragged={isDragged || undefined}
|
|
2151
|
+
data-into={isDropTarget || undefined}
|
|
2152
|
+
data-marker={marker ?? undefined}
|
|
2153
|
+
/* Which copy this is, and whether the node is pinned at all — the second is what lets
|
|
2154
|
+
the row IN THE FOLDER say so, which is the half of *"they still will exist in the
|
|
2155
|
+
folder they were stored in"* that a person has to be able to see. */
|
|
2156
|
+
data-pinned={isPinned || undefined}
|
|
2157
|
+
data-pinned-copy={pinnedCopy || undefined}
|
|
2158
|
+
/* On the way to the selection, and — when it is shut — the one HOLDING it.
|
|
2159
|
+
See `openPath`: with the selected row not drawn at all, this bar is the
|
|
2160
|
+
only thing in the tree that says where you are. */
|
|
2161
|
+
data-ancestor={openPath.path.has(node.id) || undefined}
|
|
2162
|
+
data-holds-open={
|
|
2163
|
+
(row.expandable && !row.expanded && openPath.holder === node.id) || undefined
|
|
2164
|
+
}
|
|
2165
|
+
data-access={node.access && node.access !== 'normal' ? node.access : undefined}
|
|
2166
|
+
style={{ '--cbft-depth': row.depth } as React.CSSProperties}
|
|
2167
|
+
onPointerDown={(event) => {
|
|
2168
|
+
if (inTools(event.target)) return;
|
|
2169
|
+
beginDrag(event, node);
|
|
2170
|
+
}}
|
|
2171
|
+
onClick={(event) => {
|
|
2172
|
+
if (inTools(event.target)) return;
|
|
2173
|
+
// The click a finished drag left behind — see `draggedJustNow`.
|
|
2174
|
+
if (draggedJustNow.current) {
|
|
2175
|
+
draggedJustNow.current = false;
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
if (clickSelected(node, event, list)) return;
|
|
2179
|
+
onSelect(node);
|
|
2180
|
+
}}
|
|
2181
|
+
onDoubleClick={(event) => {
|
|
2182
|
+
if (inTools(event.target)) return;
|
|
2183
|
+
(onOpen ?? onSelect)(node);
|
|
2184
|
+
}}
|
|
2185
|
+
onKeyDown={(event) => onKeyDown(event, row, list, pinnedCopy)}
|
|
2186
|
+
onFocus={() => setFocused({ id: node.id, pinned: pinnedCopy })}
|
|
2187
|
+
{...externalHandlers(node)}
|
|
2188
|
+
>
|
|
2189
|
+
{row.expandable ? (
|
|
2190
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: a disclosure twisty
|
|
2191
|
+
// inside a `treeitem`, which already carries `aria-expanded` — a nested
|
|
2192
|
+
// <button> would be a second focus stop announcing the same state twice.
|
|
2193
|
+
<span
|
|
2194
|
+
className='cbft-twisty'
|
|
2195
|
+
data-open={row.expanded || undefined}
|
|
2196
|
+
aria-hidden='true'
|
|
2197
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
2198
|
+
onClick={(event) => {
|
|
2199
|
+
event.stopPropagation();
|
|
2200
|
+
toggle(node.id);
|
|
2201
|
+
}}
|
|
2202
|
+
>
|
|
2203
|
+
{/*
|
|
2204
|
+
🔴 A drawn chevron, not a `▸` character (2026-09-09).
|
|
2205
|
+
Owner: *"the caret is too small. Use the icon and styling from ces the
|
|
2206
|
+
spreadsheet app."* A text triangle is sized by `font-size`, and the one
|
|
2207
|
+
here was 10px — below anything a pointer can comfortably aim at, and it
|
|
2208
|
+
rendered differently on every platform because it is a FONT glyph. This
|
|
2209
|
+
is the spreadsheet's own `IconChevronRight` geometry at 12px inside a
|
|
2210
|
+
16px target, which is what it has always been over there.
|
|
2211
|
+
*/}
|
|
2212
|
+
<svg
|
|
2213
|
+
viewBox='0 0 24 24'
|
|
2214
|
+
width='12'
|
|
2215
|
+
height='12'
|
|
2216
|
+
fill='none'
|
|
2217
|
+
stroke='currentColor'
|
|
2218
|
+
strokeWidth='2.2'
|
|
2219
|
+
strokeLinecap='round'
|
|
2220
|
+
strokeLinejoin='round'
|
|
2221
|
+
focusable='false'
|
|
2222
|
+
// Decorative, and the state it depicts is already announced: the
|
|
2223
|
+
// wrapping <span> is aria-hidden and the treeitem above carries
|
|
2224
|
+
// aria-expanded. Said on the SVG itself because `noSvgWithoutTitle`
|
|
2225
|
+
// inspects the element, not its ancestors — a <title> here would make
|
|
2226
|
+
// a screen reader read the chevron out a second time.
|
|
2227
|
+
aria-hidden='true'
|
|
2228
|
+
>
|
|
2229
|
+
<path d='m9 6 6 6-6 6' />
|
|
2230
|
+
</svg>
|
|
2231
|
+
</span>
|
|
2232
|
+
) : (
|
|
2233
|
+
/*
|
|
2234
|
+
🔴 The icon takes the twisty's OWN column — one glyph box per row, never
|
|
2235
|
+
two — see `.cbft-slot`. A row that can be opened is marked by its arrow
|
|
2236
|
+
and a row that cannot is marked by its icon, so the name always sits one
|
|
2237
|
+
gap from whichever glyph the row has. Reserving a second track for the
|
|
2238
|
+
icon (what this did until 2026-09-10) put a glyph's width plus a gap
|
|
2239
|
+
between every folder's arrow and its name: *"the text for a dir has too
|
|
2240
|
+
much space between its open/closed arrow icon and the name."*
|
|
2241
|
+
|
|
2242
|
+
The box is fixed and unconditional, which is the property the two-track
|
|
2243
|
+
layout was protecting and this keeps: every name at a given depth starts
|
|
2244
|
+
on one vertical whether the row draws an arrow, a type icon or a
|
|
2245
|
+
thumbnail. Since 2026-09-11 an empty folder draws its ARROW like any
|
|
2246
|
+
other folder, so this branch is a file's — or a row that opted out of the
|
|
2247
|
+
twisty with `expandable: false`, which every PINNED copy does.
|
|
2248
|
+
*/
|
|
2249
|
+
<span className='cbft-slot' aria-hidden='true'>
|
|
2250
|
+
<RowGlyph node={node} open={row.expanded} {...(renderIcon ? { renderIcon } : {})} />
|
|
2251
|
+
</span>
|
|
2252
|
+
)}
|
|
2253
|
+
|
|
2254
|
+
{isRenaming && onRename ? (
|
|
2255
|
+
nameField(
|
|
2256
|
+
node.name,
|
|
2257
|
+
(value) => onRename(node, value),
|
|
2258
|
+
() => onRenameCancel?.(),
|
|
2259
|
+
)
|
|
2260
|
+
) : (
|
|
2261
|
+
<Tooltip content={node.name}>
|
|
2262
|
+
{/* The full name, for a row whose name the ellipsis cut. `Tooltip` rather
|
|
2263
|
+
than `title=`: this tree is used on a phone, where a native tooltip
|
|
2264
|
+
does not exist at all and a truncated folder name would have no way
|
|
2265
|
+
of being read. */}
|
|
2266
|
+
<span className='cbft-name'>{node.name}</span>
|
|
2267
|
+
</Tooltip>
|
|
2268
|
+
)}
|
|
2269
|
+
|
|
2270
|
+
{/*
|
|
2271
|
+
🔴 A PRIVATE row says so, with a mark — added 2026-09-11.
|
|
2272
|
+
|
|
2273
|
+
The owner, the same day: *"When I click a 3 dot menu on a folder and choose
|
|
2274
|
+
make private nothing happens."* It did happen, three times over — his live
|
|
2275
|
+
`collections` database holds three private folders and 39 private files, all
|
|
2276
|
+
written by this control. What was missing was any sign of it: while UNLOCKED a
|
|
2277
|
+
private folder appears in the tree and, since the row padlock went on
|
|
2278
|
+
2026-09-08, looked **exactly like a public one**. The only mark was
|
|
2279
|
+
`font-style: italic` on the name, which one row in thirty does not carry
|
|
2280
|
+
visibly at 13px. So a write that worked, and a UI that said nothing, were
|
|
2281
|
+
indistinguishable from a control that was broken.
|
|
2282
|
+
|
|
2283
|
+
🔴 This is not the 2026-09-08 padlock coming back, and the difference is the
|
|
2284
|
+
whole argument. That was an ACTION — one of three always-lit glyphs on EVERY
|
|
2285
|
+
row (*"doesn't have numbers to the right of folder names or the pencil icon or
|
|
2286
|
+
lock icon"*), and its verb survived its removal: privacy is on the ⋯ menu. This
|
|
2287
|
+
is STATE, it appears only on the rows that have it, it is not clickable, and
|
|
2288
|
+
state has nowhere else to live. Deleting it would delete the fact.
|
|
2289
|
+
|
|
2290
|
+
After the name and before the tools, so it never takes the glyph column (that
|
|
2291
|
+
is the twisty's, and a folder losing its arrow would lose the control that
|
|
2292
|
+
opens it) and never moves with how many actions a consumer wired up.
|
|
2293
|
+
*/}
|
|
2294
|
+
{/* The mark's words are the consumer's when it has its own privacy
|
|
2295
|
+
vocabulary — see accessMarkCopy for why the defaults are collections'. */}
|
|
2296
|
+
{node.access === 'private' || node.access === 'locked' ? (
|
|
2297
|
+
<Tooltip content={accessMarkCopy(node.access, accessCopy)}>
|
|
2298
|
+
{/* Not `aria-hidden`: this is the one thing on the row a screen reader
|
|
2299
|
+
cannot work out from anything else, and the name alone would announce a
|
|
2300
|
+
private folder identically to a public one. */}
|
|
2301
|
+
<span className='cbft-access' role='img' aria-label='Private'>
|
|
2302
|
+
🔒
|
|
2303
|
+
</span>
|
|
2304
|
+
</Tooltip>
|
|
2305
|
+
) : null}
|
|
2306
|
+
{renderAccessory ? renderAccessory(node) : null}
|
|
2307
|
+
{node.count !== undefined ? <span className='cbft-count'>{node.count}</span> : null}
|
|
2308
|
+
|
|
2309
|
+
{/*
|
|
2310
|
+
🔴 The PIN, and it is a control rather than a mark — unlike the padlock above.
|
|
2311
|
+
|
|
2312
|
+
The padlock is state with its verb somewhere else (the ⋯ menu); this is a state whose
|
|
2313
|
+
verb has nowhere else to be. *"Pinning or unpinning one place doesn't change pinning
|
|
2314
|
+
in the other place"* describes a thing a person does, on a phone as well as with
|
|
2315
|
+
⌃⌘T, and a tree that only offered the chord would be a feature with no pointer route
|
|
2316
|
+
at all. It lives in the `.cbft-tools` shelf so it inherits the shelf's whole contract
|
|
2317
|
+
in one line — quiet at 40%, solid on hover, focus or selection, and solid with a 44px
|
|
2318
|
+
box on a coarse pointer — and so `inTools` already stops it selecting the row.
|
|
2319
|
+
|
|
2320
|
+
Drawn only when {@link FileTreeProps.onPinnedChange} is wired, like every other verb
|
|
2321
|
+
here: a consumer that cannot receive the list sees no control that fails on press.
|
|
2322
|
+
*/}
|
|
2323
|
+
{onPinnedChange ? (
|
|
2324
|
+
<span className='cbft-tools cbft-pin-shelf'>
|
|
2325
|
+
<Tooltip content={isPinned ? 'Unpin from the top' : 'Pin to the top'}>
|
|
2326
|
+
<button
|
|
2327
|
+
// guardrails-ignore no-raw-action-button: a 16px glyph inside a 22px tree row,
|
|
2328
|
+
// sized by `--cbft-glyph` exactly as the twisty beside it is. cursedbelt's
|
|
2329
|
+
// Button/IconButton carry the shared 36px control height and Tailwind classes
|
|
2330
|
+
// this stylesheet deliberately does not rely on (see fileTree.css's header for
|
|
2331
|
+
// why the tree ships plain CSS), so a control-class primitive here would make
|
|
2332
|
+
// one row taller than every other — the geometry `file-tree-geometry.json`
|
|
2333
|
+
// pins. The native element is used precisely so Enter/Space, focus and
|
|
2334
|
+
// disabled semantics stay the browser's.
|
|
2335
|
+
type='button'
|
|
2336
|
+
className='cbft-pin'
|
|
2337
|
+
aria-pressed={isPinned}
|
|
2338
|
+
aria-label={isPinned ? `Unpin ${node.name}` : `Pin ${node.name}`}
|
|
2339
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
2340
|
+
onClick={(event) => {
|
|
2341
|
+
event.stopPropagation();
|
|
2342
|
+
togglePin(node);
|
|
2343
|
+
}}
|
|
2344
|
+
>
|
|
2345
|
+
{/* Lucide's `pin`, drawn rather than imported for the reason the chevron is:
|
|
2346
|
+
the stylesheet sizes the box and the glyph, and a font or an icon package
|
|
2347
|
+
would put a third party between the two. Decorative — the button's own
|
|
2348
|
+
`aria-label` already says what it does and `aria-pressed` says its state. */}
|
|
2349
|
+
<svg
|
|
2350
|
+
viewBox='0 0 24 24'
|
|
2351
|
+
width='12'
|
|
2352
|
+
height='12'
|
|
2353
|
+
fill='none'
|
|
2354
|
+
stroke='currentColor'
|
|
2355
|
+
strokeWidth='2'
|
|
2356
|
+
strokeLinecap='round'
|
|
2357
|
+
strokeLinejoin='round'
|
|
2358
|
+
focusable='false'
|
|
2359
|
+
aria-hidden='true'
|
|
2360
|
+
>
|
|
2361
|
+
<path d='M12 17v5' />
|
|
2362
|
+
<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' />
|
|
2363
|
+
</svg>
|
|
2364
|
+
</button>
|
|
2365
|
+
</Tooltip>
|
|
2366
|
+
</span>
|
|
2367
|
+
) : null}
|
|
2368
|
+
|
|
2369
|
+
{renderTools ? <span className='cbft-tools'>{renderTools(node)}</span> : null}
|
|
2370
|
+
</div>
|
|
2371
|
+
);
|
|
2372
|
+
|
|
2373
|
+
// The "why not" only exists while the tree genuinely cannot be rearranged;
|
|
2374
|
+
// wrapping every row in a tooltip otherwise would put a hint on a working
|
|
2375
|
+
// control saying nothing.
|
|
2376
|
+
const body =
|
|
2377
|
+
unavailableReason && !canRearrange ? (
|
|
2378
|
+
<Tooltip key={node.id} content={unavailableReason}>
|
|
2379
|
+
{rowBody}
|
|
2380
|
+
</Tooltip>
|
|
2381
|
+
) : (
|
|
2382
|
+
rowBody
|
|
2383
|
+
);
|
|
2384
|
+
|
|
2385
|
+
// The draft belongs INSIDE the folder it is being made in, one level deeper —
|
|
2386
|
+
// see the note above the parentless case. Never under a PINNED copy: the draft is
|
|
2387
|
+
// being made in a place, and the place is where the folder actually lives.
|
|
2388
|
+
return !pinnedCopy && draft && draft.parentId === node.id ? (
|
|
2389
|
+
<Fragment key={node.id}>
|
|
2390
|
+
{body}
|
|
2391
|
+
{draftRow(row.depth + 1)}
|
|
2392
|
+
</Fragment>
|
|
2393
|
+
) : (
|
|
2394
|
+
body
|
|
2395
|
+
);
|
|
2396
|
+
};
|
|
1064
2397
|
|
|
1065
2398
|
return (
|
|
1066
2399
|
<div className={`cbft${className ? ` ${className}` : ''}`}>
|
|
2400
|
+
{/*
|
|
2401
|
+
* ── The pinned section — above the folders, and outside the scroller ─────────────
|
|
2402
|
+
*
|
|
2403
|
+
* Owner, 2026-09-13: *"pin open files where they stay at the top of the file tree in a
|
|
2404
|
+
* new section above the folders."* **Stay** is why it is a sibling of `.cbft-scroll`
|
|
2405
|
+
* rather than a `position: sticky` block inside it: a row that scrolls away has not
|
|
2406
|
+
* stayed anywhere, and sticky would have cost a raw z-index over the rows it overlaps
|
|
2407
|
+
* plus a hit-test that has to know which of two overlapping boxes wins. Outside the
|
|
2408
|
+
* scroller there is nothing to overlap — and the reveal, the autoscroll edges and the
|
|
2409
|
+
* blank-space drop target below all still mean exactly what they meant, because none of
|
|
2410
|
+
* them can see this box.
|
|
2411
|
+
*
|
|
2412
|
+
* 🔴 Its own `role='tree'`, not a slice of the one below. See `aria-setsize` in
|
|
2413
|
+
* `renderRow`.
|
|
2414
|
+
*/}
|
|
2415
|
+
{pinnedRows.length > 0 ? (
|
|
2416
|
+
<div className='cbft-pinned'>
|
|
2417
|
+
<div className='cbft-pinned-head'>{pinnedLabel}</div>
|
|
2418
|
+
<div
|
|
2419
|
+
role='tree'
|
|
2420
|
+
aria-label={pinnedLabel}
|
|
2421
|
+
{...(onSelectionChange ? { 'aria-multiselectable': true } : {})}
|
|
2422
|
+
className={`cbft-pinned-list${drag?.active ? ' cbft-list-dragging' : ''}`}
|
|
2423
|
+
>
|
|
2424
|
+
{pinnedRows.map((row) => renderRow(row, true))}
|
|
2425
|
+
</div>
|
|
2426
|
+
</div>
|
|
2427
|
+
) : null}
|
|
2428
|
+
|
|
1067
2429
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: the blank area under the tree is
|
|
1068
2430
|
a DROP TARGET, not a control — "get this out of here" is the one gesture with no
|
|
1069
2431
|
row to aim at. Every move it can express is also on a row's own menu. */}
|
|
@@ -1079,6 +2441,10 @@ export function FileTree({
|
|
|
1079
2441
|
<div
|
|
1080
2442
|
role='tree'
|
|
1081
2443
|
aria-label={ariaLabel}
|
|
2444
|
+
/* Only when the consumer can actually receive a second selected row. A tree that
|
|
2445
|
+
announced itself multi-selectable and then replaced the selection on every click
|
|
2446
|
+
would be telling a screen reader something the pointer disproves. */
|
|
2447
|
+
{...(onSelectionChange ? { 'aria-multiselectable': true } : {})}
|
|
1082
2448
|
className={`cbft-list${drag?.active ? ' cbft-list-dragging' : ''}`}
|
|
1083
2449
|
>
|
|
1084
2450
|
{rows.length === 0 && !draft ? <div className='cbft-empty'>{empty}</div> : null}
|
|
@@ -1093,227 +2459,7 @@ export function FileTree({
|
|
|
1093
2459
|
*/}
|
|
1094
2460
|
{draft && draft.parentId === null ? draftRow(0) : null}
|
|
1095
2461
|
|
|
1096
|
-
{rows.map((row) =>
|
|
1097
|
-
const { node } = row;
|
|
1098
|
-
const isSelected = node.id === selectedId;
|
|
1099
|
-
const isRenaming = renamingId === node.id;
|
|
1100
|
-
const isDragged = dragging?.has(node.id) === true;
|
|
1101
|
-
const isDropTarget = dropId === node.id || externalOverId === node.id;
|
|
1102
|
-
const marker =
|
|
1103
|
-
drag?.active && drag.offer.target && 'siblingId' in drag.offer.target
|
|
1104
|
-
? drag.offer.target.siblingId === node.id
|
|
1105
|
-
? drag.offer.target.kind
|
|
1106
|
-
: null
|
|
1107
|
-
: null;
|
|
1108
|
-
|
|
1109
|
-
const rowBody = (
|
|
1110
|
-
// biome-ignore lint/a11y/useFocusableInteractive: it IS focusable — `tabIndex`
|
|
1111
|
-
// is set below by the roving-focus rule (one row at 0, the rest at -1).
|
|
1112
|
-
<div
|
|
1113
|
-
role='treeitem'
|
|
1114
|
-
aria-selected={isSelected}
|
|
1115
|
-
aria-level={row.depth + 1}
|
|
1116
|
-
/* A screen reader says "3 of 7" only if it is told the level's size. Both
|
|
1117
|
-
come from `shown`, so a filtered tree counts what it drew rather than
|
|
1118
|
-
what it hid. */
|
|
1119
|
-
aria-posinset={row.index + 1}
|
|
1120
|
-
aria-setsize={setSizes.get(row.parentId ?? '') ?? 1}
|
|
1121
|
-
{...(row.expandable ? { 'aria-expanded': row.expanded } : {})}
|
|
1122
|
-
tabIndex={
|
|
1123
|
-
node.id === (focusedId ?? selectedId ?? rows[0]?.node.id) ? 0 : -1
|
|
1124
|
-
}
|
|
1125
|
-
ref={(el) => {
|
|
1126
|
-
if (el) rowRefs.current.set(node.id, el);
|
|
1127
|
-
else rowRefs.current.delete(node.id);
|
|
1128
|
-
}}
|
|
1129
|
-
key={node.id}
|
|
1130
|
-
className='cbft-row'
|
|
1131
|
-
data-selected={isSelected || undefined}
|
|
1132
|
-
data-dragged={isDragged || undefined}
|
|
1133
|
-
data-into={isDropTarget || undefined}
|
|
1134
|
-
data-marker={marker ?? undefined}
|
|
1135
|
-
/* On the way to the selection, and — when it is shut — the one HOLDING it.
|
|
1136
|
-
See `openPath`: with the selected row not drawn at all, this bar is the
|
|
1137
|
-
only thing in the tree that says where you are. */
|
|
1138
|
-
data-ancestor={openPath.path.has(node.id) || undefined}
|
|
1139
|
-
data-holds-open={
|
|
1140
|
-
(row.expandable && !row.expanded && openPath.holder === node.id) || undefined
|
|
1141
|
-
}
|
|
1142
|
-
data-access={node.access && node.access !== 'normal' ? node.access : undefined}
|
|
1143
|
-
style={{ '--cbft-depth': row.depth } as React.CSSProperties}
|
|
1144
|
-
onPointerDown={(event) => {
|
|
1145
|
-
if (inTools(event.target)) return;
|
|
1146
|
-
beginDrag(event, node);
|
|
1147
|
-
}}
|
|
1148
|
-
onClick={(event) => {
|
|
1149
|
-
if (inTools(event.target)) return;
|
|
1150
|
-
// The click a finished drag left behind — see `draggedJustNow`.
|
|
1151
|
-
if (draggedJustNow.current) {
|
|
1152
|
-
draggedJustNow.current = false;
|
|
1153
|
-
return;
|
|
1154
|
-
}
|
|
1155
|
-
onSelect(node);
|
|
1156
|
-
}}
|
|
1157
|
-
onDoubleClick={(event) => {
|
|
1158
|
-
if (inTools(event.target)) return;
|
|
1159
|
-
(onOpen ?? onSelect)(node);
|
|
1160
|
-
}}
|
|
1161
|
-
onKeyDown={(event) => onKeyDown(event, row)}
|
|
1162
|
-
onFocus={() => setFocusedId(node.id)}
|
|
1163
|
-
{...externalHandlers(node)}
|
|
1164
|
-
>
|
|
1165
|
-
{row.expandable ? (
|
|
1166
|
-
// biome-ignore lint/a11y/noStaticElementInteractions: a disclosure twisty
|
|
1167
|
-
// inside a `treeitem`, which already carries `aria-expanded` — a nested
|
|
1168
|
-
// <button> would be a second focus stop announcing the same state twice.
|
|
1169
|
-
<span
|
|
1170
|
-
className='cbft-twisty'
|
|
1171
|
-
data-open={row.expanded || undefined}
|
|
1172
|
-
aria-hidden='true'
|
|
1173
|
-
onPointerDown={(event) => event.stopPropagation()}
|
|
1174
|
-
onClick={(event) => {
|
|
1175
|
-
event.stopPropagation();
|
|
1176
|
-
toggle(node.id);
|
|
1177
|
-
}}
|
|
1178
|
-
>
|
|
1179
|
-
{/*
|
|
1180
|
-
🔴 A drawn chevron, not a `▸` character (2026-09-09).
|
|
1181
|
-
Owner: *"the caret is too small. Use the icon and styling from ces the
|
|
1182
|
-
spreadsheet app."* A text triangle is sized by `font-size`, and the one
|
|
1183
|
-
here was 10px — below anything a pointer can comfortably aim at, and it
|
|
1184
|
-
rendered differently on every platform because it is a FONT glyph. This
|
|
1185
|
-
is the spreadsheet's own `IconChevronRight` geometry at 12px inside a
|
|
1186
|
-
16px target, which is what it has always been over there.
|
|
1187
|
-
*/}
|
|
1188
|
-
<svg
|
|
1189
|
-
viewBox='0 0 24 24'
|
|
1190
|
-
width='12'
|
|
1191
|
-
height='12'
|
|
1192
|
-
fill='none'
|
|
1193
|
-
stroke='currentColor'
|
|
1194
|
-
strokeWidth='2.2'
|
|
1195
|
-
strokeLinecap='round'
|
|
1196
|
-
strokeLinejoin='round'
|
|
1197
|
-
focusable='false'
|
|
1198
|
-
// Decorative, and the state it depicts is already announced: the
|
|
1199
|
-
// wrapping <span> is aria-hidden and the treeitem above carries
|
|
1200
|
-
// aria-expanded. Said on the SVG itself because `noSvgWithoutTitle`
|
|
1201
|
-
// inspects the element, not its ancestors — a <title> here would make
|
|
1202
|
-
// a screen reader read the chevron out a second time.
|
|
1203
|
-
aria-hidden='true'
|
|
1204
|
-
>
|
|
1205
|
-
<path d='m9 6 6 6-6 6' />
|
|
1206
|
-
</svg>
|
|
1207
|
-
</span>
|
|
1208
|
-
) : (
|
|
1209
|
-
/*
|
|
1210
|
-
🔴 The icon takes the twisty's OWN column — one glyph box per row, never
|
|
1211
|
-
two — see `.cbft-slot`. A row that can be opened is marked by its arrow
|
|
1212
|
-
and a row that cannot is marked by its icon, so the name always sits one
|
|
1213
|
-
gap from whichever glyph the row has. Reserving a second track for the
|
|
1214
|
-
icon (what this did until 2026-09-10) put a glyph's width plus a gap
|
|
1215
|
-
between every folder's arrow and its name: *"the text for a dir has too
|
|
1216
|
-
much space between its open/closed arrow icon and the name."*
|
|
1217
|
-
|
|
1218
|
-
The box is fixed and unconditional, which is the property the two-track
|
|
1219
|
-
layout was protecting and this keeps: every name at a given depth starts
|
|
1220
|
-
on one vertical whether the row draws an arrow, a type icon or a
|
|
1221
|
-
thumbnail. Since 2026-09-11 an empty folder draws its ARROW like any
|
|
1222
|
-
other folder, so this branch is a file's — or a row that opted out of the
|
|
1223
|
-
twisty with `expandable: false`.
|
|
1224
|
-
*/
|
|
1225
|
-
<span className='cbft-slot' aria-hidden='true'>
|
|
1226
|
-
<RowGlyph
|
|
1227
|
-
node={node}
|
|
1228
|
-
open={row.expanded}
|
|
1229
|
-
{...(renderIcon ? { renderIcon } : {})}
|
|
1230
|
-
/>
|
|
1231
|
-
</span>
|
|
1232
|
-
)}
|
|
1233
|
-
|
|
1234
|
-
{isRenaming && onRename ? (
|
|
1235
|
-
nameField(
|
|
1236
|
-
node.name,
|
|
1237
|
-
(value) => onRename(node, value),
|
|
1238
|
-
() => onRenameCancel?.(),
|
|
1239
|
-
)
|
|
1240
|
-
) : (
|
|
1241
|
-
<Tooltip content={node.name}>
|
|
1242
|
-
{/* The full name, for a row whose name the ellipsis cut. `Tooltip` rather
|
|
1243
|
-
than `title=`: this tree is used on a phone, where a native tooltip
|
|
1244
|
-
does not exist at all and a truncated folder name would have no way
|
|
1245
|
-
of being read. */}
|
|
1246
|
-
<span className='cbft-name'>{node.name}</span>
|
|
1247
|
-
</Tooltip>
|
|
1248
|
-
)}
|
|
1249
|
-
|
|
1250
|
-
{/*
|
|
1251
|
-
🔴 A PRIVATE row says so, with a mark — added 2026-09-11.
|
|
1252
|
-
|
|
1253
|
-
The owner, the same day: *"When I click a 3 dot menu on a folder and choose
|
|
1254
|
-
make private nothing happens."* It did happen, three times over — his live
|
|
1255
|
-
`collections` database holds three private folders and 39 private files, all
|
|
1256
|
-
written by this control. What was missing was any sign of it: while UNLOCKED a
|
|
1257
|
-
private folder appears in the tree and, since the row padlock went on
|
|
1258
|
-
2026-09-08, looked **exactly like a public one**. The only mark was
|
|
1259
|
-
`font-style: italic` on the name, which one row in thirty does not carry
|
|
1260
|
-
visibly at 13px. So a write that worked, and a UI that said nothing, were
|
|
1261
|
-
indistinguishable from a control that was broken.
|
|
1262
|
-
|
|
1263
|
-
🔴 This is not the 2026-09-08 padlock coming back, and the difference is the
|
|
1264
|
-
whole argument. That was an ACTION — one of three always-lit glyphs on EVERY
|
|
1265
|
-
row (*"doesn't have numbers to the right of folder names or the pencil icon or
|
|
1266
|
-
lock icon"*), and its verb survived its removal: privacy is on the ⋯ menu. This
|
|
1267
|
-
is STATE, it appears only on the rows that have it, it is not clickable, and
|
|
1268
|
-
state has nowhere else to live. Deleting it would delete the fact.
|
|
1269
|
-
|
|
1270
|
-
After the name and before the tools, so it never takes the glyph column (that
|
|
1271
|
-
is the twisty's, and a folder losing its arrow would lose the control that
|
|
1272
|
-
opens it) and never moves with how many actions a consumer wired up.
|
|
1273
|
-
*/}
|
|
1274
|
-
{/* The mark's words are the consumer's when it has its own privacy
|
|
1275
|
-
vocabulary — see accessMarkCopy for why the defaults are collections'. */}
|
|
1276
|
-
{node.access === 'private' || node.access === 'locked' ? (
|
|
1277
|
-
<Tooltip content={accessMarkCopy(node.access, accessCopy)}>
|
|
1278
|
-
{/* Not `aria-hidden`: this is the one thing on the row a screen reader
|
|
1279
|
-
cannot work out from anything else, and the name alone would announce a
|
|
1280
|
-
private folder identically to a public one. */}
|
|
1281
|
-
<span className='cbft-access' role='img' aria-label='Private'>
|
|
1282
|
-
🔒
|
|
1283
|
-
</span>
|
|
1284
|
-
</Tooltip>
|
|
1285
|
-
) : null}
|
|
1286
|
-
{renderAccessory ? renderAccessory(node) : null}
|
|
1287
|
-
{node.count !== undefined ? (
|
|
1288
|
-
<span className='cbft-count'>{node.count}</span>
|
|
1289
|
-
) : null}
|
|
1290
|
-
{renderTools ? <span className='cbft-tools'>{renderTools(node)}</span> : null}
|
|
1291
|
-
</div>
|
|
1292
|
-
);
|
|
1293
|
-
|
|
1294
|
-
// The "why not" only exists while the tree genuinely cannot be rearranged;
|
|
1295
|
-
// wrapping every row in a tooltip otherwise would put a hint on a working
|
|
1296
|
-
// control saying nothing.
|
|
1297
|
-
const body =
|
|
1298
|
-
unavailableReason && !onMove ? (
|
|
1299
|
-
<Tooltip key={node.id} content={unavailableReason}>
|
|
1300
|
-
{rowBody}
|
|
1301
|
-
</Tooltip>
|
|
1302
|
-
) : (
|
|
1303
|
-
rowBody
|
|
1304
|
-
);
|
|
1305
|
-
|
|
1306
|
-
// The draft belongs INSIDE the folder it is being made in, one level deeper —
|
|
1307
|
-
// see the note above the parentless case.
|
|
1308
|
-
return draft && draft.parentId === node.id ? (
|
|
1309
|
-
<Fragment key={node.id}>
|
|
1310
|
-
{body}
|
|
1311
|
-
{draftRow(row.depth + 1)}
|
|
1312
|
-
</Fragment>
|
|
1313
|
-
) : (
|
|
1314
|
-
body
|
|
1315
|
-
);
|
|
1316
|
-
})}
|
|
2462
|
+
{rows.map((row) => renderRow(row, false))}
|
|
1317
2463
|
|
|
1318
2464
|
{/*
|
|
1319
2465
|
* The parent went away underneath an open draft — a filter narrowed, or the tree
|
|
@@ -1343,7 +2489,11 @@ export function FileTree({
|
|
|
1343
2489
|
style={{ left: `${drag.x}px`, top: `${drag.y}px` }}
|
|
1344
2490
|
aria-hidden='true'
|
|
1345
2491
|
>
|
|
1346
|
-
|
|
2492
|
+
{/* One row says its name; a selection says how many, because twenty names is not
|
|
2493
|
+
something a chip under a moving cursor can be read as. */}
|
|
2494
|
+
<span className='cbft-chip-name'>
|
|
2495
|
+
{drag.ids.length > 1 ? `${drag.ids.length} items` : nameOf(shown, drag.id)}
|
|
2496
|
+
</span>
|
|
1347
2497
|
{/* The refusal wins the slot: while something cannot land, "why not" outranks
|
|
1348
2498
|
"where to", and there is never both to say. */}
|
|
1349
2499
|
<span className='cbft-chip-where'>
|