@marimo-team/islands 0.25.3-dev192 → 0.25.3-dev193

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 (154) hide show
  1. package/dist/{ConnectedDataExplorerComponent-BBbOElk3.js → ConnectedDataExplorerComponent-BRuwLOIx.js} +9 -9
  2. package/dist/{ErrorBoundary-Dw6uRKVk.js → ErrorBoundary-BVu_xT-p.js} +2 -2
  3. package/dist/{abnfDiagram-VCTEODGH-DhMT6Uhd.js → abnfDiagram-VCTEODGH-LlJTOOU_.js} +2 -2
  4. package/dist/{architectureDiagram-5GKGNRK7-BVbEiJLq.js → architectureDiagram-5GKGNRK7-9PQtGLLZ.js} +1 -1
  5. package/dist/{arrays-R6JsImiT.js → arrays-DA6KkLfq.js} +6 -12
  6. package/dist/assets/__vite-browser-external-BCJsx8QS.js +1 -0
  7. package/dist/assets/{worker-C6e9ERyd.js → worker-DodjWT_Z.js} +2 -2
  8. package/dist/{capabilities-CI4xUPsp.js → capabilities-CvWU-ey_.js} +1 -1
  9. package/dist/{chat-ui-BguxTQEL.js → chat-ui-Bl2euMBD.js} +93 -93
  10. package/dist/{chunk-IMKFNOWR-By5hSbJJ.js → chunk-IMKFNOWR-DcXv-BD5.js} +1 -1
  11. package/dist/{chunk-SHT3W25Y-DFrEYG54.js → chunk-SHT3W25Y-VwnELN23.js} +1 -1
  12. package/dist/{chunk-SVP7TREG-DFXGc9ew.js → chunk-SVP7TREG-DgQpI5Zi.js} +1 -1
  13. package/dist/{chunk-TICWLB2K-DZdNIpoZ.js → chunk-TICWLB2K-CTg6VTJT.js} +1 -1
  14. package/dist/{classDiagram-ZZMXUADV-Cgj_eEQA.js → classDiagram-ZZMXUADV-UzmcFrnW.js} +2 -2
  15. package/dist/{classDiagram-v2-VYDZK3BY-DLAUrSqj.js → classDiagram-v2-VYDZK3BY-DhjiI6IQ.js} +2 -2
  16. package/dist/{code-visibility-v_7ijLtx.js → code-visibility-BTVimCUw.js} +38 -42
  17. package/dist/{common-BUwWHpnK.js → common-fTgq7gO-.js} +849 -849
  18. package/dist/{cynefinDiagram-5FMLGOSQ-Cikf9AKa.js → cynefinDiagram-5FMLGOSQ-O4ShIbJX.js} +1 -1
  19. package/dist/{dagre-GXQ25YYZ-C5KEi5_l.js → dagre-GXQ25YYZ-dtHhhcPu.js} +1 -1
  20. package/dist/{data-grid-overlay-editor-4IZymnXZ.js → data-grid-overlay-editor-DAZZkLWg.js} +1 -1
  21. package/dist/{diagram-S7CK7UJ4-BaoBTQjR.js → diagram-S7CK7UJ4-ppPYVZ6W.js} +1 -1
  22. package/dist/{diagram-UQ7AKVKN-DDufBsUA.js → diagram-UQ7AKVKN-CMZDf--k.js} +1 -1
  23. package/dist/{diagram-VX7I27RA-CY6RALJ8.js → diagram-VX7I27RA-CFvJdx-C.js} +1 -1
  24. package/dist/{diagram-Z3DM3KII-Bc-LHsD6.js → diagram-Z3DM3KII-Dt6_uFbv.js} +1 -1
  25. package/dist/{dropdown-menu-drDjxJyV.js → dropdown-menu-CeaOsjWZ.js} +2 -2
  26. package/dist/{ebnfDiagram-PWID7BFC-Cz0J-tcC.js → ebnfDiagram-PWID7BFC-DpRFrKK-.js} +2 -2
  27. package/dist/{erDiagram-RLTQ6QDP-DTXTDgFu.js → erDiagram-RLTQ6QDP-CqAU4b0u.js} +1 -1
  28. package/dist/{error-entries-CZ1NQ6Kv.js → error-entries-CYGwr5-O.js} +5596 -6012
  29. package/dist/feedback-button-DzFFn-n4.js +374 -0
  30. package/dist/{flowDiagram-HODETNUW-RbwWf2zc.js → flowDiagram-HODETNUW-i8-Gw8HO.js} +2 -2
  31. package/dist/{formats-DbXnijtH.js → formats-BtoHHy42.js} +1 -1
  32. package/dist/{glide-data-editor-DU8_EDG5.js → glide-data-editor-DFsqVbWR.js} +5 -5
  33. package/dist/{globals-BeBDOk22.js → globals-Dr0MRANu.js} +2 -2
  34. package/dist/{html-to-image-D_4_hQ8U.js → html-to-image-Cy5UDpQJ.js} +56 -56
  35. package/dist/{infoDiagram-27XIBGKW-DlmzZjmI.js → infoDiagram-27XIBGKW-DpfOKdLL.js} +1 -1
  36. package/dist/{ishikawaDiagram-5VMMS53U-EN3TYP_q.js → ishikawaDiagram-5VMMS53U-Dg7mrMG7.js} +1 -1
  37. package/dist/{kanban-definition-UXKFOSKX-CK_-Ru7Q.js → kanban-definition-UXKFOSKX-QB6e3Jrj.js} +1 -1
  38. package/dist/main.js +5875 -5876
  39. package/dist/{mermaid-DU9ZRmkG.js → mermaid-CeWG4Egr.js} +48 -48
  40. package/dist/{mindmap-definition-YA3MSWOX-C-hqgUvF.js → mindmap-definition-YA3MSWOX-BTz3uTGe.js} +1 -1
  41. package/dist/{number-overlay-editor-BoIkyh6H.js → number-overlay-editor-DupOSYHx.js} +1 -1
  42. package/dist/{once-eG8Vmvjd.js → once-DWGO2mQ2.js} +1 -1
  43. package/dist/{pegDiagram-XKGWAZYB-BiMejYPB.js → pegDiagram-XKGWAZYB-3ZIEa3mB.js} +2 -2
  44. package/dist/{pieDiagram-E7YTZNPT-B1zCZ1eq.js → pieDiagram-E7YTZNPT-BJAlk046.js} +1 -1
  45. package/dist/{process-output-DxSCHBWp.js → process-output-q9MTAIMX.js} +2 -2
  46. package/dist/{railroadDiagram-O6MQD6OU-BLcfaASl.js → railroadDiagram-O6MQD6OU-Dn3bsSRa.js} +2 -2
  47. package/dist/{requirementDiagram-BXWQKSXE-DtBOf52-.js → requirementDiagram-BXWQKSXE-wcOmQQCL.js} +1 -1
  48. package/dist/{reveal-component-5M3a2rpj.js → reveal-component-Bg8rP_Uo.js} +17 -17
  49. package/dist/{sequenceDiagram-WJ2MYXX4-DIwF19_n.js → sequenceDiagram-WJ2MYXX4-DSh_qxoF.js} +1 -1
  50. package/dist/{spec-DZDnnlgT.js → spec-C7Fos-If.js} +1 -1
  51. package/dist/{stateDiagram-D77RDMKH-sWk1AdYL.js → stateDiagram-D77RDMKH-DH_H8NMH.js} +2 -2
  52. package/dist/{stateDiagram-v2-MP3YSRHH-CiNo-OI7.js → stateDiagram-v2-MP3YSRHH-D_JM7MEK.js} +2 -2
  53. package/dist/style.css +1 -1
  54. package/dist/{swimlanes-42K2YHIH-Bj3hiOY5.js → swimlanes-42K2YHIH-CfoTJO-9.js} +1 -1
  55. package/dist/{swimlanesDiagram-VR7AAH4N-BzEo2r_N.js → swimlanesDiagram-VR7AAH4N-Z_QhvK0G.js} +2 -2
  56. package/dist/{swiper-component-BkCm-kLg.js → swiper-component-D2geL1Ku.js} +1 -1
  57. package/dist/{timeline-definition-24CTP7MA-CrPQk42H.js → timeline-definition-24CTP7MA-BFtdScaL.js} +1 -1
  58. package/dist/{toDate-Cu2pIZcC.js → toDate-CQAFSwos.js} +24 -24
  59. package/dist/{useAsyncData-DEhLdRIu.js → useAsyncData-BeyZyjso.js} +1 -1
  60. package/dist/{useDeepCompareMemoize-RjZGVhBV.js → useDeepCompareMemoize-DOQgae0W.js} +1 -1
  61. package/dist/{useIframeCapabilities-CSdWn6NP.js → useIframeCapabilities-DNvaj9wM.js} +1 -1
  62. package/dist/{useLifecycle-DtcB2s1z.js → useLifecycle-n2lnCFS8.js} +1 -1
  63. package/dist/{useTheme-CoEtr4mK.js → useTheme-CY777LXN.js} +303 -99
  64. package/dist/{vega-component-BhjTrP9-.js → vega-component-0nQzA-I8.js} +6 -6
  65. package/dist/{vennDiagram-4TSXK5OY-BaYMoIpj.js → vennDiagram-4TSXK5OY-BXY8IGCA.js} +1 -1
  66. package/dist/{wardleyDiagram-VM6X3IG4-DjHeuj8p.js → wardleyDiagram-VM6X3IG4-D0tFe6eC.js} +1 -1
  67. package/dist/{xychartDiagram-S5SC5T6Z-Bc_hcuXS.js → xychartDiagram-S5SC5T6Z-Dx8wFM-y.js} +1 -1
  68. package/package.json +1 -1
  69. package/src/__mocks__/notebook.ts +0 -1
  70. package/src/__mocks__/partial.ts +26 -0
  71. package/src/__tests__/main.test.tsx +24 -1
  72. package/src/components/app-config/app-config-form.tsx +5 -9
  73. package/src/components/editor/__tests__/data-attributes.test.tsx +0 -1
  74. package/src/components/editor/__tests__/output-persistence.test.tsx +0 -1
  75. package/src/components/editor/actions/useCellActionButton.tsx +6 -5
  76. package/src/components/editor/actions/useConfigActions.tsx +3 -4
  77. package/src/components/editor/actions/useNotebookActions.tsx +5 -4
  78. package/src/components/editor/cell/code/cell-editor.tsx +13 -6
  79. package/src/components/editor/cell/collapse.tsx +6 -1
  80. package/src/components/editor/chrome/components/feedback-button.tsx +1 -7
  81. package/src/components/editor/controls/Controls.tsx +1 -1
  82. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +8 -6
  83. package/src/components/editor/navigation/__tests__/navigation.test.ts +15 -15
  84. package/src/components/editor/navigation/__tests__/selection.test.ts +44 -21
  85. package/src/components/editor/navigation/focus-utils.ts +19 -0
  86. package/src/components/editor/navigation/multi-cell-action-toolbar.tsx +6 -13
  87. package/src/components/editor/navigation/navigation.ts +52 -43
  88. package/src/components/editor/navigation/selection.ts +30 -29
  89. package/src/components/editor/notebook-cell.tsx +10 -18
  90. package/src/components/editor/renderers/cell-array.tsx +6 -5
  91. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +0 -1
  92. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +1 -1
  93. package/src/components/pages/edit-page.tsx +0 -13
  94. package/src/components/scratchpad/scratchpad.tsx +0 -1
  95. package/src/components/slides/reveal-component.tsx +1 -1
  96. package/src/components/slides/slide-cell-view.tsx +0 -1
  97. package/src/components/sort/SortableCellsProvider.tsx +6 -5
  98. package/src/core/MarimoApp.tsx +1 -1
  99. package/src/core/cells/__tests__/__snapshots__/cells.test.ts.snap +0 -37
  100. package/src/core/cells/__tests__/actions.test.ts +190 -80
  101. package/src/core/cells/__tests__/arrange.test.ts +263 -0
  102. package/src/core/cells/__tests__/cells.test.ts +11 -1101
  103. package/src/core/cells/__tests__/document.test.ts +3 -32
  104. package/src/core/cells/__tests__/editor.test.ts +57 -0
  105. package/src/core/cells/__tests__/harness.ts +82 -0
  106. package/src/core/cells/__tests__/layout.test.ts +189 -0
  107. package/src/core/cells/__tests__/notebook-outline.test.ts +7 -1
  108. package/src/core/cells/__tests__/session.test.ts +329 -174
  109. package/src/core/cells/__tests__/utils.test.ts +0 -1
  110. package/src/core/cells/actions.ts +247 -82
  111. package/src/core/cells/arrange.ts +341 -0
  112. package/src/core/cells/cells.ts +27 -516
  113. package/src/core/cells/document.ts +6 -5
  114. package/src/core/cells/edit.ts +30 -0
  115. package/src/core/cells/editor.ts +82 -0
  116. package/src/core/cells/layout.ts +152 -0
  117. package/src/core/cells/session.ts +75 -52
  118. package/src/core/cells/types.ts +0 -5
  119. package/src/core/cells/utils.ts +6 -4
  120. package/src/core/config/__tests__/app-config.test.ts +64 -0
  121. package/src/core/config/__tests__/config-schema.test.ts +1 -1
  122. package/src/core/config/config-schema.ts +6 -2
  123. package/src/core/config/config.ts +26 -12
  124. package/src/core/islands/bootstrap.ts +0 -1
  125. package/src/core/kernel/handlers.ts +1 -13
  126. package/src/core/meta/__tests__/code-visibility.test.tsx +78 -70
  127. package/src/core/meta/code-visibility.ts +18 -9
  128. package/src/core/notebook/__tests__/operations.test.ts +51 -0
  129. package/src/core/notebook/document/__tests__/codemirror.test.ts +15 -0
  130. package/src/core/notebook/index.ts +2 -1
  131. package/src/core/notebook/operations.ts +30 -0
  132. package/src/core/saving/__tests__/state.test.ts +111 -0
  133. package/src/core/saving/save-component.tsx +19 -33
  134. package/src/core/saving/state.ts +38 -39
  135. package/src/core/saving/useAutoSave.ts +8 -35
  136. package/src/core/session/__tests__/backend.test.ts +1 -0
  137. package/src/core/session/__tests__/useNotebookReplica.hook.test.tsx +1 -0
  138. package/src/core/session/active-notebook.ts +11 -0
  139. package/src/core/session/backend.ts +17 -19
  140. package/src/core/session/embedded.ts +7 -0
  141. package/src/core/session/useNotebookReplica.ts +3 -1
  142. package/src/core/session/useNotebookSession.tsx +7 -8
  143. package/src/core/transports/__tests__/static.test.ts +139 -8
  144. package/src/core/transports/static.ts +98 -16
  145. package/src/core/wasm/__tests__/bridge.test.ts +1 -0
  146. package/src/mount.tsx +19 -10
  147. package/src/stories/cell.stories.tsx +0 -2
  148. package/src/stories/layout/vertical/one-column.stories.tsx +0 -2
  149. package/src/utils/__tests__/id-tree.test.ts +20 -1548
  150. package/src/utils/id-tree.tsx +12 -537
  151. package/dist/assets/__vite-browser-external-C8gQho07.js +0 -1
  152. package/dist/feedback-button-MpeoIn1D.js +0 -379
  153. package/src/components/editor/navigation/__tests__/state.test.ts +0 -81
  154. package/src/components/editor/navigation/state.ts +0 -65
@@ -1,36 +1,39 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { history } from "@codemirror/commands";
4
- import { EditorState } from "@codemirror/state";
5
- import { EditorView } from "@codemirror/view";
6
3
  import type * as Protocol from "@marimo-team/client/protocol";
7
- import { createStore } from "jotai";
8
4
  import { afterEach, describe, expect, it, vi } from "vitest";
9
- import { mockDocumentModel } from "@/__mocks__/document";
10
5
  import { cellId } from "@/__tests__/branded";
11
- import {
12
- cell,
13
- CONFIG,
14
- document as documentOf,
15
- withCells,
16
- } from "@/core/notebook/document/__tests__/fixtures";
6
+ import { createStore } from "jotai";
7
+ import { cell, CONFIG } from "@/core/notebook/document/__tests__/fixtures";
17
8
  import type { CellIndex } from "@/utils/id-tree";
18
9
  import {
19
10
  addSetupCellAtom,
11
+ collapseAllCellsAtom,
12
+ collapseCellAtom,
20
13
  createCellAtom,
21
14
  deleteCellsAtom,
15
+ expandAllCellsAtom,
16
+ expandCellAtom,
17
+ focusCellAtom,
22
18
  moveToNextCellAtom,
19
+ showCellIfHiddenAtom,
23
20
  undoDeleteCellAtom,
24
21
  } from "../actions";
22
+ import { moveCellsRelativeToAtom } from "../arrange";
25
23
  import {
26
24
  cellDataAtom,
27
25
  cellHandleAtom,
28
26
  exportedForTesting,
29
27
  notebookAtom,
30
- notebookReducer,
31
28
  } from "../cells";
32
- import { followDocument } from "../document";
29
+ import {
30
+ hideShownCodeAtom,
31
+ restoredEditorStatesAtom,
32
+ shownHiddenCodeAtom,
33
+ } from "../editor";
34
+ import { collapsedAtom, layoutAtom } from "../layout";
33
35
  import { type CellId, SCRATCH_CELL_ID, SETUP_CELL_ID } from "../ids";
36
+ import { setupNotebook } from "./harness";
34
37
  import { focusAndScrollCellIntoView } from "../scrollCellIntoView";
35
38
 
36
39
  vi.mock("../scrollCellIntoView", () => ({
@@ -49,53 +52,8 @@ afterEach(() => {
49
52
  vi.clearAllMocks();
50
53
  });
51
54
 
52
- /** A store whose notebook follows a document model, as the session wires it. */
53
- function setup(cells?: Record<string, Protocol.Cell | null>) {
54
- const store = createStore();
55
- store.set(notebookAtom, exportedForTesting.initialNotebookState());
56
- const { model, stop } = mockDocumentModel(
57
- store,
58
- cells ? withCells(documentOf(), cells) : documentOf(),
59
- );
60
- const controller = new AbortController();
61
- stops.push(stop, () => controller.abort());
62
- followDocument({
63
- store,
64
- model,
65
- reconcile: (document) =>
66
- store.set(notebookAtom, (state) =>
67
- notebookReducer(state, {
68
- type: "reconcileDocument",
69
- payload: document,
70
- }),
71
- ),
72
- signal: controller.signal,
73
- });
74
- return {
75
- store,
76
- model,
77
- order: () => store.get(notebookAtom).cellIds.inOrderIds,
78
- notebook: () => store.get(notebookAtom),
79
- cellIn: (id: string) => store.get(model.documentAtom)?.cells.get(id),
80
- /** Give a cell an editor, as mounting it would. */
81
- mountEditor: (id: string) => {
82
- const view = new EditorView({
83
- state: EditorState.create({
84
- doc: store.get(model.documentAtom)?.cells.get(id)?.code ?? "",
85
- extensions: [history()],
86
- }),
87
- });
88
- store.get(notebookAtom).cellHandles[id as never].current = {
89
- editorView: view,
90
- editorViewOrNull: view,
91
- };
92
- return view;
93
- },
94
- /** Apply a reducer action, as `useReducerActions()` does. */
95
- dispatch: (action: Parameters<typeof notebookReducer>[1]) =>
96
- store.set(notebookAtom, (state) => notebookReducer(state, action)),
97
- };
98
- }
55
+ const setup = (cells?: Record<string, Protocol.Cell | null>) =>
56
+ setupNotebook(stops, cells);
99
57
 
100
58
  describe("createCellAtom", () => {
101
59
  it("creates a cell after another and focuses it once it mounts", () => {
@@ -131,8 +89,8 @@ describe("createCellAtom", () => {
131
89
  focus: false,
132
90
  });
133
91
 
134
- expect(notebook().untouchedNewCells.has(hidden as never)).toBe(true);
135
- expect(notebook().untouchedNewCells.has(unfocused as never)).toBe(false);
92
+ expect(store.get(shownHiddenCodeAtom).has(hidden as CellId)).toBe(true);
93
+ expect(store.get(shownHiddenCodeAtom).has(unfocused as CellId)).toBe(false);
136
94
  expect(notebook().scrollKey).toBe(hidden);
137
95
  });
138
96
 
@@ -351,18 +309,20 @@ describe("deleteCellsAtom", () => {
351
309
  expect(notebook().cellIds.atOrThrow(1).inOrderIds).toEqual([c3]);
352
310
  });
353
311
 
354
- it("forgets the untouched state and the cell atoms of a deleted cell", () => {
355
- const { store, notebook } = setup();
312
+ it("forgets a deleted cell's editor-only state and its atoms", () => {
313
+ const { store } = setup();
356
314
  const id = store.set(createCellAtom, {
357
315
  after: c1,
358
316
  config: { hide_code: true },
359
317
  }) as CellId;
318
+ store.set(restoredEditorStatesAtom, new Map([[id, { doc: "" }]]));
360
319
  const dataAtom = cellDataAtom(id);
361
320
  const handleAtom = cellHandleAtom(id);
362
321
 
363
322
  store.set(deleteCellsAtom, { cellIds: [id] });
364
323
 
365
- expect(notebook().untouchedNewCells.has(id)).toBe(false);
324
+ expect(store.get(shownHiddenCodeAtom).has(id)).toBe(false);
325
+ expect(store.get(restoredEditorStatesAtom).has(id)).toBe(false);
366
326
  expect(cellDataAtom(id)).not.toBe(dataAtom);
367
327
  expect(cellHandleAtom(id)).not.toBe(handleAtom);
368
328
  });
@@ -382,7 +342,7 @@ describe("undoDeleteCellAtom", () => {
382
342
 
383
343
  expect(order()).toEqual([c1, c2, c3]);
384
344
  expect(cellIn(c2)?.code).toBe("y = 2");
385
- expect(notebook().cellData[c2].serializedEditorState).toMatchObject({
345
+ expect(store.get(restoredEditorStatesAtom).get(c2)).toMatchObject({
386
346
  doc: "y = 2",
387
347
  });
388
348
  expect(notebook().cellData[c2].edited).toBe(true);
@@ -423,25 +383,20 @@ describe("undoDeleteCellAtom", () => {
423
383
  });
424
384
 
425
385
  it("undoes a move recorded in the history", () => {
426
- const { store, model, order, notebook, dispatch } = setup({
386
+ const { store, order, notebook } = setup({
427
387
  c3: cell("c3", "z = 3"),
428
388
  });
429
389
  const column = notebook().cellIds.atOrThrow(0);
430
- dispatch({
431
- type: "moveCellsRelativeTo",
432
- payload: {
433
- cellIds: [c1, c2],
434
- targetCellId: c3,
435
- position: "after",
436
- previousPlacements: [
437
- { columnId: column.id, index: 0 as CellIndex },
438
- { columnId: column.id, index: 1 as CellIndex },
439
- ],
440
- },
390
+ store.set(moveCellsRelativeToAtom, {
391
+ cellIds: [c1, c2],
392
+ targetCellId: c3,
393
+ position: "after",
394
+ previousPlacements: [
395
+ { columnId: column.id, index: 0 as CellIndex },
396
+ { columnId: column.id, index: 1 as CellIndex },
397
+ ],
441
398
  });
442
399
  expect(order()).toEqual([c3, c1, c2]);
443
- // The middleware would write the order to the document.
444
- store.set(model.editAtom, (tx) => tx.setOrder([c3, c1, c2]));
445
400
 
446
401
  store.set(deleteCellsAtom, { cellIds: [c3] });
447
402
  expect(order()).toEqual([c1, c2]);
@@ -486,3 +441,158 @@ describe("undoDeleteCellAtom", () => {
486
441
  expect(order()).toEqual([c1, c2]);
487
442
  });
488
443
  });
444
+
445
+ describe("folding", () => {
446
+ it("folds a header's section and unfolds it, focusing the header", () => {
447
+ const { store, notebook, visible, heading } = setup({
448
+ c3: cell("c3", "z = 3"),
449
+ });
450
+ heading(c1, 1);
451
+
452
+ store.set(collapseCellAtom, { cellId: c1 });
453
+ expect(visible()).toEqual([c1]);
454
+ expect(store.get(layoutAtom).columns[0].folds.get(c1)).toEqual([c2, c3]);
455
+ expect(notebook().scrollKey).toBe(c1);
456
+
457
+ store.set(expandCellAtom, { cellId: c1 });
458
+ expect(visible()).toEqual([c1, c2, c3]);
459
+ expect(store.get(collapsedAtom).size).toBe(0);
460
+ });
461
+
462
+ it("creates after a folded header below its fold and opens the section", () => {
463
+ const { store, order, visible, heading } = setup({
464
+ c3: cell("c3", "z = 3"),
465
+ });
466
+ heading(c1, 1);
467
+ store.set(collapseCellAtom, { cellId: c1 });
468
+
469
+ const id = store.set(createCellAtom, { after: c1, code: "w = 4" });
470
+
471
+ // The section runs to the next heading, so the new cell is in it.
472
+ expect(order()).toEqual([c1, c2, c3, id]);
473
+ expect(visible()).toEqual([c1, c2, c3, id]);
474
+ expect(store.get(collapsedAtom).size).toBe(0);
475
+ });
476
+
477
+ it("opens every section a new cell lands in", () => {
478
+ const { store, order, visible, heading } = setup({
479
+ c3: cell("c3", "z = 3"),
480
+ });
481
+ heading(c1, 1);
482
+ heading(c2, 2);
483
+ store.set(collapseAllCellsAtom);
484
+
485
+ // After the H1, the cell lands past the folded H2 too, inside both.
486
+ const id = store.set(createCellAtom, { after: c1, code: "w = 4" });
487
+
488
+ expect(order()).toEqual([c1, c2, c3, id]);
489
+ expect(visible()).toEqual([c1, c2, c3, id]);
490
+ expect(store.get(collapsedAtom).size).toBe(0);
491
+ });
492
+
493
+ it("opens the fold a cell created before or at the end lands in", () => {
494
+ const { store, order, visible, heading } = setup({
495
+ c3: cell("c3", "z = 3"),
496
+ });
497
+ heading(c1, 1);
498
+ heading(c3, 1);
499
+ store.set(collapseCellAtom, { cellId: c1 });
500
+
501
+ // Before the second header, the cell follows the first header's fold.
502
+ const before = store.set(createCellAtom, { before: c3, code: "w = 4" });
503
+ expect(order()).toEqual([c1, c2, before, c3]);
504
+ expect(visible()).toEqual([c1, c2, before, c3]);
505
+
506
+ store.set(collapseCellAtom, { cellId: c3 });
507
+ expect(visible()).toEqual([c1, c2, before, c3]);
508
+
509
+ // At the end of the column, the cell falls under the last header.
510
+ const end = store.set(createCellAtom, { code: "v = 5" });
511
+ expect(order()).toEqual([c1, c2, before, c3, end]);
512
+ expect(visible()).toEqual([c1, c2, before, c3, end]);
513
+ expect(store.get(collapsedAtom).size).toBe(0);
514
+ });
515
+
516
+ it("leaves a fold closed over a cell created without focus", () => {
517
+ const { store, order, visible, heading } = setup({
518
+ c3: cell("c3", "z = 3"),
519
+ });
520
+ heading(c1, 1);
521
+ store.set(collapseCellAtom, { cellId: c1 });
522
+
523
+ const id = store.set(createCellAtom, {
524
+ after: c1,
525
+ code: "w = 4",
526
+ focus: false,
527
+ });
528
+
529
+ expect(order()).toEqual([c1, c2, c3, id]);
530
+ expect(visible()).toEqual([c1]);
531
+ });
532
+
533
+ it("does not fold a cell without a heading", () => {
534
+ const { store, visible } = setup();
535
+
536
+ store.set(collapseCellAtom, { cellId: c1 });
537
+
538
+ expect(visible()).toEqual([c1, c2]);
539
+ expect(store.get(collapsedAtom).size).toBe(0);
540
+ });
541
+
542
+ it("folds and unfolds every section", () => {
543
+ const { store, visible, heading } = setup({ c3: cell("c3", "z = 3") });
544
+ heading(c1, 1);
545
+ heading(c2, 2);
546
+
547
+ store.set(collapseAllCellsAtom);
548
+ expect(visible()).toEqual([c1]);
549
+ expect([...store.get(collapsedAtom)]).toEqual([c1, c2]);
550
+
551
+ store.set(expandAllCellsAtom);
552
+ expect(visible()).toEqual([c1, c2, c3]);
553
+ });
554
+
555
+ it("unfolds whatever hides a cell", () => {
556
+ const { store, visible, heading } = setup({ c3: cell("c3", "z = 3") });
557
+ heading(c1, 1);
558
+ heading(c2, 2);
559
+ store.set(collapseAllCellsAtom);
560
+
561
+ store.set(showCellIfHiddenAtom, { cellId: c3 });
562
+
563
+ expect(visible()).toEqual([c1, c2, c3]);
564
+ expect(store.get(collapsedAtom).size).toBe(0);
565
+ });
566
+
567
+ it("moves focus over folded cells", () => {
568
+ const { store, heading } = setup({ c3: cell("c3", "z = 3") });
569
+ heading(c2, 1);
570
+ store.set(collapseCellAtom, { cellId: c2 });
571
+
572
+ store.set(focusCellAtom, { cellId: c1, where: "after" });
573
+ expect(focusAndScrollCellIntoView).toHaveBeenLastCalledWith(
574
+ expect.objectContaining({ cellId: c2, codeFocus: "top" }),
575
+ );
576
+
577
+ store.set(moveToNextCellAtom, { cellId: c2, before: false, create: false });
578
+ expect(focusAndScrollCellIntoView).toHaveBeenCalledTimes(1);
579
+ });
580
+ });
581
+
582
+ describe("hidden code showing for now", () => {
583
+ it("shows a new hidden cell's code until it is hidden again", () => {
584
+ const { store } = setup();
585
+ const id = store.set(createCellAtom, {
586
+ after: c1,
587
+ config: { hide_code: true },
588
+ }) as CellId;
589
+ expect(store.get(shownHiddenCodeAtom).has(id)).toBe(true);
590
+
591
+ const before = store.get(shownHiddenCodeAtom);
592
+ store.set(hideShownCodeAtom, { cellId: id });
593
+ store.set(hideShownCodeAtom, { cellId: id });
594
+
595
+ expect(store.get(shownHiddenCodeAtom).has(id)).toBe(false);
596
+ expect(store.get(shownHiddenCodeAtom)).not.toBe(before);
597
+ });
598
+ });
@@ -0,0 +1,263 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type * as Protocol from "@marimo-team/client/protocol";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { cellId } from "@/__tests__/branded";
6
+ import { cell, CONFIG } from "@/core/notebook/document/__tests__/fixtures";
7
+ import type { CellIndex } from "@/utils/id-tree";
8
+ import { collapseCellAtom, undoDeleteCellAtom } from "../actions";
9
+ import {
10
+ dropCellOverCellAtom,
11
+ dropCellOverColumnAtom,
12
+ moveCellAtom,
13
+ moveCellsRelativeToAtom,
14
+ moveCellToIndexAtom,
15
+ sendToBottomAtom,
16
+ sendToTopAtom,
17
+ } from "../arrange";
18
+ import { layoutAtom } from "../layout";
19
+ import { setupNotebook } from "./harness";
20
+
21
+ vi.mock("../scrollCellIntoView", () => ({
22
+ focusAndScrollCellIntoView: vi.fn(),
23
+ }));
24
+
25
+ const c1 = cellId("c1");
26
+ const c2 = cellId("c2");
27
+ const c3 = cellId("c3");
28
+ const c4 = cellId("c4");
29
+ const stops: Array<() => void> = [];
30
+
31
+ afterEach(() => {
32
+ for (const stop of stops.splice(0)) {
33
+ stop();
34
+ }
35
+ });
36
+
37
+ const setup = (cells?: Record<string, Protocol.Cell | null>) =>
38
+ setupNotebook(stops, cells);
39
+
40
+ /** Four cells, the last two in a second column. */
41
+ function twoColumns() {
42
+ return setup({
43
+ c3: cell("c3", "z = 3", { config: { ...CONFIG, column: 1 } }),
44
+ c4: cell("c4", "w = 4"),
45
+ });
46
+ }
47
+
48
+ describe("moveCellAtom", () => {
49
+ it("moves a cell up and down its column and focuses it", () => {
50
+ const { store, order, notebook } = setup({ c3: cell("c3", "z = 3") });
51
+
52
+ store.set(moveCellAtom, { cellId: c3, before: true });
53
+ expect(order()).toEqual([c1, c3, c2]);
54
+ expect(notebook().scrollKey).toBe(c3);
55
+
56
+ store.set(moveCellAtom, { cellId: c3, before: false });
57
+ expect(order()).toEqual([c1, c2, c3]);
58
+ });
59
+
60
+ it("stays at the edge of its column, still focusing", () => {
61
+ const { store, order, notebook } = setup();
62
+ store.set(moveCellAtom, { cellId: c1, before: true });
63
+ expect(order()).toEqual([c1, c2]);
64
+ expect(notebook().scrollKey).toBe(c1);
65
+ });
66
+
67
+ it("carries a folded header's section with it", () => {
68
+ const { store, order, visible, heading } = setup({
69
+ c3: cell("c3", "z = 3"),
70
+ c4: cell("c4", "w = 4"),
71
+ });
72
+ heading(c2, 1);
73
+ store.set(collapseCellAtom, { cellId: c2 });
74
+ expect(visible()).toEqual([c1, c2]);
75
+
76
+ store.set(moveCellAtom, { cellId: c2, before: true });
77
+
78
+ expect(order()).toEqual([c2, c3, c4, c1]);
79
+ // The section runs to the next heading, so the cell it passed joins it.
80
+ expect(visible()).toEqual([c2]);
81
+ });
82
+
83
+ it("steps over a folded section below it", () => {
84
+ const { store, order, heading } = setup({
85
+ c3: cell("c3", "z = 3"),
86
+ c4: cell("c4", "w = 4"),
87
+ });
88
+ heading(c2, 1);
89
+ store.set(collapseCellAtom, { cellId: c2 });
90
+
91
+ store.set(moveCellAtom, { cellId: c1, before: false });
92
+ expect(order()).toEqual([c2, c3, c4, c1]);
93
+ });
94
+
95
+ it("moves to the start of the neighbouring column and names it", () => {
96
+ const { store, columns, cellIn } = twoColumns();
97
+ expect(columns()).toEqual([
98
+ [c1, c2],
99
+ [c3, c4],
100
+ ]);
101
+
102
+ store.set(moveCellAtom, { cellId: c2, direction: "right" });
103
+ expect(columns()).toEqual([[c1], [c2, c3, c4]]);
104
+ expect(cellIn(c2)?.config.column).toBe(1);
105
+ expect(cellIn(c3)?.config.column).toBeNull();
106
+
107
+ store.set(moveCellAtom, { cellId: c2, direction: "left" });
108
+ expect(columns()).toEqual([
109
+ [c2, c1],
110
+ [c3, c4],
111
+ ]);
112
+ expect(cellIn(c2)?.config.column).toBe(0);
113
+ });
114
+
115
+ it("does nothing past the last column", () => {
116
+ const { store, model, columns } = twoColumns();
117
+ const before = store.get(model.documentAtom);
118
+ store.set(moveCellAtom, { cellId: c3, direction: "right" });
119
+ expect(store.get(model.documentAtom)).toBe(before);
120
+ expect(columns()).toEqual([
121
+ [c1, c2],
122
+ [c3, c4],
123
+ ]);
124
+ });
125
+ });
126
+
127
+ describe("sendToTopAtom and sendToBottomAtom", () => {
128
+ it("move a cell to the ends of its column", () => {
129
+ const { store, order, notebook } = setup({ c3: cell("c3", "z = 3") });
130
+
131
+ store.set(sendToBottomAtom, { cellId: c1 });
132
+ expect(order()).toEqual([c2, c3, c1]);
133
+ expect(notebook().scrollKey).toBe(c1);
134
+
135
+ store.set(sendToTopAtom, { cellId: c1, scroll: false });
136
+ expect(order()).toEqual([c1, c2, c3]);
137
+ });
138
+
139
+ it("send a folded header with its section and leave an edge cell alone", () => {
140
+ const { store, model, order, heading } = setup({
141
+ c3: cell("c3", "z = 3"),
142
+ });
143
+ heading(c1, 1);
144
+ store.set(collapseCellAtom, { cellId: c1 });
145
+ const before = store.get(model.documentAtom);
146
+
147
+ store.set(sendToTopAtom, { cellId: c1 });
148
+ expect(store.get(model.documentAtom)).toBe(before);
149
+
150
+ store.set(sendToBottomAtom, { cellId: c1 });
151
+ expect(order()).toEqual([c1, c2, c3]);
152
+ });
153
+ });
154
+
155
+ describe("drag and drop", () => {
156
+ it("drops a cell where another cell is, in its own column", () => {
157
+ const { store, order } = setup({ c3: cell("c3", "z = 3") });
158
+ store.set(dropCellOverCellAtom, { cellId: c1, overCellId: c3 });
159
+ expect(order()).toEqual([c2, c3, c1]);
160
+ store.set(dropCellOverCellAtom, { cellId: c1, overCellId: c2 });
161
+ expect(order()).toEqual([c1, c2, c3]);
162
+ });
163
+
164
+ it("drops a cell before another cell in another column", () => {
165
+ const { store, columns, cellIn } = twoColumns();
166
+ store.set(dropCellOverCellAtom, { cellId: c1, overCellId: c4 });
167
+ expect(columns()).toEqual([[c2], [c3, c1, c4]]);
168
+ expect(cellIn(c2)?.config.column).toBe(0);
169
+ expect(cellIn(c3)?.config.column).toBe(1);
170
+ });
171
+
172
+ it("drops a cell at the start of a column", () => {
173
+ const { store, columns } = twoColumns();
174
+ const column = store.get(layoutAtom).columns[0].id;
175
+ store.set(dropCellOverColumnAtom, { cellId: c4, columnId: column });
176
+ expect(columns()).toEqual([[c4, c1, c2], [c3]]);
177
+ });
178
+
179
+ it("ignores a drop on a column that is gone", () => {
180
+ const { store, model, columns } = twoColumns();
181
+ const before = store.get(model.documentAtom);
182
+ store.set(dropCellOverColumnAtom, {
183
+ cellId: c4,
184
+ columnId: "gone" as never,
185
+ });
186
+ expect(store.get(model.documentAtom)).toBe(before);
187
+ expect(columns()).toEqual([
188
+ [c1, c2],
189
+ [c3, c4],
190
+ ]);
191
+ });
192
+ });
193
+
194
+ describe("moveCellToIndexAtom", () => {
195
+ it("moves a cell to an index in its column or another", () => {
196
+ const { store, columns } = twoColumns();
197
+ const [first, second] = store.get(layoutAtom).columns.map((c) => c.id);
198
+
199
+ store.set(moveCellToIndexAtom, { cellId: c1, columnId: first, index: 2 });
200
+ expect(columns()).toEqual([
201
+ [c2, c1],
202
+ [c3, c4],
203
+ ]);
204
+
205
+ store.set(moveCellToIndexAtom, { cellId: c1, columnId: second, index: 1 });
206
+ expect(columns()).toEqual([[c2], [c3, c1, c4]]);
207
+
208
+ store.set(moveCellToIndexAtom, { cellId: c2, columnId: second, index: 99 });
209
+ expect(columns()).toEqual([[], [c3, c1, c4, c2]]);
210
+ });
211
+ });
212
+
213
+ describe("moveCellsRelativeToAtom", () => {
214
+ it("moves cells after a target and records how to undo it", () => {
215
+ const { store, order, notebook } = setup({ c3: cell("c3", "z = 3") });
216
+ const column = store.get(layoutAtom).columns[0].id;
217
+
218
+ store.set(moveCellsRelativeToAtom, {
219
+ cellIds: [c1, c2],
220
+ targetCellId: c3,
221
+ position: "after",
222
+ previousPlacements: [
223
+ { columnId: column, index: 0 as CellIndex },
224
+ { columnId: column, index: 1 as CellIndex },
225
+ ],
226
+ });
227
+ expect(order()).toEqual([c3, c1, c2]);
228
+ expect(notebook().history).toEqual([
229
+ {
230
+ type: "move",
231
+ cellIds: [c1, c2],
232
+ placements: [
233
+ { columnId: column, index: 0 },
234
+ { columnId: column, index: 1 },
235
+ ],
236
+ },
237
+ ]);
238
+
239
+ store.set(undoDeleteCellAtom);
240
+ expect(order()).toEqual([c1, c2, c3]);
241
+ expect(notebook().history).toEqual([]);
242
+ expect(notebook().scrollKey).toBe(c1);
243
+ });
244
+
245
+ it("moves cells before a target, and to the end when the target moves too", () => {
246
+ const { store, order, notebook } = setup({ c3: cell("c3", "z = 3") });
247
+
248
+ store.set(moveCellsRelativeToAtom, {
249
+ cellIds: [c3],
250
+ targetCellId: c1,
251
+ position: "before",
252
+ });
253
+ expect(order()).toEqual([c3, c1, c2]);
254
+ expect(notebook().history).toEqual([]);
255
+
256
+ store.set(moveCellsRelativeToAtom, {
257
+ cellIds: [c3, c1],
258
+ targetCellId: c1,
259
+ position: "before",
260
+ });
261
+ expect(order()).toEqual([c2, c3, c1]);
262
+ });
263
+ });