@marimo-team/frontend 0.25.3-dev102 → 0.25.3-dev104

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 (79) hide show
  1. package/dist/assets/{CellStatus-fK4n2NPc.js → CellStatus-DVrvejMC.js} +1 -1
  2. package/dist/assets/{ErrorBoundary-CAEGdoFW.js → ErrorBoundary-0akNKvJm.js} +2 -2
  3. package/dist/assets/{JsonOutput-CyAKmZDD.js → JsonOutput-DeUmpc_f.js} +1 -1
  4. package/dist/assets/{MarimoErrorOutput-CgZvP0p0.js → MarimoErrorOutput-BazxeAYV.js} +1 -1
  5. package/dist/assets/{RenderHTML-DQvxDDNS.js → RenderHTML-CbAdeual.js} +1 -1
  6. package/dist/assets/{RunButton-BBDnDL6G.js → RunButton-CjKwgCFU.js} +1 -1
  7. package/dist/assets/{add-cell-with-ai-Clg5FELx.js → add-cell-with-ai-BKVEws3J.js} +1 -1
  8. package/dist/assets/{add-connection-dialog-BqdWmD-7.js → add-connection-dialog-DS7LTz1T.js} +1 -1
  9. package/dist/assets/{agent-panel-5RxG5Y82.js → agent-panel-B34QcdOK.js} +1 -1
  10. package/dist/assets/{ai-model-dropdown-Bvl1ktQq.js → ai-model-dropdown-DqFJCFVR.js} +1 -1
  11. package/dist/assets/{app-config-button-0QTeQ2Mv.js → app-config-button-CVh4UKi9.js} +1 -1
  12. package/dist/assets/{blob-5VcB8K_t.js → blob-Bw0ZQoLx.js} +1 -1
  13. package/dist/assets/{cell-editor-Byw5y-oI.js → cell-editor-DQ0io5Ye.js} +1 -1
  14. package/dist/assets/{cell-link-C5yYyzuF.js → cell-link-DvOIu6A3.js} +1 -1
  15. package/dist/assets/{cells-D_11MjVB.js → cells-Do-63MKT.js} +1 -1
  16. package/dist/assets/{chat-display-BCR_ySiV.js → chat-display-DnkhBxdh.js} +1 -1
  17. package/dist/assets/{chat-panel-RPRvBsaU.js → chat-panel-CN3qoX9G.js} +1 -1
  18. package/dist/assets/{chat-ui-CGviVaRA.js → chat-ui-B54K5qqr.js} +1 -1
  19. package/dist/assets/{column-preview-xe533-Cn.js → column-preview-DmxzHzaW.js} +1 -1
  20. package/dist/assets/{command-palette-BB9bWdWp.js → command-palette-CciCeRhy.js} +1 -1
  21. package/dist/assets/{common-CaeItbcI.js → common-BCyx7t1i.js} +1 -1
  22. package/dist/assets/{components-G6frbw0c.js → components-CDvWXfXq.js} +1 -1
  23. package/dist/assets/{datasource-deuXnlU-.js → datasource-DieftweT.js} +1 -1
  24. package/dist/assets/{dependency-graph-panel-CIykLnJe.js → dependency-graph-panel-IfcqTHgq.js} +1 -1
  25. package/dist/assets/{documentation-panel-2PQ8kQLa.js → documentation-panel-DeLCgVcq.js} +1 -1
  26. package/dist/assets/{download-dK52bLxZ.js → download-BzfVCqUq.js} +1 -1
  27. package/dist/assets/{edit-page-DrElzw10.js → edit-page-HMyk4cWw.js} +4 -4
  28. package/dist/assets/{error-entries-3GVk2KjS.js → error-entries-DjjWrbt4.js} +1 -1
  29. package/dist/assets/{error-panel-CzDRGPeC.js → error-panel-CiG3JJA3.js} +1 -1
  30. package/dist/assets/{feedback-button-C5NgWpFg.js → feedback-button-B8zVQEi6.js} +1 -1
  31. package/dist/assets/feedback-button-DQdMkSvR.js +1 -0
  32. package/dist/assets/{file-explorer-panel-B50xDnTZ.js → file-explorer-panel-BPD8Q0L-.js} +1 -1
  33. package/dist/assets/{file-name-input-DqwMSgGU.js → file-name-input-CdkF9K3I.js} +1 -1
  34. package/dist/assets/{floating-outline-ChmAuvxz.js → floating-outline-TfDWJXBm.js} +1 -1
  35. package/dist/assets/{gallery-page-CiL8aMS1.js → gallery-page-BJpenPdQ.js} +1 -1
  36. package/dist/assets/{glide-data-editor-CqRclBkp.js → glide-data-editor-DO15Nm-R.js} +1 -1
  37. package/dist/assets/{home-page-BgZVl4dP.js → home-page-mvqldGvD.js} +1 -1
  38. package/dist/assets/{hooks-DM5n7Osw.js → hooks-C4AJfqV0.js} +1 -1
  39. package/dist/assets/{index-EG2n8Oht.js → index-C2XzUsS7.js} +4 -4
  40. package/dist/assets/{layout-BpP8nTjb.js → layout-1kSh6fBF.js} +3 -3
  41. package/dist/assets/{logs-panel-B5eVAZtH.js → logs-panel--QbHo7uS.js} +1 -1
  42. package/dist/assets/{markdown-renderer-BWUzqkJM.js → markdown-renderer-DJ7H8Rps.js} +1 -1
  43. package/dist/assets/{name-cell-input-DXW68gRW.js → name-cell-input-6Gd19S8T.js} +1 -1
  44. package/dist/assets/{outline-panel-DPxJhJ_A.js → outline-panel-CjKvunvX.js} +1 -1
  45. package/dist/assets/{packages-panel-gBE2FWQr.js → packages-panel-Gyrqi-MZ.js} +1 -1
  46. package/dist/assets/{panel-accordion-state-CCF5Gxnc.js → panel-accordion-state-D49TNCLQ.js} +1 -1
  47. package/dist/assets/{panels-Dr6qMCvU.js → panels-DfbxoeGa.js} +1 -1
  48. package/dist/assets/{process-output-Cw9IDcKQ.js → process-output-CgkoeA1-.js} +1 -1
  49. package/dist/assets/{readonly-code-display-DYcZQ28T.js → readonly-code-display-BMbl_4BR.js} +1 -1
  50. package/dist/assets/{readonly-python-code-RFuQP50v.js → readonly-python-code-BnPfpOJM.js} +1 -1
  51. package/dist/assets/{reveal-component-yRrDmCQz.js → reveal-component-D84yAzG7.js} +1 -1
  52. package/dist/assets/{run-page-B69M4-zE.js → run-page-B10s8Pre.js} +1 -1
  53. package/dist/assets/{runtime-details-state-pobtu6TO.js → runtime-details-state-Bkmgb59p.js} +1 -1
  54. package/dist/assets/{scratchpad-panel-H44_pvb_.js → scratchpad-panel-DtyjJ3yN.js} +1 -1
  55. package/dist/assets/{session-panel-Bnd1iXkO.js → session-panel-Chaq1do8.js} +1 -1
  56. package/dist/assets/{snippets-panel-DKApxKx9.js → snippets-panel-BaZmddOW.js} +1 -1
  57. package/dist/assets/{state-Dv059B3A.js → state-CqnCwHhY.js} +1 -1
  58. package/dist/assets/{state-Bl9BiY_s.js → state-Db73TZVD.js} +1 -1
  59. package/dist/assets/{tracing-Clwy4SlW.js → tracing-C2vQMUI0.js} +1 -1
  60. package/dist/assets/{tracing-panel-BwjLG_aC.js → tracing-panel-B4L2S2ac.js} +2 -2
  61. package/dist/assets/{tree-actions-BZNTQ12N.js → tree-actions-JXDiioHK.js} +1 -1
  62. package/dist/assets/{useAddCell-Cjuqk2YR.js → useAddCell-CP3HIjGl.js} +1 -1
  63. package/dist/assets/{useCellActionButton-MoWLvuGe.js → useCellActionButton-yi1_-emd.js} +1 -1
  64. package/dist/assets/{useDeleteCell-BC-MW7d1.js → useDeleteCell-BPLwyClM.js} +1 -1
  65. package/dist/assets/{useDependencyPanelTab-CZU7i-B3.js → useDependencyPanelTab-DmBiydXO.js} +1 -1
  66. package/dist/assets/{useNotebookActions-wFxVdRXQ.js → useNotebookActions-CfmgcJGx.js} +1 -1
  67. package/dist/assets/{useRunCells-ByaWxoIt.js → useRunCells-jF-7tJkw.js} +1 -1
  68. package/dist/assets/{useSplitCell-YJIystwv.js → useSplitCell-B6zWL8Zf.js} +1 -1
  69. package/dist/index.html +20 -20
  70. package/package.json +1 -1
  71. package/src/core/codemirror/cells/__tests__/extensions.test.ts +15 -0
  72. package/src/core/codemirror/cells/extensions.ts +7 -3
  73. package/src/core/document/__tests__/codemirror.test.ts +483 -0
  74. package/src/core/document/__tests__/hooks.test.tsx +386 -0
  75. package/src/core/document/codemirror.ts +311 -0
  76. package/src/core/document/hooks.tsx +118 -0
  77. package/src/core/document/index.ts +10 -0
  78. package/src/core/document/select.ts +9 -0
  79. package/dist/assets/feedback-button-CONwLFpw.js +0 -1
@@ -0,0 +1,386 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, render, renderHook } from "@testing-library/react";
4
+ import { atom, createStore, Provider } from "jotai";
5
+ import type { PropsWithChildren } from "react";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { Deferred } from "@/utils/Deferred";
8
+ import type { Document } from "../../host/resources/document";
9
+ import {
10
+ createDocumentModel,
11
+ DocumentProvider,
12
+ useCellCode,
13
+ useCellConfig,
14
+ useCellName,
15
+ useCellOrder,
16
+ useDocumentEdit,
17
+ useDocumentSync,
18
+ type DocumentHost,
19
+ } from "..";
20
+ import { cell, CONFIG, document, withCells } from "./fixtures";
21
+
22
+ type HostEdit = DocumentHost["edit"];
23
+ const lifetimes: AbortController[] = [];
24
+
25
+ afterEach(() => {
26
+ act(() => lifetimes.splice(0).forEach((controller) => controller.abort()));
27
+ });
28
+
29
+ function setup(
30
+ initial: Document | null = document(),
31
+ edit: HostEdit = () => new Promise(() => {}),
32
+ ) {
33
+ const store = createStore();
34
+ const source = atom(initial);
35
+ const detached = vi.fn();
36
+ source.onMount = () => detached;
37
+ const controller = new AbortController();
38
+ lifetimes.push(controller);
39
+ const model = createDocumentModel({
40
+ store,
41
+ notebook: { documentAtom: source, edit },
42
+ signal: controller.signal,
43
+ });
44
+ const wrapper = ({ children }: PropsWithChildren) => (
45
+ <Provider store={store}>
46
+ <DocumentProvider model={model}>{children}</DocumentProvider>
47
+ </Provider>
48
+ );
49
+ return { store, source, model, controller, detached, wrapper };
50
+ }
51
+
52
+ describe("document hooks", () => {
53
+ it("flushes edits through the model and aborts work when the session ends", async () => {
54
+ const accepted = withCells(document(), { c1: cell("c1", "local") });
55
+ const edit = vi
56
+ .fn<HostEdit>()
57
+ .mockResolvedValueOnce({
58
+ ok: true,
59
+ status: 200,
60
+ data: { document: accepted.revision, cells: {} },
61
+ })
62
+ .mockImplementation(
63
+ () => new Deferred<Awaited<ReturnType<HostEdit>>>().promise,
64
+ );
65
+ const { store, source, wrapper, controller } = setup(document(), edit);
66
+ const { result, unmount } = renderHook(
67
+ () => ({
68
+ code: useCellCode("c1"),
69
+ edit: useDocumentEdit(),
70
+ sync: useDocumentSync(),
71
+ }),
72
+ { wrapper, reactStrictMode: true },
73
+ );
74
+ act(() => result.current.edit((tx) => tx.setCode("c1", "local")));
75
+ expect(result.current.sync.status).toEqual({ status: "pending" });
76
+ const { flush, retry } = result.current.sync;
77
+ await act(async () => {
78
+ const flushed = flush();
79
+ // Allow the response to arrive before publishing its confirmation.
80
+ await Promise.resolve();
81
+ store.set(source, accepted);
82
+ await expect(flushed).resolves.toEqual(accepted.revision);
83
+ });
84
+ expect(result.current.sync).toEqual({
85
+ status: { status: "idle" },
86
+ flush,
87
+ retry,
88
+ });
89
+ expect(result.current.code).toBe("local");
90
+ act(() => result.current.edit((tx) => tx.setCode("c1", "next")));
91
+ await act(async () => {
92
+ const rejected = (async () => {
93
+ await expect(flush()).rejects.toThrow("session ended");
94
+ })();
95
+ await Promise.resolve();
96
+ unmount();
97
+ controller.abort(new Error("session ended"));
98
+ await rejected;
99
+ });
100
+ expect(edit).toHaveBeenCalledTimes(2);
101
+ expect(edit.mock.calls[1][2].signal.aborted).toBe(true);
102
+ });
103
+
104
+ it("publishes compound edits atomically without changing host records", () => {
105
+ const initial = document();
106
+ const { store, source, wrapper } = setup(initial);
107
+ const seen = vi.fn();
108
+ const { result } = renderHook(
109
+ () => {
110
+ const value = {
111
+ code: useCellCode("c1"),
112
+ name: useCellName("c1"),
113
+ config: useCellConfig("c1"),
114
+ order: useCellOrder(),
115
+ };
116
+ seen(value);
117
+ return { ...value, edit: useDocumentEdit() };
118
+ },
119
+ { wrapper },
120
+ );
121
+ const edit = result.current.edit;
122
+ seen.mockClear();
123
+
124
+ act(() =>
125
+ edit((tx) => {
126
+ tx.setCode("c1", "edited");
127
+ tx.setName("c1", "renamed");
128
+ tx.updateConfig("c1", { hide_code: true });
129
+ tx.setOrder(["c2", "c1"]);
130
+ }),
131
+ );
132
+
133
+ const expected = {
134
+ code: "edited",
135
+ name: "renamed",
136
+ config: { ...CONFIG, hide_code: true },
137
+ order: ["c2", "c1"],
138
+ };
139
+ expect(result.current).toEqual({ ...expected, edit });
140
+ expect(seen.mock.calls.length).toBeGreaterThan(0);
141
+ for (const [value] of seen.mock.calls) {
142
+ expect(value).toEqual(expected);
143
+ }
144
+ expect(store.get(source)).toBe(initial);
145
+
146
+ expect(() =>
147
+ act(() =>
148
+ edit((tx) => {
149
+ tx.setCode("c1", "rolled back");
150
+ tx.deleteCell("missing");
151
+ }),
152
+ ),
153
+ ).toThrow("does not exist");
154
+ expect(result.current).toEqual({ ...expected, edit });
155
+ });
156
+
157
+ it("subscribes to individual fields and keeps the edit function stable", () => {
158
+ const { store, source, wrapper } = setup();
159
+ const onCode = vi.fn();
160
+ const onName = vi.fn();
161
+ const onConfig = vi.fn();
162
+ const onOrder = vi.fn();
163
+ const onEdit = vi.fn();
164
+ function Code() {
165
+ onCode(useCellCode("c1"));
166
+ return null;
167
+ }
168
+ function Name() {
169
+ onName(useCellName("c1"));
170
+ return null;
171
+ }
172
+ function Config() {
173
+ onConfig(useCellConfig("c1"));
174
+ return null;
175
+ }
176
+ function Order() {
177
+ onOrder(useCellOrder());
178
+ return null;
179
+ }
180
+ function Edit() {
181
+ onEdit(useDocumentEdit());
182
+ return null;
183
+ }
184
+ render(
185
+ <>
186
+ <Code />
187
+ <Name />
188
+ <Config />
189
+ <Order />
190
+ <Edit />
191
+ </>,
192
+ { wrapper },
193
+ );
194
+ const edit: ReturnType<typeof useDocumentEdit> = onEdit.mock.calls[0][0];
195
+ const clear = () =>
196
+ [onCode, onName, onConfig, onOrder, onEdit].forEach((fn) =>
197
+ fn.mockClear(),
198
+ );
199
+ clear();
200
+
201
+ act(() => edit((tx) => tx.setCode("c1", "local")));
202
+ expect(onCode).toHaveBeenCalledWith("local");
203
+ expect(onName).not.toHaveBeenCalled();
204
+ expect(onConfig).not.toHaveBeenCalled();
205
+ expect(onOrder).not.toHaveBeenCalled();
206
+ expect(onEdit).not.toHaveBeenCalled();
207
+ clear();
208
+
209
+ act(() => edit((tx) => tx.setCode("c2", "other")));
210
+ expect(
211
+ [onCode, onName, onConfig, onOrder, onEdit].map((fn) => fn.mock.calls),
212
+ ).toEqual([[], [], [], [], []]);
213
+
214
+ act(() =>
215
+ store.set(
216
+ source,
217
+ withCells(document(), {
218
+ c1: cell("c1", "remote", {
219
+ name: "host name",
220
+ config: { ...CONFIG },
221
+ }),
222
+ }),
223
+ ),
224
+ );
225
+ expect(onName).toHaveBeenCalledWith("host name");
226
+ expect(onCode).not.toHaveBeenCalled();
227
+ expect(onConfig).not.toHaveBeenCalled();
228
+ expect(onOrder).not.toHaveBeenCalled();
229
+ expect(onEdit).not.toHaveBeenCalled();
230
+ clear();
231
+
232
+ act(() =>
233
+ edit((tx) => {
234
+ tx.updateConfig("c1", { disabled: true });
235
+ tx.setOrder(["c2", "c1"]);
236
+ }),
237
+ );
238
+ expect(onConfig).toHaveBeenCalledWith({ ...CONFIG, disabled: true });
239
+ expect(onOrder).toHaveBeenCalledWith(["c2", "c1"]);
240
+ expect(onCode).not.toHaveBeenCalled();
241
+ expect(onName).not.toHaveBeenCalled();
242
+ expect(onEdit).not.toHaveBeenCalled();
243
+ });
244
+
245
+ it("switches field subscriptions when the cell id changes", () => {
246
+ const { wrapper } = setup();
247
+ const { result, rerender } = renderHook(
248
+ (id) => ({
249
+ code: useCellCode(id),
250
+ edit: useDocumentEdit(),
251
+ }),
252
+ { wrapper, initialProps: "c1" },
253
+ );
254
+ const edit = result.current.edit;
255
+ rerender("c2");
256
+ expect(result.current).toEqual({ code: "y = 2", edit });
257
+ act(() => edit((tx) => tx.setCode("c1", "old subscription")));
258
+ expect(result.current).toEqual({ code: "y = 2", edit });
259
+ act(() => edit((tx) => tx.setCode("c2", "new subscription")));
260
+ expect(result.current).toEqual({ code: "new subscription", edit });
261
+ });
262
+
263
+ it("distinguishes unavailable documents, hidden source, empty code, and deleted cells", () => {
264
+ const { store, source, wrapper } = setup(null);
265
+ const { result } = renderHook(
266
+ () => ({
267
+ order: useCellOrder(),
268
+ code: useCellCode("c1"),
269
+ name: useCellName("c1"),
270
+ config: useCellConfig("c1"),
271
+ edit: useDocumentEdit(),
272
+ }),
273
+ { wrapper },
274
+ );
275
+ const edit = result.current.edit;
276
+ const absent = {
277
+ code: undefined,
278
+ name: undefined,
279
+ config: undefined,
280
+ edit,
281
+ };
282
+ expect(result.current).toEqual({ ...absent, order: null });
283
+ const hidden = withCells(document(), {
284
+ c1: cell("c1", "", { code: null }),
285
+ });
286
+ act(() => store.set(source, hidden));
287
+ expect(result.current).toEqual({
288
+ code: null,
289
+ name: "_",
290
+ config: CONFIG,
291
+ order: ["c1", "c2"],
292
+ edit,
293
+ });
294
+ act(() => edit((tx) => tx.setCode("c1", "")));
295
+ expect(result.current.code).toBe("");
296
+ act(() => store.set(source, null));
297
+ expect(result.current).toEqual({ ...absent, order: null });
298
+ act(() => store.set(source, hidden));
299
+ expect(result.current.code).toBe("");
300
+ act(() =>
301
+ edit((tx) => {
302
+ tx.deleteCell("c1");
303
+ tx.deleteCell("c2");
304
+ }),
305
+ );
306
+ expect(result.current).toEqual({ ...absent, order: [] });
307
+ });
308
+
309
+ it("preserves drafts across Strict Mode remounts until the session ends", () => {
310
+ const { wrapper, controller, detached } = setup();
311
+ const useProbe = () => ({
312
+ code: useCellCode("c1"),
313
+ edit: useDocumentEdit(),
314
+ });
315
+ const first = renderHook(useProbe, { wrapper, reactStrictMode: true });
316
+ const edit = first.result.current.edit;
317
+ act(() => edit((tx) => tx.setCode("c1", "strict mode edit")));
318
+ expect(first.result.current.code).toBe("strict mode edit");
319
+ first.unmount();
320
+ expect(detached).not.toHaveBeenCalled();
321
+
322
+ const second = renderHook(useProbe, { wrapper, reactStrictMode: true });
323
+ expect(second.result.current.code).toBe("strict mode edit");
324
+ act(() => edit((tx) => tx.setCode("c1", "same session")));
325
+ expect(second.result.current.code).toBe("same session");
326
+ second.unmount();
327
+ controller.abort();
328
+ expect(detached).toHaveBeenCalledOnce();
329
+ const callback = vi.fn();
330
+ edit(callback);
331
+ expect(callback).not.toHaveBeenCalled();
332
+ });
333
+
334
+ it("switches reads and edits to the supplied model and store", () => {
335
+ const first = setup();
336
+ const second = setup(
337
+ withCells(document("n2"), {
338
+ c1: cell("c1", "second source", { notebook_id: "n2" }),
339
+ }),
340
+ );
341
+ const seen = vi.fn();
342
+ function Probe() {
343
+ seen({ code: useCellCode("c1"), edit: useDocumentEdit() });
344
+ return null;
345
+ }
346
+ const tree = ({ store, model }: typeof first) => (
347
+ <Provider store={store}>
348
+ <DocumentProvider model={model}>
349
+ <Probe />
350
+ </DocumentProvider>
351
+ </Provider>
352
+ );
353
+ const { rerender } = render(tree(first));
354
+ const oldEdit: ReturnType<typeof useDocumentEdit> =
355
+ seen.mock.calls.at(-1)![0].edit;
356
+ act(() => oldEdit((tx) => tx.setCode("c1", "first draft")));
357
+ rerender(tree(second));
358
+ act(() => first.controller.abort());
359
+ expect(first.detached).toHaveBeenCalledOnce();
360
+ const latest: {
361
+ code: string | null | undefined;
362
+ edit: ReturnType<typeof useDocumentEdit>;
363
+ } = seen.mock.calls.at(-1)![0];
364
+ expect(latest.code).toBe("second source");
365
+ expect(latest.edit).not.toBe(oldEdit);
366
+ act(() => latest.edit((tx) => tx.setCode("c1", "second draft")));
367
+ expect(seen.mock.calls.at(-1)![0].code).toBe("second draft");
368
+ const stale = vi.fn();
369
+ oldEdit(stale);
370
+ expect(stale).not.toHaveBeenCalled();
371
+ seen.mockClear();
372
+ act(() =>
373
+ first.store.set(
374
+ first.source,
375
+ withCells(document(), {
376
+ c1: cell("c1", "stale host"),
377
+ }),
378
+ ),
379
+ );
380
+ expect(seen).not.toHaveBeenCalled();
381
+ });
382
+
383
+ it("requires a document provider", () => {
384
+ expect(() => renderHook(useDocumentEdit)).toThrow("DocumentProvider");
385
+ });
386
+ });
@@ -0,0 +1,311 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ /**
4
+ * A CodeMirror binding between an editor and one cell's code.
5
+ *
6
+ * The editor and the document model each hold the code, and the binding keeps
7
+ * them equal in both directions.
8
+ *
9
+ * - Push. An update that could change the code writes it to the model before
10
+ * the update returns, so the model keeps pace with typing.
11
+ * - Pull. A model change reaches the editor in a microtask, because a view
12
+ * cannot dispatch while it is constructing or updating. The binding keeps
13
+ * the code both sides last agreed on, so the model's echo of a push is
14
+ * skipped while a different value, even one written during that push, is
15
+ * pulled. A real change is marked remote, stays out of undo history, and
16
+ * waits for IME composition to end.
17
+ *
18
+ * The editor shows code through marimo's language adapters, so a markdown
19
+ * cell edits the text inside `mo.md`. Both directions compare the cell's
20
+ * Python code rather than the editor text, so an adapter that normalizes what
21
+ * it reads back leaves typing alone.
22
+ *
23
+ * A pulled change replaces only what differs, using `diff` from
24
+ * `@codemirror/merge`. Separate edits stay separate, so the selection, folds,
25
+ * and diagnostics between them keep their place. The diff uses a scan limit of
26
+ * 500, as the merge view does. Past it, the search trades precision for speed,
27
+ * so a rewrite of a large cell costs microseconds and maps the selection
28
+ * coarsely instead of blocking the editor.
29
+ */
30
+
31
+ import { diff } from "@codemirror/merge";
32
+ import {
33
+ type ChangeSpec,
34
+ type EditorState,
35
+ type Extension,
36
+ type StateEffect,
37
+ Transaction,
38
+ } from "@codemirror/state";
39
+ import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
40
+ import type { CellId } from "@marimo-team/client/protocol";
41
+ import { dequal } from "dequal";
42
+ import type { Atom, createStore } from "jotai/vanilla";
43
+ import {
44
+ languageAdapterFromCode,
45
+ languageAdapterState,
46
+ switchLanguage,
47
+ } from "../codemirror/language/extension";
48
+ import { initializeSQLDialect } from "../codemirror/language/languages/sql/sql";
49
+ import {
50
+ languageMetadataField,
51
+ setLanguageMetadata,
52
+ } from "../codemirror/language/metadata";
53
+ import type { DocumentModel } from "./model";
54
+ import { selectCellField } from "./select";
55
+
56
+ /** The diff scan limit, matching the default of the merge view. */
57
+ const SCAN_LIMIT = 500;
58
+
59
+ interface Binding {
60
+ readonly store: ReturnType<typeof createStore>;
61
+ readonly model: DocumentModel;
62
+ readonly cellId: CellId;
63
+ /** Undefined while the cell is absent, null while its source is withheld. */
64
+ readonly code: Atom<string | null | undefined>;
65
+ }
66
+
67
+ /**
68
+ * Bind an editor to one cell's code.
69
+ *
70
+ * Editors without marimo's language configuration bind their text as is.
71
+ */
72
+ export function cellCodeBinding({
73
+ store,
74
+ model,
75
+ cellId,
76
+ }: {
77
+ store: ReturnType<typeof createStore>;
78
+ model: DocumentModel;
79
+ cellId: CellId;
80
+ }): Extension {
81
+ const binding: Binding = {
82
+ store,
83
+ model,
84
+ cellId,
85
+ code: selectCellField(model.documentAtom, cellId, "code"),
86
+ };
87
+ return ViewPlugin.define((view) => new CellCodeSync(view, binding), {
88
+ eventObservers: {
89
+ compositionend() {
90
+ this.compositionEnded();
91
+ },
92
+ },
93
+ });
94
+ }
95
+
96
+ class CellCodeSync {
97
+ #scheduled = false;
98
+ /** A pull arrived during IME composition and runs once it ends. */
99
+ #deferred = false;
100
+ /**
101
+ * A pull is dispatching to the editor.
102
+ *
103
+ * A language switch dispatches transactions of its own before the text, and
104
+ * their in-between states represent the wrong code. Until the switch can be
105
+ * part of the pull's one remote transaction, this keeps them from pushing.
106
+ */
107
+ #pulling = false;
108
+ /**
109
+ * The code the editor and the model last agreed on.
110
+ *
111
+ * A change the model cannot take clears it, so once the cell is available
112
+ * again its code is pulled even if it matches what was agreed before.
113
+ */
114
+ #synced: string | undefined;
115
+ #destroyed = false;
116
+ readonly #view: EditorView;
117
+ readonly #binding: Binding;
118
+ readonly #unsubscribe: () => void;
119
+
120
+ public constructor(view: EditorView, binding: Binding) {
121
+ this.#view = view;
122
+ this.#binding = binding;
123
+ // A change to the agreed code is the echo of a push and needs no pull.
124
+ this.#unsubscribe = binding.store.sub(binding.code, () => {
125
+ if (binding.store.get(binding.code) !== this.#synced) {
126
+ this.#schedule();
127
+ }
128
+ });
129
+ this.#schedule();
130
+ }
131
+
132
+ public update(update: ViewUpdate) {
133
+ if (!this.#pulling && changesCode(update)) {
134
+ this.#push(update.state);
135
+ }
136
+ if (this.#deferred && !update.view.composing) {
137
+ this.#schedule();
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Run a pull that waited for the composition.
143
+ *
144
+ * A composition that ends without changing the text need not produce an
145
+ * update. CodeMirror finishes handling the end in a microtask, so the pull
146
+ * waits for the next task.
147
+ */
148
+ public compositionEnded() {
149
+ if (this.#deferred) {
150
+ setTimeout(() => this.#schedule(), 0);
151
+ }
152
+ }
153
+
154
+ public destroy() {
155
+ this.#destroyed = true;
156
+ this.#unsubscribe();
157
+ }
158
+
159
+ /** Write the editor's code to the model unless the editor already shows it. */
160
+ #push(state: EditorState) {
161
+ const { store, model, cellId, code } = this.#binding;
162
+ const current = store.get(code);
163
+ if (typeof current !== "string") {
164
+ this.#synced = undefined;
165
+ return;
166
+ }
167
+ if (shows(state, current)) {
168
+ this.#synced = current;
169
+ return;
170
+ }
171
+ const next = codeOf(state);
172
+ this.#synced = next;
173
+ store.set(model.editAtom, (tx) => tx.setCode(cellId, next));
174
+ }
175
+
176
+ /** Show the model's code unless the editor already shows it. */
177
+ #pull() {
178
+ this.#scheduled = false;
179
+ const next = this.#binding.store.get(this.#binding.code);
180
+ if (this.#destroyed || typeof next !== "string") {
181
+ return;
182
+ }
183
+ if (this.#view.composing) {
184
+ this.#deferred = true;
185
+ return;
186
+ }
187
+ this.#deferred = false;
188
+ if (next !== this.#synced && !shows(this.#view.state, next)) {
189
+ this.#pulling = true;
190
+ try {
191
+ replaceCode(this.#view, next);
192
+ } finally {
193
+ this.#pulling = false;
194
+ }
195
+ }
196
+ this.#synced = next;
197
+ }
198
+
199
+ /** Pull once after the current update, however many changes arrive. */
200
+ #schedule() {
201
+ if (!this.#scheduled) {
202
+ this.#scheduled = true;
203
+ queueMicrotask(() => this.#pull());
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * The cell code an editor state represents.
210
+ *
211
+ * With a language adapter, this is the adapter's Python for the editor text.
212
+ */
213
+ function codeOf(state: EditorState): string {
214
+ const text = state.doc.toString();
215
+ const adapter = state.field(languageAdapterState, false);
216
+ return adapter
217
+ ? adapter.transformOut(
218
+ text,
219
+ state.field(languageMetadataField, false) ?? {},
220
+ )[0]
221
+ : text;
222
+ }
223
+
224
+ /**
225
+ * Whether an editor state shows the given cell code.
226
+ *
227
+ * An adapter can spell the same code more than one way, such as `mo.md("hi")`
228
+ * and its canonical form, so a state shows code that reads into its text and
229
+ * metadata as well as the code its own spelling produces.
230
+ */
231
+ function shows(state: EditorState, code: string): boolean {
232
+ if (codeOf(state) === code) {
233
+ return true;
234
+ }
235
+ const adapter = state.field(languageAdapterState, false);
236
+ if (!adapter) {
237
+ return false;
238
+ }
239
+ const [text, , metadata] = adapter.transformIn(code);
240
+ return (
241
+ text === state.doc.toString() &&
242
+ dequal(metadata, state.field(languageMetadataField, false))
243
+ );
244
+ }
245
+
246
+ /**
247
+ * Whether an update could change the code an editor represents.
248
+ *
249
+ * Language metadata, such as an SQL cell's output name, changes the code
250
+ * without changing the text.
251
+ */
252
+ function changesCode(update: ViewUpdate): boolean {
253
+ return (
254
+ update.docChanged ||
255
+ update.state.field(languageAdapterState, false) !==
256
+ update.startState.field(languageAdapterState, false) ||
257
+ update.state.field(languageMetadataField, false) !==
258
+ update.startState.field(languageMetadataField, false)
259
+ );
260
+ }
261
+
262
+ /**
263
+ * Show cell code in the language it implies.
264
+ *
265
+ * The language switch happens first, in its own transactions, and clears undo
266
+ * history because its entries edit the previous language's text. The text and
267
+ * metadata follow in one remote transaction that undo history leaves out. An
268
+ * SQL dialect depends on the engine in the metadata, so it is set again after.
269
+ */
270
+ function replaceCode(view: EditorView, code: string) {
271
+ let text = code;
272
+ const effects: StateEffect<unknown>[] = [];
273
+ let sql = false;
274
+ const adapter = view.state.field(languageAdapterState, false);
275
+ if (adapter) {
276
+ const target = languageAdapterFromCode(code);
277
+ sql = target.type === "sql";
278
+ if (target.type !== adapter.type) {
279
+ switchLanguage(view, { language: target.type, keepCodeAsIs: true });
280
+ }
281
+ const [inner, , metadata] = target.transformIn(code);
282
+ text = inner;
283
+ if (!dequal(metadata, view.state.field(languageMetadataField, false))) {
284
+ effects.push(setLanguageMetadata.of(metadata));
285
+ }
286
+ }
287
+ const current = view.state.doc.toString();
288
+ if (current === text && effects.length === 0) {
289
+ return;
290
+ }
291
+ view.dispatch({
292
+ changes: changesBetween(current, text),
293
+ effects,
294
+ annotations: [
295
+ Transaction.remote.of(true),
296
+ Transaction.addToHistory.of(false),
297
+ ],
298
+ });
299
+ if (sql && effects.length > 0) {
300
+ initializeSQLDialect(view);
301
+ }
302
+ }
303
+
304
+ /** The changes that turn one text into another, one per edited range. */
305
+ function changesBetween(from: string, to: string): ChangeSpec {
306
+ return diff(from, to, { scanLimit: SCAN_LIMIT }).map((change) => ({
307
+ from: change.fromA,
308
+ to: change.toA,
309
+ insert: to.slice(change.fromB, change.toB),
310
+ }));
311
+ }