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