@marimo-team/islands 0.25.3-dev169 → 0.25.3-dev170
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/{ConnectedDataExplorerComponent-BbDrlHvG.js → ConnectedDataExplorerComponent-BbSXV-Xa.js} +8 -8
- package/dist/{ErrorBoundary-RaIwGWT3.js → ErrorBoundary-NE86qEWZ.js} +4 -4
- package/dist/{abnfDiagram-VCTEODGH-Dr-fo8_z.js → abnfDiagram-VCTEODGH-D7n07nXf.js} +2 -2
- package/dist/{architectureDiagram-5GKGNRK7-ByKIl9GG.js → architectureDiagram-5GKGNRK7-D6CjRib5.js} +1 -1
- package/dist/{chat-ui-BsV2WH-4.js → chat-ui-CC7GekpG.js} +34 -34
- package/dist/{chunk-IMKFNOWR-IKDP18MQ.js → chunk-IMKFNOWR-6dtPz7uS.js} +1 -1
- package/dist/{chunk-SHT3W25Y-LkIgegwh.js → chunk-SHT3W25Y-BwnFNKDA.js} +1 -1
- package/dist/{chunk-SVP7TREG-DReBYtnB.js → chunk-SVP7TREG-D2eV4vBJ.js} +1 -1
- package/dist/{chunk-TICWLB2K-DQ0A0-1D.js → chunk-TICWLB2K-BDs4-_-r.js} +1 -1
- package/dist/{classDiagram-ZZMXUADV-CwvXdCNE.js → classDiagram-ZZMXUADV-DCJnf_8v.js} +2 -2
- package/dist/{classDiagram-v2-VYDZK3BY-D0XAxDsc.js → classDiagram-v2-VYDZK3BY-BOmSzPBD.js} +2 -2
- package/dist/{code-visibility-BMi0KLAQ.js → code-visibility-Bzvc6ajl.js} +23 -23
- package/dist/{common-BMkJrZsD.js → common-Ciw4zkh2.js} +3412 -3412
- package/dist/{cynefinDiagram-5FMLGOSQ-BFN68nHr.js → cynefinDiagram-5FMLGOSQ-Bln_Soky.js} +1 -1
- package/dist/{dagre-GXQ25YYZ-CZt4rtLq.js → dagre-GXQ25YYZ-D-nf7WzW.js} +1 -1
- package/dist/{data-grid-overlay-editor-CCc1P42P.js → data-grid-overlay-editor-AQREmxI7.js} +1 -1
- package/dist/{diagram-S7CK7UJ4-D5cOz47_.js → diagram-S7CK7UJ4-PsgSMkY2.js} +1 -1
- package/dist/{diagram-UQ7AKVKN-Qgdppb4a.js → diagram-UQ7AKVKN-DfwfUuSw.js} +1 -1
- package/dist/{diagram-VX7I27RA-BsDCvily.js → diagram-VX7I27RA-C9_e-oMc.js} +1 -1
- package/dist/{diagram-Z3DM3KII-igTkGjKI.js → diagram-Z3DM3KII-CWLa5tuY.js} +1 -1
- package/dist/{dropdown-menu-BWnnAstC.js → dropdown-menu-B6ftyzOg.js} +1 -1
- package/dist/{ebnfDiagram-PWID7BFC-BHuZGjIr.js → ebnfDiagram-PWID7BFC-B1QRM58v.js} +2 -2
- package/dist/{erDiagram-RLTQ6QDP-DKObXTVE.js → erDiagram-RLTQ6QDP-DJK_FqvW.js} +1 -1
- package/dist/{error-entries-BpUJSLUZ.js → error-entries-DgK3wUJR.js} +5640 -5591
- package/dist/{feedback-button-CMVkEos_.js → feedback-button-8a6SETmT.js} +24 -24
- package/dist/{flowDiagram-HODETNUW-DZI_kit0.js → flowDiagram-HODETNUW-Uxc25XSJ.js} +2 -2
- package/dist/{formats-CwJHWasD.js → formats-DQCyPd5Z.js} +1 -1
- package/dist/{glide-data-editor-BA2pcHSV.js → glide-data-editor-B70oke_v.js} +5 -5
- package/dist/{globals-B5jjJ0ms.js → globals-Cxd5hNlI.js} +2 -2
- package/dist/{html-to-image-CQr2-IDA.js → html-to-image-D4yG5Ru0.js} +25 -25
- package/dist/{infoDiagram-27XIBGKW-DdPl-7-n.js → infoDiagram-27XIBGKW-DmjxqwL0.js} +1 -1
- package/dist/{ishikawaDiagram-5VMMS53U-DbyW6l59.js → ishikawaDiagram-5VMMS53U-NgCLHqQi.js} +1 -1
- package/dist/{kanban-definition-UXKFOSKX-BMX9SyBS.js → kanban-definition-UXKFOSKX-CZaG-iam.js} +1 -1
- package/dist/main.js +187 -188
- package/dist/{mermaid-C8W8mTbA.js → mermaid-CSvw7c71.js} +34 -34
- package/dist/{mindmap-definition-YA3MSWOX-Zd_tAxWL.js → mindmap-definition-YA3MSWOX-DJYQBDH_.js} +1 -1
- package/dist/{number-overlay-editor-BIWiV-TU.js → number-overlay-editor-ChK-SKUk.js} +1 -1
- package/dist/{pegDiagram-XKGWAZYB-DjYydPdo.js → pegDiagram-XKGWAZYB-CHKO_dAe.js} +2 -2
- package/dist/{pieDiagram-E7YTZNPT-R5LKjO2n.js → pieDiagram-E7YTZNPT-DVevbMAo.js} +1 -1
- package/dist/{process-output-Uw-VJE5V.js → process-output-DxvBAeL3.js} +3 -3
- package/dist/{railroadDiagram-O6MQD6OU-C7uNKtfm.js → railroadDiagram-O6MQD6OU-Detj1kcB.js} +2 -2
- package/dist/{requirementDiagram-BXWQKSXE-CxAIzQfl.js → requirementDiagram-BXWQKSXE-BLFD0T_Z.js} +1 -1
- package/dist/{reveal-component-XiecZ3Ua.js → reveal-component-CXMXeDZD.js} +6 -6
- package/dist/{sequenceDiagram-WJ2MYXX4-B5tmlPd5.js → sequenceDiagram-WJ2MYXX4-BZBgatQV.js} +1 -1
- package/dist/{spec-45FnujvG.js → spec-Mnkn3XEr.js} +1 -1
- package/dist/{stateDiagram-D77RDMKH-C1hc5H7Y.js → stateDiagram-D77RDMKH-4BZW9-6_.js} +2 -2
- package/dist/{stateDiagram-v2-MP3YSRHH-DRWeqQTn.js → stateDiagram-v2-MP3YSRHH-C4sKC8Iq.js} +2 -2
- package/dist/style.css +1 -1
- package/dist/{swimlanes-42K2YHIH-NTLas--s.js → swimlanes-42K2YHIH-SL5yeGe2.js} +1 -1
- package/dist/{swimlanesDiagram-VR7AAH4N-ChPggljD.js → swimlanesDiagram-VR7AAH4N-DqPmAYUv.js} +2 -2
- package/dist/{timeline-definition-24CTP7MA-B4CZ9srJ.js → timeline-definition-24CTP7MA-BfXUMWkq.js} +1 -1
- package/dist/{toDate-rySyYxqk.js → toDate-TMubKIVL.js} +22 -22
- package/dist/{useAsyncData-B7MiltyV.js → useAsyncData-DjMGFzFy.js} +1 -1
- package/dist/{useDeepCompareMemoize-DZmHBD_1.js → useDeepCompareMemoize-BQR5TJot.js} +1 -1
- package/dist/{useLifecycle-DRbRhzxg.js → useLifecycle-C_GvKWbQ.js} +26 -23
- package/dist/{useTheme-TLkqJsvo.js → useTheme-Bns2gJZ6.js} +179 -178
- package/dist/{vega-component-DC6P127L.js → vega-component-BsVptuhz.js} +5 -5
- package/dist/{vennDiagram-4TSXK5OY-DJIvMt44.js → vennDiagram-4TSXK5OY-CH65znGa.js} +1 -1
- package/dist/{wardleyDiagram-VM6X3IG4-45H0zg0u.js → wardleyDiagram-VM6X3IG4-z9pWKb2_.js} +1 -1
- package/dist/{xychartDiagram-S5SC5T6Z-ZT-XzcPN.js → xychartDiagram-S5SC5T6Z-MxyaWjjq.js} +1 -1
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +0 -1
- package/src/components/editor/cell/StagedAICell.tsx +10 -2
- package/src/components/editor/cell/code/cell-editor.tsx +7 -14
- package/src/components/editor/chrome/wrapper/__tests__/document-sync-status.test.tsx +92 -0
- package/src/components/editor/chrome/wrapper/footer-items/document-sync-status.tsx +54 -0
- package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
- package/src/core/ai/__tests__/staged-cells.test.ts +8 -5
- package/src/core/ai/staged-cells.ts +7 -2
- package/src/core/ai/tools/__tests__/edit-notebook-tool.test.ts +3 -15
- package/src/core/ai/tools/edit-notebook-tool.ts +14 -3
- package/src/core/cells/__tests__/cells.test.ts +0 -297
- package/src/core/cells/__tests__/document.test.ts +410 -0
- package/src/core/cells/__tests__/flush-document.test.ts +40 -0
- package/src/core/cells/__tests__/notebook-outline.test.ts +1 -6
- package/src/core/cells/__tests__/with-document-save.test.ts +67 -0
- package/src/core/cells/cells.ts +27 -158
- package/src/core/cells/columns.ts +38 -0
- package/src/core/cells/document.ts +360 -0
- package/src/core/cells/types.ts +12 -0
- package/src/core/codemirror/cells/__tests__/extensions.test.ts +27 -1
- package/src/core/codemirror/cells/extensions.ts +14 -0
- package/src/core/codemirror/format.ts +6 -5
- package/src/core/codemirror/language/__tests__/utils.test.ts +187 -15
- package/src/core/codemirror/language/cell-code.ts +114 -0
- package/src/core/codemirror/language/extension.ts +47 -30
- package/src/core/codemirror/language/languages/sql/sql.ts +7 -17
- package/src/core/codemirror/language/utils.ts +9 -39
- package/src/core/codemirror/lsp/notebook-lsp.ts +6 -5
- package/src/core/codemirror/rtc/extension.ts +0 -1
- package/src/core/edit-app.tsx +4 -3
- package/src/core/export/layout.ts +2 -2
- package/src/core/islands/bridge.ts +0 -1
- package/src/core/network/requests-lazy.ts +0 -1
- package/src/core/network/requests-network.ts +0 -9
- package/src/core/network/requests-static.ts +0 -1
- package/src/core/network/requests-toasting.tsx +0 -1
- package/src/core/network/types.ts +0 -5
- package/src/core/notebook/document/__tests__/codemirror.test.ts +76 -14
- package/src/core/notebook/document/__tests__/hold.test.ts +88 -0
- package/src/core/notebook/document/__tests__/hooks.test.tsx +3 -3
- package/src/core/notebook/document/__tests__/lifecycle-scenarios.test.ts +3 -3
- package/src/core/notebook/document/__tests__/model.test.ts +71 -15
- package/src/core/notebook/document/__tests__/scenario-fixture.ts +1 -1
- package/src/core/notebook/document/codemirror.ts +27 -122
- package/src/core/notebook/document/hooks.tsx +2 -2
- package/src/core/notebook/document/index.ts +9 -1
- package/src/core/notebook/document/model.ts +74 -24
- package/src/core/notebook/document/sync.ts +53 -4
- package/src/core/run-app.tsx +44 -41
- package/src/core/saving/save-component.tsx +2 -5
- package/src/core/session/__tests__/useNotebookSession.hook.test.tsx +63 -3
- package/src/core/session/active-notebook.ts +4 -0
- package/src/core/session/useNotebookSession.tsx +39 -19
- package/src/core/wasm/__tests__/bridge.test.ts +3 -3
- package/src/core/wasm/bridge.ts +5 -12
- package/src/core/wasm/worker/worker.ts +0 -9
- package/src/utils/__tests__/{createReducer.test.ts → createReducer.test.tsx} +28 -0
- package/src/utils/createReducer.ts +42 -21
- package/src/core/cells/__tests__/apply-transaction.test.ts +0 -991
- package/src/core/cells/__tests__/document-changes.test.ts +0 -1140
- package/src/core/cells/__tests__/document-roundtrip.test.ts +0 -392
- package/src/core/cells/document-changes.ts +0 -944
|
@@ -28,34 +28,24 @@
|
|
|
28
28
|
* coarsely instead of blocking the editor.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import { diff } from "@codemirror/merge";
|
|
32
31
|
import {
|
|
33
|
-
type ChangeSpec,
|
|
34
32
|
type EditorState,
|
|
35
33
|
type Extension,
|
|
36
|
-
type StateEffect,
|
|
37
34
|
Transaction,
|
|
38
35
|
} from "@codemirror/state";
|
|
39
36
|
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
|
40
37
|
import type { CellId } from "@marimo-team/client/protocol";
|
|
41
|
-
import { dequal } from "dequal";
|
|
42
38
|
import type { Atom, createStore } from "jotai/vanilla";
|
|
43
39
|
import {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
} from "../../codemirror/language/
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
languageMetadataField,
|
|
51
|
-
setLanguageMetadata,
|
|
52
|
-
} from "../../codemirror/language/metadata";
|
|
40
|
+
editorShowsCellCode,
|
|
41
|
+
getCellCode,
|
|
42
|
+
prepareCellCodeUpdate,
|
|
43
|
+
} from "../../codemirror/language/cell-code";
|
|
44
|
+
import { languageAdapterState } from "../../codemirror/language/extension";
|
|
45
|
+
import { languageMetadataField } from "../../codemirror/language/metadata";
|
|
53
46
|
import type { DocumentModel } from "./model";
|
|
54
47
|
import { selectCellField } from "./select";
|
|
55
48
|
|
|
56
|
-
/** The diff scan limit, matching the default of the merge view. */
|
|
57
|
-
const SCAN_LIMIT = 500;
|
|
58
|
-
|
|
59
49
|
interface Binding {
|
|
60
50
|
readonly store: ReturnType<typeof createStore>;
|
|
61
51
|
readonly model: DocumentModel;
|
|
@@ -94,17 +84,11 @@ export function cellCodeBinding({
|
|
|
94
84
|
}
|
|
95
85
|
|
|
96
86
|
class CellCodeSync {
|
|
87
|
+
// Only the initial load infers a mode; subsequent edits preserve it.
|
|
88
|
+
#initialized = false;
|
|
97
89
|
#scheduled = false;
|
|
98
90
|
/** A pull arrived during IME composition and runs once it ends. */
|
|
99
91
|
#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
92
|
/**
|
|
109
93
|
* The code the editor and the model last agreed on.
|
|
110
94
|
*
|
|
@@ -130,7 +114,10 @@ class CellCodeSync {
|
|
|
130
114
|
}
|
|
131
115
|
|
|
132
116
|
public update(update: ViewUpdate) {
|
|
133
|
-
if (
|
|
117
|
+
if (
|
|
118
|
+
changesCode(update) &&
|
|
119
|
+
!update.transactions.every((tr) => tr.annotation(Transaction.remote))
|
|
120
|
+
) {
|
|
134
121
|
this.#push(update.state);
|
|
135
122
|
}
|
|
136
123
|
if (this.#deferred && !update.view.composing) {
|
|
@@ -164,11 +151,12 @@ class CellCodeSync {
|
|
|
164
151
|
this.#synced = undefined;
|
|
165
152
|
return;
|
|
166
153
|
}
|
|
167
|
-
|
|
154
|
+
this.#initialized = true;
|
|
155
|
+
if (editorShowsCellCode(state, current)) {
|
|
168
156
|
this.#synced = current;
|
|
169
157
|
return;
|
|
170
158
|
}
|
|
171
|
-
const next =
|
|
159
|
+
const next = getCellCode(state);
|
|
172
160
|
this.#synced = next;
|
|
173
161
|
store.set(model.editAtom, (tx) => tx.setCode(cellId, next));
|
|
174
162
|
}
|
|
@@ -185,15 +173,21 @@ class CellCodeSync {
|
|
|
185
173
|
return;
|
|
186
174
|
}
|
|
187
175
|
this.#deferred = false;
|
|
188
|
-
if (next !== this.#synced
|
|
189
|
-
this.#
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
this.#
|
|
176
|
+
if (next !== this.#synced) {
|
|
177
|
+
const update = prepareCellCodeUpdate(this.#view.state, next, {
|
|
178
|
+
initialize: !this.#initialized,
|
|
179
|
+
});
|
|
180
|
+
if (update) {
|
|
181
|
+
this.#view.dispatch(update, {
|
|
182
|
+
annotations: [
|
|
183
|
+
Transaction.remote.of(true),
|
|
184
|
+
Transaction.addToHistory.of(false),
|
|
185
|
+
],
|
|
186
|
+
});
|
|
194
187
|
}
|
|
195
188
|
}
|
|
196
189
|
this.#synced = next;
|
|
190
|
+
this.#initialized = true;
|
|
197
191
|
}
|
|
198
192
|
|
|
199
193
|
/** Pull once after the current update, however many changes arrive. */
|
|
@@ -205,44 +199,6 @@ class CellCodeSync {
|
|
|
205
199
|
}
|
|
206
200
|
}
|
|
207
201
|
|
|
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
202
|
/**
|
|
247
203
|
* Whether an update could change the code an editor represents.
|
|
248
204
|
*
|
|
@@ -258,54 +214,3 @@ function changesCode(update: ViewUpdate): boolean {
|
|
|
258
214
|
update.startState.field(languageMetadataField, false)
|
|
259
215
|
);
|
|
260
216
|
}
|
|
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
|
-
}
|
|
@@ -12,8 +12,8 @@ const DocumentContext = createContext<DocumentModel | null>(null);
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Expose a session-owned model to React. Construct the model with the same store
|
|
15
|
-
* as the enclosing Jotai Provider. The session owner
|
|
16
|
-
*
|
|
15
|
+
* as the enclosing Jotai Provider. The session owner starts it; mounting or
|
|
16
|
+
* unmounting this provider does not change the model's lifetime.
|
|
17
17
|
*
|
|
18
18
|
* ```tsx
|
|
19
19
|
* <Provider store={store}>
|
|
@@ -12,4 +12,12 @@ export {
|
|
|
12
12
|
useNotebookDocumentEdit,
|
|
13
13
|
useNotebookDocumentSync,
|
|
14
14
|
} from "./hooks";
|
|
15
|
-
export {
|
|
15
|
+
export {
|
|
16
|
+
createDocumentModel,
|
|
17
|
+
type DocumentEdit,
|
|
18
|
+
type DocumentEditor,
|
|
19
|
+
type DocumentModel,
|
|
20
|
+
type DocumentSyncStatus,
|
|
21
|
+
type EditableCell,
|
|
22
|
+
type EditableDocument,
|
|
23
|
+
} from "./model";
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import type * as Protocol from "@marimo-team/client/protocol";
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
type Atom,
|
|
6
|
+
atom,
|
|
7
|
+
type createStore,
|
|
8
|
+
type WritableAtom,
|
|
9
|
+
} from "jotai/vanilla";
|
|
5
10
|
import type { Document, NotebookBackend } from "../replica";
|
|
6
|
-
import {
|
|
11
|
+
import type { EditableDocument } from "./draft";
|
|
12
|
+
import {
|
|
13
|
+
createDocumentState,
|
|
14
|
+
type DocumentEdit,
|
|
15
|
+
type DocumentState,
|
|
16
|
+
} from "./state";
|
|
7
17
|
import { createDocumentSync, type DocumentSyncStatus } from "./sync";
|
|
8
18
|
|
|
9
19
|
export type { DocumentEdit, DocumentEditor } from "./state";
|
|
@@ -11,45 +21,85 @@ export type { EditableCell, EditableDocument } from "./draft";
|
|
|
11
21
|
export type { DocumentSyncStatus } from "./sync";
|
|
12
22
|
|
|
13
23
|
/** The editable document and its synchronization with the host. */
|
|
14
|
-
export interface DocumentModel
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
export interface DocumentModel {
|
|
25
|
+
/** Confirmed host state with local drafts applied, null while unavailable or stopped. */
|
|
26
|
+
readonly documentAtom: Atom<EditableDocument | null>;
|
|
27
|
+
/** Ignored while stopped. */
|
|
28
|
+
readonly editAtom: WritableAtom<null, [edit: DocumentEdit], void>;
|
|
18
29
|
readonly syncStatusAtom: Atom<DocumentSyncStatus>;
|
|
19
30
|
/** Drain local edits and return the confirmed revision. */
|
|
20
31
|
flush(): Promise<Protocol.Document>;
|
|
21
32
|
/** Retry a rejection against fresh state, or repeat an uncertain request exactly. */
|
|
22
33
|
retry(): Promise<Protocol.Document>;
|
|
34
|
+
/** Keep edits local until the returned release is called. A no-op while stopped. */
|
|
35
|
+
hold(): () => void;
|
|
36
|
+
/**
|
|
37
|
+
* Edit and synchronize until the signal aborts, which discards local drafts.
|
|
38
|
+
* The model can start again once stopped.
|
|
39
|
+
*/
|
|
40
|
+
start(options: { signal: AbortSignal }): void;
|
|
23
41
|
}
|
|
24
42
|
|
|
25
|
-
|
|
43
|
+
interface Running {
|
|
44
|
+
readonly state: DocumentState;
|
|
45
|
+
readonly sync: ReturnType<typeof createDocumentSync>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Create without subscribing to anything; editing begins on start. */
|
|
26
49
|
export function createDocumentModel({
|
|
27
50
|
store,
|
|
28
|
-
documentAtom,
|
|
51
|
+
documentAtom: source,
|
|
29
52
|
backendAtom,
|
|
30
|
-
signal,
|
|
31
53
|
}: {
|
|
32
54
|
store: ReturnType<typeof createStore>;
|
|
33
55
|
/** The document the host last confirmed. */
|
|
34
56
|
documentAtom: Atom<Document | null>;
|
|
35
57
|
/** The backend that confirmed it, whose commands reach its host. */
|
|
36
58
|
backendAtom: Atom<NotebookBackend | null>;
|
|
37
|
-
signal: AbortSignal;
|
|
38
59
|
}): DocumentModel {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
state,
|
|
46
|
-
signal,
|
|
47
|
-
});
|
|
60
|
+
const runningAtom = atom<Running | null>(null);
|
|
61
|
+
|
|
62
|
+
function stopped(): Promise<never> {
|
|
63
|
+
return Promise.reject(new Error("The document model is not started"));
|
|
64
|
+
}
|
|
65
|
+
|
|
48
66
|
return {
|
|
49
|
-
documentAtom:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
documentAtom: atom((get) => {
|
|
68
|
+
const running = get(runningAtom);
|
|
69
|
+
return running ? get(running.state.documentAtom) : null;
|
|
70
|
+
}),
|
|
71
|
+
editAtom: atom(null, (get, set, edit: DocumentEdit) => {
|
|
72
|
+
const running = get(runningAtom);
|
|
73
|
+
if (running) {
|
|
74
|
+
set(running.state.editAtom, edit);
|
|
75
|
+
}
|
|
76
|
+
}),
|
|
77
|
+
syncStatusAtom: atom((get) => {
|
|
78
|
+
const running = get(runningAtom);
|
|
79
|
+
return running ? get(running.sync.statusAtom) : { status: "unavailable" };
|
|
80
|
+
}),
|
|
81
|
+
flush: () => store.get(runningAtom)?.sync.flush() ?? stopped(),
|
|
82
|
+
retry: () => store.get(runningAtom)?.sync.retry() ?? stopped(),
|
|
83
|
+
hold: () => store.get(runningAtom)?.sync.hold() ?? (() => undefined),
|
|
84
|
+
start({ signal }) {
|
|
85
|
+
signal.throwIfAborted();
|
|
86
|
+
if (store.get(runningAtom)) {
|
|
87
|
+
throw new Error("The document model is already started");
|
|
88
|
+
}
|
|
89
|
+
const state = createDocumentState({ store, source, signal });
|
|
90
|
+
const sync = createDocumentSync({
|
|
91
|
+
store,
|
|
92
|
+
documentAtom: source,
|
|
93
|
+
backendAtom,
|
|
94
|
+
state,
|
|
95
|
+
signal,
|
|
96
|
+
});
|
|
97
|
+
const stop = () => store.set(runningAtom, null);
|
|
98
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
99
|
+
store.set(runningAtom, { state, sync });
|
|
100
|
+
if (signal.aborted) {
|
|
101
|
+
stop();
|
|
102
|
+
}
|
|
103
|
+
},
|
|
54
104
|
};
|
|
55
105
|
}
|
|
@@ -97,6 +97,13 @@ export function createDocumentSync({
|
|
|
97
97
|
let failure: Extract<DocumentSyncStatus, { status: "error" }> | null = null;
|
|
98
98
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
99
99
|
let refreshing = false;
|
|
100
|
+
// Holds keep edits local, as a legacy save needs while it writes the
|
|
101
|
+
// notebook the flush before it produced.
|
|
102
|
+
let holds = 0;
|
|
103
|
+
let releaseWaiters: Array<{
|
|
104
|
+
resolve: () => void;
|
|
105
|
+
reject: (reason: unknown) => void;
|
|
106
|
+
}> = [];
|
|
100
107
|
|
|
101
108
|
function clearTimer() {
|
|
102
109
|
clearTimeout(timer);
|
|
@@ -139,9 +146,11 @@ export function createDocumentSync({
|
|
|
139
146
|
store.set(statusAtom, { status: "idle" });
|
|
140
147
|
} else {
|
|
141
148
|
store.set(statusAtom, { status: "pending" });
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
149
|
+
if (holds === 0) {
|
|
150
|
+
timer = setTimeout(() => {
|
|
151
|
+
void flush().catch(() => undefined);
|
|
152
|
+
}, DEBOUNCE_MS);
|
|
153
|
+
}
|
|
145
154
|
}
|
|
146
155
|
} finally {
|
|
147
156
|
refreshing = false;
|
|
@@ -314,6 +323,11 @@ export function createDocumentSync({
|
|
|
314
323
|
if (running) {
|
|
315
324
|
return running;
|
|
316
325
|
}
|
|
326
|
+
if (holds > 0) {
|
|
327
|
+
return new Promise<void>((resolve, reject) => {
|
|
328
|
+
releaseWaiters.push({ resolve, reject });
|
|
329
|
+
}).then(flush);
|
|
330
|
+
}
|
|
317
331
|
const notebookId =
|
|
318
332
|
active?.submission.notebookId ?? store.get(source)?.revision.notebook_id;
|
|
319
333
|
if (notebookId === undefined) {
|
|
@@ -353,17 +367,52 @@ export function createDocumentSync({
|
|
|
353
367
|
return flush();
|
|
354
368
|
}
|
|
355
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Keep edits local until the returned release is called. Flushes
|
|
372
|
+
* requested meanwhile wait for it. Holds nest, and a release is
|
|
373
|
+
* idempotent.
|
|
374
|
+
*
|
|
375
|
+
* TODO(MO-8028): Only the legacy save needs this, since the host takes
|
|
376
|
+
* the notebook it writes as the document. Saving through the host
|
|
377
|
+
* removes both.
|
|
378
|
+
*/
|
|
379
|
+
function hold(): () => void {
|
|
380
|
+
holds += 1;
|
|
381
|
+
let held = true;
|
|
382
|
+
return () => {
|
|
383
|
+
if (!held) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
held = false;
|
|
387
|
+
holds -= 1;
|
|
388
|
+
if (holds > 0) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const waiters = releaseWaiters;
|
|
392
|
+
releaseWaiters = [];
|
|
393
|
+
for (const waiter of waiters) {
|
|
394
|
+
waiter.resolve();
|
|
395
|
+
}
|
|
396
|
+
refresh();
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
356
400
|
const unsubscribe = store.sub(observed, refresh);
|
|
357
401
|
function stop() {
|
|
358
402
|
clearTimer();
|
|
359
403
|
unsubscribe();
|
|
360
404
|
signal.removeEventListener("abort", stop);
|
|
361
405
|
active?.controller.abort(signal.reason);
|
|
406
|
+
const waiters = releaseWaiters;
|
|
407
|
+
releaseWaiters = [];
|
|
408
|
+
for (const waiter of waiters) {
|
|
409
|
+
waiter.reject(signal.reason);
|
|
410
|
+
}
|
|
362
411
|
}
|
|
363
412
|
signal.addEventListener("abort", stop, { once: true });
|
|
364
413
|
if (signal.aborted) {
|
|
365
414
|
stop();
|
|
366
415
|
}
|
|
367
416
|
refresh();
|
|
368
|
-
return { statusAtom, flush, retry };
|
|
417
|
+
return { statusAtom, flush, retry, hold };
|
|
369
418
|
}
|
package/src/core/run-app.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
useCellActions,
|
|
18
18
|
} from "./cells/cells";
|
|
19
19
|
import type { AppConfig } from "./config/config-schema";
|
|
20
|
+
import { DocumentProvider } from "./notebook/document";
|
|
20
21
|
import { RuntimeState } from "./kernel/RuntimeState";
|
|
21
22
|
import { getSessionId } from "./kernel/session";
|
|
22
23
|
import { connectionAtom } from "./network/connection";
|
|
@@ -51,7 +52,7 @@ export const RunApp: React.FC<AppProps> = ({ appConfig, hideHeader }) => {
|
|
|
51
52
|
};
|
|
52
53
|
}, [sendComponentValues]);
|
|
53
54
|
|
|
54
|
-
const { connection, reconnect } = useNotebookSession({
|
|
55
|
+
const { connection, reconnect, documentModel } = useNotebookSession({
|
|
55
56
|
autoInstantiate: true,
|
|
56
57
|
setCells: setCells,
|
|
57
58
|
sessionId: getSessionId(),
|
|
@@ -73,47 +74,49 @@ export const RunApp: React.FC<AppProps> = ({ appConfig, hideHeader }) => {
|
|
|
73
74
|
})();
|
|
74
75
|
|
|
75
76
|
return (
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
isRunning={isRunning}
|
|
79
|
-
width={appConfig.width}
|
|
80
|
-
onReconnect={reconnect}
|
|
81
|
-
>
|
|
82
|
-
<KernelStartupErrorModal />
|
|
83
|
-
<AppHeader
|
|
77
|
+
<DocumentProvider model={documentModel}>
|
|
78
|
+
<AppContainer
|
|
84
79
|
connection={connection}
|
|
85
|
-
|
|
80
|
+
isRunning={isRunning}
|
|
81
|
+
width={appConfig.width}
|
|
82
|
+
onReconnect={reconnect}
|
|
86
83
|
>
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
84
|
+
<KernelStartupErrorModal />
|
|
85
|
+
<AppHeader
|
|
86
|
+
connection={connection}
|
|
87
|
+
className={hideHeader ? "hidden" : "sm:pt-8"}
|
|
88
|
+
>
|
|
89
|
+
{galleryHref && (
|
|
90
|
+
<div className="flex items-center px-6 pt-4 sm:-mt-8">
|
|
91
|
+
<a
|
|
92
|
+
href={galleryHref}
|
|
93
|
+
aria-label="Back to gallery"
|
|
94
|
+
className={cn(
|
|
95
|
+
buttonVariants({ variant: "text", size: "sm" }),
|
|
96
|
+
"gap-2 px-0 text-muted-foreground hover:text-foreground",
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
<ArrowLeftIcon className="size-4" aria-hidden={true} />
|
|
100
|
+
<span>Back</span>
|
|
101
|
+
</a>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</AppHeader>
|
|
105
|
+
<ProgressiveBoundary
|
|
106
|
+
requires={canPaintRunAppAtom}
|
|
107
|
+
delay={2000}
|
|
108
|
+
fallback={
|
|
109
|
+
<>
|
|
110
|
+
<Spinner className="mx-auto" />
|
|
111
|
+
<p className="text-center text-sm text-muted-foreground mt-2">
|
|
112
|
+
Connecting...
|
|
113
|
+
</p>
|
|
114
|
+
</>
|
|
115
|
+
}
|
|
116
|
+
>
|
|
117
|
+
<CellsRenderer appConfig={appConfig} mode="read" />
|
|
118
|
+
</ProgressiveBoundary>
|
|
119
|
+
</AppContainer>
|
|
120
|
+
</DocumentProvider>
|
|
118
121
|
);
|
|
119
122
|
};
|
|
@@ -21,10 +21,7 @@ import { Label } from "../../components/ui/label";
|
|
|
21
21
|
import { useEvent } from "../../hooks/useEvent";
|
|
22
22
|
import { Logger } from "../../utils/Logger";
|
|
23
23
|
import { getCellConfigs, getNotebook, useNotebook } from "../cells/cells";
|
|
24
|
-
import {
|
|
25
|
-
flushDocumentChanges,
|
|
26
|
-
withDocumentSave,
|
|
27
|
-
} from "../cells/document-changes";
|
|
24
|
+
import { flushDocument, withDocumentSave } from "../cells/document";
|
|
28
25
|
import { notebookCells } from "../cells/utils";
|
|
29
26
|
import { formatAll } from "../codemirror/format";
|
|
30
27
|
import { autoSaveConfigAtom } from "../config/config";
|
|
@@ -165,7 +162,7 @@ export function useSaveNotebook() {
|
|
|
165
162
|
// TODO(MO-7955): Remove this conditional once all frontend saves use
|
|
166
163
|
// the host protocol. Host saves cannot recover edits from an editor snapshot.
|
|
167
164
|
const savedNotebook = isWasm()
|
|
168
|
-
? await
|
|
165
|
+
? await flushDocument().then(save)
|
|
169
166
|
: await withDocumentSave(save);
|
|
170
167
|
setLastSavedNotebook(savedNotebook);
|
|
171
168
|
}),
|
|
@@ -52,7 +52,10 @@ import { RuntimeManager } from "../../runtime/runtime";
|
|
|
52
52
|
import { WebSocketClosedReason, ConnectionState } from "../types";
|
|
53
53
|
import { classifyCloseEvent } from "../../transports/connection-state";
|
|
54
54
|
import { createEditorBackend } from "../backend";
|
|
55
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
fakeBackend,
|
|
57
|
+
fixtureMessages,
|
|
58
|
+
} from "../../notebook/replica/__tests__/fixtures";
|
|
56
59
|
import { useNotebookSession } from "../useNotebookSession";
|
|
57
60
|
import { type NotebookTransport, StreamStopped } from "@marimo-team/client";
|
|
58
61
|
|
|
@@ -142,7 +145,10 @@ function makeRuntimeManager(
|
|
|
142
145
|
return runtime;
|
|
143
146
|
}
|
|
144
147
|
|
|
145
|
-
function renderConnectionHook(
|
|
148
|
+
function renderConnectionHook(
|
|
149
|
+
store: ReturnType<typeof createStore>,
|
|
150
|
+
{ reactStrictMode = false } = {},
|
|
151
|
+
) {
|
|
146
152
|
const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
|
|
147
153
|
<JotaiProvider store={store}>
|
|
148
154
|
<ErrorBoundary fallback={null}>{children}</ErrorBoundary>
|
|
@@ -155,7 +161,7 @@ function renderConnectionHook(store: ReturnType<typeof createStore>) {
|
|
|
155
161
|
autoInstantiate: false,
|
|
156
162
|
setCells: vi.fn(),
|
|
157
163
|
}),
|
|
158
|
-
{ wrapper },
|
|
164
|
+
{ wrapper, reactStrictMode },
|
|
159
165
|
);
|
|
160
166
|
}
|
|
161
167
|
|
|
@@ -228,6 +234,60 @@ describe("useNotebookSession.reconnect()", () => {
|
|
|
228
234
|
});
|
|
229
235
|
});
|
|
230
236
|
|
|
237
|
+
describe("useNotebookSession document model", () => {
|
|
238
|
+
it("keeps drafts while the connection drops and discards them when the session ends", async () => {
|
|
239
|
+
const fake = withTransport();
|
|
240
|
+
vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
|
|
241
|
+
const store = createStore();
|
|
242
|
+
const { result, unmount } = renderConnectionHook(store, {
|
|
243
|
+
reactStrictMode: true,
|
|
244
|
+
});
|
|
245
|
+
await started();
|
|
246
|
+
act(() => {
|
|
247
|
+
connection(fake).open();
|
|
248
|
+
fixtureMessages("notebook-events.sse").forEach(connection(fake).receive);
|
|
249
|
+
});
|
|
250
|
+
const model = result.current.documentModel;
|
|
251
|
+
const code = () => store.get(model.documentAtom)?.cells.get("c1")?.code;
|
|
252
|
+
act(() => store.set(model.editAtom, (tx) => tx.setCode("c1", "local")));
|
|
253
|
+
expect(code()).toBe("local");
|
|
254
|
+
|
|
255
|
+
await act(async () => connection(fake).drop());
|
|
256
|
+
expect(result.current.documentModel).toBe(model);
|
|
257
|
+
expect(code()).toBe("local");
|
|
258
|
+
|
|
259
|
+
unmount();
|
|
260
|
+
expect(code()).toBeUndefined();
|
|
261
|
+
await expect(model.flush()).rejects.toThrow("not started");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("follows the host document into the notebook's cells", async () => {
|
|
265
|
+
const fake = withTransport();
|
|
266
|
+
vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
|
|
267
|
+
const store = createStore();
|
|
268
|
+
const { result, unmount } = renderConnectionHook(store);
|
|
269
|
+
await started();
|
|
270
|
+
act(() => {
|
|
271
|
+
connection(fake).open();
|
|
272
|
+
fixtureMessages("notebook-events.sse").forEach(connection(fake).receive);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
let notebook = store.get(notebookAtom);
|
|
276
|
+
expect(notebook.cellIds.inOrderIds).toEqual(["c1", "c3", "k7f2"]);
|
|
277
|
+
expect(notebook.cellData[cellId("k7f2")]).toMatchObject({
|
|
278
|
+
code: "y = x * 2",
|
|
279
|
+
config: { hide_code: true },
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const model = result.current.documentModel;
|
|
283
|
+
act(() => store.set(model.editAtom, (tx) => tx.setCode("c3", "x = 42")));
|
|
284
|
+
notebook = store.get(notebookAtom);
|
|
285
|
+
expect(notebook.cellData[cellId("c3")].code).toBe("x = 42");
|
|
286
|
+
|
|
287
|
+
unmount();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
231
291
|
describe("useNotebookSession messages", () => {
|
|
232
292
|
let store: ReturnType<typeof createStore>;
|
|
233
293
|
let fake: FakeTransport;
|