@marimo-team/islands 0.23.15-dev4 → 0.23.15-dev40
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-CU4fZJzG.js → ConnectedDataExplorerComponent-7p7rt9iw.js} +4 -4
- package/dist/{ErrorBoundary-DE6tzZf-.js → ErrorBoundary-B_CAG7_e.js} +1 -1
- package/dist/{ImageComparisonComponent-CHrI72em.js → ImageComparisonComponent-1i5IBhon.js} +173 -129
- package/dist/assets/__vite-browser-external-CjNAy01L.js +1 -0
- package/dist/assets/worker-B3XPCb6y.js +98 -0
- package/dist/{chat-ui-DinOvbWu.js → chat-ui-C0z13gJB.js} +3111 -3111
- package/dist/{code-visibility-Dk8cVOny.js → code-visibility-CSsHnVZu.js} +946 -864
- package/dist/{constants-T20xxyNf.js → debounce-BOD3DbfP.js} +1 -24
- package/dist/{formats-Dzx4J_z1.js → formats-CitsMtUm.js} +1 -1
- package/dist/{glide-data-editor-CjTu7ukN.js → glide-data-editor-CwZz71BD.js} +3 -3
- package/dist/{html-to-image-_wGfk8V-.js → html-to-image-CEo5pRYw.js} +2291 -2250
- package/dist/{input-DtsN7xm-.js → input-BGPrFH3g.js} +2 -2
- package/dist/main.js +1297 -1201
- package/dist/{mermaid-BYqXy_NE.js → mermaid-UdmxG2PZ.js} +2 -2
- package/dist/{process-output-Mh4UrjwM.js → process-output-BOvvilRG.js} +1 -1
- package/dist/{reveal-component-B5DXLyO7.js → reveal-component-DGjJVNMQ.js} +618 -605
- package/dist/{spec-Cz-Bj1JI.js → spec-DSs9v0xx.js} +1 -1
- package/dist/style.css +1 -1
- package/dist/{toDate-CWNNlFEX.js → toDate-BRJgtOGm.js} +14 -5
- package/dist/{useAsyncData-KfHB8wQR.js → useAsyncData-BSOyAbac.js} +1 -1
- package/dist/{useDeepCompareMemoize-nJMtxhm4.js → useDeepCompareMemoize-BTLeWzuR.js} +1 -1
- package/dist/{useLifecycle-DegSo0lV.js → useLifecycle-C6wHjkhW.js} +1 -1
- package/dist/{useTheme-6eZ3GOTS.js → useTheme-Df_vGflw.js} +62 -27
- package/dist/{vega-component-DzyyM9fc.js → vega-component-BAvmvTjO.js} +6 -6
- package/package.json +1 -1
- package/src/components/chat/__tests__/chat-utils.test.ts +244 -1
- package/src/components/chat/__tests__/message-queue.test.tsx +121 -0
- package/src/components/chat/chat-panel.tsx +196 -67
- package/src/components/chat/chat-utils.ts +111 -2
- package/src/components/editor/SortableCell.tsx +6 -2
- package/src/components/editor/__tests__/output-persistence.test.tsx +241 -0
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +153 -0
- package/src/components/editor/actions/pair-with-agent-commands.ts +109 -0
- package/src/components/editor/actions/pair-with-agent-modal.tsx +20 -64
- package/src/components/editor/ai/add-cell-with-ai.tsx +14 -5
- package/src/components/editor/ai/ai-completion-editor.tsx +4 -1
- package/src/components/editor/cell/cell-context-menu.tsx +7 -0
- package/src/components/editor/chrome/panels/packages-panel.tsx +11 -1
- package/src/components/editor/columns/__tests__/cell-column.test.tsx +105 -0
- package/src/components/editor/columns/cell-column.tsx +34 -4
- package/src/components/editor/columns/sortable-column.tsx +24 -4
- package/src/components/editor/file-tree/download.ts +46 -0
- package/src/components/editor/file-tree/file-explorer.tsx +3 -21
- package/src/components/editor/file-tree/file-viewer.tsx +4 -27
- package/src/components/editor/navigation/__tests__/navigation.test.ts +33 -0
- package/src/components/editor/navigation/navigation.ts +8 -1
- package/src/components/editor/notebook-cell.tsx +203 -106
- package/src/components/editor/renderers/__tests__/cells-renderer.test.tsx +66 -0
- package/src/components/editor/renderers/cell-array.tsx +26 -13
- package/src/components/editor/renderers/cells-renderer.tsx +8 -2
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +29 -0
- package/src/components/editor/renderers/slides-layout/types.ts +4 -0
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +19 -19
- package/src/components/slides/reveal-component.tsx +37 -4
- package/src/components/slides/slide-form.tsx +72 -0
- package/src/components/ui/toast.tsx +11 -3
- package/src/components/ui/toaster.tsx +65 -9
- package/src/core/cells/__tests__/utils.test.ts +59 -0
- package/src/core/cells/utils.ts +51 -0
- package/src/core/codemirror/completion/__tests__/signature-hint.test.ts +97 -3
- package/src/core/codemirror/completion/signature-hint.ts +78 -11
- package/src/core/constants.ts +6 -0
- package/src/core/islands/__tests__/bridge.test.ts +341 -46
- package/src/core/islands/__tests__/main.test.ts +176 -0
- package/src/core/islands/__tests__/parse.test.ts +105 -0
- package/src/core/islands/bootstrap.ts +8 -3
- package/src/core/islands/bridge.ts +116 -23
- package/src/core/islands/components/__tests__/web-components.test.tsx +214 -0
- package/src/core/islands/components/web-components.tsx +76 -15
- package/src/core/islands/constants.ts +1 -0
- package/src/core/islands/main.ts +69 -3
- package/src/core/islands/parse.ts +70 -23
- package/src/core/islands/worker/__tests__/controller.test.ts +173 -0
- package/src/core/islands/worker/worker.tsx +145 -57
- package/src/core/runtime/__tests__/runtime.test.ts +64 -0
- package/src/core/runtime/runtime.ts +30 -10
- package/src/core/wasm/worker/bootstrap.ts +113 -20
- package/src/css/app/Cell.css +10 -0
- package/src/hooks/__tests__/useHotkey.test.tsx +88 -0
- package/src/hooks/useHotkey.ts +29 -4
- package/src/plugins/impl/anywidget/__tests__/host.test.ts +6 -9
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +22 -61
- package/src/plugins/impl/anywidget/model-proxy.ts +0 -13
- package/src/plugins/impl/anywidget/widget-binding.ts +4 -34
- package/src/plugins/impl/image-comparison/ImageComparisonComponent.tsx +53 -2
- package/src/plugins/impl/image-comparison/__tests__/ImageComparisonComponent.test.tsx +71 -0
- package/src/plugins/impl/matplotlib/__tests__/matplotlib-renderer.test.ts +71 -3
- package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +1 -0
- package/src/theme/__tests__/useTheme.test.ts +68 -0
- package/src/theme/useTheme.ts +16 -1
- package/dist/assets/__vite-browser-external-BQCLNwri.js +0 -1
- package/dist/assets/worker-DAWRHcPq.js +0 -73
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
|
4
|
+
import type * as MainModule from "../main";
|
|
5
|
+
|
|
6
|
+
// `main.ts` is the public islands entry point (see islands/vite.config.mts),
|
|
7
|
+
// so this suite snapshots its export surface + signatures and covers each
|
|
8
|
+
// entry point. The real bridge/bootstrap spin up a Web Worker and the plugin
|
|
9
|
+
// runtime, so we mock them.
|
|
10
|
+
const mockInitializeApps = vi.fn(() => Promise.resolve());
|
|
11
|
+
const mockStopSession = vi.fn((_appId?: string) => Promise.resolve());
|
|
12
|
+
const mockInitializeIslands = vi.fn(() => Promise.resolve());
|
|
13
|
+
const mockParseMarimoIslandApps = vi.fn(() => [] as Array<{ id: string }>);
|
|
14
|
+
|
|
15
|
+
vi.mock("@/core/islands/bridge", () => ({
|
|
16
|
+
getGlobalBridge: () => ({
|
|
17
|
+
initializeApps: mockInitializeApps,
|
|
18
|
+
stopSession: mockStopSession,
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("@/core/islands/bootstrap", () => ({
|
|
23
|
+
initializeIslands: mockInitializeIslands,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("@/core/islands/parse", () => ({
|
|
27
|
+
parseMarimoIslandApps: mockParseMarimoIslandApps,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Re-import a fresh copy of `main.ts` so the module-level `bootstrapPromise`
|
|
32
|
+
* memoization does not leak between tests. The module auto-runs `initialize()`
|
|
33
|
+
* on load, so we clear the DOM first to keep that a no-op.
|
|
34
|
+
*/
|
|
35
|
+
async function importMain(): Promise<typeof MainModule> {
|
|
36
|
+
vi.resetModules();
|
|
37
|
+
return import("../main");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
document.body.replaceChildren();
|
|
42
|
+
mockInitializeApps.mockClear();
|
|
43
|
+
mockStopSession.mockClear();
|
|
44
|
+
mockInitializeIslands.mockClear();
|
|
45
|
+
mockParseMarimoIslandApps.mockReset();
|
|
46
|
+
mockParseMarimoIslandApps.mockReturnValue([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("islands public API surface", () => {
|
|
50
|
+
it("exposes a stable set of exports and signatures", async () => {
|
|
51
|
+
const mod = await importMain();
|
|
52
|
+
const surface = Object.fromEntries(
|
|
53
|
+
Object.keys(mod)
|
|
54
|
+
.toSorted()
|
|
55
|
+
.map((key) => {
|
|
56
|
+
const value = (mod as Record<string, unknown>)[key];
|
|
57
|
+
return [
|
|
58
|
+
key,
|
|
59
|
+
typeof value === "function"
|
|
60
|
+
? `function(arity ${value.length})`
|
|
61
|
+
: typeof value,
|
|
62
|
+
];
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(surface).toMatchInlineSnapshot(`
|
|
67
|
+
{
|
|
68
|
+
"canReplaceApp": "function(arity 0)",
|
|
69
|
+
"initialize": "function(arity 0)",
|
|
70
|
+
"stopApp": "function(arity 1)",
|
|
71
|
+
}
|
|
72
|
+
`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("matches the declared TypeScript signatures", () => {
|
|
76
|
+
expectTypeOf<typeof MainModule.canReplaceApp>().toEqualTypeOf<
|
|
77
|
+
() => boolean
|
|
78
|
+
>();
|
|
79
|
+
expectTypeOf<typeof MainModule.initialize>().toEqualTypeOf<
|
|
80
|
+
() => Promise<void>
|
|
81
|
+
>();
|
|
82
|
+
expectTypeOf<typeof MainModule.stopApp>().toEqualTypeOf<
|
|
83
|
+
(appId?: string) => Promise<void>
|
|
84
|
+
>();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("canReplaceApp", () => {
|
|
89
|
+
it("returns false when the document has no island element", async () => {
|
|
90
|
+
const mod = await importMain();
|
|
91
|
+
|
|
92
|
+
expect(mod.canReplaceApp()).toBe(false);
|
|
93
|
+
// Short-circuits on the DOM guard before parsing.
|
|
94
|
+
expect(mockParseMarimoIslandApps).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns true for exactly one app without materializing the DOM", async () => {
|
|
98
|
+
const mod = await importMain();
|
|
99
|
+
document.body.innerHTML = `<marimo-island data-app-id="app-1"></marimo-island>`;
|
|
100
|
+
mockParseMarimoIslandApps.mockReturnValue([{ id: "app-1" }]);
|
|
101
|
+
|
|
102
|
+
expect(mod.canReplaceApp()).toBe(true);
|
|
103
|
+
// A probe must not mutate island attributes.
|
|
104
|
+
expect(mockParseMarimoIslandApps).toHaveBeenCalledWith(document, {
|
|
105
|
+
materialize: false,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns false when more than one app is present", async () => {
|
|
110
|
+
const mod = await importMain();
|
|
111
|
+
document.body.innerHTML = `<marimo-island data-app-id="app-1"></marimo-island>`;
|
|
112
|
+
mockParseMarimoIslandApps.mockReturnValue([
|
|
113
|
+
{ id: "app-1" },
|
|
114
|
+
{ id: "app-2" },
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
expect(mod.canReplaceApp()).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns false when the island parses into zero apps", async () => {
|
|
121
|
+
const mod = await importMain();
|
|
122
|
+
document.body.innerHTML = `<marimo-island></marimo-island>`;
|
|
123
|
+
mockParseMarimoIslandApps.mockReturnValue([]);
|
|
124
|
+
|
|
125
|
+
expect(mod.canReplaceApp()).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("stopApp", () => {
|
|
130
|
+
it("forwards the requested app id to the bridge", async () => {
|
|
131
|
+
const mod = await importMain();
|
|
132
|
+
|
|
133
|
+
await mod.stopApp("app-7");
|
|
134
|
+
expect(mockStopSession).toHaveBeenCalledWith("app-7");
|
|
135
|
+
|
|
136
|
+
await mod.stopApp();
|
|
137
|
+
expect(mockStopSession).toHaveBeenLastCalledWith(undefined);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("initialize", () => {
|
|
142
|
+
it("skips bootstrap and app start when no islands are present", async () => {
|
|
143
|
+
const mod = await importMain();
|
|
144
|
+
|
|
145
|
+
await mod.initialize();
|
|
146
|
+
|
|
147
|
+
expect(mockInitializeIslands).not.toHaveBeenCalled();
|
|
148
|
+
expect(mockInitializeApps).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("bootstraps once, marks islands, and starts apps on each call", async () => {
|
|
152
|
+
const mod = await importMain();
|
|
153
|
+
document.body.innerHTML = `<marimo-island></marimo-island>`;
|
|
154
|
+
|
|
155
|
+
await mod.initialize();
|
|
156
|
+
await mod.initialize();
|
|
157
|
+
|
|
158
|
+
expect(mockInitializeIslands).toHaveBeenCalledOnce();
|
|
159
|
+
expect(mockInitializeApps).toHaveBeenCalledTimes(2);
|
|
160
|
+
expect(
|
|
161
|
+
document.querySelector("marimo-island")?.classList.contains("marimo"),
|
|
162
|
+
).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("clears the memoized bootstrap when it fails so it can be retried", async () => {
|
|
166
|
+
const mod = await importMain();
|
|
167
|
+
document.body.innerHTML = `<marimo-island></marimo-island>`;
|
|
168
|
+
mockInitializeIslands.mockRejectedValueOnce(new Error("bootstrap failed"));
|
|
169
|
+
|
|
170
|
+
await expect(mod.initialize()).rejects.toThrow("bootstrap failed");
|
|
171
|
+
await mod.initialize();
|
|
172
|
+
|
|
173
|
+
expect(mockInitializeIslands).toHaveBeenCalledTimes(2);
|
|
174
|
+
expect(mockInitializeApps).toHaveBeenCalledOnce();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
parseIslandElement,
|
|
16
16
|
parseIslandElementsIntoApps,
|
|
17
17
|
parseMarimoIslandApps,
|
|
18
|
+
retainIslandSource,
|
|
18
19
|
} from "../parse";
|
|
19
20
|
import { createMockIslandElement, createMockIslands } from "./test-utils.tsx";
|
|
20
21
|
|
|
@@ -53,6 +54,7 @@ function appendPayload(
|
|
|
53
54
|
script.type = ISLANDS_JSON_SCRIPT_TYPE;
|
|
54
55
|
script.textContent = JSON.stringify(payload);
|
|
55
56
|
root.appendChild(script);
|
|
57
|
+
return script;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
describe("createMarimoFile", () => {
|
|
@@ -317,6 +319,39 @@ describe("parseIslandElement", () => {
|
|
|
317
319
|
|
|
318
320
|
expect(result).toBeNull();
|
|
319
321
|
});
|
|
322
|
+
|
|
323
|
+
it("uses retained source after the custom element renders", () => {
|
|
324
|
+
const element = document.createElement(ISLAND_TAG_NAMES.ISLAND);
|
|
325
|
+
retainIslandSource(element, {
|
|
326
|
+
code: 'print("retained")',
|
|
327
|
+
output: "<div>retained output</div>",
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(parseIslandElement(element)).toEqual({
|
|
331
|
+
code: 'print("retained")',
|
|
332
|
+
output: "<div>retained output</div>",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
retainIslandSource(element, { code: "", output: "<div>static</div>" });
|
|
336
|
+
expect(parseIslandElement(element)).toBeNull();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("prefers new live source when an island element is reused", () => {
|
|
340
|
+
const element = createMockIslandElement({
|
|
341
|
+
code: 'print("new")',
|
|
342
|
+
innerHTML: "<div>new output</div>",
|
|
343
|
+
});
|
|
344
|
+
element.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
|
|
345
|
+
retainIslandSource(element, {
|
|
346
|
+
code: 'print("retained")',
|
|
347
|
+
output: "<div>retained output</div>",
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
expect(parseIslandElement(element)).toEqual({
|
|
351
|
+
code: 'print("new")',
|
|
352
|
+
output: "<div>new output</div>",
|
|
353
|
+
});
|
|
354
|
+
});
|
|
320
355
|
});
|
|
321
356
|
|
|
322
357
|
describe("parseIslandElementsIntoApps", () => {
|
|
@@ -630,6 +665,76 @@ describe("parseMarimoIslandApps", () => {
|
|
|
630
665
|
);
|
|
631
666
|
});
|
|
632
667
|
|
|
668
|
+
it("preserves payload-backed cells after a non-materializing capability probe", () => {
|
|
669
|
+
const island = createMockIslandElement({
|
|
670
|
+
appId: "app1",
|
|
671
|
+
cellId: "cell-2",
|
|
672
|
+
code: "dom_code = True",
|
|
673
|
+
innerHTML: "<div>dom output</div>",
|
|
674
|
+
});
|
|
675
|
+
island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
|
|
676
|
+
container.appendChild(island);
|
|
677
|
+
appendPayload(container, {
|
|
678
|
+
schemaVersion: 1,
|
|
679
|
+
appId: "app1",
|
|
680
|
+
cells: [createPayloadCell({ cellId: "cell-2" })],
|
|
681
|
+
});
|
|
682
|
+
const originalIsland = island.outerHTML;
|
|
683
|
+
|
|
684
|
+
const probed = parseMarimoIslandApps(container, { materialize: false });
|
|
685
|
+
|
|
686
|
+
expect(probed).toHaveLength(1);
|
|
687
|
+
expect(island.outerHTML).toBe(originalIsland);
|
|
688
|
+
|
|
689
|
+
expect(parseMarimoIslandApps(container)).toEqual(probed);
|
|
690
|
+
expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID)).toBeNull();
|
|
691
|
+
expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("0");
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("refreshes a reused payload anchor when its content changes", () => {
|
|
695
|
+
const island = createMockIslandElement({
|
|
696
|
+
appId: "app1",
|
|
697
|
+
cellId: "cell-1",
|
|
698
|
+
code: 'print("dom")',
|
|
699
|
+
innerHTML: "<div>dom output</div>",
|
|
700
|
+
});
|
|
701
|
+
island.insertAdjacentHTML(
|
|
702
|
+
"beforeend",
|
|
703
|
+
'<div data-marimo-element><marimo-code-editor data-initial-value="old"></marimo-code-editor></div>',
|
|
704
|
+
);
|
|
705
|
+
island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
|
|
706
|
+
container.appendChild(island);
|
|
707
|
+
const script = appendPayload(container, {
|
|
708
|
+
schemaVersion: 1,
|
|
709
|
+
appId: "app1",
|
|
710
|
+
cells: [createPayloadCell()],
|
|
711
|
+
});
|
|
712
|
+
parseMarimoIslandApps(container);
|
|
713
|
+
|
|
714
|
+
script.textContent = JSON.stringify({
|
|
715
|
+
schemaVersion: 1,
|
|
716
|
+
appId: "app1",
|
|
717
|
+
cells: [
|
|
718
|
+
createPayloadCell({
|
|
719
|
+
code: 'print("updated")',
|
|
720
|
+
outputHtml: "<div>updated output</div>",
|
|
721
|
+
}),
|
|
722
|
+
],
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
parseMarimoIslandApps(container);
|
|
726
|
+
|
|
727
|
+
expect(extractIslandCodeFromEmbed(island)).toBe('print("updated")');
|
|
728
|
+
expect(island.querySelector(ISLAND_TAG_NAMES.CELL_OUTPUT)?.innerHTML).toBe(
|
|
729
|
+
"<div>updated output</div>",
|
|
730
|
+
);
|
|
731
|
+
expect(
|
|
732
|
+
island
|
|
733
|
+
.querySelector(ISLAND_TAG_NAMES.CODE_EDITOR)
|
|
734
|
+
?.getAttribute("data-initial-value"),
|
|
735
|
+
).toBe(JSON.stringify('print("updated")'));
|
|
736
|
+
});
|
|
737
|
+
|
|
633
738
|
it("should parse Python-generated island payload snapshots", () => {
|
|
634
739
|
const html = readFileSync(
|
|
635
740
|
new URL(
|
|
@@ -108,14 +108,17 @@ export async function initializeIslands(
|
|
|
108
108
|
// Loading indicator: dim islands while Pyodide initializes
|
|
109
109
|
store.sub(shouldShowIslandsWarningIndicatorAtom, () => {
|
|
110
110
|
const showing = store.get(shouldShowIslandsWarningIndicatorAtom);
|
|
111
|
+
const currentIslands = root.querySelectorAll<HTMLElement>(
|
|
112
|
+
ISLAND_TAG_NAMES.ISLAND,
|
|
113
|
+
);
|
|
111
114
|
if (showing) {
|
|
112
115
|
toastIslandsLoading();
|
|
113
|
-
for (const island of
|
|
116
|
+
for (const island of currentIslands) {
|
|
114
117
|
island.style.setProperty("opacity", "0.5");
|
|
115
118
|
}
|
|
116
119
|
} else {
|
|
117
120
|
dismissIslandsLoadingToast();
|
|
118
|
-
for (const island of
|
|
121
|
+
for (const island of currentIslands) {
|
|
119
122
|
island.style.removeProperty("opacity");
|
|
120
123
|
}
|
|
121
124
|
}
|
|
@@ -208,7 +211,9 @@ function handleMessage(
|
|
|
208
211
|
setKernelState: Functions.NOOP,
|
|
209
212
|
onError: Logger.error,
|
|
210
213
|
});
|
|
211
|
-
|
|
214
|
+
if (!window.customElements?.get(ISLAND_TAG_NAMES.ISLAND)) {
|
|
215
|
+
defineCustomElement(ISLAND_TAG_NAMES.ISLAND, MarimoIslandElement);
|
|
216
|
+
}
|
|
212
217
|
return;
|
|
213
218
|
|
|
214
219
|
case "send-ui-element-message":
|
|
@@ -7,6 +7,7 @@ import { throwNotImplemented } from "@/utils/functions";
|
|
|
7
7
|
import type { JsonString } from "@/utils/json/base64";
|
|
8
8
|
import { Logger } from "@/utils/Logger";
|
|
9
9
|
import { generateUUID } from "@/utils/uuid";
|
|
10
|
+
import { initialNotebookState, notebookAtom } from "../cells/cells";
|
|
10
11
|
import type { CommandMessage, NotificationPayload } from "../kernel/messages";
|
|
11
12
|
import type { EditRequests, RunRequests } from "../network/types";
|
|
12
13
|
import { store as defaultStore } from "../state/jotai";
|
|
@@ -35,11 +36,12 @@ export interface IslandsBridgeConfig {
|
|
|
35
36
|
* Optional root element for parsing islands (for testing)
|
|
36
37
|
*/
|
|
37
38
|
root?: Document | Element;
|
|
39
|
+
}
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
interface AppSession {
|
|
42
|
+
appId: string;
|
|
43
|
+
code?: string;
|
|
44
|
+
sessionGeneration: number;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
@@ -51,8 +53,8 @@ export interface IslandsBridgeConfig {
|
|
|
51
53
|
* @example
|
|
52
54
|
* ```ts
|
|
53
55
|
* const bridge = new IslandsPyodideBridge();
|
|
54
|
-
* await bridge.initialized;
|
|
55
56
|
* bridge.consumeMessages(message => console.log(message));
|
|
57
|
+
* await bridge.initializeApps();
|
|
56
58
|
* ```
|
|
57
59
|
*/
|
|
58
60
|
export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
@@ -62,14 +64,17 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
62
64
|
| undefined;
|
|
63
65
|
private readonly store: typeof defaultStore;
|
|
64
66
|
private readonly root: Document | Element;
|
|
65
|
-
private
|
|
67
|
+
private session: AppSession | undefined;
|
|
68
|
+
private sessionReady = new Deferred<AppSession>();
|
|
69
|
+
private nextSessionGeneration = 0;
|
|
70
|
+
private appTransition = Promise.resolve();
|
|
71
|
+
private workerReady = new Deferred<void>();
|
|
66
72
|
|
|
67
73
|
public initialized = new Deferred<void>();
|
|
68
74
|
|
|
69
75
|
constructor(config: IslandsBridgeConfig = {}) {
|
|
70
76
|
this.store = config.store || defaultStore;
|
|
71
77
|
this.root = config.root || document;
|
|
72
|
-
this.autoStartSessions = config.autoStartSessions ?? true;
|
|
73
78
|
|
|
74
79
|
try {
|
|
75
80
|
const factory = config.workerFactory || new DefaultWorkerFactory();
|
|
@@ -88,9 +93,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
88
93
|
*/
|
|
89
94
|
private setupMessageListeners(): void {
|
|
90
95
|
this.rpc.addMessageListener("ready", () => {
|
|
91
|
-
|
|
92
|
-
this.startSessionsForAllApps();
|
|
93
|
-
}
|
|
96
|
+
this.workerReady.resolve();
|
|
94
97
|
});
|
|
95
98
|
|
|
96
99
|
this.rpc.addMessageListener("initialized", () => {
|
|
@@ -115,11 +118,16 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
115
118
|
);
|
|
116
119
|
}
|
|
117
120
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
async initializeApps(): Promise<void> {
|
|
122
|
+
await this.enqueueAppTransition(async () => {
|
|
123
|
+
await this.workerReady.promise;
|
|
124
|
+
await this.startApps();
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async startApps(): Promise<void> {
|
|
122
129
|
const apps = parseMarimoIslandApps(this.root);
|
|
130
|
+
const managesSingleApp = apps.length === 1;
|
|
123
131
|
Logger.debug(
|
|
124
132
|
`Starting sessions for ${apps.length} app(s):`,
|
|
125
133
|
apps.map((a) => `${a.id} (${a.cells.length} cells)`),
|
|
@@ -134,20 +142,83 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
134
142
|
const notebookCode = exportContext?.notebookCode;
|
|
135
143
|
for (const app of apps) {
|
|
136
144
|
const file = notebookCode || createMarimoFile(app);
|
|
145
|
+
if (
|
|
146
|
+
managesSingleApp &&
|
|
147
|
+
this.session?.appId === app.id &&
|
|
148
|
+
this.session.code === file
|
|
149
|
+
) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
137
152
|
Logger.debug(`App ${app.id} marimo file:\n`, file);
|
|
138
|
-
|
|
153
|
+
const request = {
|
|
139
154
|
code: file,
|
|
140
155
|
appId: app.id,
|
|
141
|
-
|
|
156
|
+
sessionGeneration: ++this.nextSessionGeneration,
|
|
157
|
+
};
|
|
158
|
+
const previousSession = this.session;
|
|
159
|
+
const replacesSession = managesSingleApp && previousSession !== undefined;
|
|
160
|
+
if (replacesSession) {
|
|
161
|
+
this.store.set(notebookAtom, initialNotebookState());
|
|
162
|
+
}
|
|
163
|
+
if (managesSingleApp || !previousSession) {
|
|
164
|
+
if (managesSingleApp && this.sessionReady.status !== "pending") {
|
|
165
|
+
this.sessionReady = new Deferred<AppSession>();
|
|
166
|
+
}
|
|
167
|
+
this.session = {
|
|
168
|
+
...request,
|
|
169
|
+
code: managesSingleApp ? file : undefined,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const operation = replacesSession
|
|
173
|
+
? this.rpc.proxy.request.replaceSession(request)
|
|
174
|
+
: this.startSession(request);
|
|
175
|
+
try {
|
|
176
|
+
await operation;
|
|
177
|
+
if (this.sessionReady.status === "pending" && this.session) {
|
|
178
|
+
this.sessionReady.resolve(this.session);
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (this.session?.sessionGeneration === request.sessionGeneration) {
|
|
182
|
+
this.session = previousSession;
|
|
183
|
+
}
|
|
142
184
|
Logger.error(`Failed to start session for app ${app.id}:`, error);
|
|
143
|
-
|
|
185
|
+
if (managesSingleApp) {
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
144
189
|
}
|
|
145
190
|
}
|
|
146
191
|
|
|
192
|
+
async stopSession(appId?: string): Promise<void> {
|
|
193
|
+
await this.enqueueAppTransition(async () => {
|
|
194
|
+
const session = this.session;
|
|
195
|
+
if (session?.code === undefined || (appId && session.appId !== appId)) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
await this.rpc.proxy.request.stopSession({
|
|
199
|
+
appId: session.appId,
|
|
200
|
+
sessionGeneration: session.sessionGeneration,
|
|
201
|
+
});
|
|
202
|
+
this.session = undefined;
|
|
203
|
+
this.sessionReady = new Deferred<AppSession>();
|
|
204
|
+
this.store.set(notebookAtom, initialNotebookState());
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private enqueueAppTransition(operation: () => Promise<void>): Promise<void> {
|
|
209
|
+
const result = this.appTransition.then(operation);
|
|
210
|
+
this.appTransition = result.catch(() => undefined);
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
147
214
|
/**
|
|
148
215
|
* Starts a new Python session for an app
|
|
149
216
|
*/
|
|
150
|
-
async startSession(opts: {
|
|
217
|
+
async startSession(opts: {
|
|
218
|
+
code: string;
|
|
219
|
+
appId: string;
|
|
220
|
+
sessionGeneration: number;
|
|
221
|
+
}): Promise<void> {
|
|
151
222
|
await this.rpc.proxy.request.startSession(opts);
|
|
152
223
|
}
|
|
153
224
|
|
|
@@ -203,11 +274,19 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
203
274
|
// ============================================================================
|
|
204
275
|
|
|
205
276
|
sendRun: EditRequests["sendRun"] = async (request): Promise<null> => {
|
|
206
|
-
await this.
|
|
207
|
-
await this.
|
|
208
|
-
|
|
209
|
-
|
|
277
|
+
const session = await this.getActiveSession();
|
|
278
|
+
await this.rpc.proxy.request.loadPackages({
|
|
279
|
+
appId: session.appId,
|
|
280
|
+
code: request.codes.join("\n"),
|
|
281
|
+
sessionGeneration: session.sessionGeneration,
|
|
210
282
|
});
|
|
283
|
+
await this.putControlRequest(
|
|
284
|
+
{
|
|
285
|
+
type: "execute-cells",
|
|
286
|
+
...request,
|
|
287
|
+
},
|
|
288
|
+
session,
|
|
289
|
+
);
|
|
211
290
|
return null;
|
|
212
291
|
};
|
|
213
292
|
|
|
@@ -278,13 +357,27 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
278
357
|
|
|
279
358
|
// The kernel uses msgspec to parse control requests, which requires a 'type'
|
|
280
359
|
// field for discriminated union deserialization.
|
|
281
|
-
private async putControlRequest(
|
|
360
|
+
private async putControlRequest(
|
|
361
|
+
operation: CommandMessage,
|
|
362
|
+
session?: AppSession,
|
|
363
|
+
): Promise<void> {
|
|
364
|
+
session ??= await this.getActiveSession();
|
|
282
365
|
await this.rpc.proxy.request.bridge({
|
|
366
|
+
appId: session.appId,
|
|
283
367
|
functionName: "put_control_request",
|
|
284
368
|
payload: operation,
|
|
369
|
+
sessionGeneration: session.sessionGeneration,
|
|
285
370
|
});
|
|
286
371
|
}
|
|
287
372
|
|
|
373
|
+
private async getActiveSession(): Promise<AppSession> {
|
|
374
|
+
const transition = this.appTransition;
|
|
375
|
+
const session = this.session;
|
|
376
|
+
const ready = this.sessionReady;
|
|
377
|
+
await transition;
|
|
378
|
+
return session ?? ready.promise;
|
|
379
|
+
}
|
|
380
|
+
|
|
288
381
|
/**
|
|
289
382
|
* Cleans up resources (for testing)
|
|
290
383
|
*/
|