@marimo-team/frontend 0.24.1-dev0 → 0.24.1-dev3

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.
Files changed (95) hide show
  1. package/dist/assets/{CellStatus-DqJypoFY.js → CellStatus-B_dBNTJO.js} +1 -1
  2. package/dist/assets/{JsonOutput-DnrBQBRT.js → JsonOutput-C5g_bqiZ.js} +1 -1
  3. package/dist/assets/{MarimoErrorOutput-BVdFtnJz.js → MarimoErrorOutput-Cb0evuEp.js} +1 -1
  4. package/dist/assets/{RenderHTML-X9kskQZ0.js → RenderHTML-BoN8Qu7U.js} +1 -1
  5. package/dist/assets/{RunButton-D83R2wvd.js → RunButton-C3V3v58F.js} +1 -1
  6. package/dist/assets/{add-cell-with-ai-HD16K3PB.js → add-cell-with-ai--yrESuLb.js} +1 -1
  7. package/dist/assets/{add-connection-dialog-V86wrXca.js → add-connection-dialog-DHyonb9T.js} +1 -1
  8. package/dist/assets/{agent-panel-XdLSrKjl.js → agent-panel-i9R2vDC5.js} +1 -1
  9. package/dist/assets/{ai-model-dropdown-BJp70TST.js → ai-model-dropdown-Wvw4ot3v.js} +1 -1
  10. package/dist/assets/{app-config-button-C2zJXSrK.js → app-config-button-DnmQED8O.js} +1 -1
  11. package/dist/assets/{blob-BKW9TqBu.js → blob-XD7AvCQ1.js} +1 -1
  12. package/dist/assets/{cell-editor-DQSrfeL3.js → cell-editor-CrWparei.js} +1 -1
  13. package/dist/assets/{cell-link-D26WjdN1.js → cell-link-WDIIwzUn.js} +1 -1
  14. package/dist/assets/{cells-DTFqEQb0.js → cells-BvgwUKB9.js} +1 -1
  15. package/dist/assets/{chat-display-CclXFQQP.js → chat-display-CvI32cPI.js} +1 -1
  16. package/dist/assets/{chat-panel-ft0VIKS8.js → chat-panel-nbCWEZX-.js} +1 -1
  17. package/dist/assets/{chat-ui-DUYenr6D.js → chat-ui-BmrjRgsi.js} +1 -1
  18. package/dist/assets/{column-preview-BLap3eAk.js → column-preview-DMqGohE5.js} +1 -1
  19. package/dist/assets/{command-palette-C7nz0zHt.js → command-palette-DCAiZSda.js} +1 -1
  20. package/dist/assets/{common-DKQZgzC9.js → common-Dx_4ekWj.js} +1 -1
  21. package/dist/assets/{components-Bf_hykBb.js → components-DVs9Yu7X.js} +1 -1
  22. package/dist/assets/config-DFK5eCC3.js +1 -0
  23. package/dist/assets/{datasource-DZgUyqAK.js → datasource-Le540Ng6.js} +1 -1
  24. package/dist/assets/{dependency-graph-panel-CH6kIoat.js → dependency-graph-panel-BsdBM_AS.js} +1 -1
  25. package/dist/assets/{documentation-panel-BQYA8gCW.js → documentation-panel-RJNMjUK6.js} +1 -1
  26. package/dist/assets/{download-_oAlkvj6.js → download-B8NwKEtr.js} +1 -1
  27. package/dist/assets/{edit-page-DnXTBsfC.js → edit-page-CvL5WAqs.js} +6 -6
  28. package/dist/assets/{error-entries-uxjSsTal.js → error-entries-yYB8PZoo.js} +1 -1
  29. package/dist/assets/{error-panel-Bq0TKquh.js → error-panel-Dr928gBs.js} +1 -1
  30. package/dist/assets/{file-explorer-panel-uhg7VNMd.js → file-explorer-panel-Brf39GIG.js} +1 -1
  31. package/dist/assets/{file-icons-V3puz20c.js → file-icons-D39zbjsS.js} +1 -1
  32. package/dist/assets/{file-name-input-DWf6YZwM.js → file-name-input-BLH5fTEA.js} +1 -1
  33. package/dist/assets/{floating-outline-Vqr5D17R.js → floating-outline-UPrDtFZF.js} +1 -1
  34. package/dist/assets/{focus-BTlyH5K_.js → focus-BArgHYf8.js} +1 -1
  35. package/dist/assets/{form-CRSnWxLR.js → form-BN1AKD58.js} +1 -1
  36. package/dist/assets/{form-Bb1JVuzT.js → form-BvzcNsxB.js} +1 -1
  37. package/dist/assets/{home-page-RduyhdYP.js → home-page-DcO0NzuV.js} +1 -1
  38. package/dist/assets/{html-to-image-BsXiWo0v.js → html-to-image-DKEUbBwc.js} +1 -1
  39. package/dist/assets/{index-C5WESBbZ.js → index-CXJA6BU5.js} +17 -17
  40. package/dist/assets/{kiosk-mode-BH15-Gn-.js → kiosk-mode-B8UhlOLw.js} +1 -1
  41. package/dist/assets/{layout-1X5SpE18.js → layout-C5In1pVG.js} +3 -3
  42. package/dist/assets/{logs-panel-CVxksxou.js → logs-panel-DItocggZ.js} +1 -1
  43. package/dist/assets/{markdown-renderer-BjuEVUOa.js → markdown-renderer-D7RLn6TA.js} +1 -1
  44. package/dist/assets/{name-cell-input-CtYGGTR5.js → name-cell-input-jausH-CL.js} +1 -1
  45. package/dist/assets/{outline-panel-BhxoKAbS.js → outline-panel-C_pASL6b.js} +1 -1
  46. package/dist/assets/{packages-panel-BWP3YWNW.js → packages-panel-DTqur75g.js} +1 -1
  47. package/dist/assets/{pair-with-agent-modal-BCMZbqSR.js → pair-with-agent-modal-DiDLAjxV.js} +1 -1
  48. package/dist/assets/{panel-accordion-state-0CKLFeAf.js → panel-accordion-state-DaMmC7UV.js} +1 -1
  49. package/dist/assets/{panels-D40pa9F1.js → panels-B2LHXcJf.js} +1 -1
  50. package/dist/assets/{process-output-WaMx3G7b.js → process-output-ClE9lj2U.js} +1 -1
  51. package/dist/assets/{radio-group-5KDs5aO1.js → radio-group-CSwRGU0A.js} +1 -1
  52. package/dist/assets/{readonly-code-display-DnHcjfFl.js → readonly-code-display-kRUXu5aJ.js} +1 -1
  53. package/dist/assets/{readonly-python-code-BV8otm7e.js → readonly-python-code-BCOkadQf.js} +1 -1
  54. package/dist/assets/{reveal-component-yVci-fc9.js → reveal-component-DpjFw5UY.js} +1 -1
  55. package/dist/assets/{run-page-aQycuCjn.js → run-page-BXPaOWqZ.js} +1 -1
  56. package/dist/assets/{scratchpad-panel-lsqN24Au.js → scratchpad-panel-GyB5PjiV.js} +1 -1
  57. package/dist/assets/{session-panel-C4JUkPt7.js → session-panel-CidCOM4_.js} +1 -1
  58. package/dist/assets/{skeleton-DOZFvsSE.js → skeleton-CHVfqr51.js} +1 -1
  59. package/dist/assets/{snippets-panel-QNY_Dl2J.js → snippets-panel-V4x4wLMQ.js} +1 -1
  60. package/dist/assets/state-C7arWhwL.js +3 -0
  61. package/dist/assets/{state-ByxLiTvP.js → state-m2zUPztG.js} +1 -1
  62. package/dist/assets/{switch-CDT2KH2g.js → switch-ulnbZFaI.js} +1 -1
  63. package/dist/assets/{terminal-UyHT9ie2.js → terminal-Bo6pnmSU.js} +1 -1
  64. package/dist/assets/{tracing-BiBPyXVg.js → tracing-Cmwuiwj9.js} +1 -1
  65. package/dist/assets/{tracing-panel-D8LtcU_g.js → tracing-panel-CCXeYfOu.js} +2 -2
  66. package/dist/assets/{tree-actions-DUAdNn1i.js → tree-actions-QMLf8Qhz.js} +1 -1
  67. package/dist/assets/{useBoolean-CgN_4RAl.js → useBoolean-C_UV6tQ7.js} +1 -1
  68. package/dist/assets/{useCellActionButton-Bp2eEIzO.js → useCellActionButton-D8qyXNKv.js} +1 -1
  69. package/dist/assets/{useDeleteCell-DO-9USfB.js → useDeleteCell-Ctnxl8AT.js} +1 -1
  70. package/dist/assets/{useDependencyPanelTab-Bi8pUBnn.js → useDependencyPanelTab-CYSEZi6j.js} +1 -1
  71. package/dist/assets/useNotebookActions-CxMrxNFT.js +12 -0
  72. package/dist/assets/{useRunCells-BpZ_0Xuz.js → useRunCells-DjQN3XYP.js} +1 -1
  73. package/dist/assets/{useSplitCell-C2fjcfID.js → useSplitCell-BG8Zd4lw.js} +1 -1
  74. package/dist/assets/{vega-component-3G_aA1Wa.js → vega-component-1VoQOWvU.js} +1 -1
  75. package/dist/index.html +26 -26
  76. package/package.json +1 -1
  77. package/src/__mocks__/requests.ts +4 -0
  78. package/src/components/editor/actions/export-dialog/__tests__/export-dialog.test.tsx +206 -34
  79. package/src/components/editor/actions/export-dialog/export-dialog.tsx +9 -3
  80. package/src/components/editor/actions/export-dialog/format-notice.tsx +69 -24
  81. package/src/components/editor/actions/export-dialog/use-export-dialog.ts +27 -2
  82. package/src/components/ui/toaster.tsx +14 -1
  83. package/src/core/islands/bridge.ts +1 -0
  84. package/src/core/mode.ts +6 -1
  85. package/src/core/network/__tests__/requests-lazy.test.ts +40 -0
  86. package/src/core/network/__tests__/requests-network.test.ts +13 -0
  87. package/src/core/network/requests-lazy.ts +11 -4
  88. package/src/core/network/requests-network.ts +8 -0
  89. package/src/core/network/requests-static.ts +1 -0
  90. package/src/core/network/requests-toasting.tsx +1 -0
  91. package/src/core/network/types.ts +5 -0
  92. package/src/core/wasm/bridge.ts +1 -0
  93. package/dist/assets/config-EoJoeZuK.js +0 -1
  94. package/dist/assets/state-Ci7n55A9.js +0 -3
  95. package/dist/assets/useNotebookActions-Bm9VzJ_M.js +0 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/frontend",
3
- "version": "0.24.1-dev0",
3
+ "version": "0.24.1-dev3",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -100,6 +100,10 @@ export const MockRequestClient = {
100
100
  },
101
101
  ],
102
102
  }),
103
+ installExportRequirements: vi.fn().mockResolvedValue({
104
+ source: "server",
105
+ formats: [],
106
+ }),
103
107
  exportAsHTML: vi.fn().mockResolvedValue({
104
108
  contents: "",
105
109
  filename: "notebook.html",
@@ -13,12 +13,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
13
  import { MockRequestClient } from "@/__mocks__/requests";
14
14
  import { Dialog } from "@/components/ui/dialog";
15
15
  import { TooltipProvider } from "@/components/ui/tooltip";
16
- import { viewStateAtom } from "@/core/mode";
16
+ import { kioskModeAtom, viewStateAtom } from "@/core/mode";
17
17
  import { requestClientAtom } from "@/core/network/requests";
18
+ import { createErrorToastingRequests } from "@/core/network/requests-toasting";
19
+ import type { ExportAvailabilityResponse } from "@/core/network/types";
18
20
  import { filenameAtom } from "@/core/saving/file-state";
19
21
  import { store } from "@/core/state/jotai";
20
22
  import { isWasm } from "@/core/wasm/utils";
21
23
  import * as copyModule from "@/utils/copy";
24
+ import { Deferred } from "@/utils/Deferred";
25
+ import { HTTPError } from "@/utils/errors";
22
26
  import { ExportDialog } from "../export-dialog";
23
27
  import {
24
28
  DEFAULT_EXPORT_OPTIONS,
@@ -27,14 +31,22 @@ import {
27
31
  lastExportFormatAtom,
28
32
  } from "../state";
29
33
 
30
- const { exportNotebookMock } = vi.hoisted(() => ({
34
+ const { exportNotebookMock, toastMock } = vi.hoisted(() => ({
31
35
  exportNotebookMock: vi.fn().mockResolvedValue(undefined),
36
+ toastMock: vi.fn(() => ({
37
+ dismiss: vi.fn(),
38
+ update: vi.fn(),
39
+ })),
32
40
  }));
33
41
 
34
42
  vi.mock("@/utils/copy", () => ({
35
43
  copyToClipboard: vi.fn().mockResolvedValue(undefined),
36
44
  }));
37
45
 
46
+ vi.mock("@/components/ui/use-toast", () => ({
47
+ toast: toastMock,
48
+ }));
49
+
38
50
  vi.mock("@/core/wasm/utils", async (importOriginal) => {
39
51
  const actual = await importOriginal<typeof import("@/core/wasm/utils")>();
40
52
  return { ...actual, isWasm: vi.fn(() => false) };
@@ -67,6 +79,32 @@ async function waitForExportEnabled() {
67
79
  );
68
80
  }
69
81
 
82
+ type FormatAvailability = ExportAvailabilityResponse["formats"][number];
83
+
84
+ function formatAvailability(
85
+ format: FormatAvailability["format"],
86
+ overrides: Partial<Omit<FormatAvailability, "format">> = {},
87
+ ): FormatAvailability {
88
+ return {
89
+ format,
90
+ dependenciesAvailable: true,
91
+ missingPackages: [],
92
+ missingSetup: [],
93
+ ...overrides,
94
+ };
95
+ }
96
+
97
+ function serverAvailability(
98
+ ...formats: FormatAvailability[]
99
+ ): ExportAvailabilityResponse {
100
+ return { source: "server", formats };
101
+ }
102
+
103
+ const PLAYWRIGHT_SETUP = {
104
+ name: "playwright-chromium",
105
+ command: "uv run playwright install chromium",
106
+ } as const;
107
+
70
108
  describe("ExportDialog", () => {
71
109
  beforeEach(() => {
72
110
  vi.clearAllMocks();
@@ -74,8 +112,10 @@ describe("ExportDialog", () => {
74
112
  store.set(requestClientAtom, MockRequestClient.create());
75
113
  store.set(filenameAtom, "/project/notebook.py");
76
114
  store.set(viewStateAtom, { mode: "edit", cellAnchor: null });
115
+ store.set(kioskModeAtom, false);
77
116
  store.set(exportOptionsAtom, DEFAULT_EXPORT_OPTIONS);
78
117
  store.set(lastExportFormatAtom, "html");
118
+ window.history.replaceState(null, "", "/");
79
119
  vi.mocked(isWasm).mockReturnValue(false);
80
120
  vi.stubGlobal("matchMedia", () => ({
81
121
  matches: false,
@@ -248,54 +288,80 @@ describe("ExportDialog", () => {
248
288
  expect(tablist).toHaveAttribute("aria-orientation", "vertical");
249
289
  });
250
290
 
251
- it("keeps unavailable formats visible and names missing packages", async () => {
291
+ it("installs missing packages and applies refreshed availability", async () => {
292
+ const install = new Deferred<ExportAvailabilityResponse>();
293
+ const installExportRequirements = vi.fn(() => install.promise);
294
+ const getExportAvailability = vi.fn().mockResolvedValue(
295
+ serverAvailability(
296
+ formatAvailability("ipynb", {
297
+ dependenciesAvailable: false,
298
+ missingPackages: ["nbformat"],
299
+ }),
300
+ formatAvailability("pdf", {
301
+ dependenciesAvailable: false,
302
+ missingPackages: ["nbconvert[webpdf]", "nbformat"],
303
+ }),
304
+ ),
305
+ );
252
306
  store.set(
253
307
  requestClientAtom,
254
308
  MockRequestClient.create({
255
- getExportAvailability: vi.fn().mockResolvedValue({
256
- source: "server",
257
- formats: [
258
- {
259
- format: "pdf",
260
- dependenciesAvailable: false,
261
- missingPackages: ["nbconvert[webpdf]"],
262
- missingSetup: [],
263
- },
264
- ],
265
- }),
309
+ getExportAvailability,
310
+ installExportRequirements,
266
311
  }),
267
312
  );
268
313
 
269
314
  renderDialog("pdf");
270
315
 
271
- expect(await screen.findByText("nbconvert[webpdf]")).toBeVisible();
272
- expect(
273
- screen.getByText(/where marimo is running to use this export/),
274
- ).toBeVisible();
316
+ const packages = await screen.findByText("nbconvert[webpdf], nbformat");
317
+ expect(packages.parentElement).toHaveTextContent(
318
+ "PDF export requires nbconvert[webpdf], nbformat.",
319
+ );
275
320
  expect(screen.getByTestId("export-submit")).toBeDisabled();
276
321
  expect(screen.getByTestId("export-format-pdf")).toBeVisible();
322
+
323
+ const installButton = screen.getByRole("button", { name: "Install" });
324
+ const pdfTab = screen.getByTestId("export-format-pdf");
325
+ const htmlTab = screen.getByTestId("export-format-html");
326
+
327
+ fireEvent.click(installButton);
328
+
329
+ await waitFor(() =>
330
+ expect(installExportRequirements).toHaveBeenCalledWith({ format: "pdf" }),
331
+ );
332
+ expect(installButton).toHaveAttribute("aria-busy", "true");
333
+ expect(htmlTab).toBeDisabled();
334
+
335
+ fireEvent.click(installButton);
336
+ fireEvent.click(htmlTab);
337
+ expect(installExportRequirements).toHaveBeenCalledOnce();
338
+ expect(pdfTab).toHaveAttribute("data-state", "active");
339
+
340
+ install.resolve(
341
+ serverAvailability(
342
+ formatAvailability("ipynb"),
343
+ formatAvailability("pdf"),
344
+ ),
345
+ );
346
+ await waitForExportEnabled();
347
+ expect(getExportAvailability).toHaveBeenCalledOnce();
348
+
349
+ fireEvent.click(screen.getByTestId("export-format-ipynb"));
350
+ expect(screen.getByTestId("export-submit")).toBeEnabled();
277
351
  });
278
352
 
279
353
  it("explains missing Playwright Chromium setup", async () => {
280
354
  store.set(
281
355
  requestClientAtom,
282
356
  MockRequestClient.create({
283
- getExportAvailability: vi.fn().mockResolvedValue({
284
- source: "server",
285
- formats: [
286
- {
287
- format: "pdf",
357
+ getExportAvailability: vi.fn().mockResolvedValue(
358
+ serverAvailability(
359
+ formatAvailability("pdf", {
288
360
  dependenciesAvailable: false,
289
- missingPackages: [],
290
- missingSetup: [
291
- {
292
- name: "playwright-chromium",
293
- command: "uv run playwright install chromium",
294
- },
295
- ],
296
- },
297
- ],
298
- }),
361
+ missingSetup: [PLAYWRIGHT_SETUP],
362
+ }),
363
+ ),
364
+ ),
299
365
  }),
300
366
  );
301
367
 
@@ -305,9 +371,115 @@ describe("ExportDialog", () => {
305
371
  await screen.findByText("PDF export requires Playwright Chromium."),
306
372
  ).toBeVisible();
307
373
  expect(
308
- screen.getByText("uv run playwright install chromium"),
374
+ screen.queryByText("uv run playwright install chromium"),
375
+ ).not.toBeInTheDocument();
376
+ expect(screen.getByTestId("export-submit")).toBeDisabled();
377
+ expect(screen.getByRole("button", { name: "Install" })).toBeVisible();
378
+ });
379
+
380
+ it.each([
381
+ ["read mode", "read", false, false],
382
+ ["connected kiosk mode", "edit", true, false],
383
+ ["pre-connection kiosk mode", "edit", false, true],
384
+ ] as const)(
385
+ "shows the setup command when installation is not allowed in %s",
386
+ async (_label, mode, kioskMode, kioskRequested) => {
387
+ store.set(viewStateAtom, { mode, cellAnchor: null });
388
+ store.set(kioskModeAtom, kioskMode);
389
+ if (kioskRequested) {
390
+ window.history.replaceState(null, "", "/?kiosk=true");
391
+ }
392
+ store.set(
393
+ requestClientAtom,
394
+ MockRequestClient.create({
395
+ getExportAvailability: vi.fn().mockResolvedValue(
396
+ serverAvailability(
397
+ formatAvailability("pdf", {
398
+ dependenciesAvailable: false,
399
+ missingSetup: [PLAYWRIGHT_SETUP],
400
+ }),
401
+ ),
402
+ ),
403
+ }),
404
+ );
405
+
406
+ renderDialog("pdf");
407
+
408
+ expect(
409
+ await screen.findByText("PDF export requires Playwright Chromium."),
410
+ ).toBeVisible();
411
+ expect(
412
+ screen.getByText("uv run playwright install chromium"),
413
+ ).toBeVisible();
414
+ expect(
415
+ screen.queryByRole("button", { name: "Install" }),
416
+ ).not.toBeInTheDocument();
417
+ },
418
+ );
419
+
420
+ it("allows requirement installation when kiosk is false", async () => {
421
+ window.history.replaceState(null, "", "/?kiosk=false");
422
+ store.set(
423
+ requestClientAtom,
424
+ MockRequestClient.create({
425
+ getExportAvailability: vi.fn().mockResolvedValue(
426
+ serverAvailability(
427
+ formatAvailability("pdf", {
428
+ dependenciesAvailable: false,
429
+ missingSetup: [PLAYWRIGHT_SETUP],
430
+ }),
431
+ ),
432
+ ),
433
+ }),
434
+ );
435
+
436
+ renderDialog("pdf");
437
+
438
+ expect(
439
+ await screen.findByRole("button", { name: "Install" }),
309
440
  ).toBeVisible();
441
+ });
442
+
443
+ it("restores the install action when installation fails", async () => {
444
+ const error = new HTTPError(500, "Server Error", {
445
+ detail: "Playwright Chromium installation failed. Check the server logs.",
446
+ });
447
+ const installExportRequirements = vi.fn().mockRejectedValue(error);
448
+ const getExportAvailability = vi.fn().mockResolvedValue(
449
+ serverAvailability(
450
+ formatAvailability("pdf", {
451
+ dependenciesAvailable: false,
452
+ missingPackages: ["nbconvert[webpdf]"],
453
+ }),
454
+ ),
455
+ );
456
+ store.set(
457
+ requestClientAtom,
458
+ createErrorToastingRequests(
459
+ MockRequestClient.create({
460
+ getExportAvailability,
461
+ installExportRequirements,
462
+ }),
463
+ ),
464
+ );
465
+
466
+ renderDialog("pdf");
467
+
468
+ const install = await screen.findByRole("button", { name: "Install" });
469
+ fireEvent.click(install);
470
+
471
+ await waitFor(() =>
472
+ expect(toastMock).toHaveBeenCalledWith({
473
+ title: "Failed to install export requirements",
474
+ description:
475
+ "Playwright Chromium installation failed. Check the server logs.",
476
+ variant: "danger",
477
+ }),
478
+ );
479
+ expect(install).toBeEnabled();
480
+ expect(install).toHaveAttribute("aria-busy", "false");
310
481
  expect(screen.getByTestId("export-submit")).toBeDisabled();
482
+ expect(getExportAvailability).toHaveBeenCalledOnce();
311
483
  });
312
484
 
313
485
  it("announces requirement checks as status updates", () => {
@@ -28,13 +28,17 @@ export const ExportDialog: React.FC<{
28
28
  const {
29
29
  dialogRef,
30
30
  isExporting,
31
+ isInstalling,
32
+ canInstall,
31
33
  formats,
32
34
  options,
33
35
  selected,
34
36
  selectFormat,
35
37
  updateOptions,
38
+ installRequirements,
36
39
  submit,
37
40
  } = useExportDialog({ initialFormat, onClose });
41
+ const isBusy = isExporting || isInstalling;
38
42
  const desktopLayout = useMediaQuery(DESKTOP_LAYOUT_QUERY);
39
43
  const {
40
44
  format,
@@ -86,7 +90,7 @@ export const ExportDialog: React.FC<{
86
90
  <TabsTrigger
87
91
  key={candidate}
88
92
  value={candidate}
89
- disabled={isExporting}
93
+ disabled={isBusy}
90
94
  className="min-w-0 justify-start gap-2 px-2.5 py-2 text-xs data-[state=active]:shadow-xs sm:w-full sm:text-sm"
91
95
  data-testid={`export-format-${candidate}`}
92
96
  >
@@ -119,13 +123,15 @@ export const ExportDialog: React.FC<{
119
123
  format={format}
120
124
  formatLabel={definition.label}
121
125
  status={status}
126
+ onInstall={canInstall ? installRequirements : undefined}
127
+ isInstalling={isInstalling}
122
128
  />
123
129
 
124
130
  {usesBrowserPrint ? null : (
125
131
  <FormatOptions
126
132
  options={options}
127
133
  updateOptions={updateOptions}
128
- disabled={isExporting}
134
+ disabled={isBusy}
129
135
  />
130
136
  )}
131
137
  </TabsContent>
@@ -173,7 +179,7 @@ export const ExportDialog: React.FC<{
173
179
  ) : null}
174
180
  <Button
175
181
  type="button"
176
- disabled={!status.available || isExporting}
182
+ disabled={!status.available || isBusy}
177
183
  aria-busy={isExporting}
178
184
  onClick={submit}
179
185
  className={cn(
@@ -1,9 +1,10 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { AlertCircleIcon } from "lucide-react";
3
+ import { AlertCircleIcon, DownloadCloudIcon } from "lucide-react";
4
4
  import type { PropsWithChildren } from "react";
5
5
  import { Spinner } from "@/components/icons/spinner";
6
6
  import { Alert, AlertDescription } from "@/components/ui/alert";
7
+ import { Button } from "@/components/ui/button";
7
8
  import { assertNever } from "@/utils/assertNever";
8
9
  import { cn } from "@/utils/cn";
9
10
  import type {
@@ -56,10 +57,14 @@ export function FormatNotice({
56
57
  format,
57
58
  formatLabel,
58
59
  status,
60
+ onInstall,
61
+ isInstalling = false,
59
62
  }: {
60
63
  format: ExportFormat;
61
64
  formatLabel: string;
62
65
  status: ExportFormatStatus;
66
+ onInstall?: () => void;
67
+ isInstalling?: boolean;
63
68
  }) {
64
69
  if (status.availabilityCheckFailed) {
65
70
  return (
@@ -80,20 +85,47 @@ export function FormatNotice({
80
85
  format={format}
81
86
  formatLabel={formatLabel}
82
87
  reason={status.reason}
88
+ onInstall={onInstall}
89
+ isInstalling={isInstalling}
83
90
  />
84
91
  </div>
85
92
  );
86
93
  }
87
94
 
88
- function Notice({ children }: PropsWithChildren) {
95
+ function Notice({
96
+ children,
97
+ onInstall,
98
+ isInstalling = false,
99
+ }: PropsWithChildren<{
100
+ onInstall?: () => void;
101
+ isInstalling?: boolean;
102
+ }>) {
89
103
  return (
90
104
  <Alert
91
105
  variant="warning"
92
- className="flex items-start gap-2.5 p-3 text-(--yellow-12) has-[svg]:pl-3 sm:items-center [&>svg]:static [&>svg+div]:translate-y-0"
106
+ className="flex items-center gap-2.5 px-3 py-2 text-(--yellow-12) has-[svg]:pl-3 [&>svg]:static [&>svg+div]:translate-y-0"
93
107
  >
94
- <AlertCircleIcon className="mt-0.5 size-4 shrink-0 sm:mt-0" />
95
- <AlertDescription className="min-w-0 flex-1 text-sm leading-5">
96
- {children}
108
+ <AlertCircleIcon className="size-4 shrink-0" />
109
+ <AlertDescription className="flex min-w-0 flex-1 items-center gap-3 text-sm leading-5">
110
+ <span className="min-w-0 flex-1">{children}</span>
111
+ {onInstall ? (
112
+ <Button
113
+ type="button"
114
+ variant="outline"
115
+ size="xs"
116
+ className="shrink-0 border-input bg-background text-foreground hover:border-input hover:bg-muted hover:text-foreground"
117
+ disabled={isInstalling}
118
+ aria-busy={isInstalling}
119
+ onClick={onInstall}
120
+ >
121
+ {isInstalling ? (
122
+ <Spinner size="small" className="mr-1.5 size-3.5" />
123
+ ) : (
124
+ <DownloadCloudIcon className="mr-1.5 size-3.5" />
125
+ )}
126
+ Install
127
+ </Button>
128
+ ) : null}
97
129
  </AlertDescription>
98
130
  </Alert>
99
131
  );
@@ -103,10 +135,14 @@ function ReasonNotice({
103
135
  format,
104
136
  formatLabel,
105
137
  reason,
138
+ onInstall,
139
+ isInstalling,
106
140
  }: {
107
141
  format: ExportFormat;
108
142
  formatLabel: string;
109
143
  reason: ExportBlockReason;
144
+ onInstall?: () => void;
145
+ isInstalling: boolean;
110
146
  }) {
111
147
  switch (reason.type) {
112
148
  case "checking-requirements":
@@ -126,30 +162,39 @@ function ReasonNotice({
126
162
  return <Notice>Name and save this notebook before exporting.</Notice>;
127
163
  case "missing-packages":
128
164
  return (
129
- <Notice>
130
- Install{" "}
165
+ <Notice onInstall={onInstall} isInstalling={isInstalling}>
166
+ {formatLabel} export requires{" "}
131
167
  <code className="break-words font-mono">
132
168
  {reason.packages.join(", ")}
133
- </code>{" "}
134
- where marimo is running to use this export.
169
+ </code>
170
+ .
135
171
  </Notice>
136
172
  );
137
173
  case "missing-setup":
138
174
  return (
139
- <Notice>
140
- <span className="block min-w-0">
141
- {reason.requirements.map((requirement) => {
142
- const details = SETUP_REQUIREMENTS[requirement.name];
143
- return (
144
- <span className="block" key={requirement.name}>
145
- {details.description}
146
- <code className="mt-1 block break-all font-mono">
147
- {requirement.command}
148
- </code>
149
- </span>
150
- );
151
- })}
152
- </span>
175
+ <Notice onInstall={onInstall} isInstalling={isInstalling}>
176
+ {onInstall ? (
177
+ reason.requirements
178
+ .map(
179
+ (requirement) =>
180
+ SETUP_REQUIREMENTS[requirement.name].description,
181
+ )
182
+ .join(" ")
183
+ ) : (
184
+ <span className="block min-w-0">
185
+ {reason.requirements.map((requirement) => {
186
+ const details = SETUP_REQUIREMENTS[requirement.name];
187
+ return (
188
+ <span className="block" key={requirement.name}>
189
+ {details.description}
190
+ <code className="mt-1 block break-all font-mono">
191
+ {requirement.command}
192
+ </code>
193
+ </span>
194
+ );
195
+ })}
196
+ </span>
197
+ )}
153
198
  </Notice>
154
199
  );
155
200
  case "wasm-runtime":
@@ -7,7 +7,7 @@ import {
7
7
  updateCellOutputsWithScreenshots,
8
8
  useEnrichCellOutputs,
9
9
  } from "@/core/export/hooks";
10
- import { runDuringPresentMode } from "@/core/mode";
10
+ import { runDuringPresentMode, useInstallAllowed } from "@/core/mode";
11
11
  import { useRequestClient } from "@/core/network/requests";
12
12
  import type { ExportAvailabilityResponse } from "@/core/network/types";
13
13
  import { useFilename } from "@/core/saving/filename";
@@ -116,6 +116,8 @@ function useExportDialogState(initialFormat?: ExportFormat) {
116
116
  );
117
117
  const runtime = isWasm() ? "wasm" : "server";
118
118
  const requests = useRequestClient();
119
+ const installAllowed = useInstallAllowed();
120
+ const [isInstalling, setIsInstalling] = useState(false);
119
121
 
120
122
  const availabilityRequest =
121
123
  useAsyncData<ExportAvailabilityResponse | null>(async () => {
@@ -135,6 +137,10 @@ function useExportDialogState(initialFormat?: ExportFormat) {
135
137
  availability,
136
138
  });
137
139
  const status = statusFor(format);
140
+ const canInstall =
141
+ installAllowed &&
142
+ (status.reason?.type === "missing-packages" ||
143
+ status.reason?.type === "missing-setup");
138
144
  const formats = EXPORT_FORMATS.map((candidate) => ({
139
145
  format: candidate,
140
146
  status: statusFor(candidate),
@@ -184,6 +190,22 @@ function useExportDialogState(initialFormat?: ExportFormat) {
184
190
  });
185
191
  };
186
192
 
193
+ const installRequirements = async () => {
194
+ if (!canInstall || isInstalling || format === "png") {
195
+ return;
196
+ }
197
+
198
+ setIsInstalling(true);
199
+ try {
200
+ const availability = await requests.installExportRequirements({ format });
201
+ availabilityRequest.setData(availability);
202
+ } catch {
203
+ // Request failures are surfaced by the shared toast layer.
204
+ } finally {
205
+ setIsInstalling(false);
206
+ }
207
+ };
208
+
187
209
  return {
188
210
  formats,
189
211
  options,
@@ -205,6 +227,9 @@ function useExportDialogState(initialFormat?: ExportFormat) {
205
227
  },
206
228
  selectFormat,
207
229
  updateOptions,
230
+ canInstall,
231
+ isInstalling,
232
+ installRequirements,
208
233
  };
209
234
  }
210
235
 
@@ -364,7 +389,7 @@ export function useExportDialog({
364
389
  ...state,
365
390
  ...action,
366
391
  selectFormat: (value: string) => {
367
- if (!action.isExporting) {
392
+ if (!action.isExporting && !state.isInstalling) {
368
393
  selectFormat(value);
369
394
  }
370
395
  },
@@ -1,9 +1,12 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
3
3
  import { useRef } from "react";
4
+ import { createPortal } from "react-dom";
4
5
  import { CopyClipboardIcon } from "@/components/icons/copy-icon";
5
6
  import { useToast } from "@/components/ui/use-toast";
7
+ import { StyleNamespace } from "@/theme/namespace";
6
8
  import { cn } from "@/utils/cn";
9
+ import { withFullScreenAsRoot } from "./fullscreen";
7
10
  import {
8
11
  Toast,
9
12
  ToastClose,
@@ -28,11 +31,21 @@ export const Toaster = () => {
28
31
  {...props}
29
32
  />
30
33
  ))}
31
- <ToastViewport />
34
+ <ToastViewportPortal />
32
35
  </ToastProvider>
33
36
  );
34
37
  };
35
38
 
39
+ const ToastViewportPortal = withFullScreenAsRoot(
40
+ ({ container }: { container?: Element | DocumentFragment | null }) =>
41
+ createPortal(
42
+ <StyleNamespace>
43
+ <ToastViewport />
44
+ </StyleNamespace>,
45
+ container ?? document.body,
46
+ ),
47
+ );
48
+
36
49
  type ToastItemProps = Omit<
37
50
  ComponentPropsWithoutRef<typeof Toast>,
38
51
  "title" | "children"
@@ -356,6 +356,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
356
356
  sendFileDetails = throwNotImplemented;
357
357
  openTutorial = throwNotImplemented;
358
358
  getExportAvailability = throwNotImplemented;
359
+ installExportRequirements = throwNotImplemented;
359
360
  exportAsHTML = throwNotImplemented;
360
361
  exportAsIPYNB = throwNotImplemented;
361
362
  exportAsMarkdown = throwNotImplemented;
package/src/core/mode.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { atom, useAtomValue } from "jotai";
4
+ import { KnownQueryParams } from "@/core/constants";
4
5
  import { isIslands } from "@/core/islands/utils";
5
6
  import { assertExists } from "@/utils/assertExists";
6
7
  import { invariant } from "@/utils/invariant";
@@ -98,5 +99,9 @@ export const kioskModeAtom = atom<boolean>(false);
98
99
  */
99
100
  export function useInstallAllowed(): boolean {
100
101
  const { mode } = useAtomValue(viewStateAtom);
101
- return mode !== "read";
102
+ const kioskMode = useAtomValue(kioskModeAtom);
103
+ const kioskRequested =
104
+ new URLSearchParams(window.location.search).get(KnownQueryParams.kiosk) ===
105
+ "true";
106
+ return mode !== "read" && !kioskMode && !kioskRequested;
102
107
  }