@marimo-team/islands 0.25.3-dev201 → 0.25.3-dev202
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-BsWiIPFk.js → ConnectedDataExplorerComponent-DFynurqb.js} +4 -4
- package/dist/{ErrorBoundary-JYAf40sB.js → ErrorBoundary-WrYf85lG.js} +2 -2
- package/dist/{abnfDiagram-VCTEODGH-I2x4Yu25.js → abnfDiagram-VCTEODGH-DGkUyNdz.js} +2 -2
- package/dist/{architectureDiagram-5GKGNRK7-1bSQMKzK.js → architectureDiagram-5GKGNRK7-Bav9J7uF.js} +1 -1
- package/dist/{chat-ui-DXDoSuS4.js → chat-ui-Dorq26Pp.js} +7 -7
- package/dist/{chunk-IMKFNOWR-fsCGAJ9G.js → chunk-IMKFNOWR-1UCTsTqh.js} +1 -1
- package/dist/{chunk-SHT3W25Y-D2STW4hj.js → chunk-SHT3W25Y-Bige_rTb.js} +1 -1
- package/dist/{chunk-SVP7TREG-CiY7cQG-.js → chunk-SVP7TREG-Cmr0Anfb.js} +1 -1
- package/dist/{chunk-TICWLB2K-C_t5RBp9.js → chunk-TICWLB2K-IWykFldb.js} +1 -1
- package/dist/{classDiagram-ZZMXUADV-B2JvZAP4.js → classDiagram-ZZMXUADV-BH73ECRQ.js} +2 -2
- package/dist/{classDiagram-v2-VYDZK3BY-L8VsZG5A.js → classDiagram-v2-VYDZK3BY-CCuCgq0H.js} +2 -2
- package/dist/{code-visibility-BKyYMfr_.js → code-visibility-Da3AIDPM.js} +4 -4
- package/dist/{common-BCY25vum.js → common-C2v8rzIe.js} +9 -9
- package/dist/{cynefinDiagram-5FMLGOSQ-DdllC264.js → cynefinDiagram-5FMLGOSQ-CWtL7MIJ.js} +1 -1
- package/dist/{dagre-GXQ25YYZ-BaWHw4_f.js → dagre-GXQ25YYZ-DsVO2pVC.js} +1 -1
- package/dist/{data-grid-overlay-editor-gQmJxgU-.js → data-grid-overlay-editor-D6qBi2N4.js} +1 -1
- package/dist/{diagram-S7CK7UJ4-pFt-tUDe.js → diagram-S7CK7UJ4-BuAktgZv.js} +1 -1
- package/dist/{diagram-UQ7AKVKN-DqGJiBp4.js → diagram-UQ7AKVKN-DlkAXHu5.js} +1 -1
- package/dist/{diagram-VX7I27RA-Dr-yzMjf.js → diagram-VX7I27RA-B9U9H0E8.js} +1 -1
- package/dist/{diagram-Z3DM3KII-8xzJzv-w.js → diagram-Z3DM3KII-BqmfxzUL.js} +1 -1
- package/dist/{dropdown-menu-NlnsmNA6.js → dropdown-menu-BFQYzWmC.js} +1 -1
- package/dist/{ebnfDiagram-PWID7BFC-Br04Wy-E.js → ebnfDiagram-PWID7BFC-CH5t8ATi.js} +2 -2
- package/dist/{erDiagram-RLTQ6QDP-D5-XVgvv.js → erDiagram-RLTQ6QDP-DdIo2BIb.js} +1 -1
- package/dist/{error-entries-C2Ux0Fjt.js → error-entries-B0bcEwXm.js} +4 -4
- package/dist/{feedback-button-BKgw9MHY.js → feedback-button-DRs9T1rB.js} +5 -5
- package/dist/{flowDiagram-HODETNUW-CVl2kREF.js → flowDiagram-HODETNUW-BmYk_-Na.js} +2 -2
- package/dist/{formats-BCYthwyc.js → formats-DyYnckF5.js} +1 -1
- package/dist/{glide-data-editor-CzS51bb2.js → glide-data-editor-BA4zIeS0.js} +5 -5
- package/dist/{globals-DQ4tv_a-.js → globals-Sw6xG6S5.js} +2 -2
- package/dist/{html-to-image-CkH7vdBZ.js → html-to-image-BOd7ryZJ.js} +5 -5
- package/dist/{infoDiagram-27XIBGKW-BJ4vNcZE.js → infoDiagram-27XIBGKW-DvYh-Sbk.js} +1 -1
- package/dist/{ishikawaDiagram-5VMMS53U-B_Jy1Dsm.js → ishikawaDiagram-5VMMS53U-BdHnYq8s.js} +1 -1
- package/dist/{kanban-definition-UXKFOSKX-C6MkS9vw.js → kanban-definition-UXKFOSKX-Bf8CT_tU.js} +1 -1
- package/dist/main.js +48 -70
- package/dist/{mermaid-Bh918qrq.js → mermaid-DBpOnw8c.js} +34 -34
- package/dist/{mindmap-definition-YA3MSWOX-BP3ZTuir.js → mindmap-definition-YA3MSWOX-DkYG8ltR.js} +1 -1
- package/dist/{number-overlay-editor-B3ZE3-6d.js → number-overlay-editor-Dj-a_cEA.js} +1 -1
- package/dist/{pegDiagram-XKGWAZYB-nNGOQtnt.js → pegDiagram-XKGWAZYB-DM7BQ0ip.js} +2 -2
- package/dist/{pieDiagram-E7YTZNPT-CpRz5BZz.js → pieDiagram-E7YTZNPT-CPFGg4YS.js} +1 -1
- package/dist/{process-output-OujAtFW4.js → process-output-8BbxxGrz.js} +2 -2
- package/dist/{railroadDiagram-O6MQD6OU-BqOEN4I0.js → railroadDiagram-O6MQD6OU-4rUJlIpQ.js} +2 -2
- package/dist/{requirementDiagram-BXWQKSXE-CEfg6vu9.js → requirementDiagram-BXWQKSXE-F6ffUUQC.js} +1 -1
- package/dist/{reveal-component-DXa9FXf6.js → reveal-component-DjYragzd.js} +6 -6
- package/dist/{sequenceDiagram-WJ2MYXX4-IOmpQcOi.js → sequenceDiagram-WJ2MYXX4-tNU95HVS.js} +1 -1
- package/dist/{spec-Dkp64vVQ.js → spec-Alkla7dB.js} +1 -1
- package/dist/{stateDiagram-D77RDMKH-tgU-EY9_.js → stateDiagram-D77RDMKH-BA8Modqv.js} +2 -2
- package/dist/{stateDiagram-v2-MP3YSRHH-BofhG6q1.js → stateDiagram-v2-MP3YSRHH-CFmickht.js} +2 -2
- package/dist/{swimlanes-42K2YHIH-DW4vWDBd.js → swimlanes-42K2YHIH-DBiYZZZc.js} +1 -1
- package/dist/{swimlanesDiagram-VR7AAH4N-BJcrqUJr.js → swimlanesDiagram-VR7AAH4N-CsgR851k.js} +2 -2
- package/dist/{timeline-definition-24CTP7MA-D_QU3NGs.js → timeline-definition-24CTP7MA-Dfj0CMXY.js} +1 -1
- package/dist/{toDate-BNDEXbfV.js → toDate-B0-Y0SEW.js} +1 -1
- package/dist/{useAsyncData-ASDni9vH.js → useAsyncData-Phxf6GCd.js} +1 -1
- package/dist/{useDeepCompareMemoize-B1Ogyf53.js → useDeepCompareMemoize-BXv7DUcU.js} +1 -1
- package/dist/{useLifecycle-Bfpa2m7k.js → useLifecycle-DDDsO4O2.js} +1 -1
- package/dist/{useTheme-5TwyOh3B.js → useTheme-DbBkeP6Z.js} +65 -60
- package/dist/{vega-component-BHS9UOtC.js → vega-component-C_dhEynE.js} +5 -5
- package/dist/{vennDiagram-4TSXK5OY-YFYsNIAh.js → vennDiagram-4TSXK5OY-fJ83Z7FE.js} +1 -1
- package/dist/{wardleyDiagram-VM6X3IG4-i_n8mAWC.js → wardleyDiagram-VM6X3IG4-DbO9cD15.js} +1 -1
- package/dist/{xychartDiagram-S5SC5T6Z-BYGN95wW.js → xychartDiagram-S5SC5T6Z-DmsdQJG7.js} +1 -1
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +1 -1
- package/src/components/editor/header/__tests__/filename-form.test.tsx +1 -4
- package/src/components/editor/header/filename-form.tsx +1 -1
- package/src/core/cells/__tests__/document.test.ts +1 -22
- package/src/core/cells/document.ts +0 -33
- package/src/core/islands/bridge.ts +1 -1
- package/src/core/network/requests-lazy.ts +1 -1
- package/src/core/network/requests-network.ts +8 -9
- package/src/core/network/requests-static.ts +1 -1
- package/src/core/network/requests-toasting.tsx +1 -1
- package/src/core/network/types.ts +6 -2
- package/src/core/notebook/document/model.ts +0 -3
- package/src/core/notebook/document/sync.ts +4 -53
- package/src/core/saving/__tests__/document.test.tsx +123 -0
- package/src/core/saving/document.ts +33 -0
- package/src/core/saving/save-component.tsx +24 -37
- package/src/core/session/active-notebook.ts +6 -0
- package/src/core/wasm/__tests__/bridge.test.ts +2 -126
- package/src/core/wasm/bridge.ts +1 -18
- package/src/core/wasm/worker/worker.ts +2 -2
- package/src/core/cells/__tests__/with-document-save.test.ts +0 -67
- package/src/core/notebook/document/__tests__/hold.test.ts +0 -88
|
@@ -43,7 +43,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
|
|
|
43
43
|
sendRun: throwNotInEditMode,
|
|
44
44
|
sendRunScratchpad: throwNotInEditMode,
|
|
45
45
|
sendRename: throwNotInEditMode,
|
|
46
|
-
|
|
46
|
+
saveLayout: throwNotInEditMode,
|
|
47
47
|
sendCopy: throwNotInEditMode,
|
|
48
48
|
sendInterrupt: throwNotInEditMode,
|
|
49
49
|
sendShutdown: throwNotInEditMode,
|
|
@@ -24,7 +24,7 @@ export function createErrorToastingRequests(
|
|
|
24
24
|
sendRun: "Failed to run",
|
|
25
25
|
sendRunScratchpad: "Failed to run scratchpad",
|
|
26
26
|
sendRename: "Failed to rename",
|
|
27
|
-
|
|
27
|
+
saveLayout: "", // The save reports it
|
|
28
28
|
sendCopy: "Failed to copy",
|
|
29
29
|
sendInterrupt: "Failed to interrupt",
|
|
30
30
|
sendShutdown: "Failed to shutdown",
|
|
@@ -95,7 +95,7 @@ export type ExecuteCellsRequest = schemas["ExecuteCellsRequest"];
|
|
|
95
95
|
export type ExecuteScratchpadRequest = schemas["ExecuteScratchpadRequest"];
|
|
96
96
|
export type SaveAppConfigurationRequest =
|
|
97
97
|
schemas["SaveAppConfigurationRequest"];
|
|
98
|
-
export type
|
|
98
|
+
export type SaveLayoutRequest = schemas["SaveLayoutRequest"];
|
|
99
99
|
export type CopyNotebookRequest = schemas["CopyNotebookRequest"];
|
|
100
100
|
export type SaveUserConfigurationRequest =
|
|
101
101
|
schemas["SaveUserConfigurationRequest"];
|
|
@@ -158,7 +158,11 @@ export interface RunRequests {
|
|
|
158
158
|
*/
|
|
159
159
|
export interface EditRequests {
|
|
160
160
|
sendRename: (request: RenameNotebookRequest) => Promise<null>;
|
|
161
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Write the notebook's layout beside it. The host protocol carries no
|
|
163
|
+
* layout yet, so this stays a request to the host serving the page.
|
|
164
|
+
*/
|
|
165
|
+
saveLayout: (request: SaveLayoutRequest) => Promise<null>;
|
|
162
166
|
sendCopy: (request: CopyNotebookRequest) => Promise<null>;
|
|
163
167
|
sendStdin: (request: StdinRequest) => Promise<null>;
|
|
164
168
|
sendRun: (request: ExecuteCellsRequest) => Promise<null>;
|
|
@@ -31,8 +31,6 @@ export interface DocumentModel {
|
|
|
31
31
|
flush(): Promise<Protocol.Document>;
|
|
32
32
|
/** Retry a rejection against fresh state, or repeat an uncertain request exactly. */
|
|
33
33
|
retry(): Promise<Protocol.Document>;
|
|
34
|
-
/** Keep edits local until the returned release is called. A no-op while stopped. */
|
|
35
|
-
hold(): () => void;
|
|
36
34
|
/**
|
|
37
35
|
* Edit and synchronize until the signal aborts, which discards local drafts.
|
|
38
36
|
* The model can start again once stopped.
|
|
@@ -80,7 +78,6 @@ export function createDocumentModel({
|
|
|
80
78
|
}),
|
|
81
79
|
flush: () => store.get(runningAtom)?.sync.flush() ?? stopped(),
|
|
82
80
|
retry: () => store.get(runningAtom)?.sync.retry() ?? stopped(),
|
|
83
|
-
hold: () => store.get(runningAtom)?.sync.hold() ?? (() => undefined),
|
|
84
81
|
start({ signal }) {
|
|
85
82
|
signal.throwIfAborted();
|
|
86
83
|
if (store.get(runningAtom)) {
|
|
@@ -97,13 +97,6 @@ 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
|
-
}> = [];
|
|
107
100
|
|
|
108
101
|
function clearTimer() {
|
|
109
102
|
clearTimeout(timer);
|
|
@@ -146,11 +139,9 @@ export function createDocumentSync({
|
|
|
146
139
|
store.set(statusAtom, { status: "idle" });
|
|
147
140
|
} else {
|
|
148
141
|
store.set(statusAtom, { status: "pending" });
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}, DEBOUNCE_MS);
|
|
153
|
-
}
|
|
142
|
+
timer = setTimeout(() => {
|
|
143
|
+
void flush().catch(() => undefined);
|
|
144
|
+
}, DEBOUNCE_MS);
|
|
154
145
|
}
|
|
155
146
|
} finally {
|
|
156
147
|
refreshing = false;
|
|
@@ -323,11 +314,6 @@ export function createDocumentSync({
|
|
|
323
314
|
if (running) {
|
|
324
315
|
return running;
|
|
325
316
|
}
|
|
326
|
-
if (holds > 0) {
|
|
327
|
-
return new Promise<void>((resolve, reject) => {
|
|
328
|
-
releaseWaiters.push({ resolve, reject });
|
|
329
|
-
}).then(flush);
|
|
330
|
-
}
|
|
331
317
|
const notebookId =
|
|
332
318
|
active?.submission.notebookId ?? store.get(source)?.revision.notebook_id;
|
|
333
319
|
if (notebookId === undefined) {
|
|
@@ -367,52 +353,17 @@ export function createDocumentSync({
|
|
|
367
353
|
return flush();
|
|
368
354
|
}
|
|
369
355
|
|
|
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
|
-
|
|
400
356
|
const unsubscribe = store.sub(observed, refresh);
|
|
401
357
|
function stop() {
|
|
402
358
|
clearTimer();
|
|
403
359
|
unsubscribe();
|
|
404
360
|
signal.removeEventListener("abort", stop);
|
|
405
361
|
active?.controller.abort(signal.reason);
|
|
406
|
-
const waiters = releaseWaiters;
|
|
407
|
-
releaseWaiters = [];
|
|
408
|
-
for (const waiter of waiters) {
|
|
409
|
-
waiter.reject(signal.reason);
|
|
410
|
-
}
|
|
411
362
|
}
|
|
412
363
|
signal.addEventListener("abort", stop, { once: true });
|
|
413
364
|
if (signal.aborted) {
|
|
414
365
|
stop();
|
|
415
366
|
}
|
|
416
367
|
refresh();
|
|
417
|
-
return { statusAtom, flush, retry
|
|
368
|
+
return { statusAtom, flush, retry };
|
|
418
369
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type * as Protocol from "@marimo-team/client/protocol";
|
|
4
|
+
import { renderHook } from "@testing-library/react";
|
|
5
|
+
import { atom, createStore } from "jotai";
|
|
6
|
+
import type { PropsWithChildren } from "react";
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { DocumentWrapper } from "@/__mocks__/document";
|
|
9
|
+
import { partial } from "@/__mocks__/partial";
|
|
10
|
+
import type {
|
|
11
|
+
DocumentModel,
|
|
12
|
+
DocumentSyncStatus,
|
|
13
|
+
} from "@/core/notebook/document";
|
|
14
|
+
import type { NotebookReplica } from "@/core/notebook/replica";
|
|
15
|
+
import {
|
|
16
|
+
type FakeSave,
|
|
17
|
+
fakeBackend,
|
|
18
|
+
} from "@/core/notebook/replica/__tests__/fixtures";
|
|
19
|
+
import { activeNotebookReplicaAtom } from "@/core/session/active-notebook";
|
|
20
|
+
import { Deferred } from "@/utils/Deferred";
|
|
21
|
+
import { useNotebookDocumentSave } from "../document";
|
|
22
|
+
|
|
23
|
+
const confirmed: Protocol.Document = {
|
|
24
|
+
notebook_id: "n1",
|
|
25
|
+
order: [],
|
|
26
|
+
app_config: {},
|
|
27
|
+
etag: "e1",
|
|
28
|
+
saved_etag: null,
|
|
29
|
+
order_etag: "o1",
|
|
30
|
+
app_config_etag: "a1",
|
|
31
|
+
version: 1,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const saved: FakeSave = async () => ({
|
|
35
|
+
ok: true,
|
|
36
|
+
status: 200,
|
|
37
|
+
data: { document: confirmed, cells: {} },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function setup({
|
|
41
|
+
flush = vi.fn(async () => confirmed),
|
|
42
|
+
save = vi.fn(saved),
|
|
43
|
+
connected = true,
|
|
44
|
+
}: {
|
|
45
|
+
flush?: DocumentModel["flush"];
|
|
46
|
+
save?: FakeSave;
|
|
47
|
+
connected?: boolean;
|
|
48
|
+
} = {}) {
|
|
49
|
+
const store = createStore();
|
|
50
|
+
const model = partial<DocumentModel>(
|
|
51
|
+
{
|
|
52
|
+
flush,
|
|
53
|
+
retry: vi.fn(),
|
|
54
|
+
syncStatusAtom: atom<DocumentSyncStatus>({ status: "idle" }),
|
|
55
|
+
},
|
|
56
|
+
"model",
|
|
57
|
+
);
|
|
58
|
+
if (connected) {
|
|
59
|
+
store.set(
|
|
60
|
+
activeNotebookReplicaAtom,
|
|
61
|
+
partial<NotebookReplica>(
|
|
62
|
+
{ backendAtom: atom(fakeBackend({ save })) },
|
|
63
|
+
"replica",
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const wrapper = ({ children }: PropsWithChildren) => (
|
|
68
|
+
<DocumentWrapper store={store} model={model}>
|
|
69
|
+
{children}
|
|
70
|
+
</DocumentWrapper>
|
|
71
|
+
);
|
|
72
|
+
const { result } = renderHook(() => useNotebookDocumentSave(), { wrapper });
|
|
73
|
+
return { saveDocument: result.current, flush, save };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("useNotebookDocumentSave", () => {
|
|
77
|
+
it("flushes this client's edits, then saves the flushed notebook", async () => {
|
|
78
|
+
const flushed = new Deferred<Protocol.Document>();
|
|
79
|
+
const save = vi.fn(saved);
|
|
80
|
+
const { saveDocument } = setup({ flush: () => flushed.promise, save });
|
|
81
|
+
const saving = saveDocument();
|
|
82
|
+
await Promise.resolve();
|
|
83
|
+
expect(save).not.toHaveBeenCalled();
|
|
84
|
+
flushed.resolve(confirmed);
|
|
85
|
+
await saving;
|
|
86
|
+
expect(save).toHaveBeenCalledWith("n1");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does not save when flushing fails", async () => {
|
|
90
|
+
const save = vi.fn(saved);
|
|
91
|
+
const { saveDocument } = setup({
|
|
92
|
+
flush: () => Promise.reject(new Error("unsynchronized edit")),
|
|
93
|
+
save,
|
|
94
|
+
});
|
|
95
|
+
await expect(saveDocument()).rejects.toThrow("unsynchronized edit");
|
|
96
|
+
expect(save).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reports the host's refusal", async () => {
|
|
100
|
+
const { saveDocument } = setup({
|
|
101
|
+
save: async () => ({
|
|
102
|
+
ok: false,
|
|
103
|
+
status: 409,
|
|
104
|
+
problem: {
|
|
105
|
+
type: "about:blank",
|
|
106
|
+
title: "Conflict",
|
|
107
|
+
status: 409,
|
|
108
|
+
detail: "storage full",
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
await expect(saveDocument()).rejects.toThrow("storage full");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("does not save while no notebook is connected", async () => {
|
|
116
|
+
const flush = vi.fn(async () => confirmed);
|
|
117
|
+
const { saveDocument } = setup({ flush, connected: false });
|
|
118
|
+
await expect(saveDocument()).rejects.toThrow(
|
|
119
|
+
"The notebook is not connected",
|
|
120
|
+
);
|
|
121
|
+
expect(flush).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
/** Save the host's document from React. */
|
|
4
|
+
|
|
5
|
+
import { useAtomValue } from "jotai";
|
|
6
|
+
import { useCallback } from "react";
|
|
7
|
+
import { useNotebookDocumentSync } from "@/core/notebook/document";
|
|
8
|
+
import { activeNotebookBackendAtom } from "../session/active-notebook";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Save the notebook's document through its host. This client's edits are
|
|
12
|
+
* flushed first, so the host writes nothing older than what the editor
|
|
13
|
+
* shows, and a failure at either step rejects.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* const saveDocument = useNotebookDocumentSave();
|
|
17
|
+
* await saveDocument();
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function useNotebookDocumentSave() {
|
|
21
|
+
const { flush } = useNotebookDocumentSync();
|
|
22
|
+
const backend = useAtomValue(activeNotebookBackendAtom);
|
|
23
|
+
return useCallback(async () => {
|
|
24
|
+
if (backend === null) {
|
|
25
|
+
throw new Error("The notebook is not connected");
|
|
26
|
+
}
|
|
27
|
+
const { notebook_id } = await flush();
|
|
28
|
+
const result = await backend.notebook(notebook_id).save();
|
|
29
|
+
if (!result.ok) {
|
|
30
|
+
throw new Error(result.problem.detail ?? result.problem.title);
|
|
31
|
+
}
|
|
32
|
+
}, [flush, backend]);
|
|
33
|
+
}
|
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
import { Label } from "../../components/ui/label";
|
|
21
21
|
import { useEvent } from "../../hooks/useEvent";
|
|
22
22
|
import { Logger } from "../../utils/Logger";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
23
|
+
import { toast } from "@/components/ui/use-toast";
|
|
24
|
+
import { getNotebook } from "../cells/cells";
|
|
25
25
|
import { notebookCells } from "../cells/utils";
|
|
26
26
|
import { formatAll } from "../codemirror/format";
|
|
27
27
|
import { autoSaveConfigAtom } from "../config/config";
|
|
@@ -32,9 +32,9 @@ import { connectionAtom } from "../network/connection";
|
|
|
32
32
|
import { canEditAtom } from "@/core/notebook";
|
|
33
33
|
import { useRequestClient } from "../network/requests";
|
|
34
34
|
import { ConnectionState } from "../session/types";
|
|
35
|
-
import { isWasm } from "../wasm/utils";
|
|
36
35
|
import { filenameAtom } from "./file-state";
|
|
37
36
|
import { useFilename, useUpdateFilename } from "./filename";
|
|
37
|
+
import { useNotebookDocumentSave } from "./document";
|
|
38
38
|
import { documentVersionAtom, needsSaveAtom, savedLayoutAtom } from "./state";
|
|
39
39
|
import { useAutoSave } from "./useAutoSave";
|
|
40
40
|
|
|
@@ -99,13 +99,14 @@ export const SaveComponent = () => {
|
|
|
99
99
|
};
|
|
100
100
|
|
|
101
101
|
export function useSaveNotebook() {
|
|
102
|
-
const {
|
|
102
|
+
const { saveLayout } = useRequestClient();
|
|
103
|
+
const saveDocument = useNotebookDocumentSave();
|
|
103
104
|
const { openModal, closeModal, openAlert } = useImperativeModal();
|
|
104
105
|
const updateFilename = useUpdateFilename();
|
|
105
106
|
const store = useStore();
|
|
106
107
|
|
|
107
|
-
// Save the notebook
|
|
108
|
-
const saveNotebook = useEvent((
|
|
108
|
+
// Save the notebook the host has a file for.
|
|
109
|
+
const saveNotebook = useEvent((userInitiated: boolean) =>
|
|
109
110
|
enqueueNotebookSave(async () => {
|
|
110
111
|
const connection = store.get(connectionAtom);
|
|
111
112
|
const autoSaveConfig = store.get(autoSaveConfigAtom);
|
|
@@ -121,8 +122,6 @@ export function useSaveNotebook() {
|
|
|
121
122
|
return;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
Logger.log("saving to ", filename);
|
|
125
|
-
|
|
126
125
|
if (userInitiated && autoSaveConfig.format_on_save) {
|
|
127
126
|
Logger.log("formatting notebook (onSave)");
|
|
128
127
|
await formatAll();
|
|
@@ -132,34 +131,22 @@ export function useSaveNotebook() {
|
|
|
132
131
|
return;
|
|
133
132
|
}
|
|
134
133
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
cellIds: cellIds,
|
|
148
|
-
codes,
|
|
149
|
-
names: cellNames,
|
|
150
|
-
filename,
|
|
151
|
-
configs,
|
|
152
|
-
layout: getSerializedLayout(),
|
|
153
|
-
persist: true,
|
|
134
|
+
// The notebook is the host's document plus a layout the editor keeps
|
|
135
|
+
// beside it. TODO: fold the layout into the host document.
|
|
136
|
+
const layout = store.get(layoutStateAtom);
|
|
137
|
+
try {
|
|
138
|
+
await saveLayout({ layout: getSerializedLayout() });
|
|
139
|
+
await saveDocument();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
Logger.error("Failed to save notebook", error);
|
|
142
|
+
toast({
|
|
143
|
+
title: "Failed to save",
|
|
144
|
+
description: error instanceof Error ? error.message : String(error),
|
|
145
|
+
variant: "danger",
|
|
154
146
|
});
|
|
155
|
-
return
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// the host protocol. Host saves cannot recover edits from an editor snapshot.
|
|
159
|
-
const savedLayout = isWasm()
|
|
160
|
-
? await flushDocument().then(save)
|
|
161
|
-
: await withDocumentSave(save);
|
|
162
|
-
store.set(savedLayoutAtom, savedLayout);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
store.set(savedLayoutAtom, layout);
|
|
163
150
|
}),
|
|
164
151
|
);
|
|
165
152
|
|
|
@@ -171,14 +158,14 @@ export function useSaveNotebook() {
|
|
|
171
158
|
isNamedPersistentFile(filename) &&
|
|
172
159
|
connection.state === ConnectionState.OPEN
|
|
173
160
|
) {
|
|
174
|
-
saveNotebook(
|
|
161
|
+
saveNotebook(userInitiated);
|
|
175
162
|
}
|
|
176
163
|
});
|
|
177
164
|
|
|
178
165
|
const handleSaveDialog = (pythonFilename: string) => {
|
|
179
166
|
updateFilename(pythonFilename).then((name) => {
|
|
180
167
|
if (name !== null) {
|
|
181
|
-
saveNotebook(
|
|
168
|
+
saveNotebook(true);
|
|
182
169
|
}
|
|
183
170
|
});
|
|
184
171
|
};
|
|
@@ -10,6 +10,12 @@ export const activeNotebookReplicaAtom = atom<NotebookReplica | null>(null);
|
|
|
10
10
|
/** The document model owned by the mounted notebook session. */
|
|
11
11
|
export const activeDocumentModelAtom = atom<DocumentModel | null>(null);
|
|
12
12
|
|
|
13
|
+
/** The backend of the connected notebook, or null while none is. */
|
|
14
|
+
export const activeNotebookBackendAtom = atom((get) => {
|
|
15
|
+
const replica = get(activeNotebookReplicaAtom);
|
|
16
|
+
return replica === null ? null : get(replica.backendAtom);
|
|
17
|
+
});
|
|
18
|
+
|
|
13
19
|
/**
|
|
14
20
|
* The document the host last confirmed, or null while no notebook is
|
|
15
21
|
* connected. Facts the host owns read this one, such as permission, the
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { cellId } from "@/__tests__/branded";
|
|
5
4
|
import { Deferred } from "@/utils/Deferred";
|
|
6
5
|
|
|
7
6
|
const {
|
|
@@ -11,7 +10,6 @@ const {
|
|
|
11
10
|
mockWriteNotebook,
|
|
12
11
|
mockNotebookCall,
|
|
13
12
|
mockSetNotebookLayout,
|
|
14
|
-
mockFlush,
|
|
15
13
|
parentRequests,
|
|
16
14
|
mockBootstrap,
|
|
17
15
|
rpcListeners,
|
|
@@ -24,7 +22,6 @@ const {
|
|
|
24
22
|
mockWriteNotebook: vi.fn(),
|
|
25
23
|
mockNotebookCall: vi.fn(),
|
|
26
24
|
mockSetNotebookLayout: vi.fn(),
|
|
27
|
-
mockFlush: vi.fn(),
|
|
28
25
|
parentRequests: {} as {
|
|
29
26
|
persistNotebook?: (snapshot: {
|
|
30
27
|
filename: string;
|
|
@@ -101,18 +98,9 @@ vi.mock("@/core/wasm/store", () => ({
|
|
|
101
98
|
notebookFileStore: { readFile: mockNotebookReadFile, saveFile: vi.fn() },
|
|
102
99
|
}));
|
|
103
100
|
|
|
104
|
-
vi.mock("@/core/cells/document", async (importOriginal) => ({
|
|
105
|
-
...(await importOriginal<typeof import("@/core/cells/document")>()),
|
|
106
|
-
flushDocument: mockFlush,
|
|
107
|
-
}));
|
|
108
|
-
|
|
109
101
|
// Import after all mocks are set up
|
|
110
102
|
import { store } from "@/core/state/jotai";
|
|
111
103
|
import { initialModeAtom } from "@/core/mode";
|
|
112
|
-
import { fixtureEvents } from "@/core/notebook/replica/__tests__/fixtures";
|
|
113
|
-
import { createNotebookReplica } from "@/core/notebook/replica";
|
|
114
|
-
import { activeNotebookReplicaAtom } from "@/core/session/active-notebook";
|
|
115
|
-
import { createEditorBackend } from "@/core/session/backend";
|
|
116
104
|
import { fallbackFileStore, notebookFileStore } from "../store";
|
|
117
105
|
import { getWasmWorkerName, PyodideBridge } from "../bridge";
|
|
118
106
|
|
|
@@ -200,130 +188,18 @@ describe("PyodideBridge.exportAsScript", () => {
|
|
|
200
188
|
});
|
|
201
189
|
});
|
|
202
190
|
|
|
203
|
-
describe("PyodideBridge
|
|
204
|
-
|
|
205
|
-
const request = {
|
|
206
|
-
cellIds: [cellId("cell-1")],
|
|
207
|
-
codes: ['value = "NEW"'],
|
|
208
|
-
names: ["_"],
|
|
209
|
-
filename: "notebook.py",
|
|
210
|
-
configs: [{}],
|
|
211
|
-
layout: null,
|
|
212
|
-
persist: true,
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
beforeEach(async () => {
|
|
191
|
+
describe("PyodideBridge notebook persistence", () => {
|
|
192
|
+
beforeEach(() => {
|
|
216
193
|
vi.clearAllMocks();
|
|
217
194
|
store.set(initialModeAtom, "edit");
|
|
218
195
|
rpcListeners.initialized();
|
|
219
|
-
mockFlush.mockResolvedValue(undefined);
|
|
220
196
|
mockWriteNotebook.mockResolvedValue(undefined);
|
|
221
|
-
mockNotebookUnsubscribe.mockResolvedValue(undefined);
|
|
222
|
-
const document = fixtureEvents("notebook-events.sse").find(
|
|
223
|
-
(event) => event.event === "document",
|
|
224
|
-
)?.data.document;
|
|
225
|
-
mockNotebookCall.mockResolvedValue({
|
|
226
|
-
status: 200,
|
|
227
|
-
body: { document, cells: {} },
|
|
228
|
-
});
|
|
229
|
-
mockNotebookSubscribe.mockImplementation(async ({ id }) => {
|
|
230
|
-
for (const event of fixtureEvents("notebook-events.sse")) {
|
|
231
|
-
rpcListeners.notebookEvent({ id, event: JSON.stringify(event) });
|
|
232
|
-
}
|
|
233
|
-
});
|
|
234
|
-
const replica = createNotebookReplica(store);
|
|
235
|
-
store.set(activeNotebookReplicaAtom, replica);
|
|
236
|
-
const backend = createEditorBackend({
|
|
237
|
-
static: false,
|
|
238
|
-
snapshot: null,
|
|
239
|
-
transportType: "sse",
|
|
240
|
-
url: () => "http://localhost/",
|
|
241
|
-
headers: () => ({}),
|
|
242
|
-
notebookUrl: (id) => new URL(`http://localhost/notebooks/${id}`),
|
|
243
|
-
waitUntilReady: async () => undefined,
|
|
244
|
-
checkHealth: async () => true,
|
|
245
|
-
});
|
|
246
|
-
disconnect = replica.connect(backend, { onNotification: vi.fn() });
|
|
247
|
-
await vi.waitFor(() =>
|
|
248
|
-
expect(store.get(replica.notebookAtom)?.id).toBe("n1"),
|
|
249
|
-
);
|
|
250
197
|
});
|
|
251
198
|
|
|
252
199
|
afterEach(() => {
|
|
253
|
-
disconnect();
|
|
254
|
-
store.set(activeNotebookReplicaAtom, null);
|
|
255
200
|
store.set(initialModeAtom, undefined);
|
|
256
201
|
});
|
|
257
202
|
|
|
258
|
-
it("saves through the connected backend using the stream's notebook ID", async () => {
|
|
259
|
-
const bridge = PyodideBridge.INSTANCE;
|
|
260
|
-
const createBackend = vi.spyOn(bridge, "createNotebookBackend");
|
|
261
|
-
await bridge.sendSave(request);
|
|
262
|
-
expect(createBackend).not.toHaveBeenCalled();
|
|
263
|
-
expect(mockNotebookCall).toHaveBeenCalledWith(
|
|
264
|
-
expect.objectContaining({
|
|
265
|
-
operation: "notebook.save",
|
|
266
|
-
params: { notebook_id: "n1" },
|
|
267
|
-
body: {},
|
|
268
|
-
}),
|
|
269
|
-
);
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
it.each(["unmounted", "not ready", "disconnected"])(
|
|
273
|
-
"does not save when the notebook is %s",
|
|
274
|
-
async (state) => {
|
|
275
|
-
if (state === "unmounted") {
|
|
276
|
-
store.set(activeNotebookReplicaAtom, null);
|
|
277
|
-
} else if (state === "not ready") {
|
|
278
|
-
store.set(activeNotebookReplicaAtom, createNotebookReplica(store));
|
|
279
|
-
} else {
|
|
280
|
-
disconnect();
|
|
281
|
-
}
|
|
282
|
-
await expect(PyodideBridge.INSTANCE.sendSave(request)).rejects.toThrow(
|
|
283
|
-
"The notebook is not connected",
|
|
284
|
-
);
|
|
285
|
-
expect(mockNotebookCall).not.toHaveBeenCalled();
|
|
286
|
-
},
|
|
287
|
-
);
|
|
288
|
-
|
|
289
|
-
it("flushes edits before asking the host to save without an editor snapshot", async () => {
|
|
290
|
-
const flushed = new Deferred<void>();
|
|
291
|
-
mockFlush.mockReturnValueOnce(flushed.promise);
|
|
292
|
-
const saving = PyodideBridge.INSTANCE.sendSave(request);
|
|
293
|
-
await Promise.resolve();
|
|
294
|
-
expect(mockNotebookCall).not.toHaveBeenCalled();
|
|
295
|
-
flushed.resolve();
|
|
296
|
-
await saving;
|
|
297
|
-
expect(mockSetNotebookLayout).toHaveBeenCalledWith(null);
|
|
298
|
-
expect(mockNotebookCall).toHaveBeenCalledWith(
|
|
299
|
-
expect.objectContaining({ operation: "notebook.save", body: {} }),
|
|
300
|
-
);
|
|
301
|
-
expect(mockWriteNotebook).not.toHaveBeenCalled();
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it("does not save when flushing fails", async () => {
|
|
305
|
-
mockFlush.mockRejectedValueOnce(new Error("unsynchronized edit"));
|
|
306
|
-
await expect(PyodideBridge.INSTANCE.sendSave(request)).rejects.toThrow(
|
|
307
|
-
"unsynchronized edit",
|
|
308
|
-
);
|
|
309
|
-
expect(mockNotebookCall).not.toHaveBeenCalled();
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it("reports host persistence failures", async () => {
|
|
313
|
-
mockNotebookCall.mockResolvedValueOnce({
|
|
314
|
-
status: 409,
|
|
315
|
-
body: {
|
|
316
|
-
type: "tag:marimo.io,2026:host/conflict",
|
|
317
|
-
title: "Conflict",
|
|
318
|
-
status: 409,
|
|
319
|
-
detail: "storage full",
|
|
320
|
-
},
|
|
321
|
-
});
|
|
322
|
-
await expect(PyodideBridge.INSTANCE.sendSave(request)).rejects.toThrow(
|
|
323
|
-
"storage full",
|
|
324
|
-
);
|
|
325
|
-
});
|
|
326
|
-
|
|
327
203
|
it("persists exactly the host's content before acknowledging storage", async () => {
|
|
328
204
|
const durable = new Deferred<void>();
|
|
329
205
|
const snapshot = { filename: "notebook.py", contents: "# host revision" };
|
package/src/core/wasm/bridge.ts
CHANGED
|
@@ -42,7 +42,6 @@ import type {
|
|
|
42
42
|
} from "../network/types";
|
|
43
43
|
import { filenameAtom } from "../saving/file-state";
|
|
44
44
|
import { store } from "../state/jotai";
|
|
45
|
-
import { activeNotebookReplicaAtom } from "../session/active-notebook";
|
|
46
45
|
import { PyodideRouter } from "./router";
|
|
47
46
|
import { getWorkerRPC, type PersistNotebookRequest } from "./rpc";
|
|
48
47
|
import { getWasmRuntimeConfig } from "./runtime-config";
|
|
@@ -266,23 +265,8 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
266
265
|
return null;
|
|
267
266
|
};
|
|
268
267
|
|
|
269
|
-
|
|
270
|
-
const replica = store.get(activeNotebookReplicaAtom);
|
|
271
|
-
const notebook = replica && store.get(replica.notebookAtom);
|
|
272
|
-
const backend = replica && store.get(replica.backendAtom);
|
|
273
|
-
if (!notebook || !backend) {
|
|
274
|
-
throw new Error("The notebook is not connected");
|
|
275
|
-
}
|
|
276
|
-
const result = await backend.notebook(notebook.id).save();
|
|
277
|
-
if (!result.ok) {
|
|
278
|
-
throw new Error(result.problem.detail ?? result.problem.title);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
public sendSave: EditRequests["sendSave"] = async ({ layout }) => {
|
|
283
|
-
await flushDocument();
|
|
268
|
+
public saveLayout: EditRequests["saveLayout"] = async ({ layout }) => {
|
|
284
269
|
await this.rpc.proxy.request.setNotebookLayout(layout ?? null);
|
|
285
|
-
await this.saveHostNotebook();
|
|
286
270
|
return null;
|
|
287
271
|
};
|
|
288
272
|
|
|
@@ -391,7 +375,6 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
391
375
|
functionName: "save_app_config",
|
|
392
376
|
payload: request,
|
|
393
377
|
});
|
|
394
|
-
await this.saveHostNotebook();
|
|
395
378
|
return null;
|
|
396
379
|
};
|
|
397
380
|
|
|
@@ -12,7 +12,7 @@ import type { UserConfig } from "@/core/config/config-schema";
|
|
|
12
12
|
import type {
|
|
13
13
|
ListPackagesResponse,
|
|
14
14
|
PackageOperationResponse,
|
|
15
|
-
|
|
15
|
+
SaveLayoutRequest,
|
|
16
16
|
} from "@/core/network/types";
|
|
17
17
|
import { decodeUtf8 } from "@/utils/strings";
|
|
18
18
|
import { Deferred } from "../../../utils/Deferred";
|
|
@@ -301,7 +301,7 @@ const requestHandler = createRPCRequestHandler({
|
|
|
301
301
|
};
|
|
302
302
|
},
|
|
303
303
|
|
|
304
|
-
setNotebookLayout: async (layout:
|
|
304
|
+
setNotebookLayout: async (layout: SaveLayoutRequest["layout"]) => {
|
|
305
305
|
const notebook = await getNotebook();
|
|
306
306
|
try {
|
|
307
307
|
await notebook.set_layout(JSON.stringify(layout));
|