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.
Files changed (250) hide show
  1. package/dist/react/components/ListToolbar.d.ts +94 -0
  2. package/dist/react/components/ListToolbar.d.ts.map +1 -0
  3. package/dist/react/components/ListToolbar.js +66 -0
  4. package/dist/react/components/ListToolbar.js.map +1 -0
  5. package/dist/react/file-tree/FileTree.d.ts +282 -3
  6. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  7. package/dist/react/file-tree/FileTree.js +799 -131
  8. package/dist/react/file-tree/FileTree.js.map +1 -1
  9. package/dist/react/file-tree/fileTree.css +151 -0
  10. package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
  11. package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
  12. package/dist/react/file-tree/fileTreeSelection.js +264 -0
  13. package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
  14. package/dist/react/file-tree/index.d.ts +1 -0
  15. package/dist/react/file-tree/index.d.ts.map +1 -1
  16. package/dist/react/file-tree/index.js +8 -0
  17. package/dist/react/file-tree/index.js.map +1 -1
  18. package/dist/react/index.d.ts +1 -0
  19. package/dist/react/index.d.ts.map +1 -1
  20. package/dist/react/index.js +1 -0
  21. package/dist/react/index.js.map +1 -1
  22. package/dist/react/lib/form.d.ts +3 -0
  23. package/dist/react/lib/form.d.ts.map +1 -1
  24. package/dist/react/lib/form.js +43 -5
  25. package/dist/react/lib/form.js.map +1 -1
  26. package/dist/react/media/DenseGalleryShell.d.ts +19 -3
  27. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  28. package/dist/react/media/DenseGalleryShell.js +55 -15
  29. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  30. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  31. package/dist/react/media/FocusViewer.js +14 -14
  32. package/dist/react/media/FocusViewer.js.map +1 -1
  33. package/dist/react/media/FrameGrabber.d.ts +23 -0
  34. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  35. package/dist/react/media/FrameGrabber.js +67 -1
  36. package/dist/react/media/FrameGrabber.js.map +1 -1
  37. package/dist/react/media/MediaResize.d.ts +51 -19
  38. package/dist/react/media/MediaResize.d.ts.map +1 -1
  39. package/dist/react/media/MediaResize.js +20 -13
  40. package/dist/react/media/MediaResize.js.map +1 -1
  41. package/dist/react/media/VideoChapterEditor.d.ts +67 -0
  42. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
  43. package/dist/react/media/VideoChapterEditor.js +148 -0
  44. package/dist/react/media/VideoChapterEditor.js.map +1 -0
  45. package/dist/react/media/VideoPlayer.d.ts +46 -4
  46. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  47. package/dist/react/media/VideoPlayer.js +124 -11
  48. package/dist/react/media/VideoPlayer.js.map +1 -1
  49. package/dist/react/media/VideoTurner.d.ts +15 -2
  50. package/dist/react/media/VideoTurner.d.ts.map +1 -1
  51. package/dist/react/media/VideoTurner.js +29 -2
  52. package/dist/react/media/VideoTurner.js.map +1 -1
  53. package/dist/react/media/denseGallery.css +104 -46
  54. package/dist/react/media/hlsSource.d.ts +57 -0
  55. package/dist/react/media/hlsSource.d.ts.map +1 -1
  56. package/dist/react/media/hlsSource.js +177 -1
  57. package/dist/react/media/hlsSource.js.map +1 -1
  58. package/dist/react/media/index.d.ts +2 -0
  59. package/dist/react/media/index.d.ts.map +1 -1
  60. package/dist/react/media/index.js +6 -0
  61. package/dist/react/media/index.js.map +1 -1
  62. package/dist/react/media/mediaResize.css +127 -54
  63. package/dist/react/media/pictureEditor.css +52 -6
  64. package/dist/react/media/videoChapterEditor.css +203 -0
  65. package/dist/react/media/videoChapters.d.ts +117 -0
  66. package/dist/react/media/videoChapters.d.ts.map +1 -0
  67. package/dist/react/media/videoChapters.js +95 -0
  68. package/dist/react/media/videoChapters.js.map +1 -0
  69. package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
  70. package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
  71. package/dist/react/media-gallery/GalleryTable.js +40 -9
  72. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  73. package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
  74. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  75. package/dist/react/media-gallery/MediaGallery.js +484 -104
  76. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  77. package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
  78. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
  79. package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
  80. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
  81. package/dist/react/media-gallery/index.d.ts +2 -0
  82. package/dist/react/media-gallery/index.d.ts.map +1 -1
  83. package/dist/react/media-gallery/index.js +5 -0
  84. package/dist/react/media-gallery/index.js.map +1 -1
  85. package/dist/react/media-gallery/mediaGallery.css +180 -4
  86. package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
  87. package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
  88. package/dist/react/media-gallery/mediaMeta.js +56 -0
  89. package/dist/react/media-gallery/mediaMeta.js.map +1 -0
  90. package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
  91. package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
  92. package/dist/react/media-gallery/playbackPreferences.js +42 -0
  93. package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
  94. package/dist/react/media-gallery/types.d.ts +34 -0
  95. package/dist/react/media-gallery/types.d.ts.map +1 -1
  96. package/dist/scripts/guardrailsEnforce.d.ts +29 -0
  97. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
  98. package/dist/styles-areas/activity-bar.css +30 -0
  99. package/dist/styles-areas/analytics.css +672 -0
  100. package/dist/styles-areas/auth.css +154 -0
  101. package/dist/styles-areas/calendar.css +416 -0
  102. package/dist/styles-areas/chart-table.css +41 -0
  103. package/dist/styles-areas/charts.css +89 -0
  104. package/dist/styles-areas/chat.css +364 -0
  105. package/dist/styles-areas/clipboard.css +26 -0
  106. package/dist/styles-areas/code-editor.css +130 -0
  107. package/dist/styles-areas/companion-link.css +70 -0
  108. package/dist/styles-areas/core.css +2503 -0
  109. package/dist/styles-areas/dashboard-grid.css +84 -0
  110. package/dist/styles-areas/data-table.css +657 -0
  111. package/dist/styles-areas/deep-link.css +26 -0
  112. package/dist/styles-areas/diff-viewer.css +30 -0
  113. package/dist/styles-areas/disk-usage.css +262 -0
  114. package/dist/styles-areas/emoji.css +154 -0
  115. package/dist/styles-areas/fields.css +582 -0
  116. package/dist/styles-areas/file-tree.css +33 -0
  117. package/dist/styles-areas/filter-rail.css +235 -0
  118. package/dist/styles-areas/folder-tree.css +219 -0
  119. package/dist/styles-areas/keep-awake.css +26 -0
  120. package/dist/styles-areas/layout-engine.css +239 -0
  121. package/dist/styles-areas/markdown.css +87 -0
  122. package/dist/styles-areas/master-detail.css +239 -0
  123. package/dist/styles-areas/master-lock.css +26 -0
  124. package/dist/styles-areas/media-gallery.css +351 -0
  125. package/dist/styles-areas/media.css +658 -0
  126. package/dist/styles-areas/nav.css +488 -0
  127. package/dist/styles-areas/notifications.css +245 -0
  128. package/dist/styles-areas/overlays.css +133 -0
  129. package/dist/styles-areas/palette.css +140 -0
  130. package/dist/styles-areas/pdf-viewer.css +47 -0
  131. package/dist/styles-areas/problem-detail.css +97 -0
  132. package/dist/styles-areas/rich-text.css +167 -0
  133. package/dist/styles-areas/sharing.css +153 -0
  134. package/dist/styles-areas/spreadsheet.css +37 -0
  135. package/dist/styles-areas/stats.css +281 -0
  136. package/dist/styles-areas/test-report.css +133 -0
  137. package/dist/styles-areas/virtual.css +30 -0
  138. package/dist/styles-areas/wizard.css +381 -0
  139. package/dist/styles-areas/workbench.css +543 -0
  140. package/dist/styles-areas/workbook-viewer.css +44 -0
  141. package/dist/styles-static.css +34 -23
  142. package/dist/styles.css +34 -23
  143. package/package.json +111 -31
  144. package/scripts/checkDistExports.ts +67 -0
  145. package/scripts/cssRules.ts +125 -0
  146. package/scripts/fixtureAppCss.ts +344 -0
  147. package/scripts/generateAreaStyles.ts +253 -0
  148. package/scripts/guardrailsEnforce.spec.ts +62 -0
  149. package/scripts/guardrailsEnforce.ts +67 -3
  150. package/scripts/styleAreas.ts +585 -0
  151. package/scripts/verify.ts +5 -0
  152. package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
  153. package/src/docsMatchTheSplit.spec.ts +98 -0
  154. package/src/fixtureAppCss.spec.ts +177 -0
  155. package/src/namedSubpathsResolve.spec.ts +35 -0
  156. package/src/publishShape.spec.ts +67 -0
  157. package/src/react/components/ListToolbar.spec.tsx +172 -0
  158. package/src/react/components/ListToolbar.tsx +151 -0
  159. package/src/react/file-tree/FileTree.spec.tsx +1009 -0
  160. package/src/react/file-tree/FileTree.tsx +1459 -309
  161. package/src/react/file-tree/fileTree.css +151 -0
  162. package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
  163. package/src/react/file-tree/fileTreeSelection.ts +321 -0
  164. package/src/react/file-tree/index.ts +21 -0
  165. package/src/react/index.ts +1 -0
  166. package/src/react/lib/form.ts +51 -8
  167. package/src/react/media/DenseGalleryShell.tsx +108 -36
  168. package/src/react/media/FocusViewer.tsx +15 -8
  169. package/src/react/media/FrameGrabber.spec.tsx +175 -0
  170. package/src/react/media/FrameGrabber.tsx +67 -0
  171. package/src/react/media/MediaResize.spec.tsx +84 -14
  172. package/src/react/media/MediaResize.tsx +51 -19
  173. package/src/react/media/VideoChapterEditor.tsx +428 -0
  174. package/src/react/media/VideoPlayer.spec.tsx +111 -1
  175. package/src/react/media/VideoPlayer.tsx +158 -11
  176. package/src/react/media/VideoTurner.spec.tsx +67 -0
  177. package/src/react/media/VideoTurner.tsx +70 -15
  178. package/src/react/media/denseGallery.css +104 -46
  179. package/src/react/media/hlsSource.recovery.spec.ts +213 -0
  180. package/src/react/media/hlsSource.spec.ts +8 -0
  181. package/src/react/media/hlsSource.ts +189 -1
  182. package/src/react/media/index.ts +14 -0
  183. package/src/react/media/mediaResize.css +127 -54
  184. package/src/react/media/pictureEditor.css +52 -6
  185. package/src/react/media/videoChapterEditor.css +203 -0
  186. package/src/react/media/videoChapters.spec.ts +176 -0
  187. package/src/react/media/videoChapters.ts +181 -0
  188. package/src/react/media-gallery/GalleryTable.tsx +93 -3
  189. package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
  190. package/src/react/media-gallery/MediaGallery.tsx +813 -95
  191. package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
  192. package/src/react/media-gallery/galleryTable.spec.ts +41 -1
  193. package/src/react/media-gallery/index.ts +7 -0
  194. package/src/react/media-gallery/mediaGallery.css +180 -4
  195. package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
  196. package/src/react/media-gallery/mediaMeta.ts +101 -0
  197. package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
  198. package/src/react/media-gallery/playbackPreferences.ts +100 -0
  199. package/src/react/media-gallery/types.ts +34 -0
  200. package/src/shippedFilesAreTracked.spec.ts +69 -0
  201. package/src/styles-areas/activity-bar.css +30 -0
  202. package/src/styles-areas/analytics.css +672 -0
  203. package/src/styles-areas/auth.css +154 -0
  204. package/src/styles-areas/calendar.css +416 -0
  205. package/src/styles-areas/chart-table.css +41 -0
  206. package/src/styles-areas/charts.css +89 -0
  207. package/src/styles-areas/chat.css +364 -0
  208. package/src/styles-areas/clipboard.css +26 -0
  209. package/src/styles-areas/code-editor.css +130 -0
  210. package/src/styles-areas/companion-link.css +70 -0
  211. package/src/styles-areas/core.css +2503 -0
  212. package/src/styles-areas/dashboard-grid.css +84 -0
  213. package/src/styles-areas/data-table.css +657 -0
  214. package/src/styles-areas/deep-link.css +26 -0
  215. package/src/styles-areas/diff-viewer.css +30 -0
  216. package/src/styles-areas/disk-usage.css +262 -0
  217. package/src/styles-areas/emoji.css +154 -0
  218. package/src/styles-areas/fields.css +582 -0
  219. package/src/styles-areas/file-tree.css +33 -0
  220. package/src/styles-areas/filter-rail.css +235 -0
  221. package/src/styles-areas/folder-tree.css +219 -0
  222. package/src/styles-areas/keep-awake.css +26 -0
  223. package/src/styles-areas/layout-engine.css +239 -0
  224. package/src/styles-areas/markdown.css +87 -0
  225. package/src/styles-areas/master-detail.css +239 -0
  226. package/src/styles-areas/master-lock.css +26 -0
  227. package/src/styles-areas/media-gallery.css +351 -0
  228. package/src/styles-areas/media.css +658 -0
  229. package/src/styles-areas/nav.css +488 -0
  230. package/src/styles-areas/notifications.css +245 -0
  231. package/src/styles-areas/overlays.css +133 -0
  232. package/src/styles-areas/palette.css +140 -0
  233. package/src/styles-areas/pdf-viewer.css +47 -0
  234. package/src/styles-areas/problem-detail.css +97 -0
  235. package/src/styles-areas/rich-text.css +167 -0
  236. package/src/styles-areas/sharing.css +153 -0
  237. package/src/styles-areas/spreadsheet.css +37 -0
  238. package/src/styles-areas/stats.css +281 -0
  239. package/src/styles-areas/test-report.css +133 -0
  240. package/src/styles-areas/virtual.css +30 -0
  241. package/src/styles-areas/wizard.css +381 -0
  242. package/src/styles-areas/workbench.css +543 -0
  243. package/src/styles-areas/workbook-viewer.css +44 -0
  244. package/src/styles-static.css +34 -23
  245. package/src/styles.css +34 -23
  246. package/src/stylesAreas.spec.ts +247 -0
  247. package/src/stylesUtilitiesMatches.spec.ts +6 -77
  248. package/src/testFilesRunInParallel.spec.ts +79 -0
  249. package/src/typecheckCachesAreSeparate.spec.ts +128 -0
  250. 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 instead of being forced open (see the effect below).
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
- onDraftCommit?: (name: string) => void;
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
- const collapsed = collapsedProp ?? ownCollapsed;
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
- const [focusedId, setFocusedId] = useState<string | null>(null);
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
- const set = new Set(collapsed);
459
- if (set.has(id)) set.delete(id);
460
- else set.add(id);
461
- setCollapsed([...set]);
1004
+ if (collapsed.includes(id)) openFolders([id]);
1005
+ else shutFolders([id]);
462
1006
  },
463
- [collapsed, setCollapsed],
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
- setCollapsed(collapsed.filter((c) => c !== id));
1013
+ openFolders([id]);
470
1014
  return true;
471
1015
  },
472
- [collapsed, setCollapsed],
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. 🔴 **A shut folder stays shut.** Its `data-holds-open` marker is scrolled to instead.
488
- * Auto-expanding is what `explorer.autoReveal` does and it is the behaviour people turn
489
- * off, because it silently undoes a collapse they chose — and `collapsed` here is often
490
- * a CONTROLLED value the consumer persists, so expanding would write it open for good.
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
- (x: number, y: number): { row: FileTreeRow; ratio: number } | null => {
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 && onMove) {
558
- onMove(state.id, state.offer.target);
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 shut = new Set(collapsed);
564
- for (const id of sprung.current) shut.add(id);
1236
+ const back = sprung.current;
565
1237
  sprung.current = [];
566
- setCollapsed([...shut]);
1238
+ shutFolders(back);
567
1239
  }
568
1240
  setDrag(null);
569
1241
  },
570
- [collapsed, onMove, setCollapsed, stopAutoScroll, stopSpring],
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 (!onMove || event.button !== 0 || node.draggable === false) return;
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
- const offer = resolveDrop({
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
- dragId: node.id,
603
- allowReorder,
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
- onMove,
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 (drag && !rows.some((r) => r.node.id === drag.id)) setDrag(null);
698
- }, [drag, rows]);
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
- const focusRow = useCallback((id: string | null) => {
711
- setFocusedId(id);
712
- if (id) rowRefs.current.get(id)?.focus();
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
- (event: React.KeyboardEvent<HTMLElement>, row: FileTreeRow) => {
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 = rows.findIndex((r) => r.node.id === row.node.id);
1482
+ const index = list.findIndex((r) => r.node.id === row.node.id);
746
1483
  const step = (delta: number) => {
747
- const next = rows[Math.min(rows.length - 1, Math.max(0, index + delta))];
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
- const box = rowRefs.current.get(row.node.id)?.getBoundingClientRect();
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
- onMove &&
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
- onMove(row.node.id, offer.target);
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
- else if (row.parentId) focusRow(row.parentId);
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 (rows[0]) focusRow(rows[0].node.id);
1729
+ if (list[0]) focusRow(list[0].node.id, pinnedCopy);
822
1730
  return;
823
1731
  case 'End':
824
1732
  event.preventDefault();
825
- if (rows.at(-1)) focusRow((rows.at(-1) as FileTreeRow).node.id);
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(rows, word, index);
1824
+ const found = matchTypeahead(list, word, index);
889
1825
  if (found >= 0) {
890
1826
  event.preventDefault();
891
- focusRow((rows[found] as FileTreeRow).node.id);
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
- onMove,
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 shut = new Set(collapsed);
950
- for (const id of sprung.current) shut.add(id);
1897
+ const back = sprung.current;
951
1898
  sprung.current = [];
952
- setCollapsed([...shut]);
1899
+ shutFolders(back);
953
1900
  },
954
- [collapsed, setCollapsed, stopSpring],
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 ? descendantIdsOf(shown, drag.id) : null;
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) => onDraftCommit?.(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 = (initial: string, commit: (value: string) => void, cancel: () => void) => (
1037
- <Input
1038
- className='cbft-name-input'
1039
- defaultValue={initial}
1040
- autoFocus
1041
- aria-label='Name'
1042
- onFocus={(event) => event.currentTarget.select()}
1043
- onClick={(event) => event.stopPropagation()}
1044
- onPointerDown={(event) => event.stopPropagation()}
1045
- onBlur={(event) => {
1046
- const value = event.currentTarget.value.trim();
1047
- if (!value || value === initial) cancel();
1048
- else commit(value);
1049
- }}
1050
- onKeyDown={(event) => {
1051
- event.stopPropagation();
1052
- if (event.key === 'Escape') {
1053
- event.preventDefault();
1054
- cancel();
1055
- } else if (event.key === 'Enter') {
1056
- event.preventDefault();
1057
- const value = event.currentTarget.value.trim();
1058
- if (!value || value === initial) cancel();
1059
- else commit(value);
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
- <span className='cbft-chip-name'>{nameOf(shown, drag.id)}</span>
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'>