@marimo-team/islands 0.25.3-dev209 → 0.25.3-dev210

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 (140) hide show
  1. package/dist/{ConnectedDataExplorerComponent-ApTYh0V3.js → ConnectedDataExplorerComponent-D6RHJV8D.js} +114 -112
  2. package/dist/{ErrorBoundary-B1QlXN-y.js → ErrorBoundary-VruaQLnK.js} +10 -10
  3. package/dist/{abnfDiagram-VCTEODGH-uo9xhZY0.js → abnfDiagram-VCTEODGH-0q7Lbmii.js} +2 -2
  4. package/dist/{any-language-editor-BVFHDDQ3.js → any-language-editor-jGaR0EAo.js} +5 -5
  5. package/dist/{architectureDiagram-5GKGNRK7-Hz6eyeOD.js → architectureDiagram-5GKGNRK7-1PydKPmE.js} +1 -1
  6. package/dist/{button-BQRdqQi1.js → button-B6RjDKJx.js} +54 -51
  7. package/dist/{capabilities-C2QUNRXi.js → capabilities-CkVzWJEX.js} +1 -1
  8. package/dist/{chat-ui-BTA1I5i1.js → chat-ui-B5pH_aXk.js} +414 -413
  9. package/dist/{check-C9Mp9eCK.js → check-Z_vtI3OL.js} +1 -1
  10. package/dist/{chunk-IMKFNOWR-DBM2i23k.js → chunk-IMKFNOWR-DEzZZ1Zc.js} +1 -1
  11. package/dist/{chunk-SHT3W25Y-L8KQLLBy.js → chunk-SHT3W25Y-CuHj-hnn.js} +1 -1
  12. package/dist/{chunk-SVP7TREG-BOUJKJEP.js → chunk-SVP7TREG-BPiZK2bA.js} +1 -1
  13. package/dist/{chunk-TICWLB2K-DY3CzFjX.js → chunk-TICWLB2K-DF6XcIjM.js} +1 -1
  14. package/dist/{classDiagram-ZZMXUADV-s9iwW-q4.js → classDiagram-ZZMXUADV-CMdR-cP4.js} +2 -2
  15. package/dist/{classDiagram-v2-VYDZK3BY-2GI5CjGB.js → classDiagram-v2-VYDZK3BY-DRvc3_7Y.js} +2 -2
  16. package/dist/{code-visibility-CCGlDSq4.js → code-visibility-BdFhHfIC.js} +28 -28
  17. package/dist/{common-C1PmVtVW.js → common-Ce1SgsX1.js} +8034 -8027
  18. package/dist/{toDate-BZZ64K_m.js → config-DbjzjwxX.js} +98 -164
  19. package/dist/{copy-icon-De584pZ4.js → copy-icon-CG57cxwb.js} +3 -3
  20. package/dist/createReducer-DePYJ0F3.js +63 -0
  21. package/dist/{cynefinDiagram-5FMLGOSQ-DOxnhBK2.js → cynefinDiagram-5FMLGOSQ-BkBjEY9l.js} +1 -1
  22. package/dist/{dagre-GXQ25YYZ-D1AhEfpM.js → dagre-GXQ25YYZ-D-HEaW4I.js} +1 -1
  23. package/dist/{data-grid-overlay-editor-B3KXkZvo.js → data-grid-overlay-editor-Bps1p6jQ.js} +1 -1
  24. package/dist/{diagram-S7CK7UJ4-DV_GebF_.js → diagram-S7CK7UJ4-Bn-oF-rB.js} +1 -1
  25. package/dist/{diagram-UQ7AKVKN-6dNzGFfD.js → diagram-UQ7AKVKN-BpzzXWBj.js} +1 -1
  26. package/dist/{diagram-VX7I27RA-DUh9jsfi.js → diagram-VX7I27RA-COOtvfBP.js} +1 -1
  27. package/dist/{diagram-Z3DM3KII-Cug-qcQ8.js → diagram-Z3DM3KII-K5cXvbrl.js} +1 -1
  28. package/dist/{dialog-DVRfQuhD.js → dialog-DsZ49mmU.js} +26 -26
  29. package/dist/{dist-BJtmSgcE.js → dist-11T5wq3_.js} +41 -40
  30. package/dist/{dist-B1M0bGO3.js → dist-17ugwGZc.js} +59 -58
  31. package/dist/{dist-D7mrh5sO.js → dist-7cRjf--y.js} +135 -134
  32. package/dist/{dist-Ck_C32ye.js → dist-B8pkfkzc.js} +31 -30
  33. package/dist/{dist-Ba2VIn1K.js → dist-B9UoM_wq.js} +54 -53
  34. package/dist/{dist-KN80qWCP.js → dist-BGuMTs1i.js} +118 -117
  35. package/dist/{dist-T5rh6JvL.js → dist-BHW11fni.js} +18 -17
  36. package/dist/dist-BLY4m1Wi.js +850 -0
  37. package/dist/{dist-Bj4bmwUm.js → dist-BNlcVoxs.js} +31 -30
  38. package/dist/{dist-BA13RJj5.js → dist-BQYe5Bju.js} +47 -46
  39. package/dist/{dist-OUle2RT7.js → dist-BX1mn8na.js} +297 -297
  40. package/dist/{dist-CTi5FTiV.js → dist-Bh0PTKUW.js} +108 -107
  41. package/dist/{dist-D9Q3xjiP.js → dist-CKaXK92p.js} +56 -55
  42. package/dist/{dist-DYXM73-k.js → dist-CLhwfhpJ.js} +58 -57
  43. package/dist/{dist-BmS-ZGjl.js → dist-CYeqK4Xw.js} +1854 -2702
  44. package/dist/{dist-D1fnFp1J.js → dist-Cxlp3WH6.js} +16 -15
  45. package/dist/{dist-DVVwOfrD.js → dist-DSJPGj42.js} +64 -63
  46. package/dist/{dist-Bzogh7CW.js → dist-DU9ICsWZ.js} +93 -92
  47. package/dist/{dist-DjeybPOb.js → dist-DWv0ALBi.js} +47 -46
  48. package/dist/{dist-D3x9__Fu.js → dist-DiBpWEsB.js} +39 -38
  49. package/dist/{dist-DtGn9N4h.js → dist-DiLFTlnl.js} +165 -165
  50. package/dist/{dist-DTv9KkpW.js → dist-cVDV5IuI.js} +26 -25
  51. package/dist/{dist-DP60Ubh8.js → dist-fRfaOoq8.js} +44 -43
  52. package/dist/{dist-O0qFpEJh.js → dist-gTTI-9Px.js} +66 -65
  53. package/dist/{dropdown-menu-dlEm41Bt.js → dropdown-menu-CSNdPSnS.js} +2886 -1529
  54. package/dist/{ebnfDiagram-PWID7BFC-d5LKx7Vb.js → ebnfDiagram-PWID7BFC-BI1ZmjVm.js} +2 -2
  55. package/dist/{erDiagram-RLTQ6QDP-BwvlkcID.js → erDiagram-RLTQ6QDP-BtWjj9xy.js} +1 -1
  56. package/dist/{error-banner-DxJ3s8YO.js → error-banner-IsonnvA0.js} +47 -46
  57. package/dist/error-entries-CRPfxMqm.js +2505 -0
  58. package/dist/{esm-Bbw_Njjx.js → esm-BgKjMvWw.js} +389 -388
  59. package/dist/{esm-mDQNEFe_.js → esm-CHjVSexr.js} +305 -306
  60. package/dist/{feedback-button-UsovT9sL.js → feedback-button-2ycmR9GG.js} +112 -112
  61. package/dist/{flowDiagram-HODETNUW-CJcSoFer.js → flowDiagram-HODETNUW-DQvZh96A.js} +2 -2
  62. package/dist/{formats-Ry7c-aIV.js → formats-BK4VefeF.js} +6 -6
  63. package/dist/{glide-data-editor-CRd8kBSp.js → glide-data-editor-Buj2OZjL.js} +99 -99
  64. package/dist/{glide-utils-D98laidb.js → glide-utils-CQddGpa-.js} +3 -3
  65. package/dist/{globals-BDWrOstF.js → globals-B-14fUeb.js} +3 -3
  66. package/dist/{error-entries-CVUHA5r_.js → html-to-image-CgAnxFi8.js} +26314 -25474
  67. package/dist/{infoDiagram-27XIBGKW-Cezgppw3.js → infoDiagram-27XIBGKW-BdAObnc_.js} +1 -1
  68. package/dist/{ishikawaDiagram-5VMMS53U-BahZQwJz.js → ishikawaDiagram-5VMMS53U-DP-jE8dw.js} +1 -1
  69. package/dist/{kanban-definition-UXKFOSKX-Cvfn_Obm.js → kanban-definition-UXKFOSKX-BkMgPjfm.js} +1 -1
  70. package/dist/{loader-DOVB0Syl.js → loader-BisjOLiW.js} +1 -1
  71. package/dist/main.js +2996 -2992
  72. package/dist/{mermaid-BrdwbArF.js → mermaid-B-OFqheA.js} +36 -36
  73. package/dist/{mindmap-definition-YA3MSWOX-uB4B2d-J.js → mindmap-definition-YA3MSWOX-Du7toAZX.js} +1 -1
  74. package/dist/{number-overlay-editor-Bp9a4z5Q.js → number-overlay-editor-ZVFcJ7_u.js} +1 -1
  75. package/dist/{dist-CThPVKaP.js → objectWithoutPropertiesLoose-ai5NUqGa.js} +1139 -1120
  76. package/dist/{pegDiagram-XKGWAZYB-CYaIwAmH.js → pegDiagram-XKGWAZYB-CX_729j0.js} +2 -2
  77. package/dist/{pieDiagram-E7YTZNPT-BJ1oUxVv.js → pieDiagram-E7YTZNPT-dtTFJcVI.js} +1 -1
  78. package/dist/{process-output-SjJxB1wh.js → process-output-Bmev5azU.js} +5 -5
  79. package/dist/{railroadDiagram-O6MQD6OU-BqKSaBux.js → railroadDiagram-O6MQD6OU-Dsc2Cf4c.js} +2 -2
  80. package/dist/{requirementDiagram-BXWQKSXE-DwFhgDhH.js → requirementDiagram-BXWQKSXE-D3g0c60I.js} +1 -1
  81. package/dist/{reveal-component-ov3pS-oO.js → reveal-component-BWz1KjWe.js} +725 -725
  82. package/dist/schemas-CdscWNy_.js +4747 -0
  83. package/dist/select-BSxlITBo.js +1200 -0
  84. package/dist/{sequenceDiagram-WJ2MYXX4-L7ENjfKF.js → sequenceDiagram-WJ2MYXX4-w1pWbIpk.js} +1 -1
  85. package/dist/{spec-BcJ9ncAY.js → spec-Dq8RNplp.js} +4 -4
  86. package/dist/{stateDiagram-D77RDMKH-CVf88u-p.js → stateDiagram-D77RDMKH-BCv7kP4H.js} +2 -2
  87. package/dist/{stateDiagram-v2-MP3YSRHH-DvpkQ1V6.js → stateDiagram-v2-MP3YSRHH-D1cTOt0x.js} +2 -2
  88. package/dist/static-state-BldGd99T.js +72 -0
  89. package/dist/strings-BGW-fXVJ.js +507 -0
  90. package/dist/{swimlanes-42K2YHIH-DIuFHbVE.js → swimlanes-42K2YHIH-DNO6jDnd.js} +1 -1
  91. package/dist/{swimlanesDiagram-VR7AAH4N-CAqHAziW.js → swimlanesDiagram-VR7AAH4N-CkSWsxYJ.js} +2 -2
  92. package/dist/{swiper-component-BVfnhbnt.js → swiper-component-Dir1o8ml.js} +2 -2
  93. package/dist/{timeline-definition-24CTP7MA-xO4EQqBm.js → timeline-definition-24CTP7MA-DQvqGFM9.js} +1 -1
  94. package/dist/toast-BWIPi3aF.js +491 -0
  95. package/dist/{tooltip-s4zZxwKe.js → tooltip-C3UnvsRe.js} +58 -58
  96. package/dist/use-restore-focus-BWO0z1lo.js +1672 -0
  97. package/dist/{useAsyncData-Dqw22wZQ.js → useAsyncData-DsZMvNXn.js} +1 -1
  98. package/dist/{useDateFormatter-D3Tkfzej.js → useDateFormatter-ChB4eyim.js} +2 -2
  99. package/dist/{useDeepCompareMemoize-D3gozcjJ.js → useDeepCompareMemoize-KMBBux8C.js} +1 -1
  100. package/dist/{useIframeCapabilities-6a_yDNXp.js → useIframeCapabilities-cMNQ80CK.js} +1 -1
  101. package/dist/useLifecycle-BquH9ET-.js +112 -0
  102. package/dist/useTheme-B9C1JIy9.js +307 -0
  103. package/dist/utils-DXkj82sK.js +813 -0
  104. package/dist/{vega-component-Blvspl7v.js → vega-component-9NOEITbg.js} +55 -54
  105. package/dist/{vennDiagram-4TSXK5OY-DzCi-lRh.js → vennDiagram-4TSXK5OY-BfzXp4SH.js} +1 -1
  106. package/dist/{wardleyDiagram-VM6X3IG4-CW48xa0n.js → wardleyDiagram-VM6X3IG4-CiqEE8eq.js} +1 -1
  107. package/dist/{xychartDiagram-S5SC5T6Z-UPxGGI2B.js → xychartDiagram-S5SC5T6Z-BTIHCVOI.js} +1 -1
  108. package/package.json +1 -1
  109. package/src/components/editor/cell/__tests__/StagedAICell.test.tsx +1 -1
  110. package/src/components/editor/cell/code/cell-editor.tsx +13 -36
  111. package/src/components/editor/chrome/wrapper/__tests__/pending-ai-cells.test.tsx +1 -1
  112. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +11 -5
  113. package/src/components/editor/notebook-cell.tsx +0 -1
  114. package/src/components/scratchpad/scratchpad.test.tsx +3 -11
  115. package/src/components/scratchpad/scratchpad.tsx +24 -22
  116. package/src/components/scratchpad/state.ts +7 -0
  117. package/src/core/ai/__tests__/staged-cells.test.ts +6 -8
  118. package/src/core/ai/staged-cells.ts +4 -2
  119. package/src/core/cells/__tests__/cells.test.ts +44 -60
  120. package/src/core/cells/__tests__/document.test.ts +1 -139
  121. package/src/core/cells/__tests__/notebook-outline.test.ts +8 -6
  122. package/src/core/cells/actions.ts +39 -0
  123. package/src/core/cells/cells.ts +15 -35
  124. package/src/core/cells/document.ts +9 -84
  125. package/src/core/codemirror/__tests__/format.test.ts +0 -34
  126. package/src/core/codemirror/cells/extensions.ts +20 -18
  127. package/src/core/codemirror/cells/state.ts +0 -5
  128. package/src/core/codemirror/format.ts +1 -19
  129. package/src/core/codemirror/misc/__tests__/paste.test.ts +3 -1
  130. package/src/core/codemirror/misc/paste.ts +1 -1
  131. package/src/core/notebook/document/__tests__/codemirror.test.ts +172 -0
  132. package/src/stories/layout/vertical/one-column.stories.tsx +26 -18
  133. package/dist/html-to-image--E3uFbJ9.js +0 -3348
  134. package/dist/label-Qvw1E97W.js +0 -281
  135. package/dist/objectWithoutPropertiesLoose-BRDcTsfB.js +0 -23
  136. package/dist/select-2KOiReNY.js +0 -1423
  137. package/dist/toast-B5t9o6s_.js +0 -1848
  138. package/dist/use-restore-focus-BgDTd2tc.js +0 -6418
  139. package/dist/useLifecycle-CaZi4wTw.js +0 -172
  140. package/dist/useTheme-TzPgWXwB.js +0 -1114
@@ -1,8 +1,9 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  /**
4
- * Keep the notebook state and the host document in step while cells live
5
- * in both.
4
+ * Make the notebook state follow the host document while cells live in
5
+ * both. Edits go the other way through the document model directly: the
6
+ * cell operations write it, and each cell editor is bound to its cell.
6
7
  *
7
8
  * TODO(MO-8028): Delete this module once components read cells from the
8
9
  * document model and `notebookAtom` holds only runtime and editor state.
@@ -10,20 +11,15 @@
10
11
 
11
12
  import { dequal as isEqual } from "dequal";
12
13
  import type { createStore } from "jotai";
13
- import { createRef, type RefObject } from "react";
14
- import type { CellHandle } from "@/components/editor/notebook-cell";
14
+ import { createRef } from "react";
15
15
  import type {
16
- DocumentEdit,
17
- DocumentEditor,
18
16
  DocumentModel,
19
17
  EditableCell,
20
18
  EditableDocument,
21
19
  } from "@/core/notebook/document";
22
- import type { DispatchedActionOf } from "@/utils/createReducer";
23
- import { prepareCellCodeUpdate } from "../codemirror/language/cell-code";
24
20
  import { store } from "../state/jotai";
25
21
  import { activeDocumentModelAtom } from "../session/active-notebook";
26
- import type { NotebookState, ReducerCellActions } from "./cells";
22
+ import type { NotebookState } from "./cells";
27
23
  import type { CellId } from "./ids";
28
24
  import { followColumnsAtom } from "./layout";
29
25
  import {
@@ -34,16 +30,10 @@ import {
34
30
  isEdited,
35
31
  } from "./types";
36
32
 
37
- type CellAction = DispatchedActionOf<ReducerCellActions>;
38
-
39
33
  // Following the document ----------------------------------------------------
40
34
 
41
- /** The cell's data with the document's fields, and its editor brought along. */
42
- function followCell(
43
- current: CellData,
44
- cell: EditableCell,
45
- handle: RefObject<CellHandle | null> | undefined,
46
- ): CellData {
35
+ /** The cell's data after the document's cell, the same object when nothing differs. */
36
+ function followCell(current: CellData, cell: EditableCell): CellData {
47
37
  // Withheld source keeps the code this client has.
48
38
  const code = cell.code ?? current.code;
49
39
  const config = isEqual(current.config, cell.config)
@@ -59,13 +49,6 @@ function followCell(
59
49
  if (code === current.code) {
60
50
  return { ...current, name: cell.name, config };
61
51
  }
62
- // The editor takes the host's code; one that shows it already, as the
63
- // editor this client types in does, is left alone.
64
- const view = handle?.current?.editorViewOrNull;
65
- const update = view && prepareCellCodeUpdate(view.state, code);
66
- if (update) {
67
- view.dispatch(update);
68
- }
69
52
  return {
70
53
  ...current,
71
54
  code,
@@ -110,7 +93,7 @@ export function applyDocument(
110
93
  code,
111
94
  name: cell.name,
112
95
  config: createCellConfig(cell.config),
113
- edited: code.trim().length > 0,
96
+ edited: isEdited(null, code),
114
97
  }),
115
98
  };
116
99
  cellRuntime = {
@@ -120,7 +103,7 @@ export function applyDocument(
120
103
  cellHandles = { ...cellHandles, [id]: createRef() };
121
104
  continue;
122
105
  }
123
- const next = followCell(current, cell, state.cellHandles[id]);
106
+ const next = followCell(current, cell);
124
107
  if (next !== current) {
125
108
  cellData = { ...cellData, [id]: next };
126
109
  }
@@ -176,64 +159,6 @@ export function followDocument({
176
159
  follow();
177
160
  }
178
161
 
179
- // Editing the document ------------------------------------------------------
180
-
181
- /**
182
- * The edit that writes the code a reducer action changed, or null when none
183
- * did. Order, creation, deletion, names and configs are edited on the model
184
- * directly by their atoms.
185
- */
186
- function diffNotebook(
187
- prev: NotebookState,
188
- next: NotebookState,
189
- ): DocumentEdit | null {
190
- const steps: Array<(tx: DocumentEditor) => void> = [];
191
- for (const id of next.cellIds) {
192
- const cell = next.cellData[id];
193
- const before = prev.cellData[id];
194
- if (before !== undefined && cell !== before && before.code !== cell.code) {
195
- steps.push((tx) => tx.setCode(id, cell.code));
196
- }
197
- }
198
- if (steps.length === 0) {
199
- return null;
200
- }
201
- return (tx) => {
202
- for (const step of steps) {
203
- step(tx);
204
- }
205
- };
206
- }
207
-
208
- /**
209
- * Middleware for the notebook reducer. Writes what an action changed about
210
- * the cells and their order into the document model as one edit.
211
- *
212
- * TODO(MO-8028): Only code is left in `diffNotebook`. The middleware goes
213
- * with it, once the editors bind to the model.
214
- */
215
- export function documentEditMiddleware(
216
- prev: NotebookState,
217
- next: NotebookState,
218
- action: CellAction,
219
- ): void {
220
- // The document replaces the notebook's cells, so following it is no edit.
221
- if (action.type === "reconcileDocument") {
222
- return;
223
- }
224
- if (prev.cellIds === next.cellIds && prev.cellData === next.cellData) {
225
- return;
226
- }
227
- const model = store.get(activeDocumentModelAtom);
228
- if (model === null) {
229
- return;
230
- }
231
- const edit = diffNotebook(prev, next);
232
- if (edit !== null) {
233
- store.set(model.editAtom, edit);
234
- }
235
- }
236
-
237
162
  // Commands that read the document on the host -------------------------------
238
163
 
239
164
  /**
@@ -17,7 +17,6 @@ import { OverridingHotkeyProvider } from "@/core/hotkeys/hotkeys";
17
17
  import { requestClientAtom } from "@/core/network/requests";
18
18
  import type { MarimoConfig } from "@/core/network/types";
19
19
  import { store } from "@/core/state/jotai";
20
- import { type CodemirrorCellActions, cellActionsState } from "../cells/state";
21
20
  import { cellIdState } from "../config/extension";
22
21
  import { formatAll, formatEditorViews, formatSQL } from "../format";
23
22
  import {
@@ -44,7 +43,6 @@ vi.mock("@/core/config/config", () => ({
44
43
  }),
45
44
  }));
46
45
 
47
- const updateCellCode = vi.fn();
48
46
  const createdViews: EditorView[] = [];
49
47
 
50
48
  function createEditor(content: string, cellId: CellId) {
@@ -65,9 +63,6 @@ function createEditor(content: string, cellId: CellId) {
65
63
  lspConfig: {},
66
64
  }),
67
65
  cellIdState.of(cellId),
68
- cellActionsState.of({
69
- updateCellCode,
70
- } as unknown as CodemirrorCellActions),
71
66
  ],
72
67
  });
73
68
 
@@ -85,7 +80,6 @@ const mockConfig = {
85
80
  } as MarimoConfig;
86
81
 
87
82
  beforeEach(() => {
88
- updateCellCode.mockClear();
89
83
  vi.clearAllMocks();
90
84
  // Set the mock request client in the atom
91
85
  store.set(requestClientAtom, mockRequestClient);
@@ -131,16 +125,6 @@ describe("format", () => {
131
125
 
132
126
  expect(views[cid1].state.doc.toString()).toBe(formattedCode1);
133
127
  expect(views[cid2].state.doc.toString()).toBe(formattedCode2);
134
- expect(updateCellCode).toHaveBeenCalledWith({
135
- cellId: cid1,
136
- code: formattedCode1,
137
- formattingChange: true,
138
- });
139
- expect(updateCellCode).toHaveBeenCalledWith({
140
- cellId: cid2,
141
- code: formattedCode2,
142
- formattingChange: true,
143
- });
144
128
  });
145
129
 
146
130
  it("should not update editor if formatted code is same as original", async () => {
@@ -161,7 +145,6 @@ describe("format", () => {
161
145
  await formatEditorViews(views);
162
146
 
163
147
  expect(views[cid].state.doc.toString()).toBe(originalCode);
164
- expect(updateCellCode).not.toHaveBeenCalled();
165
148
  });
166
149
  });
167
150
 
@@ -194,12 +177,6 @@ describe("format", () => {
194
177
  },
195
178
  lineLength: 88,
196
179
  });
197
-
198
- expect(updateCellCode).toHaveBeenCalledWith({
199
- cellId: cid1,
200
- code: "import numpy as np",
201
- formattingChange: true,
202
- });
203
180
  });
204
181
  });
205
182
 
@@ -220,11 +197,6 @@ describe("format", () => {
220
197
  WHERE
221
198
  id = 1"
222
199
  `);
223
- expect(updateCellCode).toHaveBeenCalledWith({
224
- cellId: cid,
225
- code: editor.state.doc.toString(),
226
- formattingChange: true,
227
- });
228
200
  });
229
201
 
230
202
  it("should not format if language adapter is not SQL", async () => {
@@ -238,7 +210,6 @@ describe("format", () => {
238
210
  expect(editor.state.doc.toString()).toBe(
239
211
  "SELECT * FROM table WHERE id = 1",
240
212
  );
241
- expect(updateCellCode).not.toHaveBeenCalled();
242
213
  });
243
214
  });
244
215
 
@@ -257,10 +228,5 @@ describe("format", () => {
257
228
  WHERE
258
229
  id = 1"
259
230
  `);
260
- expect(updateCellCode).toHaveBeenCalledWith({
261
- cellId: cid,
262
- code: editor.state.doc.toString(),
263
- formattingChange: true,
264
- });
265
231
  });
266
232
  });
@@ -23,7 +23,7 @@ import { createObservable } from "@/core/state/observable";
23
23
  import { formatKeymapExtension } from "../extensions";
24
24
  import { formattingChangeEffect } from "../format";
25
25
  import { goToDefinitionAtCursorPosition } from "../go-to-definition/utils";
26
- import { getEditorCodeAsPython } from "../language/utils";
26
+ import { getCellCode } from "../language/cell-code";
27
27
  import { isAtEndOfEditor, isAtStartOfEditor } from "../utils";
28
28
  import {
29
29
  breakpointGutter,
@@ -323,26 +323,28 @@ function cellKeymaps({
323
323
  */
324
324
  function cellCodeEditing(hotkeys: HotkeyProvider): Extension[] {
325
325
  const onChangePlugin = EditorView.updateListener.of((update) => {
326
- if (update.docChanged) {
327
- // Check if the doc update was a formatting change
328
- // e.g. changing from python to markdown
329
- const isFormattingChange = update.transactions.some((tr) =>
330
- tr.effects.some((effect) => effect.is(formattingChangeEffect)),
331
- );
332
- const nextCode = getEditorCodeAsPython(update.view);
333
- const cellActions = update.view.state.facet(cellActionsState);
334
- const cellId = update.view.state.facet(cellIdState);
335
- cellActions.updateCellCode({
326
+ if (!update.docChanged) {
327
+ return;
328
+ }
329
+ const cellId = update.view.state.facet(cellIdState);
330
+ // The code itself reaches the document through the editor's binding.
331
+ // Formatting, including a language switch, rewrites the code without
332
+ // editing it, so the cell stays as run or as edited as it was.
333
+ const formatted = update.transactions.some((tr) =>
334
+ tr.effects.some((effect) => effect.is(formattingChangeEffect)),
335
+ );
336
+ if (formatted) {
337
+ update.view.state.facet(cellActionsState).codeFormatted({
336
338
  cellId,
337
- code: nextCode,
338
- formattingChange: isFormattingChange,
339
+ before: getCellCode(update.startState),
340
+ after: getCellCode(update.state),
339
341
  });
342
+ }
340
343
 
341
- // Clear pending cut state if this cell was marked for cut
342
- const pendingCutCellIds = store.get(pendingCutCellIdsAtom);
343
- if (pendingCutCellIds.has(cellId)) {
344
- store.set(clearPendingCutAtom);
345
- }
344
+ // Clear pending cut state if this cell was marked for cut
345
+ const pendingCutCellIds = store.get(pendingCutCellIdsAtom);
346
+ if (pendingCutCellIds.has(cellId)) {
347
+ store.set(clearPendingCutAtom);
346
348
  }
347
349
  });
348
350
 
@@ -8,11 +8,6 @@ export interface CodemirrorCellActions extends CellActions {
8
8
  toggleHideCode: () => boolean;
9
9
  aiCellCompletion: () => boolean;
10
10
  createManyBelow: (content: string[]) => void;
11
- /**
12
- * Create the setup cell with the given code, or append the code to the
13
- * existing setup cell if one already exists.
14
- */
15
- addOrAppendSetupCell: (code: string) => void;
16
11
  onRun: () => void;
17
12
  deleteCell: () => void;
18
13
  afterToggleMarkdown: () => void;
@@ -13,8 +13,6 @@ import { notebookCellEditorViews } from "../cells/utils";
13
13
  import { getResolvedMarimoConfig } from "../config/config";
14
14
  import type { ConnectionName } from "../datasets/engines";
15
15
  import { getRequestClient } from "../network/requests";
16
- import { cellActionsState } from "./cells/state";
17
- import { cellIdState } from "./config/extension";
18
16
  import { languageAdapterState } from "./language/extension";
19
17
  import { SCHEMA_CACHE } from "./language/languages/sql/completion-store";
20
18
  import { isKnownDialect } from "./language/languages/sql/utils";
@@ -54,15 +52,9 @@ export async function formatEditorViews(views: Record<CellId, EditorView>) {
54
52
  continue;
55
53
  }
56
54
 
57
- const actions = view.state.facet(cellActionsState);
58
- actions.updateCellCode({
59
- cellId,
60
- code: formattedCode,
61
- formattingChange: true,
62
- });
63
55
  const update = prepareCellCodeUpdate(view.state, formattedCode);
64
56
  if (update) {
65
- view.dispatch(update);
57
+ view.dispatch(update, { effects: formattingChangeEffect.of(true) });
66
58
  }
67
59
  }
68
60
  }
@@ -111,16 +103,6 @@ export async function formatSQL(editor: EditorView, engine: ConnectionName) {
111
103
  return;
112
104
  }
113
105
 
114
- // Update Python in the notebook state
115
- const codeAsPython = languageAdapter.transformIn(formattedSQL)[0];
116
- const actions = editor.state.facet(cellActionsState);
117
- const cellId = editor.state.facet(cellIdState);
118
- actions.updateCellCode({
119
- cellId,
120
- code: codeAsPython,
121
- formattingChange: true,
122
- });
123
-
124
106
  // Update editor with formatted SQL
125
107
  replaceEditorContent(editor, formattedSQL, {
126
108
  effects: [formattingChangeEffect.of(true)],
@@ -443,7 +443,9 @@ def _():
443
443
  view.dom.onpaste = (evt) => extension[0].domEventHandlers.paste(evt, view);
444
444
  view.dom.dispatchEvent(event);
445
445
 
446
- expect(addOrAppendSetupCell).toHaveBeenCalledWith("import numpy as np");
446
+ expect(addOrAppendSetupCell).toHaveBeenCalledWith({
447
+ code: "import numpy as np",
448
+ });
447
449
  expect(createManyBelow).toHaveBeenCalledWith(["x = 1"]);
448
450
  });
449
451
  });
@@ -23,7 +23,7 @@ export function pasteBundle(): Extension[] {
23
23
  // The `with app.setup` block belongs in the special setup cell, not in
24
24
  // an ordinary cell.
25
25
  if (setup !== null) {
26
- actions.addOrAppendSetupCell(setup);
26
+ actions.addOrAppendSetupCell({ code: setup });
27
27
  }
28
28
  if (cells.length > 0) {
29
29
  actions.createManyBelow(cells);
@@ -22,6 +22,10 @@ import {
22
22
  languageMetadataField,
23
23
  setLanguageMetadata,
24
24
  } from "../../../codemirror/language/metadata";
25
+ import {
26
+ editorShowsCellCode,
27
+ getCellCode,
28
+ } from "../../../codemirror/language/cell-code";
25
29
  import { SCHEMA_CACHE } from "../../../codemirror/language/languages/sql/completion-store";
26
30
  import type { Document } from "../../replica/resources/document";
27
31
  import { cellCodeBinding } from "../codemirror";
@@ -62,6 +66,7 @@ function languages(id: CellId) {
62
66
  aiCellCompletion: () => false,
63
67
  createManyBelow: vi.fn(),
64
68
  addOrAppendSetupCell: vi.fn(),
69
+ setCellCode: vi.fn(),
65
70
  createCell: vi.fn(),
66
71
  moveToNextCell: vi.fn(),
67
72
  addSetupCell: vi.fn(),
@@ -584,3 +589,170 @@ describe("cellCodeBinding before the cell has source", () => {
584
589
  expect(view.state.field(languageAdapterState).type).toBe("markdown");
585
590
  });
586
591
  });
592
+
593
+ describe("cellCodeBinding under random interleavings", () => {
594
+ // A small deterministic generator, so a failure names the seed to replay.
595
+ function generator(seed: number) {
596
+ let state = seed >>> 0;
597
+ return () => {
598
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
599
+ return state / 2 ** 32;
600
+ };
601
+ }
602
+
603
+ const TOKENS = ["x", " ", "\n", "= 1", "mo.md(", ")", '"""', "#", "y + 2"];
604
+ const REMOTE = [
605
+ "x = 2",
606
+ 'mo.md(r"""hi""")',
607
+ "y = x + 1\nz = y",
608
+ "",
609
+ "# other",
610
+ ];
611
+
612
+ async function stress(seed: number, adapters: boolean) {
613
+ const random = generator(seed);
614
+ const pick = <T>(items: readonly T[]) =>
615
+ items[Math.floor(random() * items.length)];
616
+ const { store, source, model, view, editor, code } = setup({
617
+ adapters,
618
+ initial: withCells(document(), { c1: cell("c1", "x = 1") }),
619
+ });
620
+ const other = editor();
621
+ await settle();
622
+ let composing = false;
623
+ Object.defineProperty(view, "composing", { get: () => composing });
624
+ let version = 1;
625
+ let hostCode = "x = 1";
626
+ const host = (next: string | null) => {
627
+ version += 1;
628
+ store.set(
629
+ source,
630
+ withCells(document(), {
631
+ c1:
632
+ next === null
633
+ ? null
634
+ : cell("c1", next, { version, etag: `c1-${version}` }),
635
+ }),
636
+ );
637
+ };
638
+ const agrees = (state = view.state) => {
639
+ const current = code();
640
+ return typeof current !== "string" || editorShowsCellCode(state, current);
641
+ };
642
+ // Every write to the model is one local or host step. A binding that
643
+ // echoed its own pulls back as pushes would write many more.
644
+ let writes = 0;
645
+ let steps = 0;
646
+ // Checks inside the loop collect here, so one assertion reports them all.
647
+ const failures: string[] = [];
648
+ const check = (label: string, ok: boolean) => {
649
+ if (!ok) {
650
+ failures.push(label);
651
+ }
652
+ };
653
+ const unsubscribe = store.sub(model.documentAtom, () => {
654
+ writes += 1;
655
+ });
656
+
657
+ for (let step = 0; step < 200; step++) {
658
+ const roll = random();
659
+ steps += 1;
660
+ if (roll < 0.35) {
661
+ const length = view.state.doc.length;
662
+ const from = Math.floor(random() * (length + 1));
663
+ view.dispatch({
664
+ changes: { from, insert: pick(TOKENS) },
665
+ userEvent: "input.type",
666
+ });
667
+ // A local change reaches the model before the update returns.
668
+ check(`step ${step} after typing`, agrees());
669
+ } else if (roll < 0.45) {
670
+ const length = view.state.doc.length;
671
+ if (length > 0) {
672
+ const from = Math.floor(random() * length);
673
+ const to = Math.min(length, from + 1 + Math.floor(random() * 5));
674
+ view.dispatch({
675
+ changes: { from, to },
676
+ userEvent: "delete.backward",
677
+ });
678
+ check(`step ${step} after deleting`, agrees());
679
+ }
680
+ } else if (roll < 0.6) {
681
+ // The host confirms what this client wrote.
682
+ const current = code();
683
+ if (typeof current === "string") {
684
+ hostCode = current;
685
+ host(hostCode);
686
+ }
687
+ } else if (roll < 0.75) {
688
+ // Another client changes the cell.
689
+ hostCode = pick(REMOTE);
690
+ host(hostCode);
691
+ } else if (roll < 0.8) {
692
+ // The cell leaves the document and returns.
693
+ host(null);
694
+ await settle();
695
+ host(hostCode);
696
+ } else if (roll < 0.88) {
697
+ composing = !composing;
698
+ if (!composing) {
699
+ view.contentDOM.dispatchEvent(new Event("compositionend"));
700
+ }
701
+ } else if (roll < 0.94) {
702
+ other.dispatch({
703
+ changes: { from: other.state.doc.length, insert: pick(TOKENS) },
704
+ userEvent: "input.type",
705
+ });
706
+ } else if (roll < 0.97) {
707
+ // The host withholds the cell's source for a while.
708
+ version += 1;
709
+ store.set(
710
+ source,
711
+ withCells(document(), {
712
+ c1: cell("c1", "", { code: null, version, etag: `c1-${version}` }),
713
+ }),
714
+ );
715
+ } else {
716
+ // The document goes away and comes back.
717
+ store.set(source, null);
718
+ await settle();
719
+ host(hostCode);
720
+ }
721
+ if (random() < 0.5) {
722
+ await settle();
723
+ check(`step ${step} after settling`, composing || agrees());
724
+ check(`step ${step} other editor`, agrees(other.state));
725
+ }
726
+ }
727
+ unsubscribe();
728
+ check(`${writes} writes for ${steps} steps`, writes <= 2 * steps);
729
+
730
+ composing = false;
731
+ view.contentDOM.dispatchEvent(new Event("compositionend"));
732
+ host(hostCode);
733
+ await settle();
734
+ await settle();
735
+ const current = code();
736
+ check("cell present at the end", typeof current === "string");
737
+ check("editor agrees at the end", agrees());
738
+ check("other editor agrees at the end", agrees(other.state));
739
+ // Without adapters the agreement is plain text equality.
740
+ check(
741
+ "text equal at the end",
742
+ adapters || getCellCode(view.state) === current,
743
+ );
744
+ return failures;
745
+ }
746
+
747
+ const seeds = Array.from({ length: 16 }, (_, i) => i + 1);
748
+ for (const seed of seeds) {
749
+ it(`keeps editor and model in agreement, seed ${seed}`, async () => {
750
+ expect(await stress(seed, false)).toEqual([]);
751
+ });
752
+ }
753
+ for (const seed of seeds) {
754
+ it(`keeps editor and model in agreement with language adapters, seed ${seed}`, async () => {
755
+ expect(await stress(seed, true)).toEqual([]);
756
+ });
757
+ }
758
+ });
@@ -3,11 +3,13 @@
3
3
  import type { Meta } from "@storybook/react-vite";
4
4
  import { createStore, Provider } from "jotai";
5
5
  import { createRef } from "react";
6
+ import { mockDocumentModel } from "@/__mocks__/document";
6
7
  import { cellId } from "@/__tests__/branded";
7
8
  import { type NotebookState, notebookAtom } from "@/core/cells/cells";
8
9
  import { createCellRuntimeState } from "@/core/cells/types";
9
10
  import { defaultUserConfig, parseAppConfig } from "@/core/config/config-schema";
10
11
  import { showCodeInRunModeAtom } from "@/core/meta/state";
12
+ import { DocumentProvider } from "@/core/notebook/document";
11
13
  import { connectionAtom } from "@/core/network/connection";
12
14
  import { requestClientAtom } from "@/core/network/requests";
13
15
  import { resolveRequestClient } from "@/core/network/resolve";
@@ -105,18 +107,21 @@ const EditModeCodeShown = () => {
105
107
  store.set(connectionAtom, { state: ConnectionState.OPEN });
106
108
  store.set(requestClientAtom, resolveRequestClient());
107
109
  store.set(showCodeInRunModeAtom, true);
110
+ const { model } = mockDocumentModel(store, null);
108
111
 
109
112
  return (
110
113
  <Provider store={store}>
111
- <TooltipProvider>
112
- <CellsRenderer appConfig={parseAppConfig({})} mode="edit">
113
- <CellArray
114
- mode="edit"
115
- userConfig={defaultUserConfig()}
116
- appConfig={parseAppConfig({})}
117
- />
118
- </CellsRenderer>
119
- </TooltipProvider>
114
+ <DocumentProvider model={model}>
115
+ <TooltipProvider>
116
+ <CellsRenderer appConfig={parseAppConfig({})} mode="edit">
117
+ <CellArray
118
+ mode="edit"
119
+ userConfig={defaultUserConfig()}
120
+ appConfig={parseAppConfig({})}
121
+ />
122
+ </CellsRenderer>
123
+ </TooltipProvider>
124
+ </DocumentProvider>
120
125
  </Provider>
121
126
  );
122
127
  };
@@ -130,18 +135,21 @@ const EditModeCodeHidden = () => {
130
135
  store.set(connectionAtom, { state: ConnectionState.OPEN });
131
136
  store.set(requestClientAtom, resolveRequestClient());
132
137
  store.set(showCodeInRunModeAtom, true);
138
+ const { model } = mockDocumentModel(store, null);
133
139
 
134
140
  return (
135
141
  <Provider store={store}>
136
- <TooltipProvider>
137
- <CellsRenderer appConfig={parseAppConfig({})} mode="edit">
138
- <CellArray
139
- mode="edit"
140
- userConfig={defaultUserConfig()}
141
- appConfig={parseAppConfig({})}
142
- />
143
- </CellsRenderer>
144
- </TooltipProvider>
142
+ <DocumentProvider model={model}>
143
+ <TooltipProvider>
144
+ <CellsRenderer appConfig={parseAppConfig({})} mode="edit">
145
+ <CellArray
146
+ mode="edit"
147
+ userConfig={defaultUserConfig()}
148
+ appConfig={parseAppConfig({})}
149
+ />
150
+ </CellsRenderer>
151
+ </TooltipProvider>
152
+ </DocumentProvider>
145
153
  </Provider>
146
154
  );
147
155
  };