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
@@ -639,6 +639,199 @@ describe('where you are', () => {
639
639
  });
640
640
  });
641
641
 
642
+ /**
643
+ * πŸ”΄ `expandOnReveal` β€” limit 1 of the reveal, opted into. See `FileTree.tsx`'s section on it.
644
+ *
645
+ * Owner, `ideas/collections-09-14.md`: *"clicking a file in media gallery content window should
646
+ * show that file in the file tree by like vscode does when you select a tab with a file in it."*
647
+ * He named the application whose `explorer.autoReveal` defaults to `true`, so the tree does it β€”
648
+ * and the half of the old refusal that was never taste (a CONTROLLED `collapsed` the consumer
649
+ * persists must not be rewritten behind its back) is what these tests hold down.
650
+ */
651
+ describe('the reveal that opens the folder', () => {
652
+ /** The whole tree with `simpsons/screencaps` shut, reporting what it is asked to store. */
653
+ const withShut = (
654
+ props: Partial<React.ComponentProps<typeof FileTree>>,
655
+ shut: readonly string[],
656
+ onChange: (next: string[]) => void,
657
+ ) => (
658
+ <FileTree
659
+ nodes={TREE}
660
+ selectedId={null}
661
+ onSelect={() => undefined}
662
+ collapsed={shut}
663
+ onCollapsedChange={onChange}
664
+ {...props}
665
+ />
666
+ );
667
+
668
+ test('πŸ”΄ a shut folder on the way to the selection OPENS when the prop is wired', () => {
669
+ const shut: readonly string[] = ['simpsons/screencaps'];
670
+ const view = render(withShut({ expandOnReveal: true }, shut, () => undefined));
671
+ expect(screen.queryByText('s04')).toBeNull();
672
+
673
+ view.rerender(
674
+ withShut(
675
+ { expandOnReveal: true, selectedId: 'simpsons/screencaps/s04' },
676
+ shut,
677
+ () => undefined,
678
+ ),
679
+ );
680
+ expect(screen.getByText('s04')).toBeDefined();
681
+ // Nothing is left marked as "shut and holding it" β€” the row itself is the answer now.
682
+ expect(view.container.querySelectorAll('[data-holds-open]')).toHaveLength(0);
683
+ });
684
+
685
+ test('…and it stays SHUT without the prop β€” every existing consumer is unmoved', () => {
686
+ const shut: readonly string[] = ['simpsons/screencaps'];
687
+ const view = render(withShut({}, shut, () => undefined));
688
+ view.rerender(withShut({ selectedId: 'simpsons/screencaps/s04' }, shut, () => undefined));
689
+ expect(screen.queryByText('s04')).toBeNull();
690
+ expect(view.container.querySelectorAll('[data-holds-open]')).toHaveLength(1);
691
+ });
692
+
693
+ test('πŸ”΄ the open is NEVER written back β€” the persisted collapse is the one it was', () => {
694
+ // The real objection limit 1 recorded, and a correctness issue rather than a taste one:
695
+ // `collapsed` is controlled and the consumer writes it to storage, so a reveal that called
696
+ // `onCollapsedChange` would undo a collapse the person chose on every later mount. The
697
+ // expansion lives in an overlay of the tree's own instead, and this is what proves it.
698
+ let stored: readonly string[] = ['simpsons', 'simpsons/screencaps'];
699
+ const seen: string[][] = [];
700
+ const record = (next: string[]) => {
701
+ stored = next;
702
+ seen.push(next);
703
+ };
704
+
705
+ const view = render(withShut({ expandOnReveal: true }, stored, record));
706
+ view.rerender(
707
+ withShut({ expandOnReveal: true, selectedId: 'simpsons/screencaps/s04' }, stored, record),
708
+ );
709
+ // Both shut ancestors opened β€” `screencaps` was not even drawn before this, so the folder
710
+ // the reveal needs is found from the tree rather than from a `data-holds-open` marker.
711
+ expect(screen.getByText('s04')).toBeDefined();
712
+ expect(seen).toEqual([]);
713
+ expect(stored).toEqual(['simpsons', 'simpsons/screencaps']);
714
+
715
+ // And the proof that matters: mounted again with the value the consumer still holds, the
716
+ // folders are shut, exactly as they were left.
717
+ cleanup();
718
+ render(withShut({ expandOnReveal: true, selectedId: null }, stored, record));
719
+ expect(screen.queryByText('screencaps')).toBeNull();
720
+ });
721
+
722
+ test('πŸ”΄ a folder shut BY HAND afterwards stays shut β€” the reveal fires once per selection', () => {
723
+ // What `explorer.autoReveal` does, and the only thing that leaves the twisty working: an
724
+ // expansion that re-asserted itself would make the control do nothing at all while the
725
+ // same file is open.
726
+ const seen: string[][] = [];
727
+ const stored: readonly string[] = ['simpsons/screencaps'];
728
+ const props = { expandOnReveal: true, selectedId: 'simpsons/screencaps/s04' };
729
+ const view = render(withShut({ expandOnReveal: true }, stored, (n) => seen.push(n)));
730
+ view.rerender(withShut(props, stored, (n) => seen.push(n)));
731
+ expect(screen.getByText('s04')).toBeDefined();
732
+
733
+ const row = screen.getByText('screencaps').closest('[role="treeitem"]') as HTMLElement;
734
+ fireEvent.click(row.querySelector('.cbft-twisty') as HTMLElement);
735
+ expect(screen.queryByText('s04')).toBeNull();
736
+ // It was already shut in the value the consumer holds, so there is nothing to write there
737
+ // either β€” shutting it only dropped the overlay.
738
+ expect(seen).toEqual([]);
739
+
740
+ // Still shut on the next render of the same selection.
741
+ view.rerender(withShut(props, stored, (n) => seen.push(n)));
742
+ expect(screen.queryByText('s04')).toBeNull();
743
+ });
744
+
745
+ test('πŸ”΄ it SAYS which folders it opened β€” the half a lazily-fetched tree needs', () => {
746
+ // `apps/collections` asks the server for a folder's files only for the folders it believes
747
+ // are open, so an expansion it cannot see draws the folder open and empty with the file
748
+ // still missing. The overlay is silent; `onRevealExpand` is what stops it being invisible.
749
+ const told: string[][] = [];
750
+ const stored: readonly string[] = ['simpsons', 'simpsons/screencaps'];
751
+ const seen: string[][] = [];
752
+ const view = render(
753
+ <FileTree
754
+ nodes={TREE}
755
+ selectedId={null}
756
+ onSelect={() => undefined}
757
+ collapsed={stored}
758
+ onCollapsedChange={(next) => seen.push(next)}
759
+ expandOnReveal
760
+ onRevealExpand={(ids) => told.push([...ids])}
761
+ />,
762
+ );
763
+ view.rerender(
764
+ <FileTree
765
+ nodes={TREE}
766
+ selectedId='simpsons/screencaps/s04'
767
+ onSelect={() => undefined}
768
+ collapsed={stored}
769
+ onCollapsedChange={(next) => seen.push(next)}
770
+ expandOnReveal
771
+ onRevealExpand={(ids) => told.push([...ids])}
772
+ />,
773
+ );
774
+ // Both folders on the way, once, in root-first order β€” and still nothing to persist.
775
+ expect(told).toEqual([['simpsons', 'simpsons/screencaps']]);
776
+ expect(seen).toEqual([]);
777
+
778
+ // A selection inside a folder that is already open has nothing to say.
779
+ view.rerender(
780
+ <FileTree
781
+ nodes={TREE}
782
+ selectedId='simpsons/screencaps'
783
+ onSelect={() => undefined}
784
+ collapsed={stored}
785
+ onCollapsedChange={(next) => seen.push(next)}
786
+ expandOnReveal
787
+ onRevealExpand={(ids) => told.push([...ids])}
788
+ />,
789
+ );
790
+ expect(told).toHaveLength(1);
791
+ });
792
+
793
+ test('πŸ”΄ the row it opened is the row it scrolls to', () => {
794
+ // Two passes, and this is the second: the row does not exist in the DOM until the folders
795
+ // above it are open, so an expansion that scrolled in the same pass would scroll to
796
+ // nothing. happy-dom computes no layout β€” the frame is stubbed and every row's rect is
797
+ // zeros, which puts all of them outside it.
798
+ //
799
+ // πŸ”΄ The stub goes on `HTMLElement.prototype`, not on `Element.prototype`, and the reason
800
+ // is a leak rather than a preference: `MediaGallery.spec.tsx` assigns
801
+ // `HTMLElement.prototype.scrollIntoView` and never puts it back, and `bun test` runs every
802
+ // spec in ONE process. The derived prototype shadows the base one, so a stub on `Element`
803
+ // is never reached once that file has run β€” this test passed alone and failed in the suite
804
+ // for exactly that reason. The row it needs is not in the DOM yet, which is why this one
805
+ // cannot stub per instance the way the two reveal tests above do.
806
+ const scrolled: string[] = [];
807
+ const proto = HTMLElement.prototype as unknown as { scrollIntoView?: () => void };
808
+ const prior = proto.scrollIntoView;
809
+ proto.scrollIntoView = function record(this: Element) {
810
+ scrolled.push((this.textContent ?? '').trim());
811
+ };
812
+ try {
813
+ const stored: readonly string[] = ['simpsons/screencaps'];
814
+ const view = render(withShut({ expandOnReveal: true }, stored, () => undefined));
815
+ // Both rects are stubbed, so the leaked `HTMLElement.prototype.getBoundingClientRect`
816
+ // from that same file (a 800Γ—600 box at the origin) cannot decide this either way: the
817
+ // frame starts at 100, and every candidate row's top is above it.
818
+ (view.container.querySelector('.cbft-scroll') as HTMLElement).getBoundingClientRect = () =>
819
+ ({ top: 100, bottom: 400 }) as DOMRect;
820
+ view.rerender(
821
+ withShut(
822
+ { expandOnReveal: true, selectedId: 'simpsons/screencaps/s04' },
823
+ stored,
824
+ () => undefined,
825
+ ),
826
+ );
827
+ expect(scrolled.at(-1)).toContain('s04');
828
+ } finally {
829
+ if (prior) proto.scrollIntoView = prior;
830
+ else delete proto.scrollIntoView;
831
+ }
832
+ });
833
+ });
834
+
642
835
  describe('the drag, beyond the pointer', () => {
643
836
  /** Start a real gesture over the blank space: press, then travel past `DRAG_THRESHOLD`. */
644
837
  const lift = (row: HTMLElement) => {
@@ -807,6 +1000,599 @@ describe('the keyboard, beyond walking', () => {
807
1000
  });
808
1001
  });
809
1002
 
1003
+ /**
1004
+ * πŸ”΄ MULTI-SELECT β€” owner, 2026-09-14, `ideas/file-tree.md` item 2.
1005
+ *
1006
+ * *"If I click on a file in the sidebar file tree with the folders then hold shift and click
1007
+ * another item in the folder, I want it to select all the files from the first file to the one
1008
+ * I clicked next while holding shift. Then with all of those selected I want to be able to
1009
+ * click and hold on any of them to drag and drop them to another folder or to the root outside
1010
+ * of any folder. Another key shortcut functionality I need is selecting multiple files by
1011
+ * holding the command key on my mac and clicking each file I want to select then dragging them
1012
+ * to another folder. For both of these new selection functionalities I also want to be able to
1013
+ * hold command and click delete to delete all the selected files. I also want to be able to cut
1014
+ * and paste to move them with command + x and command + v, and copy them with command + c and
1015
+ * command + v."*
1016
+ *
1017
+ * The arithmetic β€” which rows a range covers, whether a drop is legal for N ids β€” is
1018
+ * `fileTreeSelection.spec.ts`, without a DOM. What is here is the DOM half: which gesture
1019
+ * reports what, and the compatibility rule that holds the whole design together β€”
1020
+ * **a consumer that wired none of the plural props sees exactly what it saw before.**
1021
+ */
1022
+ describe('selecting more than one row', () => {
1023
+ const idsOf = (name: string) =>
1024
+ (screen.getByText(name).closest('[role="treeitem"]') as HTMLElement) ?? null;
1025
+
1026
+ test('πŸ”΄ shift-click selects the range between the anchor and the click', () => {
1027
+ const reported: string[][] = [];
1028
+ const picked: string[] = [];
1029
+ tree({
1030
+ onSelect: (n) => picked.push(n.id),
1031
+ onSelectionChange: (ids) => reported.push(ids),
1032
+ });
1033
+ // A plain click sets the anchor β€” and still selects, because the ordinary act is
1034
+ // unchanged by any of this.
1035
+ fireEvent.click(idsOf('screencaps'));
1036
+ expect(picked).toEqual(['simpsons/screencaps']);
1037
+ expect(reported).toEqual([['simpsons/screencaps']]);
1038
+
1039
+ fireEvent.click(idsOf('Travels'), { shiftKey: true });
1040
+ expect(reported.at(-1)).toEqual([
1041
+ 'simpsons/screencaps',
1042
+ 'simpsons/screencaps/s04',
1043
+ 'dinos',
1044
+ 'travels',
1045
+ ]);
1046
+ // πŸ”΄ …and it did NOT also navigate. Extending a selection is not opening anything, and in
1047
+ // a consumer that shows the selected row in the main pane it would be.
1048
+ expect(picked).toEqual(['simpsons/screencaps']);
1049
+ });
1050
+
1051
+ test('πŸ”΄ the anchor does not move, so a second shift-click re-covers from the same start', () => {
1052
+ // The behaviour every file explorer has: shift-click down four rows, realise you meant
1053
+ // two, shift-click the second β€” and get two, not six.
1054
+ const reported: string[][] = [];
1055
+ tree({ onSelectionChange: (ids) => reported.push(ids) });
1056
+ fireEvent.click(idsOf('Simpsons'));
1057
+ fireEvent.click(idsOf('Travels'), { shiftKey: true });
1058
+ expect(reported.at(-1)).toHaveLength(5);
1059
+ fireEvent.click(idsOf('screencaps'), { shiftKey: true });
1060
+ expect(reported.at(-1)).toEqual(['simpsons', 'simpsons/screencaps']);
1061
+ });
1062
+
1063
+ test('πŸ”΄ ⌘-click adds one, and ⌘-clicking it again takes it out', () => {
1064
+ const reported: string[][] = [];
1065
+ const picked: string[] = [];
1066
+ const view = tree({
1067
+ selectedIds: ['travels'],
1068
+ onSelect: (n) => picked.push(n.id),
1069
+ onSelectionChange: (ids) => reported.push(ids),
1070
+ });
1071
+ fireEvent.click(idsOf('Dinos'), { metaKey: true });
1072
+ expect(reported.at(-1)).toEqual(['travels', 'dinos']);
1073
+ expect(picked).toEqual([]);
1074
+
1075
+ view.rerender(
1076
+ <FileTree
1077
+ nodes={TREE}
1078
+ selectedId={null}
1079
+ selectedIds={['travels', 'dinos']}
1080
+ onSelect={(n) => picked.push(n.id)}
1081
+ onSelectionChange={(ids) => reported.push(ids)}
1082
+ />,
1083
+ );
1084
+ fireEvent.click(idsOf('Travels'), { metaKey: true });
1085
+ expect(reported.at(-1)).toEqual(['dinos']);
1086
+ });
1087
+
1088
+ test('every selected row is drawn selected, and the tree says it takes more than one', () => {
1089
+ const { container } = tree({
1090
+ selectedIds: ['dinos', 'travels'],
1091
+ onSelectionChange: () => undefined,
1092
+ });
1093
+ const marked = [...container.querySelectorAll('[data-selected]')].map((r) =>
1094
+ (r.textContent ?? '').trim(),
1095
+ );
1096
+ expect(marked).toEqual(['Dinos', 'Travels']);
1097
+ expect(
1098
+ (container.querySelector('[role="tree"]') as HTMLElement).getAttribute(
1099
+ 'aria-multiselectable',
1100
+ ),
1101
+ ).toBe('true');
1102
+ });
1103
+
1104
+ test('πŸ”΄ a consumer that wired none of it is untouched β€” one selected row, no promise of more', () => {
1105
+ // The whole reason `selectedId` did not move. `apps/collections` and `apps/music` both
1106
+ // read it as "what the main pane is showing", and a tree that silently became
1107
+ // multi-select would have broken that on the day this published.
1108
+ const picked: string[] = [];
1109
+ const { container } = tree({ selectedId: 'dinos', onSelect: (n) => picked.push(n.id) });
1110
+ expect(container.querySelectorAll('[data-selected]')).toHaveLength(1);
1111
+ expect(
1112
+ (container.querySelector('[role="tree"]') as HTMLElement).getAttribute(
1113
+ 'aria-multiselectable',
1114
+ ),
1115
+ ).toBeNull();
1116
+ // Shift and ⌘ are not this tree's keys unless the consumer can receive a selection.
1117
+ fireEvent.click(idsOf('Travels'), { shiftKey: true });
1118
+ fireEvent.click(idsOf('Simpsons'), { metaKey: true });
1119
+ expect(picked).toEqual(['travels', 'simpsons']);
1120
+ });
1121
+ });
1122
+
1123
+ describe('dragging a selection', () => {
1124
+ /** As in `the drag, beyond the pointer` β€” happy-dom computes no layout, so say where. */
1125
+ const layOut = (underPointer: string | null) => {
1126
+ for (const row of screen.getAllByRole('treeitem') as HTMLElement[]) {
1127
+ const hit = (row.textContent ?? '').trim() === underPointer;
1128
+ row.getBoundingClientRect = () =>
1129
+ ({ top: hit ? 80 : 500, bottom: hit ? 100 : 520, left: 0, right: 200, height: 20 }) as DOMRect;
1130
+ }
1131
+ };
1132
+
1133
+ /** Press `from`, travel past `DRAG_THRESHOLD` onto whatever `layOut` put under the path. */
1134
+ const dragTo = (from: HTMLElement, over: string | null) => {
1135
+ fireEvent.pointerDown(from, { button: 0, clientX: 10, clientY: 10, pointerId: 1 });
1136
+ layOut(over);
1137
+ fireEvent.pointerMove(from, { clientX: 10, clientY: 90, pointerId: 1 });
1138
+ };
1139
+
1140
+ /**
1141
+ * The ROW with this name β€” not the drag chip, which carries the same name and is in the
1142
+ * document from the first `pointermove` onward.
1143
+ */
1144
+ const rowNamed = (name: string) =>
1145
+ screen
1146
+ .getAllByText(name)
1147
+ .map((el) => el.closest('[role="treeitem"]'))
1148
+ .find(Boolean) as HTMLElement;
1149
+
1150
+ test('πŸ”΄ pressing a row that is part of a selection carries the whole selection', () => {
1151
+ // *"with all of those selected I want to be able to click and hold on any of them to drag
1152
+ // and drop them to another folder"*.
1153
+ const moved: Array<[readonly string[], unknown]> = [];
1154
+ const { container } = tree({
1155
+ selectedIds: ['dinos', 'travels'],
1156
+ onSelectionChange: () => undefined,
1157
+ onMoveMany: (ids, drop) => moved.push([ids, drop]),
1158
+ });
1159
+ dragTo(rowNamed('Travels'), 'Simpsons');
1160
+ // The chip counts rather than naming β€” twenty names is not something a chip under a
1161
+ // moving cursor can be read as.
1162
+ expect(container.querySelector('.cbft-chip-name')?.textContent).toBe('2 items');
1163
+ expect(container.querySelector('.cbft-chip-where')?.textContent).toBe('Into Simpsons');
1164
+ fireEvent.pointerUp(rowNamed('Travels'), { pointerId: 1 });
1165
+ expect(moved).toEqual([[['dinos', 'travels'], { kind: 'into', parentId: 'simpsons' }]]);
1166
+ });
1167
+
1168
+ test('every carried row is drawn lifted, contents included', () => {
1169
+ const { container } = tree({
1170
+ selectedIds: ['simpsons', 'travels'],
1171
+ onSelectionChange: () => undefined,
1172
+ onMoveMany: () => undefined,
1173
+ });
1174
+ dragTo(rowNamed('Travels'), 'Dinos');
1175
+ const lifted = [...container.querySelectorAll('[data-dragged]')].map((r) =>
1176
+ (r.textContent ?? '').trim(),
1177
+ );
1178
+ // `Simpsons` takes `screencaps` and `s04` with it β€” the union of what each row carries.
1179
+ expect(lifted).toEqual(['Simpsons', 'screencaps', 's04', 'Travels']);
1180
+ });
1181
+
1182
+ test('πŸ”΄ one illegal destination refuses the whole drop, rather than half-landing it', () => {
1183
+ const moved: unknown[] = [];
1184
+ const { container } = tree({
1185
+ selectedIds: ['dinos', 'simpsons'],
1186
+ onSelectionChange: () => undefined,
1187
+ onMoveMany: (ids, drop) => moved.push([ids, drop]),
1188
+ });
1189
+ // `screencaps` is inside `Simpsons`, which is one of the two rows in the air.
1190
+ dragTo(rowNamed('Dinos'), 'screencaps');
1191
+ expect((container.querySelector('.cbft-chip') as HTMLElement).getAttribute('data-refused')).toBe(
1192
+ 'true',
1193
+ );
1194
+ expect(container.querySelector('.cbft-chip-where')?.textContent).toBe(
1195
+ 'A folder cannot go inside one of its own folders',
1196
+ );
1197
+ fireEvent.pointerUp(rowNamed('Dinos'), { pointerId: 1 });
1198
+ expect(moved).toEqual([]);
1199
+ });
1200
+
1201
+ test('pressing a row OUTSIDE the selection carries only that row', () => {
1202
+ // Finder's rule, and the only one that lets you move a single row out of a group you have
1203
+ // built without dismantling it first.
1204
+ const many: unknown[] = [];
1205
+ const one: Array<[string, unknown]> = [];
1206
+ tree({
1207
+ selectedIds: ['dinos', 'travels'],
1208
+ onSelectionChange: () => undefined,
1209
+ onMove: (id, drop) => one.push([id, drop]),
1210
+ onMoveMany: (ids) => many.push(ids),
1211
+ });
1212
+ dragTo(rowNamed('screencaps'), 'Dinos');
1213
+ fireEvent.pointerUp(rowNamed('screencaps'), { pointerId: 1 });
1214
+ expect(many).toEqual([]);
1215
+ expect(one).toEqual([['simpsons/screencaps', { kind: 'into', parentId: 'dinos' }]]);
1216
+ });
1217
+
1218
+ test('πŸ”΄ without `onMoveMany` the drag is the one row it started on, exactly as before', () => {
1219
+ // The rule that keeps every existing consumer still: N > 1 goes through the plural verb,
1220
+ // and without it the gesture never invents a batch nobody can receive.
1221
+ const one: Array<[string, unknown]> = [];
1222
+ tree({
1223
+ selectedIds: ['dinos', 'travels'],
1224
+ onSelectionChange: () => undefined,
1225
+ onMove: (id, drop) => one.push([id, drop]),
1226
+ });
1227
+ dragTo(rowNamed('Travels'), 'Simpsons');
1228
+ fireEvent.pointerUp(rowNamed('Travels'), { pointerId: 1 });
1229
+ expect(one).toEqual([['travels', { kind: 'into', parentId: 'simpsons' }]]);
1230
+ });
1231
+
1232
+ test('πŸ”΄ one undraggable row in the set stops the gesture β€” all or nothing', () => {
1233
+ // A drag that moved four of five rows and said nothing is the failure the plural drop rule
1234
+ // exists to prevent, and it is no better for arriving at the start of the gesture.
1235
+ // `apps/collections` marks its albums `draggable: false`.
1236
+ const moved: unknown[] = [];
1237
+ const nodes: FileTreeNode[] = [
1238
+ { id: 'album', name: 'Album', kind: 'folder', draggable: false, children: [] },
1239
+ folder('dinos', 'Dinos'),
1240
+ folder('travels', 'Travels'),
1241
+ ];
1242
+ const { container } = render(
1243
+ <FileTree
1244
+ nodes={nodes}
1245
+ selectedId={null}
1246
+ selectedIds={['dinos', 'album']}
1247
+ onSelect={() => undefined}
1248
+ onSelectionChange={() => undefined}
1249
+ onMoveMany={(ids) => moved.push(ids)}
1250
+ />,
1251
+ );
1252
+ dragTo(screen.getByText('Dinos').closest('[role="treeitem"]') as HTMLElement, 'Travels');
1253
+ expect(container.querySelector('.cbft-chip')).toBeNull();
1254
+ expect(moved).toEqual([]);
1255
+ });
1256
+ });
1257
+
1258
+ describe('deleting a selection', () => {
1259
+ const focusRow = (name: string) => {
1260
+ const row = screen.getByText(name).closest('[role="treeitem"]') as HTMLElement;
1261
+ row.focus();
1262
+ return row;
1263
+ };
1264
+
1265
+ test('πŸ”΄ ⌘⌫ on a selection deletes all of it', () => {
1266
+ // *"I also want to be able to hold command and click delete to delete all the selected
1267
+ // files."* It arrives as Backspace WITH metaKey.
1268
+ const many: string[][] = [];
1269
+ const one: string[] = [];
1270
+ tree({
1271
+ selectedIds: ['dinos', 'travels'],
1272
+ onSelectionChange: () => undefined,
1273
+ onDelete: (n) => one.push(n.id),
1274
+ onDeleteMany: (nodes) => many.push(nodes.map((n) => n.id)),
1275
+ });
1276
+ fireEvent.keyDown(focusRow('Dinos'), { key: 'Backspace', metaKey: true });
1277
+ expect(many).toEqual([['dinos', 'travels']]);
1278
+ // Never both β€” a row deleted twice is a confirmation dialog asked twice.
1279
+ expect(one).toEqual([]);
1280
+ });
1281
+
1282
+ test('a folder and a file inside it ask for ONE deletion, not two', () => {
1283
+ const many: string[][] = [];
1284
+ tree({
1285
+ selectedIds: ['simpsons', 'simpsons/screencaps/s04'],
1286
+ onSelectionChange: () => undefined,
1287
+ onDeleteMany: (nodes) => many.push(nodes.map((n) => n.id)),
1288
+ });
1289
+ fireEvent.keyDown(focusRow('Simpsons'), { key: 'Delete' });
1290
+ expect(many).toEqual([['simpsons']]);
1291
+ });
1292
+
1293
+ test('πŸ”΄ without `onDeleteMany` it is the focused row alone β€” today’s behaviour, kept', () => {
1294
+ const one: string[] = [];
1295
+ tree({
1296
+ selectedIds: ['dinos', 'travels'],
1297
+ onSelectionChange: () => undefined,
1298
+ onDelete: (n) => one.push(n.id),
1299
+ });
1300
+ fireEvent.keyDown(focusRow('Dinos'), { key: 'Backspace', metaKey: true });
1301
+ expect(one).toEqual(['dinos']);
1302
+ });
1303
+
1304
+ test('a focused row OUTSIDE the selection is the only thing the key means', () => {
1305
+ const many: unknown[] = [];
1306
+ const one: string[] = [];
1307
+ tree({
1308
+ selectedIds: ['dinos', 'travels'],
1309
+ onSelectionChange: () => undefined,
1310
+ onDelete: (n) => one.push(n.id),
1311
+ onDeleteMany: (nodes) => many.push(nodes.map((n) => n.id)),
1312
+ });
1313
+ fireEvent.keyDown(focusRow('screencaps'), { key: 'Backspace', metaKey: true });
1314
+ expect(many).toEqual([]);
1315
+ expect(one).toEqual(['simpsons/screencaps']);
1316
+ });
1317
+ });
1318
+
1319
+ /**
1320
+ * ⌘X / ⌘C / ⌘V β€” *"cut and paste to move them with command + x and command + v, and copy them
1321
+ * with command + c and command + v. Copying should allow copying in another folder or the same
1322
+ * folder. These are similar to how finder works except cutting and pasting will be easy with
1323
+ * just command + x."*
1324
+ */
1325
+ describe('the clipboard', () => {
1326
+ const focusRow = (name: string) => {
1327
+ const row = screen.getByText(name).closest('[role="treeitem"]') as HTMLElement;
1328
+ row.focus();
1329
+ return row;
1330
+ };
1331
+
1332
+ test('πŸ”΄ ⌘X marks the rows, and ⌘V moves them into the focused row', () => {
1333
+ const moved: Array<[readonly string[], unknown]> = [];
1334
+ const { container } = tree({
1335
+ selectedIds: ['dinos', 'travels'],
1336
+ onSelectionChange: () => undefined,
1337
+ onMoveMany: (ids, drop) => moved.push([ids, drop]),
1338
+ });
1339
+ fireEvent.keyDown(focusRow('Dinos'), { key: 'x', metaKey: true });
1340
+ // A cut with no mark is a keystroke that appears to have done nothing β€” so the next thing
1341
+ // a person does is press it again somewhere else, and then ⌘V moves the wrong rows.
1342
+ expect([...container.querySelectorAll('[data-cut]')].map((r) => (r.textContent ?? '').trim())).toEqual(
1343
+ ['Dinos', 'Travels'],
1344
+ );
1345
+ fireEvent.keyDown(focusRow('Simpsons'), { key: 'v', metaKey: true });
1346
+ expect(moved).toEqual([[['dinos', 'travels'], { kind: 'into', parentId: 'simpsons' }]]);
1347
+ // Consumed: the rows have moved, and a second paste would move them again from wherever
1348
+ // they now are.
1349
+ expect(container.querySelectorAll('[data-cut]')).toHaveLength(0);
1350
+ });
1351
+
1352
+ test('πŸ”΄ a paste the drag would refuse is refused, in the same words', () => {
1353
+ // One opinion about what is legal. A keyboard route that reaches a destination the pointer
1354
+ // refuses is the second, weaker opinion nobody looks at.
1355
+ const moved: unknown[] = [];
1356
+ const { container } = tree({ onMove: (id) => moved.push(id) });
1357
+ fireEvent.keyDown(focusRow('Simpsons'), { key: 'x', metaKey: true });
1358
+ fireEvent.keyDown(focusRow('s04'), { key: 'v', metaKey: true });
1359
+ expect(moved).toEqual([]);
1360
+ expect(container.querySelector('.cbft-sr')?.textContent).toBe(
1361
+ 'A folder cannot go inside one of its own folders',
1362
+ );
1363
+ });
1364
+
1365
+ test('Escape puts a cut down, so the mark is never stuck on', () => {
1366
+ const { container } = tree({ onMove: () => undefined });
1367
+ fireEvent.keyDown(focusRow('Dinos'), { key: 'x', metaKey: true });
1368
+ expect(container.querySelectorAll('[data-cut]')).toHaveLength(1);
1369
+ fireEvent.keyDown(focusRow('Dinos'), { key: 'Escape' });
1370
+ expect(container.querySelectorAll('[data-cut]')).toHaveLength(0);
1371
+ });
1372
+
1373
+ test('πŸ”΄ ⌘C then ⌘V copies, and the clipboard SURVIVES the paste', () => {
1374
+ // Finder pastes the same copy again, and *"copying in another folder or the same folder"*
1375
+ // is two pastes of one ⌘C.
1376
+ const copied: Array<[readonly string[], unknown]> = [];
1377
+ tree({
1378
+ selectedIds: ['dinos', 'travels'],
1379
+ onSelectionChange: () => undefined,
1380
+ onCopy: (ids, drop) => copied.push([ids, drop]),
1381
+ });
1382
+ fireEvent.keyDown(focusRow('Dinos'), { key: 'c', metaKey: true });
1383
+ fireEvent.keyDown(focusRow('Simpsons'), { key: 'v', metaKey: true });
1384
+ fireEvent.keyDown(focusRow('screencaps'), { key: 'v', metaKey: true });
1385
+ expect(copied).toEqual([
1386
+ [['dinos', 'travels'], { kind: 'into', parentId: 'simpsons' }],
1387
+ [['dinos', 'travels'], { kind: 'into', parentId: 'simpsons/screencaps' }],
1388
+ ]);
1389
+ });
1390
+
1391
+ test('πŸ”΄ `canCopy` vetoes the take, not the paste β€” apps/music has nothing to duplicate', () => {
1392
+ // A folder there is derived from the `folder` column of the tracks in it, so copying one
1393
+ // would mean duplicating audio the owner has exactly one copy of. A refusal after ⌘V
1394
+ // would be a refusal with nothing left to say it about.
1395
+ const copied: unknown[] = [];
1396
+ const { container } = tree({
1397
+ onCopy: (ids) => copied.push(ids),
1398
+ canCopy: (node) => (node.kind === 'folder' ? 'A folder here is derived, not a copy' : null),
1399
+ });
1400
+ fireEvent.keyDown(focusRow('Dinos'), { key: 'c', metaKey: true });
1401
+ expect(container.querySelector('.cbft-sr')?.textContent).toBe(
1402
+ 'A folder here is derived, not a copy',
1403
+ );
1404
+ expect(container.querySelectorAll('[data-cut]')).toHaveLength(0);
1405
+ fireEvent.keyDown(focusRow('Simpsons'), { key: 'v', metaKey: true });
1406
+ expect(copied).toEqual([]);
1407
+ });
1408
+
1409
+ test('πŸ”΄ a key with no verb behind it is not this tree’s key at all', () => {
1410
+ // No `onCopy` and ⌘C is not a chord this component has β€” the event falls through to the
1411
+ // browser rather than being swallowed by a control that would do nothing with it.
1412
+ const cut = tree({ onCopy: undefined });
1413
+ const row = focusRow('Dinos');
1414
+ const copyEvent = new KeyboardEvent('keydown', {
1415
+ key: 'c',
1416
+ metaKey: true,
1417
+ bubbles: true,
1418
+ cancelable: true,
1419
+ });
1420
+ row.dispatchEvent(copyEvent);
1421
+ expect(copyEvent.defaultPrevented).toBe(false);
1422
+ // …and with no move verb either, neither is ⌘X.
1423
+ const cutEvent = new KeyboardEvent('keydown', {
1424
+ key: 'x',
1425
+ metaKey: true,
1426
+ bubbles: true,
1427
+ cancelable: true,
1428
+ });
1429
+ row.dispatchEvent(cutEvent);
1430
+ expect(cutEvent.defaultPrevented).toBe(false);
1431
+ expect(cut.container.querySelectorAll('[data-cut]')).toHaveLength(0);
1432
+ });
1433
+ });
1434
+
1435
+ /**
1436
+ * βŒƒβŒ˜N β€” *"allow make dir from file where dir wraps file location and gets name of file"*.
1437
+ *
1438
+ * Its own fixture, with real FILES in it: `TREE` above is all folders, and an extension is
1439
+ * half of what this gesture is about.
1440
+ */
1441
+ describe('New Folder with Selection', () => {
1442
+ const file = (id: string, name: string): FileTreeNode => ({ id, name, kind: 'file' });
1443
+ /**
1444
+ * Trips/
1445
+ * beach.jpg
1446
+ * alps.png
1447
+ * .gitignore
1448
+ */
1449
+ const WRAP_TREE: FileTreeNode[] = [
1450
+ folder('trips', 'Trips', [file('trips/beach', 'beach.jpg'), file('trips/alps', 'alps.png')]),
1451
+ file('dotfile', '.gitignore'),
1452
+ ];
1453
+
1454
+ const wrapTree = (props: Partial<React.ComponentProps<typeof FileTree>> = {}) =>
1455
+ render(
1456
+ <FileTree nodes={WRAP_TREE} selectedId={null} onSelect={() => undefined} {...props} />,
1457
+ );
1458
+
1459
+ const focusRow = (name: string) => {
1460
+ const row = screen.getByText(name).closest('[role="treeitem"]') as HTMLElement;
1461
+ row.focus();
1462
+ return row;
1463
+ };
1464
+
1465
+ test('πŸ”΄ βŒƒβŒ˜N asks for a folder WHERE THE FILE IS, around the row it was pressed on', () => {
1466
+ const asked: Array<[string[], string | null]> = [];
1467
+ wrapTree({ onWrapStart: (nodes, parentId) => asked.push([nodes.map((n) => n.id), parentId]) });
1468
+ fireEvent.keyDown(focusRow('beach.jpg'), { key: 'n', ctrlKey: true, metaKey: true });
1469
+ // Not the root, not inside the selection: the folder is made in `beach.jpg`'s own parent.
1470
+ expect(asked).toEqual([[['trips/beach'], 'trips']]);
1471
+ });
1472
+
1473
+ test('πŸ”΄ the draft opens PRE-FILLED with the name minus its extension, and selected', () => {
1474
+ wrapTree({
1475
+ draft: { parentId: 'trips', wrap: ['trips/beach'] },
1476
+ onDraftCommit: () => undefined,
1477
+ });
1478
+ const field = screen.getByLabelText('Name') as HTMLInputElement;
1479
+ expect(field.value).toBe('beach');
1480
+ // Selected, so one keystroke replaces the suggestion instead of appending to it.
1481
+ fireEvent.focus(field);
1482
+ expect([field.selectionStart, field.selectionEnd]).toEqual([0, 'beach'.length]);
1483
+ });
1484
+
1485
+ test('πŸ”΄ committing makes the folder AND re-files the ids in ONE call', () => {
1486
+ // Two round trips is how a folder ends up made, empty, and named after a file still
1487
+ // sitting outside it β€” so there is exactly one callback and it carries both.
1488
+ const made: Array<[string, readonly string[] | undefined]> = [];
1489
+ wrapTree({
1490
+ draft: { parentId: 'trips', wrap: ['trips/beach'] },
1491
+ onDraftCommit: (name, wrap) => made.push([name, wrap]),
1492
+ });
1493
+ // Enter on the SUGGESTION, unchanged β€” which is the person agreeing with it, not
1494
+ // cancelling. An unchanged RENAME still cancels; see the rename spec above.
1495
+ fireEvent.keyDown(screen.getByLabelText('Name'), { key: 'Enter' });
1496
+ expect(made).toEqual([['beach', ['trips/beach']]]);
1497
+ });
1498
+
1499
+ test('πŸ”΄ a multi-wrap is named after the FIRST IN TREE ORDER, whatever order it was built', () => {
1500
+ const made: Array<[string, readonly string[] | undefined]> = [];
1501
+ wrapTree({
1502
+ // Clicked bottom-up, which is an ordinary way to build a selection.
1503
+ draft: { parentId: 'trips', wrap: ['trips/alps', 'trips/beach'] },
1504
+ onDraftCommit: (name, wrap) => made.push([name, wrap]),
1505
+ });
1506
+ const field = screen.getByLabelText('Name') as HTMLInputElement;
1507
+ expect(field.value).toBe('beach');
1508
+ fireEvent.keyDown(field, { key: 'Enter' });
1509
+ expect(made).toEqual([['beach', ['trips/beach', 'trips/alps']]]);
1510
+ });
1511
+
1512
+ test('a name that is all extension keeps the whole string', () => {
1513
+ wrapTree({ draft: { parentId: null, wrap: ['dotfile'] }, onDraftCommit: () => undefined });
1514
+ expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('.gitignore');
1515
+ });
1516
+
1517
+ test('typing over the suggestion is what is committed, and Escape still cancels', () => {
1518
+ const made: string[] = [];
1519
+ let cancels = 0;
1520
+ wrapTree({
1521
+ draft: { parentId: 'trips', wrap: ['trips/beach'] },
1522
+ onDraftCommit: (name) => made.push(name),
1523
+ onDraftCancel: () => {
1524
+ cancels += 1;
1525
+ },
1526
+ });
1527
+ const field = screen.getByLabelText('Name') as HTMLInputElement;
1528
+ fireEvent.change(field, { target: { value: 'Beach day' } });
1529
+ fireEvent.keyDown(field, { key: 'Enter' });
1530
+ expect(made).toEqual(['Beach day']);
1531
+ fireEvent.keyDown(screen.getByLabelText('Name'), { key: 'Escape' });
1532
+ expect(cancels).toBe(1);
1533
+ });
1534
+
1535
+ test('πŸ”΄ `canWrap` refuses BEFORE the draft opens β€” a tree of derived folders makes none', () => {
1536
+ // The refusal has to arrive while there is still something to say it about. After a name
1537
+ // has been typed it is a refusal aimed at somebody who has already done the work.
1538
+ const asked: unknown[] = [];
1539
+ const { container } = wrapTree({
1540
+ onWrapStart: (nodes) => asked.push(nodes.map((n) => n.id)),
1541
+ canWrap: () => 'Folders here are derived from the tracks',
1542
+ });
1543
+ fireEvent.keyDown(focusRow('beach.jpg'), { key: 'n', ctrlKey: true, metaKey: true });
1544
+ expect(asked).toEqual([]);
1545
+ expect(container.querySelector('.cbft-sr')?.textContent).toBe(
1546
+ 'Folders here are derived from the tracks',
1547
+ );
1548
+ });
1549
+
1550
+ test('a row that cannot be dragged cannot be wrapped either β€” wrapping MOVES it', () => {
1551
+ const asked: unknown[] = [];
1552
+ const { container } = render(
1553
+ <FileTree
1554
+ nodes={[{ id: 'album', name: 'Simpsons', kind: 'folder', draggable: false }]}
1555
+ selectedId={null}
1556
+ onSelect={() => undefined}
1557
+ onWrapStart={(nodes) => asked.push(nodes.map((n) => n.id))}
1558
+ />,
1559
+ );
1560
+ fireEvent.keyDown(focusRow('Simpsons'), { key: 'n', ctrlKey: true, metaKey: true });
1561
+ expect(asked).toEqual([]);
1562
+ // Said out loud: a chord that silently does nothing reads as a broken control.
1563
+ expect(container.querySelector('.cbft-sr')?.textContent).toBe('Simpsons cannot be moved');
1564
+ });
1565
+
1566
+ test('πŸ”΄ a consumer wiring none of it sees no new control and no new argument', () => {
1567
+ // The compatibility rule this component has held since multi-select: a gesture nobody
1568
+ // wired is not a gesture this tree has.
1569
+ const commit = jest.fn();
1570
+ const plain = tree({ draft: { parentId: 'simpsons' }, onDraftCommit: commit });
1571
+ const row = screen.getByText('Dinos').closest('[role="treeitem"]') as HTMLElement;
1572
+ row.focus();
1573
+ const chord = new KeyboardEvent('keydown', {
1574
+ key: 'n',
1575
+ ctrlKey: true,
1576
+ metaKey: true,
1577
+ bubbles: true,
1578
+ cancelable: true,
1579
+ });
1580
+ row.dispatchEvent(chord);
1581
+ // Not swallowed: with no `onWrapStart` this is not a chord this component has, and the
1582
+ // event belongs to whatever the app around it bound.
1583
+ expect(chord.defaultPrevented).toBe(false);
1584
+ expect(plain.container.querySelector('.cbft-row-draft')).not.toBeNull();
1585
+
1586
+ const field = screen.getByLabelText('Name') as HTMLInputElement;
1587
+ // An ordinary draft still opens EMPTY β€” there is nothing for it to be named after.
1588
+ expect(field.value).toBe('');
1589
+ fireEvent.change(field, { target: { value: 'posters' } });
1590
+ fireEvent.keyDown(field, { key: 'Enter' });
1591
+ // One argument, exactly as before `wrap` existed.
1592
+ expect(commit.mock.calls).toEqual([['posters']]);
1593
+ });
1594
+ });
1595
+
810
1596
  describe('what a screen reader is told about the level', () => {
811
1597
  test('a row says its position AND the size of its level', () => {
812
1598
  tree();
@@ -818,3 +1604,226 @@ describe('what a screen reader is told about the level', () => {
818
1604
  expect(nested.getAttribute('aria-setsize')).toBe('1');
819
1605
  });
820
1606
  });
1607
+
1608
+ /**
1609
+ * ── Pinned rows ───────────────────────────────────────────────────────────────────────────
1610
+ *
1611
+ * Owner, 2026-09-13: *"need … ability to pin open files where they stay at the top of the file
1612
+ * tree in a new section above the folders. Pinned will mean I want to keep them there until I
1613
+ * unpin them … They still will exist in the folder they were stored in and unpinning takes it
1614
+ * back to normal. Pinning or unpinning one place doesn't change pinning in the other place."*
1615
+ *
1616
+ * The two requirements a quick implementation loses are the two this block spends most of its
1617
+ * assertions on: the row is drawn TWICE (a section, not a sort order), and the state is the
1618
+ * consumer's, which is the whole of "one place doesn't change the other".
1619
+ */
1620
+ describe('pinned rows', () => {
1621
+ const pinnedTree = () => screen.getByRole('tree', { name: 'Pinned' });
1622
+ const fileTree = () => screen.getByRole('tree', { name: 'Files' });
1623
+ const rowsOf = (list: HTMLElement) => [...list.querySelectorAll('.cbft-row')] as HTMLElement[];
1624
+ const named = (list: HTMLElement) =>
1625
+ rowsOf(list).map((row) => row.querySelector('.cbft-name')?.textContent ?? '');
1626
+
1627
+ test('πŸ”΄ a pinned row is drawn in a section ABOVE the folders AND still where it lives', () => {
1628
+ // *"in a new section above the folders"* and *"they still will exist in the folder they
1629
+ // were stored in"* β€” the second clause is what makes this a section rather than a sort.
1630
+ const { container } = tree({ pinnedIds: ['dinos'] });
1631
+
1632
+ const lists = [...container.querySelectorAll('[role="tree"]')] as HTMLElement[];
1633
+ expect(lists).toHaveLength(2);
1634
+ expect(lists[0]?.getAttribute('aria-label')).toBe('Pinned');
1635
+ expect(lists[1]?.getAttribute('aria-label')).toBe('Files');
1636
+
1637
+ // The very first row in the component, above every folder.
1638
+ expect(named(pinnedTree())).toEqual(['Dinos']);
1639
+ expect((rowsOf(container as unknown as HTMLElement)[0] as HTMLElement).dataset.pinnedCopy).toBe(
1640
+ 'true',
1641
+ );
1642
+ // …and the tree below is the tree it always was, `Dinos` in its own place among them.
1643
+ expect(named(fileTree())).toEqual(['Simpsons', 'screencaps', 's04', 'Dinos', 'Travels']);
1644
+ // Two rows, one node: the copy is addressed separately and the identity is shared.
1645
+ expect(screen.getAllByText('Dinos')).toHaveLength(2);
1646
+ });
1647
+
1648
+ test('πŸ”΄ a pin reaches a row inside a SHUT folder β€” which is what it is for', () => {
1649
+ // The pinned section is derived from the TREE, not from the rows on screen. A pin whose
1650
+ // folder is closed is the ordinary case (that is why somebody pinned it), so building the
1651
+ // section off `rows` would make the feature work only where it is not needed.
1652
+ tree({ pinnedIds: ['simpsons/screencaps/s04'], collapsed: ['simpsons'] });
1653
+ expect(named(pinnedTree())).toEqual(['s04']);
1654
+ expect(named(fileTree())).toEqual(['Simpsons', 'Dinos', 'Travels']);
1655
+ });
1656
+
1657
+ test('a pinned copy is drawn FLAT β€” no depth, no twisty, level 1', () => {
1658
+ // It is a list of shortcuts, not a second hierarchy beside the first one.
1659
+ tree({ pinnedIds: ['simpsons'] });
1660
+ const copy = rowsOf(pinnedTree())[0] as HTMLElement;
1661
+ expect(copy.getAttribute('aria-level')).toBe('1');
1662
+ expect(copy.style.getPropertyValue('--cbft-depth')).toBe('0');
1663
+ expect(copy.querySelector('.cbft-twisty')).toBeNull();
1664
+ // The real row keeps both β€” a pin changes nothing about the row in the folder.
1665
+ const real = rowsOf(fileTree())[0] as HTMLElement;
1666
+ expect(real.getAttribute('aria-level')).toBe('1');
1667
+ expect(real.querySelector('.cbft-twisty')).not.toBeNull();
1668
+ });
1669
+
1670
+ test('πŸ”΄ the aria counts of the tree are UNCHANGED by a pin', () => {
1671
+ // The trap: `aria-setsize` is computed from the rows on screen, on the assumption that
1672
+ // every sibling of a visible row is itself visible. Folding pins into that row list makes
1673
+ // the counts wrong for EVERY row in the tree, not only for the pinned ones β€” which is why
1674
+ // the section is its own `role='tree'` with counts of its own.
1675
+ tree({ pinnedIds: ['dinos', 'travels'] });
1676
+ const inTree = (name: string) =>
1677
+ rowsOf(fileTree()).find((row) => row.textContent?.includes(name)) as HTMLElement;
1678
+ expect(inTree('Dinos').getAttribute('aria-posinset')).toBe('2');
1679
+ expect(inTree('Dinos').getAttribute('aria-setsize')).toBe('3');
1680
+ expect(inTree('screencaps').getAttribute('aria-posinset')).toBe('1');
1681
+ expect(inTree('screencaps').getAttribute('aria-setsize')).toBe('1');
1682
+ // And the section counts ITSELF: two pins, positions one and two.
1683
+ const pins = rowsOf(pinnedTree());
1684
+ expect(pins.map((row) => row.getAttribute('aria-posinset'))).toEqual(['1', '2']);
1685
+ expect(pins.map((row) => row.getAttribute('aria-setsize'))).toEqual(['2', '2']);
1686
+ });
1687
+
1688
+ test('the pin control reports the whole next list, and appends rather than resorts', () => {
1689
+ const asked: string[][] = [];
1690
+ tree({ pinnedIds: ['travels'], onPinnedChange: (next) => asked.push(next) });
1691
+ fireEvent.click(screen.getByRole('button', { name: 'Pin Dinos' }));
1692
+ // Appended: the section keeps the order it was built in rather than being resorted under
1693
+ // somebody who arranged it.
1694
+ expect(asked).toEqual([['travels', 'dinos']]);
1695
+ });
1696
+
1697
+ test('πŸ”΄ unpinning removes ONLY the top copy, and leaves nothing behind', () => {
1698
+ // *"unpinning takes it back to normal"* β€” the row in the folder is the row it always was,
1699
+ // byte for byte the tree a consumer that never pinned anything sees.
1700
+ const asked: string[][] = [];
1701
+ const pinned = tree({ pinnedIds: ['dinos'], onPinnedChange: (next) => asked.push(next) });
1702
+ // The control on the TOP copy β€” two rows carry an "Unpin Dinos" button, and pressing
1703
+ // either is the same act on the same node.
1704
+ const [top] = screen.getAllByRole('button', { name: 'Unpin Dinos' });
1705
+ fireEvent.click(top as HTMLElement);
1706
+ expect(asked).toEqual([[]]);
1707
+
1708
+ pinned.rerender(
1709
+ <FileTree
1710
+ nodes={TREE}
1711
+ selectedId={null}
1712
+ onSelect={() => undefined}
1713
+ pinnedIds={[]}
1714
+ onPinnedChange={(next) => asked.push(next)}
1715
+ />,
1716
+ );
1717
+ expect(screen.queryByRole('tree', { name: 'Pinned' })).toBeNull();
1718
+ expect(screen.getAllByText('Dinos')).toHaveLength(1);
1719
+ expect(named(fileTree())).toEqual(['Simpsons', 'screencaps', 's04', 'Dinos', 'Travels']);
1720
+ });
1721
+
1722
+ test('πŸ”΄ a pinned FOLDER still takes a drop, and it lands in the REAL folder', () => {
1723
+ /*
1724
+ * happy-dom computes no layout, so the POINTER drag cannot be exercised here at all (see
1725
+ * this file's header) β€” `rowAt` hit-tests against rects that are all zeros. The external
1726
+ * drag is the one drop path that is real DOM and needs no layout, and it answers the same
1727
+ * question: the pinned copy is a live drop target, and what it reports is the node itself
1728
+ * rather than some shadow of it. The pointer half resolves through the very same
1729
+ * `FileTreeRow`, which carries the row's real `parentId`.
1730
+ */
1731
+ const drops: (string | null)[] = [];
1732
+ tree({ pinnedIds: ['dinos'], onDropExternal: (node) => drops.push(node?.id ?? null) });
1733
+ const copy = rowsOf(pinnedTree())[0] as HTMLElement;
1734
+ fireEvent.dragOver(copy);
1735
+ expect(copy.getAttribute('data-into')).toBe('true');
1736
+ // Both copies light up, because there is one folder and the drag is over it.
1737
+ const real = rowsOf(fileTree()).find((row) => row.textContent?.includes('Dinos')) as HTMLElement;
1738
+ expect(real.getAttribute('data-into')).toBe('true');
1739
+ fireEvent.drop(copy);
1740
+ expect(drops).toEqual(['dinos']);
1741
+ });
1742
+
1743
+ test('βŒƒβŒ˜T pins the focused row, and pins the row in the folder just the same', () => {
1744
+ // Finder's own Add to Sidebar chord, anchored on Ctrl so βŒƒT works on the Windows and
1745
+ // Linux hosts this tree also runs on β€” the rule βŒƒβŒ˜N already follows.
1746
+ const asked: string[][] = [];
1747
+ tree({ onPinnedChange: (next) => asked.push(next) });
1748
+ const row = screen.getByText('Travels').closest('[role="treeitem"]') as HTMLElement;
1749
+ row.focus();
1750
+ fireEvent.keyDown(row, { key: 't', ctrlKey: true, metaKey: true });
1751
+ expect(asked).toEqual([['travels']]);
1752
+ });
1753
+
1754
+ test('πŸ”΄ exactly ONE copy draws the rename field', () => {
1755
+ // `renamingId` names a node, and a pinned node is two rows β€” the naive test opens two
1756
+ // autofocused inputs sharing one accessible name. The tree's copy wins while it exists.
1757
+ const { container } = tree({
1758
+ pinnedIds: ['dinos'],
1759
+ renamingId: 'dinos',
1760
+ onRename: () => undefined,
1761
+ });
1762
+ expect(container.querySelectorAll('.cbft-name-input')).toHaveLength(1);
1763
+ expect(rowsOf(fileTree()).some((row) => row.querySelector('.cbft-name-input'))).toBe(true);
1764
+ });
1765
+
1766
+ test('πŸ”΄ …and the PINNED copy draws it when the real row is inside a shut folder', () => {
1767
+ // Otherwise a rename started from the section opens nowhere at all, which is the "control
1768
+ // that silently does nothing" this component refuses everywhere else.
1769
+ const { container } = tree({
1770
+ pinnedIds: ['simpsons/screencaps/s04'],
1771
+ collapsed: ['simpsons'],
1772
+ renamingId: 'simpsons/screencaps/s04',
1773
+ onRename: () => undefined,
1774
+ });
1775
+ expect(container.querySelectorAll('.cbft-name-input')).toHaveLength(1);
1776
+ expect(rowsOf(pinnedTree()).some((row) => row.querySelector('.cbft-name-input'))).toBe(true);
1777
+ });
1778
+
1779
+ test('a pin the tree no longer holds is simply not drawn', () => {
1780
+ // A selection outlives a refresh and so does a pin: the server re-derives the tree between
1781
+ // a pin and a render, and a ghost row nothing can be done with is worse than an absent one.
1782
+ tree({ pinnedIds: ['dinos', 'gone-yesterday'] });
1783
+ expect(named(pinnedTree())).toEqual(['Dinos']);
1784
+ });
1785
+
1786
+ test('the section takes the consumer’s own word for it', () => {
1787
+ // The mechanism is general and the vocabulary is not β€” the same reason `accessCopy` exists.
1788
+ tree({ pinnedIds: ['dinos'], pinnedLabel: 'Favourites' });
1789
+ expect(screen.getByRole('tree', { name: 'Favourites' })).toBeDefined();
1790
+ expect(screen.getByText('Favourites')).toBeDefined();
1791
+ });
1792
+
1793
+ test('πŸ”΄ a consumer wiring none of it sees byte-for-byte its current tree', () => {
1794
+ /*
1795
+ * The compatibility assertion this component has held since multi-select, and the one
1796
+ * `tasks/Completed/119-*.md` established for this file: a gesture nobody wired is not a
1797
+ * gesture this tree has. An empty `pinnedIds` and a label are both inert β€” no section, no
1798
+ * control, no chord, and not one attribute of difference in the markup.
1799
+ */
1800
+ const plain = render(
1801
+ <FileTree nodes={TREE} selectedId={null} onSelect={() => undefined} />,
1802
+ );
1803
+ const withPins = render(
1804
+ <FileTree
1805
+ nodes={TREE}
1806
+ selectedId={null}
1807
+ onSelect={() => undefined}
1808
+ pinnedIds={[]}
1809
+ pinnedLabel='Favourites'
1810
+ />,
1811
+ );
1812
+ expect(withPins.container.innerHTML).toBe(plain.container.innerHTML);
1813
+ expect(plain.container.querySelector('.cbft-pin')).toBeNull();
1814
+
1815
+ // And βŒƒβŒ˜T is not swallowed: with no `onPinnedChange` this is not a chord this component
1816
+ // has, and the event belongs to whatever the app around it bound.
1817
+ const row = plain.container.querySelector('[role="treeitem"]') as HTMLElement;
1818
+ row.focus();
1819
+ const chord = new KeyboardEvent('keydown', {
1820
+ key: 't',
1821
+ ctrlKey: true,
1822
+ metaKey: true,
1823
+ bubbles: true,
1824
+ cancelable: true,
1825
+ });
1826
+ row.dispatchEvent(chord);
1827
+ expect(chord.defaultPrevented).toBe(false);
1828
+ });
1829
+ });