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
@@ -200,6 +200,76 @@
200
200
  cursor: grabbing;
201
201
  }
202
202
 
203
+ /*
204
+ * ── The pinned section ───────────────────────────────────────────────────────────────────
205
+ *
206
+ * Owner, 2026-09-13: *"pin open files where they stay at the top of the file tree in a new
207
+ * section above the folders."* It is a SIBLING of `.cbft-scroll`, not a block inside it — see
208
+ * the note above it in `FileTree.tsx` — so "stay at the top" is structural rather than a
209
+ * `position: sticky` that has to out-paint the rows it covers.
210
+ *
211
+ * 🔴 It may shrink and it may scroll, and it may never take the tree. `.cbft-scroll` is
212
+ * `flex: 1 1 auto` with `min-block-size: 0`, so an uncapped section of forty pins would
213
+ * squeeze the tree it is a shortcut INTO down to nothing — a feature that eats the thing it
214
+ * exists to help you reach. 40% is the cap; past it this box scrolls on its own.
215
+ */
216
+ .cbft-pinned {
217
+ flex: 0 1 auto;
218
+ min-block-size: 0;
219
+ max-block-size: 40%;
220
+ display: flex;
221
+ flex-direction: column;
222
+ /* The one line that says the section ends and the tree begins. A border rather than a
223
+ heavier fill: the rows inside are the same rows, and a tinted band would read as a
224
+ different KIND of row rather than as the same row in a second place. */
225
+ border-block-end: 1px solid var(--border);
226
+ }
227
+
228
+ .cbft-pinned-list {
229
+ display: flex;
230
+ flex-direction: column;
231
+ /* The same 4px band `.cbft-list` takes, for the same reason. */
232
+ padding-block: 4px;
233
+ /* A plain scroller, and a scroller it must be: the section is capped above, so without
234
+ this a pin past the cap is unreachable. `ScrollArea` is not usable here for the reason
235
+ `.cbft-scroll` records — the tree hit-tests these boxes during a drag.
236
+ guardrails-ignore no-raw-overflow: see above. */
237
+ overflow-y: auto;
238
+ overscroll-behavior: contain;
239
+ scrollbar-width: thin;
240
+ scrollbar-color: var(--cbft-scroll-thumb) transparent;
241
+ }
242
+
243
+ /*
244
+ * The heading. 12px is DESIGN.md §4's floor exactly, and this is the smallest thing in the
245
+ * component that is genuinely TEXT — a section label a person reads, not a glyph.
246
+ *
247
+ * Uppercase with tracking, which is what every sidebar section header in this design system
248
+ * does (and what VS Code's "OPEN EDITORS" does): it separates the label from the row names
249
+ * under it by shape rather than by size, so the label can stay legible without competing with
250
+ * the rows for weight.
251
+ */
252
+ .cbft-pinned-head {
253
+ flex: none;
254
+ padding: 0.375rem var(--cbft-inset) 0.125rem;
255
+ font-size: 0.75rem;
256
+ font-weight: 600;
257
+ letter-spacing: 0.06em;
258
+ text-transform: uppercase;
259
+ color: var(--muted-foreground);
260
+ }
261
+
262
+ /*
263
+ * 🔴 A pinned copy has NO indentation guides, whatever depth its real row is at.
264
+ *
265
+ * `--cbft-depth` is 0 on every row in this section, which already blanks the repeating
266
+ * gradient — this states it so that a future change to the guide's arithmetic cannot bring
267
+ * back a line pointing at a parent that is not drawn here.
268
+ */
269
+ .cbft-pinned-list .cbft-row {
270
+ background-image: none;
271
+ }
272
+
203
273
  .cbft-empty {
204
274
  padding: 0.75rem 0.875rem;
205
275
  font-size: 0.8125rem;
@@ -371,6 +441,19 @@
371
441
  opacity: 0.4;
372
442
  }
373
443
 
444
+ /*
445
+ * Held by a ⌘X that has not been pasted yet — Finder's own cue, and the same language as the
446
+ * row in the air above, one step weaker.
447
+ *
448
+ * 🔴 It has to be VISIBLE, and this is the reason the clipboard lives in the component rather
449
+ * than in a prop. A cut with no mark is a keystroke that appears to have done nothing, so the
450
+ * next thing a person does is press it again on something else — and then ⌘V moves the wrong
451
+ * rows. Escape puts it down; see `FileTree.tsx`'s `clipboard`.
452
+ */
453
+ .cbft-row[data-cut] {
454
+ opacity: 0.55;
455
+ }
456
+
374
457
  /* Where it would land. A ring rather than a fill, so the row underneath stays readable. */
375
458
  .cbft-row[data-into] {
376
459
  outline: 2px solid var(--accent-solid, var(--accent));
@@ -530,6 +613,74 @@
530
613
  color: var(--muted-foreground);
531
614
  }
532
615
 
616
+ /*
617
+ * ── The pin ──────────────────────────────────────────────────────────────────────────────
618
+ *
619
+ * 🔴 It rides in a `.cbft-tools` shelf of its own rather than inventing a fourth resting
620
+ * state, which is how it inherits the whole contract below in one class: 40% at rest, solid
621
+ * on hover / focus / selection, solid AND a 44px box on a coarse pointer, and `inTools`
622
+ * already stopping a press on it from selecting the row or starting a drag. A hand-rolled
623
+ * opacity here is exactly the drift the shelf's own header records (*"a value is copied and
624
+ * a rule is not"*).
625
+ *
626
+ * The glyph is 12px in the same 16px box the twisty and the type icon use, so a row with a
627
+ * pin is not one pixel taller than a row without one — `file-tree-geometry.json` pins the
628
+ * row's height to the glyph, and the pin is the third thing to go into that box.
629
+ */
630
+ .cbft-pin {
631
+ flex: 0 0 var(--cbft-glyph);
632
+ display: inline-flex;
633
+ align-items: center;
634
+ justify-content: center;
635
+ inline-size: var(--cbft-glyph);
636
+ block-size: var(--cbft-glyph);
637
+ padding: 0;
638
+ border: 0;
639
+ background: none;
640
+ color: var(--muted-foreground);
641
+ cursor: pointer;
642
+ }
643
+
644
+ .cbft-pin:hover {
645
+ color: var(--cbft-fg);
646
+ }
647
+
648
+ /*
649
+ * 🔴 A PINNED row's pin is lit and never fades, in both copies.
650
+ *
651
+ * It is the only thing on the row in the folder that says *"this is also at the top"*, and a
652
+ * state mark that is invisible until hovered is a state nobody can scan for — the same
653
+ * failure the shelf's 40% floor exists to prevent, one step further. The colour is the
654
+ * accent, which is this tree's one strong mark and correct here for the same reason it is
655
+ * correct on the focus ring: it answers a different question from "where am I".
656
+ */
657
+ .cbft-row[data-pinned] .cbft-pin-shelf {
658
+ opacity: 1;
659
+ }
660
+
661
+ .cbft-row[data-pinned] .cbft-pin {
662
+ color: var(--accent-solid, var(--accent));
663
+ }
664
+
665
+ /*
666
+ * Filled, not outlined — the one-glance difference between pinned and pinnable, and the half
667
+ * of it that survives for anyone who cannot see the colour change at all.
668
+ *
669
+ * 🔴 On the `<svg>` itself, not inherited from the button. The element carries `fill="none"`
670
+ * as a presentation attribute, and a presentation attribute on an element beats a value
671
+ * INHERITED from its parent — so `fill` on `.cbft-pin` would be silently ignored. Any rule
672
+ * targeting the svg wins, which is what this is.
673
+ */
674
+ .cbft-row[data-pinned] .cbft-pin svg {
675
+ fill: currentColor;
676
+ }
677
+
678
+ .cbft-pin:focus-visible {
679
+ outline: 2px solid var(--ring, var(--accent-solid));
680
+ outline-offset: -2px;
681
+ border-radius: var(--radius-sm, 0.375rem);
682
+ }
683
+
533
684
  /*
534
685
  * 🔴 Quiet, never invisible.
535
686
  *
@@ -0,0 +1,327 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ type FileTreeNode,
4
+ flattenFileTree,
5
+ } from 'cursedbelt-core/file-tree/file-tree-model';
6
+ import {
7
+ draggedIdsOf,
8
+ nodesByIds,
9
+ planWrap,
10
+ refuseCopy,
11
+ refuseWrap,
12
+ resolveMultiDrop,
13
+ selectionRange,
14
+ toggleSelected,
15
+ topmostSelected,
16
+ wrapFolderName,
17
+ } from './fileTreeSelection';
18
+
19
+ /**
20
+ * The multi-selection arithmetic, with no renderer anywhere near it.
21
+ *
22
+ * 🔴 This is the half of the feature that CAN be tested. happy-dom computes no layout, so a
23
+ * pointer drag in `FileTree.spec.tsx` hit-tests against zeros — which is why the drop rules
24
+ * for one dragged row live in `core/file-tree/fileTreeModel.spec.ts` and the rules for N live
25
+ * here. Asserting "a drag of three rows refuses a folder inside one of them" through a DOM
26
+ * would produce a test that passes on any implementation.
27
+ */
28
+
29
+ const folder = (id: string, name: string, children: FileTreeNode[] = []): FileTreeNode => ({
30
+ id,
31
+ name,
32
+ kind: 'folder',
33
+ children,
34
+ });
35
+ const file = (id: string, name: string): FileTreeNode => ({ id, name, kind: 'file' });
36
+
37
+ /**
38
+ * Simpsons/
39
+ * screencaps/
40
+ * s04.png
41
+ * s05.png
42
+ * poster.png
43
+ * Dinos/ (empty)
44
+ * Travels/
45
+ * loose.txt
46
+ */
47
+ const TREE: FileTreeNode[] = [
48
+ folder('simpsons', 'Simpsons', [
49
+ folder('caps', 'screencaps', [file('s04', 's04.png'), file('s05', 's05.png')]),
50
+ file('poster', 'poster.png'),
51
+ ]),
52
+ folder('dinos', 'Dinos'),
53
+ folder('travels', 'Travels'),
54
+ file('loose', 'loose.txt'),
55
+ ];
56
+
57
+ const ROWS = flattenFileTree(TREE, []);
58
+ /** The same tree with `screencaps` shut — the case a range must not reach into. */
59
+ const SHUT_ROWS = flattenFileTree(TREE, ['caps']);
60
+
61
+ describe('selectionRange — what shift-click covers', () => {
62
+ test('every row from the anchor to the click, inclusive', () => {
63
+ expect(selectionRange(ROWS, 's04', 'travels')).toEqual(['s04', 's05', 'poster', 'dinos', 'travels']);
64
+ });
65
+
66
+ test('it runs the same both ways — a range has no direction', () => {
67
+ expect(selectionRange(ROWS, 'travels', 's04')).toEqual(selectionRange(ROWS, 's04', 'travels'));
68
+ });
69
+
70
+ test('an anchor that IS the click is a selection of one', () => {
71
+ expect(selectionRange(ROWS, 'dinos', 'dinos')).toEqual(['dinos']);
72
+ });
73
+
74
+ test('🔴 it covers the rows ON SCREEN, never the children of a shut folder', () => {
75
+ // The rule that keeps ⌘⌫ from deleting things nobody drew: a range is what you can see
76
+ // yourself covering. `screencaps` is shut, so its two files are not in it at all — the
77
+ // folder row is, because that one IS on screen.
78
+ expect(selectionRange(SHUT_ROWS, 'simpsons', 'dinos')).toEqual(['simpsons', 'caps', 'poster', 'dinos']);
79
+ });
80
+
81
+ test('no anchor yet degenerates to the clicked row, not to the whole list', () => {
82
+ expect(selectionRange(ROWS, null, 'dinos')).toEqual(['dinos']);
83
+ // Same answer for an anchor the tree has since re-derived away — the honest reading of
84
+ // "from where?" when there is no where.
85
+ expect(selectionRange(ROWS, 'deleted-since', 'dinos')).toEqual(['dinos']);
86
+ });
87
+
88
+ test('a click on a row that is not drawn selects nothing', () => {
89
+ expect(selectionRange(ROWS, 'dinos', 'ghost')).toEqual([]);
90
+ });
91
+ });
92
+
93
+ describe('toggleSelected — what ⌘-click leaves behind', () => {
94
+ test('in if it was out, out if it was in', () => {
95
+ expect(toggleSelected(['a'], 'b')).toEqual(['a', 'b']);
96
+ expect(toggleSelected(['a', 'b'], 'a')).toEqual(['b']);
97
+ });
98
+
99
+ test('the order of the rest survives a toggle', () => {
100
+ // The drag chip counts this and a consumer's "first selected" reads it, so re-sorting
101
+ // would shuffle a selection under the person still building it.
102
+ expect(toggleSelected(['c', 'a', 'b'], 'a')).toEqual(['c', 'b']);
103
+ expect(toggleSelected(['c', 'a'], 'b')).toEqual(['c', 'a', 'b']);
104
+ });
105
+ });
106
+
107
+ describe('topmostSelected — the row inside another selected row is not a second move', () => {
108
+ test('🔴 a selected child of a selected folder is dropped', () => {
109
+ // Without this, dragging `screencaps` and `s04` into `Travels` moves the folder AND the
110
+ // file, so a file that was two levels deep comes out one level deep beside its own former
111
+ // parent. The person asked for one move and got the tree flattened.
112
+ expect(topmostSelected(TREE, ['caps', 's04'])).toEqual(['caps']);
113
+ });
114
+
115
+ test('a GRANDchild too, not only a direct one', () => {
116
+ expect(topmostSelected(TREE, ['simpsons', 's04'])).toEqual(['simpsons']);
117
+ });
118
+
119
+ test('unrelated rows all survive, in the order they were given', () => {
120
+ expect(topmostSelected(TREE, ['travels', 'dinos', 'loose'])).toEqual(['travels', 'dinos', 'loose']);
121
+ });
122
+
123
+ test('a row the tree no longer holds is kept rather than silently dropped', () => {
124
+ // `parentIdOf` answers null for a row this tree does not contain, so the walk ends and
125
+ // the id passes through — the consumer is the one that knows whether it still exists.
126
+ expect(topmostSelected(TREE, ['ghost'])).toEqual(['ghost']);
127
+ });
128
+ });
129
+
130
+ describe('resolveMultiDrop — whether a drop is legal for N dragged ids', () => {
131
+ const rowFor = (id: string) => ROWS.find((row) => row.node.id === id) ?? null;
132
+
133
+ test('all of them may go there, so the drop is offered', () => {
134
+ expect(
135
+ resolveMultiDrop({ nodes: TREE, row: rowFor('travels'), offsetRatio: 0.5, dragIds: ['dinos', 'loose'] }),
136
+ ).toEqual({ target: { kind: 'into', parentId: 'travels' }, refused: false, reason: null });
137
+ });
138
+
139
+ test('the blank space below the tree is the top level for a whole selection', () => {
140
+ // *"drag and drop them to another folder or to the root outside of any folder"* — the one
141
+ // gesture with no target row, and it must survive the plural.
142
+ expect(
143
+ resolveMultiDrop({ nodes: TREE, row: null, offsetRatio: 0.5, dragIds: ['s04', 's05'] }),
144
+ ).toEqual({ target: { kind: 'root' }, refused: false, reason: null });
145
+ });
146
+
147
+ test('🔴 ONE refusal refuses the whole drop, and its words are what comes back', () => {
148
+ // The worst outcome of a multi-drag is the one that half-lands: four rows moved, the
149
+ // fifth silently left behind, and a tree that looks like it obeyed.
150
+ const offer = resolveMultiDrop({
151
+ nodes: TREE,
152
+ row: rowFor('caps'),
153
+ offsetRatio: 0.5,
154
+ dragIds: ['dinos', 'simpsons'],
155
+ });
156
+ expect(offer.refused).toBe(true);
157
+ expect(offer.target).toBeNull();
158
+ expect(offer.reason).toBe('A folder cannot go inside one of its own folders');
159
+ });
160
+
161
+ test('a folder dragged beside its own target refuses — not only the target itself', () => {
162
+ const offer = resolveMultiDrop({
163
+ nodes: TREE,
164
+ row: rowFor('dinos'),
165
+ offsetRatio: 0.5,
166
+ dragIds: ['travels', 'dinos'],
167
+ });
168
+ expect(offer.refused).toBe(true);
169
+ expect(offer.reason).toBe('A folder cannot go inside itself');
170
+ });
171
+
172
+ test("a target the consumer's `canDrop` vetoes for ANY one of them refuses for all", () => {
173
+ // `canDrop` is asked once per dragged id, so a veto that depends on WHICH row is moving
174
+ // still gets asked about each one.
175
+ const offer = resolveMultiDrop({
176
+ nodes: TREE,
177
+ row: rowFor('travels'),
178
+ offsetRatio: 0.5,
179
+ dragIds: ['dinos', 'loose'],
180
+ canDrop: (dragId) => (dragId === 'loose' ? 'That file is pinned' : null),
181
+ });
182
+ expect(offer.refused).toBe(true);
183
+ expect(offer.reason).toBe('That file is pinned');
184
+ });
185
+
186
+ test('`acceptsDrop: false` on the target refuses the set, with the consumer’s words', () => {
187
+ const locked: FileTreeNode[] = [
188
+ { id: 'vault', name: 'Vault', kind: 'folder', acceptsDrop: false, children: [] },
189
+ file('a', 'a.txt'),
190
+ file('b', 'b.txt'),
191
+ ];
192
+ const rows = flattenFileTree(locked, []);
193
+ const offer = resolveMultiDrop({
194
+ nodes: locked,
195
+ row: rows[0] ?? null,
196
+ offsetRatio: 0.5,
197
+ dragIds: ['a', 'b'],
198
+ lockedReason: 'That album is locked',
199
+ });
200
+ expect(offer).toEqual({ target: null, refused: true, reason: 'That album is locked' });
201
+ });
202
+
203
+ test('no dragged ids at all is an EXTERNAL payload — the same thing `dragId: null` means', () => {
204
+ expect(
205
+ resolveMultiDrop({ nodes: TREE, row: rowFor('simpsons'), offsetRatio: 0.5, dragIds: [] }),
206
+ ).toEqual({ target: { kind: 'into', parentId: 'simpsons' }, refused: false, reason: null });
207
+ });
208
+
209
+ test('a file row still means the folder that row is in, for all of them', () => {
210
+ expect(
211
+ resolveMultiDrop({ nodes: TREE, row: rowFor('poster'), offsetRatio: 0.5, dragIds: ['dinos', 'travels'] }),
212
+ ).toEqual({ target: { kind: 'into', parentId: 'simpsons' }, refused: false, reason: null });
213
+ });
214
+
215
+ test('🔴 pasting a copy back into the SAME folder is legal — the owner asked for it by name', () => {
216
+ // *"Copying should allow copying in another folder or the same folder."* A row's own
217
+ // parent is not a descendant of it, so the rule that refuses copy-into-itself leaves this
218
+ // alone. `s04` and `s05` live in `screencaps`.
219
+ expect(
220
+ resolveMultiDrop({ nodes: TREE, row: rowFor('caps'), offsetRatio: 0.5, dragIds: ['s04', 's05'] }),
221
+ ).toEqual({ target: { kind: 'into', parentId: 'caps' }, refused: false, reason: null });
222
+ });
223
+ });
224
+
225
+ describe('refuseCopy — the consumer’s veto on the one clipboard verb that is not universal', () => {
226
+ test('no veto wired means every row may be copied', () => {
227
+ expect(refuseCopy(TREE, ['dinos', 'loose'], undefined)).toBeNull();
228
+ });
229
+
230
+ test('🔴 the first row that refuses is the one that speaks', () => {
231
+ // `apps/music`'s folders are derived from the `folder` column of the tracks in them, so
232
+ // copying one would mean duplicating audio there is exactly one copy of.
233
+ expect(
234
+ refuseCopy(TREE, ['loose', 'dinos'], (node) =>
235
+ node.kind === 'folder' ? 'A folder here is derived, not a thing to duplicate' : null,
236
+ ),
237
+ ).toBe('A folder here is derived, not a thing to duplicate');
238
+ });
239
+
240
+ test('a veto that allows everything asked of it returns null', () => {
241
+ expect(refuseCopy(TREE, ['loose'], (node) => (node.kind === 'folder' ? 'no' : null))).toBeNull();
242
+ });
243
+ });
244
+
245
+ /**
246
+ * *"allow make dir from file where dir wraps file location and gets name of file"* — the
247
+ * three requirements in that sentence, one test each.
248
+ */
249
+ describe('planWrap — New Folder with Selection, before any of it is drawn', () => {
250
+ test('🔴 the folder goes where the file IS, and is named after it without the extension', () => {
251
+ expect(planWrap(TREE, ['s04'])).toEqual({
252
+ ids: ['s04'],
253
+ // Not the root, and not inside the selection: `s04.png` lives in `screencaps`, so that
254
+ // is where the folder wrapping it is made.
255
+ parentId: 'caps',
256
+ name: 's04',
257
+ });
258
+ });
259
+
260
+ test('a top-level row wraps at the top level', () => {
261
+ expect(planWrap(TREE, ['loose'])?.parentId).toBeNull();
262
+ });
263
+
264
+ test('🔴 a multi-wrap is named after the first in TREE order, not the first clicked', () => {
265
+ // A selection is built by clicking, so ['s05', 's04'] is an ordinary thing to hold —
266
+ // and Finder names the folder after the first ITEM, which is the first in the list.
267
+ const plan = planWrap(TREE, ['s05', 's04']);
268
+ expect(plan?.name).toBe('s04');
269
+ expect(plan?.ids).toEqual(['s04', 's05']);
270
+ });
271
+
272
+ test('🔴 a row inside another chosen row is not moved twice', () => {
273
+ // The same rule `topmostSelected` holds for a drag: wrapping a folder wraps what is in
274
+ // it, so naming the child as well would flatten it out of its own parent.
275
+ expect(planWrap(TREE, ['caps', 's04'])?.ids).toEqual(['caps']);
276
+ });
277
+
278
+ test('ids the tree no longer holds are forgotten, and a wrap of nothing is null', () => {
279
+ expect(planWrap(TREE, ['ghost', 'loose'])?.ids).toEqual(['loose']);
280
+ expect(planWrap(TREE, ['ghost'])).toBeNull();
281
+ expect(planWrap(TREE, [])).toBeNull();
282
+ });
283
+ });
284
+
285
+ describe('wrapFolderName — where a name ends', () => {
286
+ test('the extension comes off, and a name without one is kept whole', () => {
287
+ expect(wrapFolderName('beach.jpg')).toBe('beach');
288
+ expect(wrapFolderName('2026.09.16 notes.md')).toBe('2026.09.16 notes');
289
+ expect(wrapFolderName('Travels')).toBe('Travels');
290
+ });
291
+
292
+ test('🔴 a name that is ALL extension keeps every character', () => {
293
+ // `.gitignore` has no basename to offer, and a field pre-filled with the empty string is
294
+ // worse than one pre-filled with the name it came from.
295
+ expect(wrapFolderName('.gitignore')).toBe('.gitignore');
296
+ });
297
+ });
298
+
299
+ describe('refuseWrap — the same veto shape, for a tree whose folders are derived', () => {
300
+ test('no veto wired means anything may be wrapped', () => {
301
+ expect(refuseWrap(TREE, ['loose'], undefined)).toBeNull();
302
+ });
303
+
304
+ test('🔴 the first row that refuses is the one that speaks', () => {
305
+ // `apps/music` has no folder to MAKE: its folders are derived from the tracks in them.
306
+ expect(
307
+ refuseWrap(TREE, ['loose', 'dinos'], (node) =>
308
+ node.kind === 'folder' ? 'Folders here are derived from the tracks' : null,
309
+ ),
310
+ ).toBe('Folders here are derived from the tracks');
311
+ });
312
+ });
313
+
314
+ describe('the small shared readers', () => {
315
+ test('nodesByIds keeps the order asked for and skips what the tree lost', () => {
316
+ // A selection outlives a refresh: the server re-derives the tree between a ⌘-click and a
317
+ // ⌘⌫ and one of the rows is gone.
318
+ expect(nodesByIds(TREE, ['travels', 'ghost', 's04']).map((n) => n.name)).toEqual([
319
+ 'Travels',
320
+ 's04.png',
321
+ ]);
322
+ });
323
+
324
+ test('draggedIdsOf is the union of what every carried row takes with it', () => {
325
+ expect([...draggedIdsOf(TREE, ['caps', 'dinos'])].sort()).toEqual(['caps', 'dinos', 's04', 's05']);
326
+ });
327
+ });