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

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-C3Xv3yzV.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-BCcqmP58.js} +38 -42
  17. package/dist/{common-BUwWHpnK.js → common-DrNB2bY2.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-TGXV_Hf4.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-PTiPn2GP.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-BSyexjkB.js} +5 -5
  33. package/dist/{globals-BeBDOk22.js → globals-B7gkJAxm.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-Bb91yQ46.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-vTdjiyDv.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 +33 -7
  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,17 +1,21 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { renderHook } from "@testing-library/react";
4
- import { createStore, Provider } from "jotai";
4
+ import type * as Protocol from "@marimo-team/client/protocol";
5
+ import { atom, createStore, Provider } from "jotai";
5
6
  import type { ReactNode } from "react";
6
7
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
8
+ import { partial } from "@/__mocks__/partial";
7
9
  import { showCodeInRunModeAtom } from "@/core/meta/state";
8
10
  import { type AppMode, kioskModeAtom, viewStateAtom } from "@/core/mode";
11
+ import type { Document, NotebookReplica } from "@/core/notebook/replica";
9
12
  import {
10
13
  remoteAdapter,
11
14
  runtimeAdapterAtom,
12
15
  staticAdapter,
13
16
  type RuntimeAdapter,
14
17
  } from "@/core/runtime/adapter";
18
+ import { activeNotebookReplicaAtom } from "@/core/session/active-notebook";
15
19
  import { useNotebookCodeAvailable } from "../code-visibility";
16
20
 
17
21
  interface StoreOpts {
@@ -19,6 +23,39 @@ interface StoreOpts {
19
23
  kiosk?: boolean;
20
24
  showInRunMode?: boolean;
21
25
  runtime?: RuntimeAdapter;
26
+ /** Each cell's source as the host sent it; null is withheld. */
27
+ codes?: ReadonlyArray<string | null>;
28
+ }
29
+
30
+ function document(codes: ReadonlyArray<string | null>): Document {
31
+ const cells = codes.map((code, index): Protocol.Cell => ({
32
+ id: `cell-${index}` as Protocol.CellId,
33
+ notebook_id: "nb",
34
+ code,
35
+ name: "_",
36
+ config: {
37
+ column: null,
38
+ disabled: false,
39
+ hide_code: false,
40
+ expand_output: false,
41
+ },
42
+ etag: `etag-${index}`,
43
+ code_hash: `hash-${index}`,
44
+ version: 1,
45
+ }));
46
+ return {
47
+ revision: {
48
+ notebook_id: "nb",
49
+ order: cells.map((cell) => cell.id),
50
+ app_config: {},
51
+ etag: "etag",
52
+ saved_etag: "etag",
53
+ order_etag: "order",
54
+ app_config_etag: "config",
55
+ version: 1,
56
+ },
57
+ cells: new Map(cells.map((cell) => [cell.id, cell])),
58
+ };
22
59
  }
23
60
 
24
61
  function makeStore({
@@ -26,23 +63,32 @@ function makeStore({
26
63
  kiosk = false,
27
64
  showInRunMode = true,
28
65
  runtime = remoteAdapter,
66
+ codes = ["x = 1", ""],
29
67
  }: StoreOpts = {}) {
30
68
  const store = createStore();
31
69
  store.set(viewStateAtom, { mode, cellAnchor: null });
32
70
  store.set(kioskModeAtom, kiosk);
33
71
  store.set(showCodeInRunModeAtom, showInRunMode);
34
72
  store.set(runtimeAdapterAtom, runtime);
73
+ store.set(
74
+ activeNotebookReplicaAtom,
75
+ partial<NotebookReplica>(
76
+ { documentAtom: atom(document(codes)) },
77
+ "replica",
78
+ ),
79
+ );
35
80
  return store;
36
81
  }
37
82
 
38
- function wrap(store: ReturnType<typeof createStore>) {
39
- return ({ children }: { children: ReactNode }) => (
83
+ function render(store: ReturnType<typeof createStore>) {
84
+ const wrapper = ({ children }: { children: ReactNode }) => (
40
85
  <Provider store={store}>{children}</Provider>
41
86
  );
87
+ return renderHook(() => useNotebookCodeAvailable(), { wrapper }).result
88
+ .current;
42
89
  }
43
90
 
44
- const cellsWithCode = [{ code: "x = 1" }, { code: "" }];
45
- const cellsWithoutCode = [{ code: "" }, { code: "" }];
91
+ const withheld = [null, null];
46
92
 
47
93
  const originalHref = window.location.href;
48
94
 
@@ -60,21 +106,11 @@ describe("useNotebookCodeAvailable", () => {
60
106
  });
61
107
 
62
108
  it("returns true in edit mode regardless of cells", () => {
63
- const store = makeStore({ mode: "edit" });
64
- const { result } = renderHook(
65
- () => useNotebookCodeAvailable(cellsWithoutCode),
66
- { wrapper: wrap(store) },
67
- );
68
- expect(result.current).toBe(true);
109
+ expect(render(makeStore({ mode: "edit", codes: withheld }))).toBe(true);
69
110
  });
70
111
 
71
112
  it("returns true in present mode (only reachable from edit)", () => {
72
- const store = makeStore({ mode: "present" });
73
- const { result } = renderHook(
74
- () => useNotebookCodeAvailable(cellsWithoutCode),
75
- { wrapper: wrap(store) },
76
- );
77
- expect(result.current).toBe(true);
113
+ expect(render(makeStore({ mode: "present", codes: withheld }))).toBe(true);
78
114
  });
79
115
 
80
116
  it("returns true in kiosk mode even when read mode and no code", () => {
@@ -82,88 +118,60 @@ describe("useNotebookCodeAvailable", () => {
82
118
  mode: "read",
83
119
  kiosk: true,
84
120
  showInRunMode: false,
121
+ codes: withheld,
85
122
  });
86
- const { result } = renderHook(
87
- () => useNotebookCodeAvailable(cellsWithoutCode),
88
- { wrapper: wrap(store) },
89
- );
90
- expect(result.current).toBe(true);
123
+ expect(render(store)).toBe(true);
91
124
  });
92
125
 
93
126
  it("returns true in read mode when at least one cell has code", () => {
94
- const store = makeStore({ mode: "read" });
95
- const { result } = renderHook(
96
- () => useNotebookCodeAvailable(cellsWithCode),
97
- { wrapper: wrap(store) },
98
- );
99
- expect(result.current).toBe(true);
127
+ expect(render(makeStore({ mode: "read" }))).toBe(true);
128
+ });
129
+
130
+ it("counts an empty cell as code the host sent", () => {
131
+ expect(render(makeStore({ mode: "read", codes: ["", null] }))).toBe(true);
132
+ });
133
+
134
+ it("returns false in read mode when the host withheld every source", () => {
135
+ expect(render(makeStore({ mode: "read", codes: withheld }))).toBe(false);
100
136
  });
101
137
 
102
- it("returns false in read mode when every cell.code is empty (server stripped)", () => {
138
+ it("returns false in read mode before a document arrives", () => {
103
139
  const store = makeStore({ mode: "read" });
104
- const { result } = renderHook(
105
- () => useNotebookCodeAvailable(cellsWithoutCode),
106
- { wrapper: wrap(store) },
107
- );
108
- expect(result.current).toBe(false);
140
+ store.set(activeNotebookReplicaAtom, null);
141
+ expect(render(store)).toBe(false);
109
142
  });
110
143
 
111
144
  it("returns false in read mode when host opts out via showAppCode", () => {
112
- const store = makeStore({ mode: "read", showInRunMode: false });
113
- const { result } = renderHook(
114
- () => useNotebookCodeAvailable(cellsWithCode),
115
- { wrapper: wrap(store) },
145
+ expect(render(makeStore({ mode: "read", showInRunMode: false }))).toBe(
146
+ false,
116
147
  );
117
- expect(result.current).toBe(false);
118
148
  });
119
149
 
120
150
  it("returns false in read mode when ?include-code=false", () => {
121
151
  setSearch("?include-code=false");
122
- const store = makeStore({ mode: "read" });
123
- const { result } = renderHook(
124
- () => useNotebookCodeAvailable(cellsWithCode),
125
- { wrapper: wrap(store) },
126
- );
127
- expect(result.current).toBe(false);
152
+ expect(render(makeStore({ mode: "read" }))).toBe(false);
128
153
  });
129
154
 
130
155
  it("ignores ?include-code=false in kiosk mode", () => {
131
156
  setSearch("?include-code=false");
132
- const store = makeStore({ mode: "read", kiosk: true });
133
- const { result } = renderHook(
134
- () => useNotebookCodeAvailable(cellsWithoutCode),
135
- { wrapper: wrap(store) },
136
- );
137
- expect(result.current).toBe(true);
157
+ expect(
158
+ render(makeStore({ mode: "read", kiosk: true, codes: withheld })),
159
+ ).toBe(true);
138
160
  });
139
161
 
140
162
  it("checks embedded code in a static kiosk", () => {
141
- const store = makeStore({
163
+ const options = {
142
164
  mode: "read",
143
165
  kiosk: true,
144
166
  runtime: staticAdapter,
145
- });
146
-
147
- const withoutCode = renderHook(
148
- () => useNotebookCodeAvailable(cellsWithoutCode),
149
- { wrapper: wrap(store) },
150
- );
151
- const withCode = renderHook(() => useNotebookCodeAvailable(cellsWithCode), {
152
- wrapper: wrap(store),
153
- });
154
-
155
- expect(withoutCode.result.current).toBe(false);
156
- expect(withCode.result.current).toBe(true);
167
+ } as const;
168
+ expect(render(makeStore({ ...options, codes: withheld }))).toBe(false);
169
+ expect(render(makeStore(options))).toBe(true);
157
170
  });
158
171
 
159
172
  it("returns false for non-edit/non-read modes (home, gallery)", () => {
160
173
  for (const mode of ["home", "gallery"] as const) {
161
- const store = makeStore({ mode });
162
- const { result } = renderHook(
163
- () => useNotebookCodeAvailable(cellsWithCode),
164
- { wrapper: wrap(store) },
165
- );
166
- expect(result.current).toBe(false);
174
+ expect(render(makeStore({ mode }))).toBe(false);
167
175
  }
168
176
  });
169
177
  });
@@ -1,22 +1,31 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { useAtomValue } from "jotai";
3
+ import { atom, useAtomValue } from "jotai";
4
4
  import { KnownQueryParams } from "@/core/constants";
5
5
  import { showCodeInRunModeAtom } from "@/core/meta/state";
6
6
  import { kioskModeAtom, viewStateAtom } from "@/core/mode";
7
7
  import { runtimeAdapterAtom } from "@/core/runtime/adapter";
8
+ import { confirmedDocumentAtom } from "@/core/session/active-notebook";
8
9
  import { logNever } from "@/utils/assertNever";
9
10
 
11
+ /** Whether any cell's source reached this client. A host withholds source as null. */
12
+ export const codeAvailableAtom = atom((get) => {
13
+ const document = get(confirmedDocumentAtom);
14
+ return (
15
+ document !== null &&
16
+ [...document.cells.values()].some((cell) => cell.code !== null)
17
+ );
18
+ });
19
+
10
20
  /**
11
- * Whether the notebook source code reached the frontend and can be rendered.
21
+ * Whether the notebook source code can be rendered.
12
22
  *
13
- * In `marimo run` the server omits cell sources unless `--include-code` is
14
- * set (every `cell.code` arrives as `""`). Use this to gate any "show code"
15
- * / "copy code" / "download .py" affordance.
23
+ * In `marimo run` the server withholds cell sources unless `--include-code`
24
+ * is set. Use this to gate any "show code" / "copy code" / "download .py"
25
+ * affordance.
16
26
  */
17
- export function useNotebookCodeAvailable(
18
- cells: ReadonlyArray<{ code: string }>,
19
- ): boolean {
27
+ export function useNotebookCodeAvailable(): boolean {
28
+ const available = useAtomValue(codeAvailableAtom);
20
29
  const kioskMode = useAtomValue(kioskModeAtom);
21
30
  const { mode } = useAtomValue(viewStateAtom);
22
31
  const showInRunMode = useAtomValue(showCodeInRunModeAtom);
@@ -41,7 +50,7 @@ export function useNotebookCodeAvailable(
41
50
  if (params.get(KnownQueryParams.includeCode) === "false") {
42
51
  return false;
43
52
  }
44
- return cells.some((cell) => Boolean(cell.code));
53
+ return available;
45
54
  }
46
55
  default:
47
56
  logNever(mode);
@@ -0,0 +1,51 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { NotebookOutput } from "@marimo-team/client/protocol";
4
+ import { atom, createStore } from "jotai";
5
+ import { describe, expect, it } from "vitest";
6
+ import { partial } from "@/__mocks__/partial";
7
+ import { kioskModeAtom } from "@/core/mode";
8
+ import type { NotebookReplica } from "@/core/notebook/replica";
9
+ import { activeNotebookReplicaAtom } from "@/core/session/active-notebook";
10
+ import { canEditAtom, notebookOperationsAtom } from "../operations";
11
+
12
+ function connect(
13
+ store: ReturnType<typeof createStore>,
14
+ operations: readonly string[] | null,
15
+ ) {
16
+ const notebook =
17
+ operations === null
18
+ ? null
19
+ : ({ id: "nb", operations } as unknown as NotebookOutput);
20
+ store.set(
21
+ activeNotebookReplicaAtom,
22
+ partial<NotebookReplica>({ notebookAtom: atom(notebook) }, "replica"),
23
+ );
24
+ }
25
+
26
+ describe("notebook operations", () => {
27
+ it("advertises nothing until the notebook is ready", () => {
28
+ const store = createStore();
29
+ expect(store.get(notebookOperationsAtom)).toEqual([]);
30
+ expect(store.get(canEditAtom)).toBe(false);
31
+ connect(store, null);
32
+ expect(store.get(canEditAtom)).toBe(false);
33
+ });
34
+
35
+ it("allows editing when the host lists notebook.edit", () => {
36
+ const store = createStore();
37
+ connect(store, ["notebook.edit", "runtime.run"]);
38
+ expect(store.get(canEditAtom)).toBe(true);
39
+ connect(store, ["runtime.run"]);
40
+ expect(store.get(canEditAtom)).toBe(false);
41
+ });
42
+
43
+ it("withholds editing from a kiosk connection", () => {
44
+ const store = createStore();
45
+ connect(store, ["notebook.edit"]);
46
+ store.set(kioskModeAtom, true);
47
+ expect(store.get(canEditAtom)).toBe(false);
48
+ store.set(kioskModeAtom, false);
49
+ expect(store.get(canEditAtom)).toBe(true);
50
+ });
51
+ });
@@ -1,6 +1,8 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { history, undo } from "@codemirror/commands";
4
+ import { MySQL, PostgreSQL } from "@codemirror/lang-sql";
5
+ import { language as activeLanguage } from "@codemirror/language";
4
6
  import { EditorSelection, EditorState } from "@codemirror/state";
5
7
  import { EditorView } from "@codemirror/view";
6
8
  import type { CellId } from "@marimo-team/client/protocol";
@@ -65,6 +67,21 @@ function languages(id: CellId) {
65
67
  addSetupCell: vi.fn(),
66
68
  deleteCells: vi.fn(),
67
69
  undoDeleteCell: vi.fn(),
70
+ focusCell: vi.fn(),
71
+ focusTopCell: vi.fn(),
72
+ focusBottomCell: vi.fn(),
73
+ collapseCell: vi.fn(),
74
+ expandCell: vi.fn(),
75
+ collapseAllCells: vi.fn(),
76
+ expandAllCells: vi.fn(),
77
+ showCellIfHidden: vi.fn(),
78
+ moveCell: vi.fn(),
79
+ moveCellToIndex: vi.fn(),
80
+ moveCellsRelativeTo: vi.fn(),
81
+ dropCellOverCell: vi.fn(),
82
+ dropCellOverColumn: vi.fn(),
83
+ sendToTop: vi.fn(),
84
+ sendToBottom: vi.fn(),
68
85
  onRun: vi.fn(),
69
86
  deleteCell: vi.fn(),
70
87
  afterToggleMarkdown: vi.fn(),
@@ -504,14 +521,23 @@ describe("cellCodeBinding", () => {
504
521
  });
505
522
 
506
523
  it("sets the SQL dialect from the host's engine", async () => {
507
- const { store, source } = setup({
524
+ const { store, source, view } = setup({
508
525
  adapters: true,
509
526
  initial: withCells(document(), {
510
527
  c1: cell("c1", 'df = mo.sql("SELECT 1")'),
511
528
  }),
512
529
  });
513
- await settle();
514
- const dialect = vi.spyOn(SCHEMA_CACHE, "getDialect");
530
+ await Promise.resolve();
531
+ const getDialect = SCHEMA_CACHE.getDialect.bind(SCHEMA_CACHE);
532
+ const dialects = new Map([
533
+ ["warehouse", PostgreSQL],
534
+ ["lake", MySQL],
535
+ ]);
536
+ const dialect = vi
537
+ .spyOn(SCHEMA_CACHE, "getDialect")
538
+ .mockImplementation(
539
+ (engine) => dialects.get(engine) ?? getDialect(engine),
540
+ );
515
541
  try {
516
542
  store.set(
517
543
  source,
@@ -519,8 +545,8 @@ describe("cellCodeBinding", () => {
519
545
  c1: cell("c1", '_df = mo.sql(f"""SELECT 1""", engine=warehouse)'),
520
546
  }),
521
547
  );
522
- await settle();
523
- expect(dialect).toHaveBeenLastCalledWith("warehouse");
548
+ await Promise.resolve();
549
+ expect(view.state.facet(activeLanguage)).toBe(PostgreSQL.language);
524
550
 
525
551
  store.set(
526
552
  source,
@@ -528,8 +554,8 @@ describe("cellCodeBinding", () => {
528
554
  c1: cell("c1", '_df = mo.sql(f"""SELECT 1""", engine=lake)'),
529
555
  }),
530
556
  );
531
- await settle();
532
- expect(dialect).toHaveBeenLastCalledWith("lake");
557
+ await Promise.resolve();
558
+ expect(view.state.facet(activeLanguage)).toBe(MySQL.language);
533
559
  } finally {
534
560
  dialect.mockRestore();
535
561
  }
@@ -1,6 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- /** React hooks for the current notebook. */
3
+ /** What the editor reads about the current notebook. */
4
4
 
5
5
  export {
6
6
  useNotebookAttachments,
@@ -16,3 +16,4 @@ export {
16
16
  useNotebookDocumentEdit,
17
17
  useNotebookDocumentSync,
18
18
  } from "./document";
19
+ export { canEditAtom, notebookOperationsAtom } from "./operations";
@@ -0,0 +1,30 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ /** What the host lets this client do to the notebook. */
4
+
5
+ import { atom } from "jotai";
6
+ import { kioskModeAtom } from "../mode";
7
+ import { activeNotebookReplicaAtom } from "../session/active-notebook";
8
+
9
+ const NO_OPERATIONS: readonly string[] = [];
10
+
11
+ /** Operations the host advertises for this notebook, none until its stream is ready. */
12
+ export const notebookOperationsAtom = atom((get) => {
13
+ const replica = get(activeNotebookReplicaAtom);
14
+ const notebook = replica === null ? null : get(replica.notebookAtom);
15
+ return notebook?.operations ?? NO_OPERATIONS;
16
+ });
17
+
18
+ /**
19
+ * Whether this client may edit the document.
20
+ *
21
+ * TODO(MO-8028): The host advertises the same operations to every
22
+ * connection of a notebook, so a kiosk connection, an interactor that may
23
+ * not edit, narrows it here. Once the host narrows `operations` per
24
+ * connection by consumer capability, the kiosk term goes.
25
+ */
26
+ export const canEditAtom = atom(
27
+ (get) =>
28
+ !get(kioskModeAtom) &&
29
+ get(notebookOperationsAtom).includes("notebook.edit"),
30
+ );
@@ -0,0 +1,111 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type * as Protocol from "@marimo-team/client/protocol";
4
+ import { atom, createStore } from "jotai";
5
+ import { describe, expect, it } from "vitest";
6
+ import { partial } from "@/__mocks__/partial";
7
+ import type {
8
+ DocumentModel,
9
+ DocumentSyncStatus,
10
+ } from "@/core/notebook/document";
11
+ import type { Document, NotebookReplica } from "@/core/notebook/replica";
12
+ import {
13
+ activeDocumentModelAtom,
14
+ activeNotebookReplicaAtom,
15
+ } from "@/core/session/active-notebook";
16
+ import { initialLayoutState, layoutStateAtom } from "../../layout/state";
17
+ import { documentVersionAtom, needsSaveAtom, savedLayoutAtom } from "../state";
18
+
19
+ function revision(
20
+ overrides: Partial<Protocol.Document> = {},
21
+ ): Protocol.Document {
22
+ return {
23
+ notebook_id: "nb",
24
+ order: [],
25
+ app_config: {},
26
+ etag: "v2",
27
+ saved_etag: "v2",
28
+ order_etag: "order",
29
+ app_config_etag: "config",
30
+ version: 2,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ function connect(
36
+ store: ReturnType<typeof createStore>,
37
+ document: Document | null,
38
+ status: DocumentSyncStatus["status"] = "idle",
39
+ ) {
40
+ store.set(
41
+ activeNotebookReplicaAtom,
42
+ partial<NotebookReplica>({ documentAtom: atom(document) }, "replica"),
43
+ );
44
+ store.set(
45
+ activeDocumentModelAtom,
46
+ partial<DocumentModel>(
47
+ { syncStatusAtom: atom({ status } as DocumentSyncStatus) },
48
+ "model",
49
+ ),
50
+ );
51
+ }
52
+
53
+ describe("needsSaveAtom", () => {
54
+ it("is false without a connected document", () => {
55
+ const store = createStore();
56
+ expect(store.get(needsSaveAtom)).toBe(false);
57
+ expect(store.get(documentVersionAtom)).toBeNull();
58
+ connect(store, null);
59
+ expect(store.get(needsSaveAtom)).toBe(false);
60
+ });
61
+
62
+ it("compares the host's etag with what it last wrote", () => {
63
+ const store = createStore();
64
+ connect(store, { revision: revision(), cells: new Map() });
65
+ expect(store.get(needsSaveAtom)).toBe(false);
66
+ expect(store.get(documentVersionAtom)).toBe(2);
67
+ connect(store, { revision: revision({ etag: "v3" }), cells: new Map() });
68
+ expect(store.get(needsSaveAtom)).toBe(true);
69
+ });
70
+
71
+ it("treats a document never written as unsaved", () => {
72
+ const store = createStore();
73
+ connect(store, {
74
+ revision: revision({ saved_etag: null }),
75
+ cells: new Map(),
76
+ });
77
+ expect(store.get(needsSaveAtom)).toBe(true);
78
+ });
79
+
80
+ it.each(["pending", "sending", "confirming", "error"] as const)(
81
+ "counts edits the host has not confirmed (%s)",
82
+ (status) => {
83
+ const store = createStore();
84
+ connect(store, { revision: revision(), cells: new Map() }, status);
85
+ expect(store.get(needsSaveAtom)).toBe(true);
86
+ },
87
+ );
88
+
89
+ it("ignores a model that is not running", () => {
90
+ const store = createStore();
91
+ connect(store, { revision: revision(), cells: new Map() }, "unavailable");
92
+ expect(store.get(needsSaveAtom)).toBe(false);
93
+ });
94
+
95
+ it("compares the layout with the one last saved", () => {
96
+ const store = createStore();
97
+ connect(store, { revision: revision(), cells: new Map() });
98
+ store.set(layoutStateAtom, {
99
+ selectedLayout: "grid",
100
+ layoutData: { grid: null },
101
+ });
102
+ expect(store.get(needsSaveAtom)).toBe(false);
103
+ store.set(savedLayoutAtom, initialLayoutState());
104
+ expect(store.get(needsSaveAtom)).toBe(true);
105
+ store.set(savedLayoutAtom, {
106
+ selectedLayout: "grid",
107
+ layoutData: { grid: null },
108
+ });
109
+ expect(store.get(needsSaveAtom)).toBe(false);
110
+ });
111
+ });