@marimo-team/frontend 0.25.3-dev101 → 0.25.3-dev103
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.
- package/dist/assets/{CellStatus-fK4n2NPc.js → CellStatus-DVrvejMC.js} +1 -1
- package/dist/assets/{ErrorBoundary-CAEGdoFW.js → ErrorBoundary-0akNKvJm.js} +2 -2
- package/dist/assets/{JsonOutput-CyAKmZDD.js → JsonOutput-DeUmpc_f.js} +1 -1
- package/dist/assets/{MarimoErrorOutput-CgZvP0p0.js → MarimoErrorOutput-BazxeAYV.js} +1 -1
- package/dist/assets/{RenderHTML-DQvxDDNS.js → RenderHTML-CbAdeual.js} +1 -1
- package/dist/assets/{RunButton-BBDnDL6G.js → RunButton-CjKwgCFU.js} +1 -1
- package/dist/assets/{add-cell-with-ai-Clg5FELx.js → add-cell-with-ai-BKVEws3J.js} +1 -1
- package/dist/assets/{add-connection-dialog-BqdWmD-7.js → add-connection-dialog-DS7LTz1T.js} +1 -1
- package/dist/assets/{agent-panel-5RxG5Y82.js → agent-panel-B34QcdOK.js} +1 -1
- package/dist/assets/{ai-model-dropdown-Bvl1ktQq.js → ai-model-dropdown-DqFJCFVR.js} +1 -1
- package/dist/assets/{app-config-button-0QTeQ2Mv.js → app-config-button-CVh4UKi9.js} +1 -1
- package/dist/assets/{blob-5VcB8K_t.js → blob-Bw0ZQoLx.js} +1 -1
- package/dist/assets/{cell-editor-Byw5y-oI.js → cell-editor-DQ0io5Ye.js} +1 -1
- package/dist/assets/{cell-link-C5yYyzuF.js → cell-link-DvOIu6A3.js} +1 -1
- package/dist/assets/{cells-D_11MjVB.js → cells-Do-63MKT.js} +1 -1
- package/dist/assets/{chat-display-BCR_ySiV.js → chat-display-DnkhBxdh.js} +1 -1
- package/dist/assets/{chat-panel-RPRvBsaU.js → chat-panel-CN3qoX9G.js} +1 -1
- package/dist/assets/{chat-ui-CGviVaRA.js → chat-ui-B54K5qqr.js} +1 -1
- package/dist/assets/{column-preview-xe533-Cn.js → column-preview-DmxzHzaW.js} +1 -1
- package/dist/assets/{command-palette-BB9bWdWp.js → command-palette-CciCeRhy.js} +1 -1
- package/dist/assets/{common-CaeItbcI.js → common-BCyx7t1i.js} +1 -1
- package/dist/assets/{components-G6frbw0c.js → components-CDvWXfXq.js} +1 -1
- package/dist/assets/{datasource-deuXnlU-.js → datasource-DieftweT.js} +1 -1
- package/dist/assets/{dependency-graph-panel-CIykLnJe.js → dependency-graph-panel-IfcqTHgq.js} +1 -1
- package/dist/assets/{documentation-panel-2PQ8kQLa.js → documentation-panel-DeLCgVcq.js} +1 -1
- package/dist/assets/{download-dK52bLxZ.js → download-BzfVCqUq.js} +1 -1
- package/dist/assets/{edit-page-DrElzw10.js → edit-page-HMyk4cWw.js} +4 -4
- package/dist/assets/{error-entries-3GVk2KjS.js → error-entries-DjjWrbt4.js} +1 -1
- package/dist/assets/{error-panel-CzDRGPeC.js → error-panel-CiG3JJA3.js} +1 -1
- package/dist/assets/{feedback-button-C5NgWpFg.js → feedback-button-B8zVQEi6.js} +1 -1
- package/dist/assets/feedback-button-DQdMkSvR.js +1 -0
- package/dist/assets/{file-explorer-panel-B50xDnTZ.js → file-explorer-panel-BPD8Q0L-.js} +1 -1
- package/dist/assets/{file-name-input-DqwMSgGU.js → file-name-input-CdkF9K3I.js} +1 -1
- package/dist/assets/{floating-outline-ChmAuvxz.js → floating-outline-TfDWJXBm.js} +1 -1
- package/dist/assets/{gallery-page-CiL8aMS1.js → gallery-page-BJpenPdQ.js} +1 -1
- package/dist/assets/{glide-data-editor-CqRclBkp.js → glide-data-editor-DO15Nm-R.js} +1 -1
- package/dist/assets/{home-page-BgZVl4dP.js → home-page-mvqldGvD.js} +1 -1
- package/dist/assets/{hooks-DM5n7Osw.js → hooks-C4AJfqV0.js} +1 -1
- package/dist/assets/{index-EG2n8Oht.js → index-C2XzUsS7.js} +4 -4
- package/dist/assets/{layout-BpP8nTjb.js → layout-1kSh6fBF.js} +3 -3
- package/dist/assets/{logs-panel-B5eVAZtH.js → logs-panel--QbHo7uS.js} +1 -1
- package/dist/assets/{markdown-renderer-BWUzqkJM.js → markdown-renderer-DJ7H8Rps.js} +1 -1
- package/dist/assets/{name-cell-input-DXW68gRW.js → name-cell-input-6Gd19S8T.js} +1 -1
- package/dist/assets/{outline-panel-DPxJhJ_A.js → outline-panel-CjKvunvX.js} +1 -1
- package/dist/assets/{packages-panel-gBE2FWQr.js → packages-panel-Gyrqi-MZ.js} +1 -1
- package/dist/assets/{panel-accordion-state-CCF5Gxnc.js → panel-accordion-state-D49TNCLQ.js} +1 -1
- package/dist/assets/{panels-Dr6qMCvU.js → panels-DfbxoeGa.js} +1 -1
- package/dist/assets/{process-output-Cw9IDcKQ.js → process-output-CgkoeA1-.js} +1 -1
- package/dist/assets/{readonly-code-display-DYcZQ28T.js → readonly-code-display-BMbl_4BR.js} +1 -1
- package/dist/assets/{readonly-python-code-RFuQP50v.js → readonly-python-code-BnPfpOJM.js} +1 -1
- package/dist/assets/{reveal-component-yRrDmCQz.js → reveal-component-D84yAzG7.js} +1 -1
- package/dist/assets/{run-page-B69M4-zE.js → run-page-B10s8Pre.js} +1 -1
- package/dist/assets/{runtime-details-state-pobtu6TO.js → runtime-details-state-Bkmgb59p.js} +1 -1
- package/dist/assets/{scratchpad-panel-H44_pvb_.js → scratchpad-panel-DtyjJ3yN.js} +1 -1
- package/dist/assets/{session-panel-Bnd1iXkO.js → session-panel-Chaq1do8.js} +1 -1
- package/dist/assets/{snippets-panel-DKApxKx9.js → snippets-panel-BaZmddOW.js} +1 -1
- package/dist/assets/{state-Dv059B3A.js → state-CqnCwHhY.js} +1 -1
- package/dist/assets/{state-Bl9BiY_s.js → state-Db73TZVD.js} +1 -1
- package/dist/assets/{tracing-Clwy4SlW.js → tracing-C2vQMUI0.js} +1 -1
- package/dist/assets/{tracing-panel-BwjLG_aC.js → tracing-panel-B4L2S2ac.js} +2 -2
- package/dist/assets/{tree-actions-BZNTQ12N.js → tree-actions-JXDiioHK.js} +1 -1
- package/dist/assets/{useAddCell-Cjuqk2YR.js → useAddCell-CP3HIjGl.js} +1 -1
- package/dist/assets/{useCellActionButton-MoWLvuGe.js → useCellActionButton-yi1_-emd.js} +1 -1
- package/dist/assets/{useDeleteCell-BC-MW7d1.js → useDeleteCell-BPLwyClM.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-CZU7i-B3.js → useDependencyPanelTab-DmBiydXO.js} +1 -1
- package/dist/assets/{useNotebookActions-wFxVdRXQ.js → useNotebookActions-CfmgcJGx.js} +1 -1
- package/dist/assets/{useRunCells-ByaWxoIt.js → useRunCells-jF-7tJkw.js} +1 -1
- package/dist/assets/{useSplitCell-YJIystwv.js → useSplitCell-B6zWL8Zf.js} +1 -1
- package/dist/index.html +20 -20
- package/package.json +1 -1
- package/src/core/codemirror/cells/__tests__/extensions.test.ts +15 -0
- package/src/core/codemirror/cells/extensions.ts +7 -3
- package/src/core/document/__tests__/codemirror.test.ts +483 -0
- package/src/core/document/__tests__/model.test.ts +6 -6
- package/src/core/document/__tests__/state.test.ts +5 -13
- package/src/core/document/__tests__/submission.test.ts +2 -0
- package/src/core/document/__tests__/user.ts +3 -3
- package/src/core/document/codemirror.ts +311 -0
- package/src/core/document/index.ts +3 -1
- package/src/core/document/model.ts +7 -8
- package/src/core/document/select.ts +41 -0
- package/src/core/document/state.ts +3 -30
- package/src/core/document/sync.ts +29 -25
- package/dist/assets/feedback-button-CONwLFpw.js +0 -1
|
@@ -67,8 +67,8 @@ function setup() {
|
|
|
67
67
|
unmounted,
|
|
68
68
|
edit: (code: string) =>
|
|
69
69
|
store.set(model.editAtom, (tx) => tx.setCode("c1", code)),
|
|
70
|
-
|
|
71
|
-
value: () => store.get(model.
|
|
70
|
+
status: () => store.get(model.syncStatusAtom),
|
|
71
|
+
value: () => store.get(model.documentAtom)?.cells.get("c1"),
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -102,7 +102,7 @@ describe("document synchronization", () => {
|
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
it("fails without repeating a request when the response names another notebook", async () => {
|
|
105
|
-
const { sent, replies, edit, model,
|
|
105
|
+
const { sent, replies, edit, model, status } = setup();
|
|
106
106
|
edit("local");
|
|
107
107
|
const rejected = expectFailure(model.flush(), "another notebook");
|
|
108
108
|
await vi.advanceTimersByTimeAsync(0);
|
|
@@ -110,7 +110,7 @@ describe("document synchronization", () => {
|
|
|
110
110
|
await rejected;
|
|
111
111
|
await vi.advanceTimersByTimeAsync(5000);
|
|
112
112
|
expect(sent).toHaveBeenCalledOnce();
|
|
113
|
-
expect(
|
|
113
|
+
expect(status()).toMatchObject({ status: "error" });
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
it("ignores a late response and source update after its lifetime ends", async () => {
|
|
@@ -145,7 +145,7 @@ describe("document synchronization", () => {
|
|
|
145
145
|
});
|
|
146
146
|
|
|
147
147
|
it("aborts an in-flight edit when notebook identity changes", async () => {
|
|
148
|
-
const { store, source, sent, edit, model,
|
|
148
|
+
const { store, source, sent, edit, model, status } = setup();
|
|
149
149
|
edit("local");
|
|
150
150
|
const flushed = model.flush();
|
|
151
151
|
const rejected = expectFailure(flushed, "notebook changed");
|
|
@@ -153,6 +153,6 @@ describe("document synchronization", () => {
|
|
|
153
153
|
store.set(source, document("n2"));
|
|
154
154
|
await rejected;
|
|
155
155
|
expect(sent.mock.calls[0][0].signal.aborted).toBe(true);
|
|
156
|
-
expect(
|
|
156
|
+
expect(status()).toMatchObject({ status: "error", retryable: false });
|
|
157
157
|
});
|
|
158
158
|
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type DocumentEdit,
|
|
9
9
|
type DocumentEditor,
|
|
10
10
|
} from "../state";
|
|
11
|
+
import { selectCell, selectOrder } from "../select";
|
|
11
12
|
import { cell, document, withCells } from "./fixtures";
|
|
12
13
|
|
|
13
14
|
const lifetimes: AbortController[] = [];
|
|
@@ -29,6 +30,8 @@ function setup(initial: Document | null = document()) {
|
|
|
29
30
|
store,
|
|
30
31
|
source,
|
|
31
32
|
...model,
|
|
33
|
+
cellAtom: (id: string) => selectCell(model.documentAtom, id),
|
|
34
|
+
orderAtom: selectOrder(model.documentAtom),
|
|
32
35
|
abort: () => controller.abort(),
|
|
33
36
|
edit: (callback: DocumentEdit) => store.set(model.editAtom, callback),
|
|
34
37
|
};
|
|
@@ -252,10 +255,9 @@ describe("document model", () => {
|
|
|
252
255
|
unsubscribeOrder();
|
|
253
256
|
});
|
|
254
257
|
|
|
255
|
-
it("isolates cell
|
|
258
|
+
it("isolates cell and order subscriptions across local and host updates", () => {
|
|
256
259
|
const initial = document();
|
|
257
|
-
const { store, source, cellAtom, orderAtom,
|
|
258
|
-
setup(initial);
|
|
260
|
+
const { store, source, cellAtom, orderAtom, edit } = setup(initial);
|
|
259
261
|
edit((tx) => {
|
|
260
262
|
tx.setCode("c1", "local");
|
|
261
263
|
tx.createCell({ id: "c3", code: "new" });
|
|
@@ -263,16 +265,13 @@ describe("document model", () => {
|
|
|
263
265
|
const c1 = cellAtom("c1");
|
|
264
266
|
const first = store.get(c1);
|
|
265
267
|
const order = store.get(orderAtom);
|
|
266
|
-
const config = store.get(appConfigAtom);
|
|
267
268
|
const onFirst = vi.fn();
|
|
268
269
|
const onSecond = vi.fn();
|
|
269
270
|
const onOrder = vi.fn();
|
|
270
|
-
const onConfig = vi.fn();
|
|
271
271
|
const unsubscribe = [
|
|
272
272
|
store.sub(c1, onFirst),
|
|
273
273
|
store.sub(cellAtom("c2"), onSecond),
|
|
274
274
|
store.sub(orderAtom, onOrder),
|
|
275
|
-
store.sub(appConfigAtom, onConfig),
|
|
276
275
|
];
|
|
277
276
|
|
|
278
277
|
edit((tx) => tx.setCode("c2", "other local"));
|
|
@@ -289,11 +288,9 @@ describe("document model", () => {
|
|
|
289
288
|
});
|
|
290
289
|
expect(store.get(c1)).toBe(first);
|
|
291
290
|
expect(store.get(orderAtom)).toBe(order);
|
|
292
|
-
expect(store.get(appConfigAtom)).toBe(config);
|
|
293
291
|
expect(onFirst).not.toHaveBeenCalled();
|
|
294
292
|
expect(onSecond).toHaveBeenCalledOnce();
|
|
295
293
|
expect(onOrder).not.toHaveBeenCalled();
|
|
296
|
-
expect(onConfig).not.toHaveBeenCalled();
|
|
297
294
|
|
|
298
295
|
store.set(
|
|
299
296
|
source,
|
|
@@ -311,11 +308,6 @@ describe("document model", () => {
|
|
|
311
308
|
edit((tx) => tx.setOrder(["c3", "c2", "c1"]));
|
|
312
309
|
expect(onOrder).toHaveBeenCalledOnce();
|
|
313
310
|
expect(onFirst).toHaveBeenCalledOnce();
|
|
314
|
-
store.set(source, {
|
|
315
|
-
...remote,
|
|
316
|
-
revision: { ...remote.revision, app_config: { width: "full" } },
|
|
317
|
-
});
|
|
318
|
-
expect(onConfig).toHaveBeenCalledOnce();
|
|
319
311
|
unsubscribe.forEach((fn) => fn());
|
|
320
312
|
});
|
|
321
313
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { atom, createStore } from "jotai/vanilla";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { Document } from "../../host/resources/document";
|
|
6
|
+
import { selectCell } from "../select";
|
|
6
7
|
import { createDocumentState, type DocumentSubmission } from "../state";
|
|
7
8
|
import { cell, document, withCells } from "./fixtures";
|
|
8
9
|
|
|
@@ -25,6 +26,7 @@ function setup() {
|
|
|
25
26
|
store,
|
|
26
27
|
source,
|
|
27
28
|
...model,
|
|
29
|
+
cellAtom: (id: string) => selectCell(model.documentAtom, id),
|
|
28
30
|
submit() {
|
|
29
31
|
const submission = store.set(model.submitAtom);
|
|
30
32
|
if (submission === null) {
|
|
@@ -43,7 +43,7 @@ export function createUser(
|
|
|
43
43
|
store.set(model.editAtom, (tx) => tx.deleteCell(id));
|
|
44
44
|
},
|
|
45
45
|
cell(id: Protocol.CellId) {
|
|
46
|
-
const cell = store.get(model.
|
|
46
|
+
const cell = store.get(model.documentAtom)?.cells.get(id);
|
|
47
47
|
return cell === undefined
|
|
48
48
|
? undefined
|
|
49
49
|
: {
|
|
@@ -53,8 +53,8 @@ export function createUser(
|
|
|
53
53
|
config: cell.config,
|
|
54
54
|
};
|
|
55
55
|
},
|
|
56
|
-
order: () => store.get(model.
|
|
57
|
-
status: () => store.get(model.
|
|
56
|
+
order: () => store.get(model.documentAtom)?.order ?? null,
|
|
57
|
+
status: () => store.get(model.syncStatusAtom),
|
|
58
58
|
sync: () => observe(model.flush()),
|
|
59
59
|
retry: () => observe(model.retry()),
|
|
60
60
|
};
|
|
@@ -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
|
+
}
|
|
@@ -6,18 +6,19 @@ import { createDocumentState, type DocumentState } from "./state";
|
|
|
6
6
|
import {
|
|
7
7
|
createDocumentSync,
|
|
8
8
|
type DocumentHost,
|
|
9
|
-
type
|
|
9
|
+
type DocumentSyncStatus,
|
|
10
10
|
} from "./sync";
|
|
11
11
|
|
|
12
12
|
export type { DocumentEdit, DocumentEditor } from "./state";
|
|
13
|
-
export type {
|
|
13
|
+
export type { EditableCell, EditableDocument } from "./draft";
|
|
14
|
+
export type { DocumentHost, DocumentSyncStatus } from "./sync";
|
|
14
15
|
|
|
15
16
|
/** The editable document and its synchronization with the host. */
|
|
16
17
|
export interface DocumentModel extends Pick<
|
|
17
18
|
DocumentState,
|
|
18
|
-
"
|
|
19
|
+
"documentAtom" | "editAtom"
|
|
19
20
|
> {
|
|
20
|
-
readonly
|
|
21
|
+
readonly syncStatusAtom: Atom<DocumentSyncStatus>;
|
|
21
22
|
/** Drain local edits and return the confirmed revision. */
|
|
22
23
|
flush(): Promise<Protocol.Document>;
|
|
23
24
|
/** Retry a rejection against fresh state, or repeat an uncertain request exactly. */
|
|
@@ -42,11 +43,9 @@ export function createDocumentModel({
|
|
|
42
43
|
});
|
|
43
44
|
const sync = createDocumentSync({ store, notebook, state, signal });
|
|
44
45
|
return {
|
|
45
|
-
|
|
46
|
-
orderAtom: state.orderAtom,
|
|
47
|
-
appConfigAtom: state.appConfigAtom,
|
|
46
|
+
documentAtom: state.documentAtom,
|
|
48
47
|
editAtom: state.editAtom,
|
|
49
|
-
|
|
48
|
+
syncStatusAtom: sync.statusAtom,
|
|
50
49
|
flush: sync.flush,
|
|
51
50
|
retry: sync.retry,
|
|
52
51
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type * as Protocol from "@marimo-team/client/protocol";
|
|
4
|
+
import { dequal } from "dequal";
|
|
5
|
+
import type { Atom } from "jotai/vanilla";
|
|
6
|
+
import { selectAtom } from "jotai/vanilla/utils";
|
|
7
|
+
import { type EditableCell, type EditableDocument, sameCell } from "./draft";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Select one cell of a document.
|
|
11
|
+
*
|
|
12
|
+
* The atom notifies when the cell's code, name, or config changes. A new host
|
|
13
|
+
* record with the same values leaves it unchanged. Each call creates an atom,
|
|
14
|
+
* so keep it for as long as its subscription.
|
|
15
|
+
*/
|
|
16
|
+
export function selectCell(
|
|
17
|
+
document: Atom<EditableDocument | null>,
|
|
18
|
+
id: Protocol.CellId,
|
|
19
|
+
): Atom<EditableCell | undefined> {
|
|
20
|
+
return selectAtom(
|
|
21
|
+
document,
|
|
22
|
+
(value) => value?.cells.get(id),
|
|
23
|
+
(a, b) => a === b || (a !== undefined && b !== undefined && sameCell(a, b)),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Select a document's cell order, null while the document is unavailable. */
|
|
28
|
+
export function selectOrder(
|
|
29
|
+
document: Atom<EditableDocument | null>,
|
|
30
|
+
): Atom<readonly Protocol.CellId[] | null> {
|
|
31
|
+
return selectAtom(document, (value) => value?.order ?? null, dequal);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Select one field of a cell, unchanged until that field's value changes. */
|
|
35
|
+
export function selectCellField<K extends keyof EditableCell>(
|
|
36
|
+
document: Atom<EditableDocument | null>,
|
|
37
|
+
id: Protocol.CellId,
|
|
38
|
+
field: K,
|
|
39
|
+
): Atom<EditableCell[K] | undefined> {
|
|
40
|
+
return selectAtom(document, (value) => value?.cells.get(id)?.[field], dequal);
|
|
41
|
+
}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import { dequal } from "dequal";
|
|
4
3
|
import {
|
|
5
4
|
atom,
|
|
6
5
|
type Atom,
|
|
7
6
|
type createStore,
|
|
8
7
|
type WritableAtom,
|
|
9
8
|
} from "jotai/vanilla";
|
|
10
|
-
import { selectAtom } from "jotai/vanilla/utils";
|
|
11
9
|
import type { Document } from "../host/resources/document";
|
|
12
10
|
import * as Protocol from "@marimo-team/client/protocol";
|
|
13
11
|
import {
|
|
@@ -16,14 +14,12 @@ import {
|
|
|
16
14
|
createCell,
|
|
17
15
|
deleteCell,
|
|
18
16
|
type Draft,
|
|
19
|
-
type EditableCell,
|
|
20
17
|
type EditableDocument,
|
|
21
18
|
editCell,
|
|
22
19
|
EMPTY_DRAFT,
|
|
23
20
|
isEmptyDraft,
|
|
24
21
|
type Placement,
|
|
25
22
|
prune,
|
|
26
|
-
sameCell,
|
|
27
23
|
setOrder,
|
|
28
24
|
toChanges,
|
|
29
25
|
viewDocument,
|
|
@@ -70,12 +66,8 @@ interface ModelState {
|
|
|
70
66
|
* Switching notebook identity invalidates it, retaining each notebook's draft.
|
|
71
67
|
*/
|
|
72
68
|
export interface DocumentState {
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
readonly orderAtom: Atom<readonly Protocol.CellId[] | null>;
|
|
76
|
-
readonly appConfigAtom: Atom<Readonly<
|
|
77
|
-
Protocol.Document["app_config"]
|
|
78
|
-
> | null>;
|
|
69
|
+
/** Confirmed host state with local drafts applied, null while unavailable. */
|
|
70
|
+
readonly documentAtom: Atom<EditableDocument | null>;
|
|
79
71
|
/** Null when the document or source needed by a put is unavailable. */
|
|
80
72
|
readonly changesAtom: Atom<readonly Protocol.Change[] | null>;
|
|
81
73
|
/** Remains pending until both the response and its streamed revision arrive. */
|
|
@@ -158,23 +150,6 @@ export function createDocumentState({
|
|
|
158
150
|
? null
|
|
159
151
|
: viewDocument(state.confirmed, draftOf(state));
|
|
160
152
|
});
|
|
161
|
-
const cellAtom = (id: Protocol.CellId) =>
|
|
162
|
-
selectAtom(
|
|
163
|
-
documentAtom,
|
|
164
|
-
(document) => document?.cells.get(id),
|
|
165
|
-
(a, b) =>
|
|
166
|
-
a === b || (a !== undefined && b !== undefined && sameCell(a, b)),
|
|
167
|
-
);
|
|
168
|
-
const orderAtom = selectAtom(
|
|
169
|
-
documentAtom,
|
|
170
|
-
(document) => document?.order ?? null,
|
|
171
|
-
dequal,
|
|
172
|
-
);
|
|
173
|
-
const appConfigAtom = selectAtom(
|
|
174
|
-
stateAtom,
|
|
175
|
-
(state) => state.confirmed?.revision.app_config ?? null,
|
|
176
|
-
dequal,
|
|
177
|
-
);
|
|
178
153
|
const changesAtom = atom((get): readonly Protocol.Change[] | null => {
|
|
179
154
|
const state = get(stateAtom);
|
|
180
155
|
return state.confirmed === null
|
|
@@ -304,9 +279,7 @@ export function createDocumentState({
|
|
|
304
279
|
}
|
|
305
280
|
|
|
306
281
|
return {
|
|
307
|
-
|
|
308
|
-
orderAtom,
|
|
309
|
-
appConfigAtom,
|
|
282
|
+
documentAtom,
|
|
310
283
|
changesAtom,
|
|
311
284
|
pendingAtom,
|
|
312
285
|
editAtom,
|