@marimo-team/frontend 0.23.6-dev9 → 0.23.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{CellStatus-BasmzQBh.js → CellStatus-CO_unhk0.js} +1 -1
- package/dist/assets/{ConnectedDataExplorerComponent-DNoHDaQW.js → ConnectedDataExplorerComponent-k8s9vETJ.js} +1 -1
- package/dist/assets/{JsonOutput-BQaSLDBi.js → JsonOutput-ugBhs7bt.js} +11 -11
- package/dist/assets/{MarimoErrorOutput-Dm4hVwDq.js → MarimoErrorOutput-1q6qtvxi.js} +1 -1
- package/dist/assets/RenderHTML-DkEHxB1r.js +1 -0
- package/dist/assets/{RunButton-DHxYW_jZ.js → RunButton-CJsJsGj-.js} +1 -1
- package/dist/assets/__vite-browser-external-D0cSGXjR.js +1 -0
- package/dist/assets/__vite-browser-external-DQc2JVNq.js +1 -0
- package/dist/assets/{add-cell-with-ai-5QPp1nPl.js → add-cell-with-ai-D5JeNTNV.js} +1 -1
- package/dist/assets/{add-connection-dialog-C3IbqmBw.js → add-connection-dialog-_nMyst1l.js} +1 -1
- package/dist/assets/{agent-panel-Dbk-1jxq.js → agent-panel-CBj2Q42_.js} +1 -1
- package/dist/assets/ai-model-dropdown-B-9yxYM4.js +5 -0
- package/dist/assets/{app-config-button-DyNQc93A.js → app-config-button-BOc_z0uX.js} +1 -1
- package/dist/assets/{cell-editor-BaK05lPI.js → cell-editor-C1BpIU12.js} +3 -3
- package/dist/assets/{cell-link-8o8mCUzE.js → cell-link-CC2MiW7a.js} +1 -1
- package/dist/assets/{cells-Jdt48eTG.js → cells-DAxz8J5R.js} +3 -3
- package/dist/assets/{chat-display-D4A5J3ON.js → chat-display-BiUNr6dU.js} +1 -1
- package/dist/assets/{chat-panel--TAJZjAo.js → chat-panel-BHsaaTzR.js} +1 -1
- package/dist/assets/{chat-ui-Dlmd_U56.js → chat-ui-CTYG4pnL.js} +1 -1
- package/dist/assets/{column-preview-B1oiFlur.js → column-preview-DBPye87P.js} +1 -1
- package/dist/assets/{command-palette-kgF1SANg.js → command-palette-C3fn0qCr.js} +1 -1
- package/dist/assets/{common-C6ZHulDM.js → common-Qy2P7rii.js} +1 -1
- package/dist/assets/{components-Du9BBcZY.js → components-CZy03693.js} +1 -1
- package/dist/assets/{components-BiZUhizd.js → components-D9aJNSr-.js} +1 -1
- package/dist/assets/config-CPqw1wUv.js +1 -0
- package/dist/assets/{datasource-BGCJgHn8.js → datasource-C1JWjcmE.js} +1 -1
- package/dist/assets/{dependency-graph-panel-BFoJRcHv.js → dependency-graph-panel-DOHj-hNc.js} +1 -1
- package/dist/assets/{documentation-panel-Bi7B6BDD.js → documentation-panel-BM7hNnyp.js} +1 -1
- package/dist/assets/{download-q17GDjRk.js → download-CXTuIv7r.js} +1 -1
- package/dist/assets/{edit-page-B4TUnRbq.js → edit-page-Ct5Ke1wi.js} +6 -6
- package/dist/assets/{error-panel-Elx5tZAZ.js → error-panel-BN-anQD3.js} +1 -1
- package/dist/assets/{file-explorer-panel-omTFPhXg.js → file-explorer-panel-D1O0vw5C.js} +3 -3
- package/dist/assets/{file-icons-D1lzcliR.js → file-icons-5G4ZC70N.js} +1 -1
- package/dist/assets/{floating-outline-DVqXePmL.js → floating-outline-DHE0ukvC.js} +1 -1
- package/dist/assets/{focus-Bz1PGBQ2.js → focus-Crs_4nnQ.js} +1 -1
- package/dist/assets/{form-CpNkZ10P.js → form-DRJZl2zK.js} +1 -1
- package/dist/assets/{glide-data-editor-CxV4Ph39.js → glide-data-editor-DqqLCmqF.js} +1 -1
- package/dist/assets/{globals-B-ZMi0ZU.js → globals-DUw71mRV.js} +1 -1
- package/dist/assets/{home-page-B6hZQb34.js → home-page-BM_BZnw7.js} +1 -1
- package/dist/assets/{hooks-S16Gx0fA.js → hooks-CUEvgvEQ.js} +1 -1
- package/dist/assets/{html-to-image-BJDxFKbb.js → html-to-image-v-_444d3.js} +1 -1
- package/dist/assets/{index-6t5Y6NP5.js → index-BMxMikGP.js} +7 -7
- package/dist/assets/{kiosk-mode-DlJLa7MP.js → kiosk-mode-DKb6W1WN.js} +1 -1
- package/dist/assets/layout-Bp1vAdBy.js +9 -0
- package/dist/assets/{logs-panel--5x174b0.js → logs-panel-BWCDk8Zy.js} +1 -1
- package/dist/assets/{markdown-renderer-9gIJjISB.js → markdown-renderer-CbuqHMPu.js} +1 -1
- package/dist/assets/{mermaid-CXmjgj4p.js → mermaid-BfdNvRSd.js} +1 -1
- package/dist/assets/{name-cell-input-DUUJ7fdG.js → name-cell-input-DoYtA-nF.js} +1 -1
- package/dist/assets/{outline-panel-B6y7CdzK.js → outline-panel-C5bTOsj4.js} +1 -1
- package/dist/assets/{packages-panel-bTONt5Lb.js → packages-panel-CHVjLKJK.js} +1 -1
- package/dist/assets/panels-GT2UyjFN.js +1 -0
- package/dist/assets/{process-output-TR8koJYc.js → process-output-CKd9tbdL.js} +1 -1
- package/dist/assets/{radio-group-B0jYgGRE.js → radio-group-BI3wOhfc.js} +1 -1
- package/dist/assets/{readonly-python-code-BNCBnJYH.js → readonly-python-code-CaKJ84fy.js} +1 -1
- package/dist/assets/{renderShortcut-DZpkrZaP.js → renderShortcut-Bfk4NjRL.js} +1 -1
- package/dist/assets/{reveal-component-BakhgglY.js → reveal-component-DC85rv-5.js} +1 -1
- package/dist/assets/run-page-CldmTgnu.js +1 -0
- package/dist/assets/{save-worker-CtJsIYIM.js → save-worker-CvbUHJh7.js} +3 -3
- package/dist/assets/{scratchpad-panel-Bc-DomsH.js → scratchpad-panel-C6QArhRa.js} +1 -1
- package/dist/assets/{session-panel-BAAW6R7U.js → session-panel-bu72cztx.js} +1 -1
- package/dist/assets/{snippets-panel-roW3_D4d.js → snippets-panel-BvuRa1mc.js} +1 -1
- package/dist/assets/state-CjlRDG08.js +3 -0
- package/dist/assets/{state-Bhut5iLD.js → state-DgRSUIT8.js} +1 -1
- package/dist/assets/{switch-ecwOrzz3.js → switch-BvybnC9P.js} +1 -1
- package/dist/assets/{terminal-CQZ69wrS.js → terminal-zehb39z5.js} +23 -23
- package/dist/assets/{textarea-DapfQSws.js → textarea-GPxmW3rS.js} +1 -1
- package/dist/assets/{tracing-D3nIh6vh.js → tracing-CVQn241p.js} +1 -1
- package/dist/assets/{tracing-panel-B23uvwYc.js → tracing-panel-TnogJs00.js} +2 -2
- package/dist/assets/{useBoolean-B_vDzBHf.js → useBoolean-xXcxYCaI.js} +1 -1
- package/dist/assets/{useCellActionButton-DrzG-_Bd.js → useCellActionButton-C8PCItmw.js} +1 -1
- package/dist/assets/{useDeleteCell-CcnkgOq-.js → useDeleteCell-DMZGFMOB.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-Dv04JzMr.js → useDependencyPanelTab-Jnl7B-vS.js} +1 -1
- package/dist/assets/{useHotkey-Cm0DaHEJ.js → useHotkey-DccKPSPx.js} +1 -1
- package/dist/assets/useNotebookActions-U6kQhg4l.js +1 -0
- package/dist/assets/{useRunCells-BeRcytF8.js → useRunCells-BMgfN_OV.js} +1 -1
- package/dist/assets/{useSplitCell-DSLPh_8D.js → useSplitCell-B5YK7yVe.js} +1 -1
- package/dist/assets/{useTheme-Cb4Wekek.js → useTheme-DFXuDFj9.js} +1 -1
- package/dist/assets/utils-BrXijSdZ.js +1 -0
- package/dist/assets/{vega-component-BDK4MwYT.js → vega-component-DOyQwlmD.js} +1 -1
- package/dist/assets/{worker-ztl1wuLb.js → worker-CF8V9c2V.js} +3 -3
- package/dist/index.html +29 -29
- package/package.json +5 -5
- package/src/components/ai/ai-provider-icon.tsx +1 -0
- package/src/components/ai/ai-utils.ts +1 -0
- package/src/components/app-config/ai-config.tsx +30 -0
- package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +47 -0
- package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
- package/src/components/editor/renderers/cell-array.tsx +14 -7
- package/src/components/slides/slide-form.tsx +43 -0
- package/src/components/terminal/terminal.tsx +16 -0
- package/src/components/ui/links.tsx +2 -1
- package/src/core/ai/ids/ids.ts +1 -0
- package/src/core/codemirror/markdown/__tests__/commands.test.ts +36 -0
- package/src/core/codemirror/markdown/commands.ts +4 -1
- package/src/core/config/config-schema.ts +1 -0
- package/src/core/edit-app.tsx +1 -0
- package/src/core/run-app.tsx +9 -2
- package/src/core/runtime/runtime.ts +3 -2
- package/src/core/static/static-state.ts +5 -1
- package/src/core/wasm/PyodideLoader.tsx +54 -16
- package/src/core/wasm/__tests__/PyodideLoader.test.ts +72 -0
- package/src/core/wasm/__tests__/bridge.test.ts +26 -1
- package/src/core/wasm/bridge.ts +24 -6
- package/src/core/wasm/state.ts +3 -0
- package/src/core/wasm/worker/getController.ts +7 -0
- package/src/core/wasm/worker/save-worker.ts +2 -1
- package/src/core/wasm/worker/worker.ts +2 -1
- package/src/plugins/core/RenderHTML.tsx +49 -3
- package/src/plugins/core/__test__/RenderHTML.test.ts +54 -0
- package/src/plugins/impl/common/labeled.tsx +1 -1
- package/dist/assets/RenderHTML-CA4tujDX.js +0 -1
- package/dist/assets/__vite-browser-external-D-ioOGDE.js +0 -1
- package/dist/assets/__vite-browser-external-DgbEhFq1.js +0 -1
- package/dist/assets/ai-model-dropdown-DRqlfqFb.js +0 -5
- package/dist/assets/config-DczIUz0b.js +0 -1
- package/dist/assets/layout-D1lkx0Aj.js +0 -9
- package/dist/assets/panels-gY-_wGFa.js +0 -1
- package/dist/assets/run-page-DwUpSMBL.js +0 -1
- package/dist/assets/state-D5aL5woK.js +0 -3
- package/dist/assets/useNotebookActions-DC5VHfXc.js +0 -1
- package/dist/assets/utils-DIGrmLDO.js +0 -1
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
CookieIcon,
|
|
9
9
|
PanelRightCloseIcon,
|
|
10
10
|
PanelRightOpenIcon,
|
|
11
|
+
KeyboardIcon,
|
|
11
12
|
} from "lucide-react";
|
|
12
13
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
13
14
|
import {
|
|
@@ -28,6 +29,7 @@ import type {
|
|
|
28
29
|
import { useState } from "react";
|
|
29
30
|
import { Tooltip } from "../ui/tooltip";
|
|
30
31
|
import { Button } from "../ui/button";
|
|
32
|
+
import { Kbd } from "../ui/kbd";
|
|
31
33
|
import type { RuntimeCell } from "@/core/cells/types";
|
|
32
34
|
|
|
33
35
|
export const DEFAULT_SLIDE_TYPE: SlideType = "slide";
|
|
@@ -132,10 +134,51 @@ const SlidesForm = ({
|
|
|
132
134
|
<TabsContent value="deck" className="mt-0 flex-1">
|
|
133
135
|
<DeckConfigForm layout={layout} setLayout={setLayout} />
|
|
134
136
|
</TabsContent>
|
|
137
|
+
<hr />
|
|
138
|
+
<KeyboardTips />
|
|
135
139
|
</Tabs>
|
|
136
140
|
);
|
|
137
141
|
};
|
|
138
142
|
|
|
143
|
+
const KEYBOARD_TIPS: { keys: string[]; description: string }[] = [
|
|
144
|
+
{ keys: ["F"], description: "Enter fullscreen" },
|
|
145
|
+
{ keys: ["C"], description: "Toggle code editor" },
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
const KEYBOARD_SHORTCUTS_URL =
|
|
149
|
+
"https://vlaaad.github.io/reveal/keyboard-shortcuts";
|
|
150
|
+
|
|
151
|
+
const KeyboardTips = () => {
|
|
152
|
+
return (
|
|
153
|
+
<div className="flex flex-col gap-2 text-xs text-muted-foreground">
|
|
154
|
+
<div className="flex items-center gap-1.5 font-medium text-foreground/80">
|
|
155
|
+
<KeyboardIcon className="h-3.5 w-3.5" />
|
|
156
|
+
<span>Shortcuts</span>
|
|
157
|
+
</div>
|
|
158
|
+
<ul className="flex flex-col gap-1.5">
|
|
159
|
+
{KEYBOARD_TIPS.map(({ keys, description }) => (
|
|
160
|
+
<li key={description} className="flex items-center justify-between">
|
|
161
|
+
<span>{description}</span>
|
|
162
|
+
<span className="flex gap-1">
|
|
163
|
+
{keys.map((key) => (
|
|
164
|
+
<Kbd key={key}>{key}</Kbd>
|
|
165
|
+
))}
|
|
166
|
+
</span>
|
|
167
|
+
</li>
|
|
168
|
+
))}
|
|
169
|
+
</ul>
|
|
170
|
+
<a
|
|
171
|
+
href={KEYBOARD_SHORTCUTS_URL}
|
|
172
|
+
target="_blank"
|
|
173
|
+
rel="noopener noreferrer"
|
|
174
|
+
className="text-link hover:underline"
|
|
175
|
+
>
|
|
176
|
+
See all shortcuts
|
|
177
|
+
</a>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
139
182
|
const SlideConfigForm = ({
|
|
140
183
|
layout,
|
|
141
184
|
setLayout,
|
|
@@ -270,6 +270,22 @@ const TerminalComponent: React.FC<TerminalComponentProps> = ({
|
|
|
270
270
|
|
|
271
271
|
const handleOpen = () => {
|
|
272
272
|
updateReadyState();
|
|
273
|
+
// Send initial dimensions: the mount-time fit() may have fired
|
|
274
|
+
// before the WS was OPEN, dropping the resize message and leaving
|
|
275
|
+
// the PTY at its default 0x0 winsize.
|
|
276
|
+
fitAddon.fit();
|
|
277
|
+
if (terminal.cols > 0 && terminal.rows > 0) {
|
|
278
|
+
socket.send(
|
|
279
|
+
JSON.stringify({
|
|
280
|
+
type: "resize",
|
|
281
|
+
cols: terminal.cols,
|
|
282
|
+
rows: terminal.rows,
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
// The fit() above may have triggered onResize → scheduled a
|
|
286
|
+
// debounced send. Cancel it; we just sent the same dims.
|
|
287
|
+
handleBackendResizeDebounced.cancel();
|
|
288
|
+
}
|
|
273
289
|
};
|
|
274
290
|
|
|
275
291
|
const handleDisconnect = () => {
|
|
@@ -15,7 +15,8 @@ export const ExternalLink = ({
|
|
|
15
15
|
| `https://marimo.io/${string}`
|
|
16
16
|
| `https://links.marimo.app/${string}`
|
|
17
17
|
| `https://wandb.ai/${string}`
|
|
18
|
-
| `https://portal.azure.com/${string}
|
|
18
|
+
| `https://portal.azure.com/${string}`
|
|
19
|
+
| `https://opencode.ai/${string}`;
|
|
19
20
|
children: React.ReactNode;
|
|
20
21
|
}) => {
|
|
21
22
|
return (
|
package/src/core/ai/ids/ids.ts
CHANGED
|
@@ -385,6 +385,42 @@ describe("insertImage", () => {
|
|
|
385
385
|
);
|
|
386
386
|
});
|
|
387
387
|
|
|
388
|
+
test("normalizes Windows backslash paths to forward slashes in image URL", async () => {
|
|
389
|
+
view = createEditor("Hello, world!");
|
|
390
|
+
view.dispatch({
|
|
391
|
+
selection: { anchor: 7, head: 7 },
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
vi.spyOn(store, "get").mockImplementation((atom) => {
|
|
395
|
+
if (atom === filenameAtom) {
|
|
396
|
+
return "C:\\Users\\user\\project\\notebook.py";
|
|
397
|
+
}
|
|
398
|
+
if (atom === requestClientAtom) {
|
|
399
|
+
return mockRequestClient;
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
mockRequestClient.sendCreateFileOrFolder.mockResolvedValueOnce({
|
|
404
|
+
success: true,
|
|
405
|
+
message: null,
|
|
406
|
+
info: {
|
|
407
|
+
path: "C:\\Users\\user\\project\\public\\hello.png",
|
|
408
|
+
name: "hello.png",
|
|
409
|
+
children: [],
|
|
410
|
+
id: "",
|
|
411
|
+
isDirectory: false,
|
|
412
|
+
isMarimoFile: false,
|
|
413
|
+
lastModified: null,
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
await insertImage(view, mockPngFile());
|
|
418
|
+
|
|
419
|
+
expect(view.state.doc.toString()).toMatchInlineSnapshot(
|
|
420
|
+
`"Hello, world!"`,
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
388
424
|
test("saves image as file different extension", async () => {
|
|
389
425
|
view = createEditor("Hello, world!");
|
|
390
426
|
view.dispatch({
|
|
@@ -360,7 +360,10 @@ export async function insertImage(view: EditorView, file: File) {
|
|
|
360
360
|
notebookDir &&
|
|
361
361
|
savedFilePath.startsWith(notebookDir)
|
|
362
362
|
) {
|
|
363
|
-
savedFilePath = Paths.rest(savedFilePath, notebookDir)
|
|
363
|
+
savedFilePath = Paths.rest(savedFilePath, notebookDir).replaceAll(
|
|
364
|
+
"\\",
|
|
365
|
+
"/",
|
|
366
|
+
);
|
|
364
367
|
}
|
|
365
368
|
|
|
366
369
|
toast({
|
|
@@ -166,6 +166,7 @@ export const UserConfigSchema = z
|
|
|
166
166
|
ollama: AiConfigSchema.optional(),
|
|
167
167
|
openrouter: AiConfigSchema.optional(),
|
|
168
168
|
wandb: AiConfigSchema.optional(),
|
|
169
|
+
opencode_go: AiConfigSchema.optional(),
|
|
169
170
|
open_ai_compatible: AiConfigSchema.optional(),
|
|
170
171
|
azure: AiConfigSchema.optional(),
|
|
171
172
|
bedrock: z
|
package/src/core/edit-app.tsx
CHANGED
package/src/core/run-app.tsx
CHANGED
|
@@ -10,7 +10,11 @@ import { buttonVariants } from "@/components/ui/button";
|
|
|
10
10
|
import { DelayMount } from "@/components/utils/delay-mount";
|
|
11
11
|
import { cn } from "@/utils/cn";
|
|
12
12
|
import { CellsRenderer } from "../components/editor/renderers/cells-renderer";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
hasCellsAtom,
|
|
15
|
+
notebookIsRunningAtom,
|
|
16
|
+
useCellActions,
|
|
17
|
+
} from "./cells/cells";
|
|
14
18
|
import type { AppConfig } from "./config/config-schema";
|
|
15
19
|
import { RuntimeState } from "./kernel/RuntimeState";
|
|
16
20
|
import { getSessionId } from "./kernel/session";
|
|
@@ -42,10 +46,13 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
|
|
|
42
46
|
|
|
43
47
|
const isRunning = useAtomValue(notebookIsRunningAtom);
|
|
44
48
|
const isConnecting = isAppConnecting(connection.state);
|
|
49
|
+
// Skip the "Connecting..." gate when we already have cells to show — from
|
|
50
|
+
// an embedded snapshot or a prior connection.
|
|
51
|
+
const hasExistingCells = useAtomValue(hasCellsAtom);
|
|
45
52
|
|
|
46
53
|
const renderCells = () => {
|
|
47
54
|
// If we are connecting for more than 2 seconds, show a spinner
|
|
48
|
-
if (isConnecting) {
|
|
55
|
+
if (isConnecting && !hasExistingCells) {
|
|
49
56
|
return (
|
|
50
57
|
<DelayMount milliseconds={2000} fallback={null}>
|
|
51
58
|
<Spinner className="mx-auto" />
|
|
@@ -5,6 +5,7 @@ import { Logger } from "@/utils/Logger";
|
|
|
5
5
|
import { KnownQueryParams } from "../constants";
|
|
6
6
|
import { isIslands } from "../islands/utils";
|
|
7
7
|
import { getSessionId, type SessionId } from "../kernel/session";
|
|
8
|
+
import { isStaticNotebook } from "../static/static-state";
|
|
8
9
|
import { isWasm } from "../wasm/utils";
|
|
9
10
|
import type { RuntimeConfig } from "./types";
|
|
10
11
|
|
|
@@ -178,8 +179,8 @@ export class RuntimeManager {
|
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
async isHealthy(): Promise<boolean> {
|
|
181
|
-
// Always healthy if WASM
|
|
182
|
-
if (isWasm() || isIslands()) {
|
|
182
|
+
// Always healthy if WASM, Islands, or a static notebook (no server)
|
|
183
|
+
if (isWasm() || isIslands() || isStaticNotebook()) {
|
|
183
184
|
return true;
|
|
184
185
|
}
|
|
185
186
|
|
|
@@ -43,7 +43,11 @@ function isMarimoStaticState(
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function getMarimoStaticState(): Readonly<MarimoStaticState> | undefined {
|
|
46
|
-
|
|
46
|
+
// `typeof window` guard handles the identifier-undeclared case (e.g.
|
|
47
|
+
// leaked async work firing after jsdom teardown in tests); `?.` only
|
|
48
|
+
// short-circuits on null/undefined.
|
|
49
|
+
const state =
|
|
50
|
+
typeof window === "undefined" ? undefined : window.__MARIMO_STATIC__;
|
|
47
51
|
return isMarimoStaticState(state) ? state : undefined;
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
import { useAtomValue } from "jotai";
|
|
4
4
|
import type React from "react";
|
|
5
5
|
import type { PropsWithChildren } from "react";
|
|
6
|
+
import { useEffect, useRef } from "react";
|
|
6
7
|
import { LargeSpinner } from "@/components/icons/large-spinner";
|
|
8
|
+
import { toast } from "@/components/ui/use-toast";
|
|
9
|
+
import { hasCellsAtom } from "@/core/cells/cells";
|
|
7
10
|
import { showCodeInRunModeAtom } from "@/core/meta/state";
|
|
8
11
|
import { store } from "@/core/state/jotai";
|
|
9
12
|
import { useAsyncData } from "@/hooks/useAsyncData";
|
|
13
|
+
import { prettyError } from "@/utils/errors";
|
|
14
|
+
import { Logger } from "@/utils/Logger";
|
|
10
15
|
import { hasQueryParam } from "@/utils/urls";
|
|
11
16
|
import { KnownQueryParams } from "../constants";
|
|
12
|
-
import { getInitialAppMode } from "../mode";
|
|
17
|
+
import { type AppMode, getInitialAppMode } from "../mode";
|
|
13
18
|
import { PyodideBridge } from "./bridge";
|
|
14
19
|
import { hasAnyOutputAtom, wasmInitializationAtom } from "./state";
|
|
15
20
|
import { isWasm } from "./utils";
|
|
@@ -26,30 +31,44 @@ export const PyodideLoader: React.FC<PropsWithChildren> = ({ children }) => {
|
|
|
26
31
|
};
|
|
27
32
|
|
|
28
33
|
const PyodideLoaderInner: React.FC<PropsWithChildren> = ({ children }) => {
|
|
29
|
-
//
|
|
30
|
-
|
|
34
|
+
// Don't block render on Pyodide: a hydrated snapshot can paint immediately
|
|
35
|
+
// while Pyodide downloads in the background.
|
|
36
|
+
const { error } = useAsyncData(async () => {
|
|
31
37
|
await PyodideBridge.INSTANCE.initialized.promise;
|
|
32
38
|
return true;
|
|
33
39
|
}, []);
|
|
34
40
|
|
|
41
|
+
const hasCells = useAtomValue(hasCellsAtom);
|
|
35
42
|
const hasOutput = useAtomValue(hasAnyOutputAtom);
|
|
43
|
+
const nothingToShow = shouldShowSpinner({
|
|
44
|
+
hasCells,
|
|
45
|
+
hasOutput,
|
|
46
|
+
mode: getInitialAppMode(),
|
|
47
|
+
codeHidden: isCodeHidden(),
|
|
48
|
+
});
|
|
36
49
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
50
|
+
const didToastErrorRef = useRef(false);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
// With snapshot content on-screen, toast instead of throwing so the
|
|
53
|
+
// snapshot stays readable. The ref ensures we only toast once even if
|
|
54
|
+
// nothingToShow toggles later.
|
|
55
|
+
if (error && !nothingToShow && !didToastErrorRef.current) {
|
|
56
|
+
didToastErrorRef.current = true;
|
|
57
|
+
Logger.error("Pyodide failed to initialize", error);
|
|
58
|
+
toast({
|
|
59
|
+
title: "Failed to start the notebook runtime",
|
|
60
|
+
description: prettyError(error),
|
|
61
|
+
variant: "danger",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}, [error, nothingToShow]);
|
|
40
65
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// - we are not showing the code
|
|
44
|
-
// - and there is no output
|
|
45
|
-
// then show the spinner
|
|
46
|
-
if (!hasOutput && getInitialAppMode() === "read" && isCodeHidden()) {
|
|
47
|
-
return <WasmSpinner />;
|
|
66
|
+
if (error && nothingToShow) {
|
|
67
|
+
throw error;
|
|
48
68
|
}
|
|
49
69
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
throw error;
|
|
70
|
+
if (nothingToShow) {
|
|
71
|
+
return <WasmSpinner />;
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
return children;
|
|
@@ -65,6 +84,25 @@ function isCodeHidden() {
|
|
|
65
84
|
);
|
|
66
85
|
}
|
|
67
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Pure predicate: should the WASM loader render a spinner instead of its
|
|
89
|
+
* children? We block render only when nothing user-visible would appear:
|
|
90
|
+
* - no cells have been hydrated (Pyodide hasn't parsed the notebook), or
|
|
91
|
+
* - we are in headless run mode (code hidden) with no outputs to display.
|
|
92
|
+
*/
|
|
93
|
+
export function shouldShowSpinner(input: {
|
|
94
|
+
hasCells: boolean;
|
|
95
|
+
hasOutput: boolean;
|
|
96
|
+
mode: AppMode;
|
|
97
|
+
codeHidden: boolean;
|
|
98
|
+
}): boolean {
|
|
99
|
+
const { hasCells, hasOutput, mode, codeHidden } = input;
|
|
100
|
+
if (!hasCells) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
return !hasOutput && mode === "read" && codeHidden;
|
|
104
|
+
}
|
|
105
|
+
|
|
68
106
|
export const WasmSpinner: React.FC<PropsWithChildren> = ({ children }) => {
|
|
69
107
|
const wasmInitialization = useAtomValue(wasmInitializationAtom);
|
|
70
108
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { shouldShowSpinner } from "../PyodideLoader";
|
|
5
|
+
|
|
6
|
+
describe("shouldShowSpinner", () => {
|
|
7
|
+
it("shows the spinner when there are no cells yet (Pyodide hasn't parsed)", () => {
|
|
8
|
+
expect(
|
|
9
|
+
shouldShowSpinner({
|
|
10
|
+
hasCells: false,
|
|
11
|
+
hasOutput: false,
|
|
12
|
+
mode: "read",
|
|
13
|
+
codeHidden: false,
|
|
14
|
+
}),
|
|
15
|
+
).toBe(true);
|
|
16
|
+
|
|
17
|
+
expect(
|
|
18
|
+
shouldShowSpinner({
|
|
19
|
+
hasCells: false,
|
|
20
|
+
hasOutput: true,
|
|
21
|
+
mode: "edit",
|
|
22
|
+
codeHidden: false,
|
|
23
|
+
}),
|
|
24
|
+
).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("renders children once cells exist with code visible", () => {
|
|
28
|
+
// run mode, code visible, no outputs yet — the user can read the code
|
|
29
|
+
expect(
|
|
30
|
+
shouldShowSpinner({
|
|
31
|
+
hasCells: true,
|
|
32
|
+
hasOutput: false,
|
|
33
|
+
mode: "read",
|
|
34
|
+
codeHidden: false,
|
|
35
|
+
}),
|
|
36
|
+
).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders children once cells exist with cached outputs (snapshot case)", () => {
|
|
40
|
+
expect(
|
|
41
|
+
shouldShowSpinner({
|
|
42
|
+
hasCells: true,
|
|
43
|
+
hasOutput: true,
|
|
44
|
+
mode: "read",
|
|
45
|
+
codeHidden: true,
|
|
46
|
+
}),
|
|
47
|
+
).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps the spinner up in headless run mode with no outputs", () => {
|
|
51
|
+
// read mode + code hidden + no outputs = nothing visible to render
|
|
52
|
+
expect(
|
|
53
|
+
shouldShowSpinner({
|
|
54
|
+
hasCells: true,
|
|
55
|
+
hasOutput: false,
|
|
56
|
+
mode: "read",
|
|
57
|
+
codeHidden: true,
|
|
58
|
+
}),
|
|
59
|
+
).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("never blocks edit mode once cells exist", () => {
|
|
63
|
+
expect(
|
|
64
|
+
shouldShowSpinner({
|
|
65
|
+
hasCells: true,
|
|
66
|
+
hasOutput: false,
|
|
67
|
+
mode: "edit",
|
|
68
|
+
codeHidden: true,
|
|
69
|
+
}),
|
|
70
|
+
).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -58,7 +58,7 @@ vi.mock("@/core/wasm/store", () => ({
|
|
|
58
58
|
// Import after all mocks are set up
|
|
59
59
|
import { store } from "@/core/state/jotai";
|
|
60
60
|
import { initialModeAtom } from "@/core/mode";
|
|
61
|
-
import { PyodideBridge } from "../bridge";
|
|
61
|
+
import { getWasmWorkerName, PyodideBridge } from "../bridge";
|
|
62
62
|
|
|
63
63
|
// Access INSTANCE once at module level so the constructor runs (and
|
|
64
64
|
// addMessageListener populates rpcListeners) before any test executes.
|
|
@@ -111,3 +111,28 @@ describe("PyodideBridge.readCode", () => {
|
|
|
111
111
|
expect(mockNotebookReadFile).not.toHaveBeenCalled();
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
describe("getWasmWorkerName", () => {
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
delete (window as unknown as { __MARIMO_HAS_WASM_CONTROLLER__?: boolean })
|
|
118
|
+
.__MARIMO_HAS_WASM_CONTROLLER__;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns the version without suffix by default", () => {
|
|
122
|
+
expect(getWasmWorkerName()).toBe("0.0.0-test");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("appends ::controller when the host opts in", () => {
|
|
126
|
+
(
|
|
127
|
+
window as unknown as { __MARIMO_HAS_WASM_CONTROLLER__?: boolean }
|
|
128
|
+
).__MARIMO_HAS_WASM_CONTROLLER__ = true;
|
|
129
|
+
expect(getWasmWorkerName()).toBe("0.0.0-test::controller");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("does not append the suffix for non-true values", () => {
|
|
133
|
+
(
|
|
134
|
+
window as unknown as { __MARIMO_HAS_WASM_CONTROLLER__?: unknown }
|
|
135
|
+
).__MARIMO_HAS_WASM_CONTROLLER__ = "true";
|
|
136
|
+
expect(getWasmWorkerName()).toBe("0.0.0-test");
|
|
137
|
+
});
|
|
138
|
+
});
|
package/src/core/wasm/bridge.ts
CHANGED
|
@@ -37,7 +37,7 @@ import type { IConnectionTransport } from "../websocket/transports/transport";
|
|
|
37
37
|
import { PyodideRouter } from "./router";
|
|
38
38
|
import { getWorkerRPC } from "./rpc";
|
|
39
39
|
import { createShareableLink } from "./share";
|
|
40
|
-
import { wasmInitializationAtom } from "./state";
|
|
40
|
+
import { wasmInitializationAtom, wasmInitStatusAtom } from "./state";
|
|
41
41
|
import { fallbackFileStore, notebookFileStore } from "./store";
|
|
42
42
|
import { isWasm } from "./utils";
|
|
43
43
|
import type { SaveWorkerSchema } from "./worker/save-worker";
|
|
@@ -81,9 +81,9 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
81
81
|
new URL("./worker/save-worker.ts", import.meta.url),
|
|
82
82
|
{
|
|
83
83
|
type: "module",
|
|
84
|
-
// Pass the version to the worker
|
|
84
|
+
// Pass the version (and optional capability suffix) to the worker
|
|
85
85
|
/* @vite-ignore */
|
|
86
|
-
name:
|
|
86
|
+
name: getWasmWorkerName(),
|
|
87
87
|
},
|
|
88
88
|
);
|
|
89
89
|
|
|
@@ -101,9 +101,9 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
101
101
|
new URL("./worker/worker.ts", import.meta.url),
|
|
102
102
|
{
|
|
103
103
|
type: "module",
|
|
104
|
-
// Pass the version to the worker
|
|
104
|
+
// Pass the version (and optional capability suffix) to the worker
|
|
105
105
|
/* @vite-ignore */
|
|
106
|
-
name:
|
|
106
|
+
name: getWasmWorkerName(),
|
|
107
107
|
},
|
|
108
108
|
);
|
|
109
109
|
|
|
@@ -119,13 +119,15 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
119
119
|
// By initializing after, we get hits on cached network requests
|
|
120
120
|
this.saveRpc = this.getSaveWorker();
|
|
121
121
|
this.setInterruptBuffer();
|
|
122
|
+
store.set(wasmInitStatusAtom, "ready");
|
|
122
123
|
this.initialized.resolve();
|
|
123
124
|
});
|
|
124
125
|
this.rpc.addMessageListener("initializingMessage", ({ message }) => {
|
|
125
126
|
store.set(wasmInitializationAtom, message);
|
|
126
127
|
});
|
|
127
128
|
this.rpc.addMessageListener("initializedError", ({ error }) => {
|
|
128
|
-
// If already
|
|
129
|
+
// If already initialized, surface as a toast and leave the deferred /
|
|
130
|
+
// init status alone — the worker is healthy, this is a runtime error.
|
|
129
131
|
if (this.initialized.status === "resolved") {
|
|
130
132
|
Logger.error(error);
|
|
131
133
|
toast({
|
|
@@ -133,7 +135,9 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
133
135
|
description: error,
|
|
134
136
|
variant: "danger",
|
|
135
137
|
});
|
|
138
|
+
return;
|
|
136
139
|
}
|
|
140
|
+
store.set(wasmInitStatusAtom, "error");
|
|
137
141
|
this.initialized.reject(new Error(error));
|
|
138
142
|
});
|
|
139
143
|
this.rpc.addMessageListener("kernelMessage", ({ message }) => {
|
|
@@ -634,3 +638,17 @@ export function createPyodideConnection(): IConnectionTransport {
|
|
|
634
638
|
PyodideBridge.INSTANCE.attachMessageConsumer(callback);
|
|
635
639
|
});
|
|
636
640
|
}
|
|
641
|
+
|
|
642
|
+
// Compose the worker name. The version prefix is read by getMarimoVersion()
|
|
643
|
+
// in the worker; the optional "::controller" suffix tells getController.ts
|
|
644
|
+
// that the host page provides a custom /wasm/controller.js and that the
|
|
645
|
+
// dynamic import should be attempted. Hosts opt in by setting
|
|
646
|
+
// `window.__MARIMO_HAS_WASM_CONTROLLER__ = true` before
|
|
647
|
+
// PyodideBridge/worker initialization.
|
|
648
|
+
export function getWasmWorkerName(): string {
|
|
649
|
+
const hasCustomController =
|
|
650
|
+
typeof window !== "undefined" &&
|
|
651
|
+
(window as unknown as { __MARIMO_HAS_WASM_CONTROLLER__?: boolean })
|
|
652
|
+
.__MARIMO_HAS_WASM_CONTROLLER__ === true;
|
|
653
|
+
return getMarimoVersion() + (hasCustomController ? "::controller" : "");
|
|
654
|
+
}
|
package/src/core/wasm/state.ts
CHANGED
|
@@ -5,6 +5,9 @@ import { isOutputEmpty } from "../cells/outputs";
|
|
|
5
5
|
|
|
6
6
|
export const wasmInitializationAtom = atom<string>("Initializing...");
|
|
7
7
|
|
|
8
|
+
export type WasmInitStatus = "loading" | "ready" | "error";
|
|
9
|
+
export const wasmInitStatusAtom = atom<WasmInitStatus>("loading");
|
|
10
|
+
|
|
8
11
|
export const hasAnyOutputAtom = atom<boolean>((get) => {
|
|
9
12
|
const notebook = get(notebookAtom);
|
|
10
13
|
const runtimeStates = Object.values(notebook.cellRuntime);
|
|
@@ -5,6 +5,13 @@ import type { WasmController } from "./types";
|
|
|
5
5
|
// Load the controller
|
|
6
6
|
// Falls back to the default controller
|
|
7
7
|
export async function getController(version: string): Promise<WasmController> {
|
|
8
|
+
// Hosts that provide a custom /wasm/controller.js opt in via the worker
|
|
9
|
+
// name (see bridge.ts). Default: skip the dynamic import to avoid a
|
|
10
|
+
// guaranteed-404 round trip on the standard build.
|
|
11
|
+
const hasCustomController = self.name?.includes("::controller") ?? false;
|
|
12
|
+
if (!hasCustomController) {
|
|
13
|
+
return new DefaultWasmController();
|
|
14
|
+
}
|
|
8
15
|
try {
|
|
9
16
|
const controller = await import(
|
|
10
17
|
/* @vite-ignore */ `/wasm/controller.js?version=${version}`
|
|
@@ -103,5 +103,6 @@ const rpc = createRPC<SaveWorkerSchema, ParentSchema>({
|
|
|
103
103
|
rpc.send("ready", {});
|
|
104
104
|
|
|
105
105
|
function getMarimoVersion() {
|
|
106
|
-
|
|
106
|
+
// Worker name is "<version>" or "<version>::<capability>" — see bridge.ts.
|
|
107
|
+
return self.name.split("::")[0];
|
|
107
108
|
}
|
|
@@ -375,7 +375,8 @@ const namesThatRequireSync = new Set<keyof RawBridge>([
|
|
|
375
375
|
]);
|
|
376
376
|
|
|
377
377
|
function getMarimoVersion() {
|
|
378
|
-
|
|
378
|
+
// Worker name is "<version>" or "<version>::<capability>" — see bridge.ts.
|
|
379
|
+
return self.name.split("::")[0];
|
|
379
380
|
}
|
|
380
381
|
|
|
381
382
|
const pyodideReadyPromise = t.wrapAsync(loadPyodideAndPackages)();
|