@marimo-team/islands 0.23.17-dev1 → 0.23.17-dev13

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 (46) hide show
  1. package/dist/{chat-ui-DsZJj75A.js → chat-ui-DH3hwWoI.js} +2 -2
  2. package/dist/{common-BGCQJb-W.js → common-D8DGPf0N.js} +5 -5
  3. package/dist/{html-to-image-CZ1kLKkq.js → html-to-image-CxlSazqu.js} +2095 -2088
  4. package/dist/main.js +5 -5
  5. package/dist/{process-output-bVfbcx5_.js → process-output-B24cDGzV.js} +1 -1
  6. package/dist/{reveal-component-NNi374so.js → reveal-component-UfXDaVe-.js} +2 -2
  7. package/dist/style.css +1 -1
  8. package/package.json +1 -1
  9. package/src/__mocks__/requests.ts +5 -0
  10. package/src/components/databases/icons/huggingface.svg +8 -0
  11. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +1 -1
  12. package/src/components/editor/actions/export-dialog/__tests__/export-command.test.ts +115 -0
  13. package/src/components/editor/actions/export-dialog/__tests__/export-dialog.test.tsx +458 -0
  14. package/src/components/editor/actions/export-dialog/__tests__/export-notebook.test.ts +178 -0
  15. package/src/components/editor/actions/export-dialog/__tests__/state.test.ts +295 -0
  16. package/src/components/editor/actions/export-dialog/__tests__/use-export-dialog.test.tsx +287 -0
  17. package/src/components/editor/actions/export-dialog/export-command.ts +149 -0
  18. package/src/components/editor/actions/export-dialog/export-dialog.tsx +194 -0
  19. package/src/components/editor/actions/export-dialog/export-notebook.ts +98 -0
  20. package/src/components/editor/actions/export-dialog/format-notice.tsx +166 -0
  21. package/src/components/editor/actions/export-dialog/format-options.tsx +531 -0
  22. package/src/components/editor/actions/export-dialog/state.ts +339 -0
  23. package/src/components/editor/actions/export-dialog/use-export-dialog.ts +372 -0
  24. package/src/components/editor/actions/pair-with-agent-commands.ts +1 -21
  25. package/src/components/editor/actions/useNotebookActions.tsx +72 -170
  26. package/src/components/editor/chrome/panels/outline/__tests__/useActiveOutline.test.ts +26 -0
  27. package/src/components/editor/connections/add-connection-dialog.tsx +1 -1
  28. package/src/components/editor/connections/quick-add-data-sources.tsx +14 -7
  29. package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +14 -0
  30. package/src/components/editor/connections/storage/__tests__/as-code.test.ts +20 -0
  31. package/src/components/editor/connections/storage/add-storage-form.tsx +10 -0
  32. package/src/components/editor/connections/storage/as-code.ts +19 -1
  33. package/src/components/editor/connections/storage/schemas.ts +19 -0
  34. package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +187 -0
  35. package/src/components/editor/controls/notebook-menu-dropdown.tsx +4 -2
  36. package/src/components/storage/__tests__/storage-snippets.test.ts +88 -0
  37. package/src/components/storage/components.tsx +2 -0
  38. package/src/components/storage/storage-snippets.ts +58 -0
  39. package/src/core/__tests__/mode.test.ts +85 -0
  40. package/src/core/dom/outline.ts +25 -2
  41. package/src/core/mode.ts +11 -9
  42. package/src/core/network/__tests__/requests-lazy.test.ts +1 -0
  43. package/src/core/storage/types.ts +1 -0
  44. package/src/utils/__tests__/download.test.tsx +6 -4
  45. package/src/utils/download.ts +3 -1
  46. package/src/utils/shell.ts +17 -0
@@ -0,0 +1,187 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import { Provider } from "jotai";
5
+ import type React from "react";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { MockRequestClient } from "@/__mocks__/requests";
8
+ import { ModalProvider } from "@/components/modal/ImperativeModal";
9
+ import { TooltipProvider } from "@/components/ui/tooltip";
10
+ import { layoutStateAtom } from "@/core/layout/layout";
11
+ import { kioskModeAtom, viewStateAtom } from "@/core/mode";
12
+ import { requestClientAtom } from "@/core/network/requests";
13
+ import { filenameAtom } from "@/core/saving/file-state";
14
+ import { store } from "@/core/state/jotai";
15
+ import { isWasm } from "@/core/wasm/utils";
16
+ import {
17
+ DEFAULT_EXPORT_OPTIONS,
18
+ exportOptionsAtom,
19
+ lastExportFormatAtom,
20
+ } from "../../actions/export-dialog/state";
21
+ import { NotebookMenuDropdown } from "../notebook-menu-dropdown";
22
+
23
+ vi.mock("@/core/wasm/utils", async (importOriginal) => {
24
+ const actual = await importOriginal<typeof import("@/core/wasm/utils")>();
25
+ return { ...actual, isWasm: vi.fn(() => false) };
26
+ });
27
+
28
+ function wrapper({ children }: { children: React.ReactNode }) {
29
+ return (
30
+ <Provider store={store}>
31
+ <TooltipProvider>
32
+ <ModalProvider>{children}</ModalProvider>
33
+ </TooltipProvider>
34
+ </Provider>
35
+ );
36
+ }
37
+
38
+ function openNotebookMenu() {
39
+ fireEvent.pointerDown(screen.getByTestId("notebook-menu-dropdown"), {
40
+ button: 0,
41
+ ctrlKey: false,
42
+ });
43
+ }
44
+
45
+ async function openDownloadMenu() {
46
+ openNotebookMenu();
47
+ fireEvent.click(await screen.findByRole("menuitem", { name: "Download" }));
48
+ }
49
+
50
+ async function selectDownload(name: string | RegExp) {
51
+ await openDownloadMenu();
52
+ fireEvent.click(await screen.findByRole("menuitem", { name }));
53
+ }
54
+
55
+ describe("NotebookMenuDropdown", () => {
56
+ beforeEach(() => {
57
+ vi.clearAllMocks();
58
+ localStorage.clear();
59
+ store.set(requestClientAtom, MockRequestClient.create());
60
+ store.set(filenameAtom, "/project/notebook.py");
61
+ store.set(viewStateAtom, { mode: "edit", cellAnchor: null });
62
+ store.set(kioskModeAtom, false);
63
+ store.set(layoutStateAtom, {
64
+ selectedLayout: "vertical",
65
+ layoutData: {},
66
+ });
67
+ store.set(exportOptionsAtom, DEFAULT_EXPORT_OPTIONS);
68
+ store.set(lastExportFormatAtom, "html");
69
+ vi.mocked(isWasm).mockReturnValue(false);
70
+ vi.stubGlobal("PointerEvent", MouseEvent);
71
+ vi.stubGlobal("matchMedia", () => ({
72
+ matches: false,
73
+ addEventListener: vi.fn(),
74
+ removeEventListener: vi.fn(),
75
+ }));
76
+ });
77
+
78
+ afterEach(() => {
79
+ document.title = "";
80
+ vi.unstubAllGlobals();
81
+ });
82
+
83
+ it("returns focus to the notebook menu when the dialog closes", async () => {
84
+ render(<NotebookMenuDropdown />, { wrapper });
85
+ const menuButton = screen.getByTestId("notebook-menu-dropdown");
86
+
87
+ openNotebookMenu();
88
+ fireEvent.click(
89
+ await screen.findByRole("menuitem", {
90
+ name: "Export…",
91
+ }),
92
+ );
93
+ fireEvent.click(await screen.findByRole("button", { name: "Close" }));
94
+
95
+ await waitFor(() => expect(menuButton).toHaveFocus());
96
+ });
97
+
98
+ it("opens the HTML shortcut with code excluded", async () => {
99
+ render(<NotebookMenuDropdown />, { wrapper });
100
+
101
+ await selectDownload("Download as HTML (exclude code)");
102
+
103
+ expect(await screen.findByTestId("export-dialog")).toBeVisible();
104
+ expect(screen.getByTestId("export-format-html")).toHaveAttribute(
105
+ "aria-selected",
106
+ "true",
107
+ );
108
+ expect(
109
+ screen.getByRole("switch", { name: "Include code" }),
110
+ ).not.toBeChecked();
111
+ });
112
+
113
+ it("opens the slides PDF shortcut with the slides layout", async () => {
114
+ store.set(layoutStateAtom, {
115
+ selectedLayout: "slides",
116
+ layoutData: {},
117
+ });
118
+ render(<NotebookMenuDropdown />, { wrapper });
119
+
120
+ await openDownloadMenu();
121
+ fireEvent.click(
122
+ await screen.findByRole("menuitem", {
123
+ name: "Download as PDF",
124
+ }),
125
+ );
126
+ fireEvent.click(
127
+ await screen.findByRole("menuitem", {
128
+ name: /Slides Layout/,
129
+ }),
130
+ );
131
+
132
+ expect(await screen.findByTestId("export-dialog")).toBeVisible();
133
+ expect(screen.getByTestId("export-format-pdf")).toHaveAttribute(
134
+ "aria-selected",
135
+ "true",
136
+ );
137
+ expect(screen.getByRole("radio", { name: "Slides" })).toBeChecked();
138
+ });
139
+
140
+ it("hides the slides PDF shortcut in WebAssembly", async () => {
141
+ vi.mocked(isWasm).mockReturnValue(true);
142
+ store.set(layoutStateAtom, {
143
+ selectedLayout: "slides",
144
+ layoutData: {},
145
+ });
146
+ render(<NotebookMenuDropdown />, { wrapper });
147
+
148
+ await openDownloadMenu();
149
+ fireEvent.click(
150
+ await screen.findByRole("menuitem", {
151
+ name: "Download as PDF",
152
+ }),
153
+ );
154
+
155
+ expect(
156
+ screen.getByRole("menuitem", { name: "Document Layout" }),
157
+ ).toBeVisible();
158
+ expect(
159
+ screen.queryByRole("menuitem", { name: /Slides Layout/ }),
160
+ ).not.toBeInTheDocument();
161
+ });
162
+
163
+ it("preselects each Python format from its download shortcut", async () => {
164
+ render(<NotebookMenuDropdown />, { wrapper });
165
+
166
+ await selectDownload("Download notebook source");
167
+
168
+ expect(await screen.findByTestId("export-dialog")).toBeVisible();
169
+ expect(screen.getByTestId("export-format-script")).toHaveAttribute(
170
+ "aria-selected",
171
+ "true",
172
+ );
173
+ expect(
174
+ screen.getByRole("radio", { name: "Notebook source" }),
175
+ ).toBeChecked();
176
+
177
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
178
+ await waitFor(() =>
179
+ expect(screen.queryByTestId("export-dialog")).not.toBeInTheDocument(),
180
+ );
181
+
182
+ await selectDownload("Download flat script");
183
+
184
+ expect(await screen.findByTestId("export-dialog")).toBeVisible();
185
+ expect(screen.getByRole("radio", { name: "Flat script" })).toBeChecked();
186
+ });
187
+ });
@@ -1,7 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { MenuIcon } from "lucide-react";
4
- import React from "react";
4
+ import React, { useRef } from "react";
5
5
  import { useLocale } from "react-aria";
6
6
  import { Button } from "@/components/editor/inputs/Inputs";
7
7
  import {
@@ -33,7 +33,8 @@ export const NotebookMenuDropdown: React.FC<Props> = ({
33
33
  disabled = false,
34
34
  tooltip = "Actions",
35
35
  }) => {
36
- const actions = useNotebookActions();
36
+ const exportDialogReturnFocusRef = useRef<HTMLButtonElement>(null);
37
+ const actions = useNotebookActions({ exportDialogReturnFocusRef });
37
38
  const { locale } = useLocale();
38
39
  // Create tooltip content with keyboard shortcut decoration
39
40
  const tooltipContent = (
@@ -50,6 +51,7 @@ export const NotebookMenuDropdown: React.FC<Props> = ({
50
51
 
51
52
  const button = (
52
53
  <Button
54
+ ref={exportDialogReturnFocusRef}
53
55
  aria-label="Config"
54
56
  shape="circle"
55
57
  size="small"
@@ -44,6 +44,94 @@ describe("read-file snippet", () => {
44
44
  `);
45
45
  });
46
46
 
47
+ it("huggingface backend (dataset)", () => {
48
+ expect(
49
+ readSnippet.getCode(
50
+ makeCtx({
51
+ backendType: "huggingface",
52
+ entry: {
53
+ path: "datasets/scikit-learn/Fish/Fish.csv",
54
+ kind: "file",
55
+ size: 100,
56
+ lastModified: null,
57
+ },
58
+ }),
59
+ ),
60
+ ).toMatchInlineSnapshot(`
61
+ "from huggingface_hub import hf_hub_download
62
+
63
+ local_path = hf_hub_download(
64
+ repo_id="scikit-learn/Fish",
65
+ filename="Fish.csv",
66
+ repo_type="dataset",
67
+ )
68
+
69
+ with open(local_path, "rb") as f:
70
+ _data = f.read()
71
+ _data"
72
+ `);
73
+ });
74
+
75
+ it("huggingface backend (model)", () => {
76
+ expect(
77
+ readSnippet.getCode(
78
+ makeCtx({
79
+ backendType: "huggingface",
80
+ entry: {
81
+ path: "google-bert/bert-base-uncased/config.json",
82
+ kind: "file",
83
+ size: 100,
84
+ lastModified: null,
85
+ },
86
+ }),
87
+ ),
88
+ ).toMatchInlineSnapshot(`
89
+ "from huggingface_hub import hf_hub_download
90
+
91
+ local_path = hf_hub_download(
92
+ repo_id="google-bert/bert-base-uncased",
93
+ filename="config.json",
94
+ repo_type="model",
95
+ )
96
+
97
+ with open(local_path, "rb") as f:
98
+ _data = f.read()
99
+ _data"
100
+ `);
101
+ });
102
+
103
+ it("huggingface backend returns null for unparsable paths", () => {
104
+ expect(
105
+ readSnippet.getCode(
106
+ makeCtx({
107
+ backendType: "huggingface",
108
+ entry: {
109
+ path: "buckets/my-bucket/file.csv",
110
+ kind: "file",
111
+ size: 100,
112
+ lastModified: null,
113
+ },
114
+ }),
115
+ ),
116
+ ).toBeNull();
117
+ });
118
+
119
+ it("huggingface backend does not misparse a dataset/space root as a model repo", () => {
120
+ // These paths are missing a filename segment, so they aren't valid
121
+ // dataset/space repo files. They must not fall through and be treated
122
+ // as the model repo "datasets/scikit-learn" or "spaces/gradio".
123
+ for (const path of ["datasets/scikit-learn/Fish", "spaces/gradio/demo"]) {
124
+ expect(
125
+ readSnippet.getCode(
126
+ makeCtx({
127
+ backendType: "huggingface",
128
+ entry: { path, kind: "file", size: 100, lastModified: null },
129
+ }),
130
+ ),
131
+ ).toBeNull();
132
+ }
133
+ });
134
+
47
135
  it("returns null for directories", () => {
48
136
  expect(
49
137
  readSnippet.getCode(
@@ -9,6 +9,7 @@ import CoreweaveDarkIcon from "@marimo-team/llm-info/icons/coreweave-dark.svg?in
9
9
  import { DatabaseZapIcon, GlobeIcon, HardDriveIcon } from "lucide-react";
10
10
  import GoogleCloudIcon from "@/components/databases/icons/google-cloud-storage.svg?inline";
11
11
  import GoogleDriveIcon from "@/components/databases/icons/google-drive.svg?inline";
12
+ import HuggingfaceIcon from "@/components/databases/icons/huggingface.svg?inline";
12
13
  import { GitHubIcon } from "@/components/icons/github";
13
14
  import type { KnownStorageProtocol } from "@/core/storage/types";
14
15
  import { useTheme } from "@/theme/useTheme";
@@ -28,6 +29,7 @@ const PROTOCOL_ICONS: Record<KnownStorageProtocol, IconEntry> = {
28
29
  file: HardDriveIcon,
29
30
  "in-memory": DatabaseZapIcon,
30
31
  gdrive: { src: GoogleDriveIcon },
32
+ hf: { src: HuggingfaceIcon },
31
33
  github: GitHubIcon,
32
34
  };
33
35
 
@@ -27,6 +27,50 @@ function escapeForPythonString(value: string): string {
27
27
  return JSON.stringify(value).slice(1, -1);
28
28
  }
29
29
 
30
+ interface ParsedHfRepoPath {
31
+ repoType: "model" | "dataset" | "space";
32
+ repoId: string;
33
+ filename: string;
34
+ }
35
+
36
+ // Namespaced prefixes are reserved: they can never be the first segment of a
37
+ // model repo id, so a path like "datasets/org" that is missing a filename
38
+ // must not fall through and be misparsed as the model repo "datasets/org".
39
+ const RESERVED_PREFIXES = new Set(["datasets", "spaces", "buckets"]);
40
+
41
+ function parseHfRepoPath(path: string): ParsedHfRepoPath | null {
42
+ const parts = path.split("/").filter(Boolean);
43
+ if (parts[0] === "datasets" && parts.length >= 4) {
44
+ return {
45
+ repoType: "dataset",
46
+ repoId: `${parts[1]}/${parts[2]}`,
47
+ filename: parts.slice(3).join("/"),
48
+ };
49
+ }
50
+ if (parts[0] === "spaces" && parts.length >= 4) {
51
+ return {
52
+ repoType: "space",
53
+ repoId: `${parts[1]}/${parts[2]}`,
54
+ filename: parts.slice(3).join("/"),
55
+ };
56
+ }
57
+ if (parts.length >= 3 && !RESERVED_PREFIXES.has(parts[0])) {
58
+ return {
59
+ repoType: "model",
60
+ repoId: `${parts[0]}/${parts[1]}`,
61
+ filename: parts.slice(2).join("/"),
62
+ };
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function hfHubDownloadSnippet(parsed: ParsedHfRepoPath): string {
68
+ const repoId = escapeForPythonString(parsed.repoId);
69
+ const filename = escapeForPythonString(parsed.filename);
70
+ const repoType = escapeForPythonString(parsed.repoType);
71
+ return `from huggingface_hub import hf_hub_download\n\nlocal_path = hf_hub_download(\n repo_id="${repoId}",\n filename="${filename}",\n repo_type="${repoType}",\n)`;
72
+ }
73
+
30
74
  export const STORAGE_SNIPPETS: StorageSnippet[] = [
31
75
  {
32
76
  id: "read-file",
@@ -37,6 +81,13 @@ export const STORAGE_SNIPPETS: StorageSnippet[] = [
37
81
  return null;
38
82
  }
39
83
  const path = escapeForPythonString(ctx.entry.path);
84
+ if (ctx.backendType === "huggingface") {
85
+ const parsed = parseHfRepoPath(ctx.entry.path);
86
+ if (!parsed) {
87
+ return null;
88
+ }
89
+ return `${hfHubDownloadSnippet(parsed)}\n\nwith open(local_path, "rb") as f:\n _data = f.read()\n_data`;
90
+ }
40
91
  if (ctx.backendType === "obstore") {
41
92
  return `_data = ${ctx.variableName}.get("${path}").bytes()\n_data`;
42
93
  }
@@ -52,6 +103,13 @@ export const STORAGE_SNIPPETS: StorageSnippet[] = [
52
103
  return null;
53
104
  }
54
105
  const path = escapeForPythonString(ctx.entry.path);
106
+ if (ctx.backendType === "huggingface") {
107
+ const parsed = parseHfRepoPath(ctx.entry.path);
108
+ if (!parsed) {
109
+ return null;
110
+ }
111
+ return `${hfHubDownloadSnippet(parsed)}\nlocal_path`;
112
+ }
55
113
  if (ctx.backendType === "obstore") {
56
114
  if (NOT_SIGNABLE_PROTOCOLS.has(ctx.protocol)) {
57
115
  return null;
@@ -0,0 +1,85 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { CellId } from "@/core/cells/ids";
5
+ import { runDuringPresentMode, viewStateAtom } from "@/core/mode";
6
+ import { store } from "@/core/state/jotai";
7
+
8
+ const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
9
+ callback(0);
10
+ return 0;
11
+ });
12
+
13
+ async function runAfterRender(fn: () => void | Promise<void>): Promise<void> {
14
+ const result = runDuringPresentMode(fn);
15
+ await vi.runAllTimersAsync();
16
+ return result;
17
+ }
18
+
19
+ describe("runDuringPresentMode", () => {
20
+ beforeEach(() => {
21
+ vi.useFakeTimers();
22
+ vi.stubGlobal("requestAnimationFrame", requestAnimationFrameMock);
23
+ requestAnimationFrameMock.mockClear();
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.useRealTimers();
28
+ vi.unstubAllGlobals();
29
+ });
30
+
31
+ it("runs in present mode and restores the captured view state", async () => {
32
+ const state = { mode: "edit" as const, cellAnchor: CellId.create() };
33
+ store.set(viewStateAtom, state);
34
+
35
+ await runAfterRender(() => {
36
+ expect(store.get(viewStateAtom)).toEqual({
37
+ ...state,
38
+ mode: "present",
39
+ });
40
+ });
41
+
42
+ expect(store.get(viewStateAtom)).toEqual(state);
43
+ expect(requestAnimationFrameMock).toHaveBeenCalledTimes(2);
44
+ });
45
+
46
+ it("restores the captured view state when the callback rejects", async () => {
47
+ const state = { mode: "edit" as const, cellAnchor: CellId.create() };
48
+ const error = new Error("capture failed");
49
+ store.set(viewStateAtom, state);
50
+
51
+ const result = runDuringPresentMode(() => Promise.reject(error));
52
+ const rejection = expect(result).rejects.toBe(error);
53
+ await vi.runAllTimersAsync();
54
+ await rejection;
55
+
56
+ expect(store.get(viewStateAtom)).toEqual(state);
57
+ });
58
+
59
+ it("runs directly when already in present mode", async () => {
60
+ const state = { mode: "present" as const, cellAnchor: CellId.create() };
61
+ store.set(viewStateAtom, state);
62
+
63
+ await runDuringPresentMode(() => {
64
+ expect(store.get(viewStateAtom)).toEqual(state);
65
+ });
66
+
67
+ expect(store.get(viewStateAtom)).toEqual(state);
68
+ expect(requestAnimationFrameMock).not.toHaveBeenCalled();
69
+ });
70
+
71
+ it.each(["read", "home", "gallery"] as const)(
72
+ "runs directly without changing %s mode",
73
+ async (mode) => {
74
+ const state = { mode, cellAnchor: CellId.create() };
75
+ store.set(viewStateAtom, state);
76
+
77
+ await runDuringPresentMode(() => {
78
+ expect(store.get(viewStateAtom)).toEqual(state);
79
+ });
80
+
81
+ expect(store.get(viewStateAtom)).toEqual(state);
82
+ expect(requestAnimationFrameMock).not.toHaveBeenCalled();
83
+ },
84
+ );
85
+ });
@@ -63,13 +63,36 @@ function getOutline(html: string): Outline | null {
63
63
  return { items };
64
64
  }
65
65
 
66
+ function toXPathStringLiteral(value: string): string {
67
+ if (!value.includes('"')) {
68
+ return `"${value}"`;
69
+ }
70
+ if (!value.includes("'")) {
71
+ return `'${value}'`;
72
+ }
73
+
74
+ const segments = value.split('"');
75
+ const literals: string[] = [];
76
+ for (const [index, segment] of segments.entries()) {
77
+ if (segment) {
78
+ literals.push(`"${segment}"`);
79
+ }
80
+ if (index < segments.length - 1) {
81
+ literals.push(`'"'`);
82
+ }
83
+ }
84
+ return `concat(${literals.join(", ")})`;
85
+ }
86
+
66
87
  export function headingToIdentifier(heading: Element): OutlineItem["by"] {
67
88
  const id = heading.id;
68
89
  if (id) {
69
90
  return { id };
70
91
  }
71
- const name = heading.textContent;
72
- return { path: `//${heading.tagName}[contains(., "${name}")]` };
92
+ const name = heading.textContent ?? "";
93
+ return {
94
+ path: `//${heading.tagName}[contains(., ${toXPathStringLiteral(name)})]`,
95
+ };
73
96
  }
74
97
 
75
98
  export function mergeOutlines(outlines: (Outline | null)[]): Outline {
package/src/core/mode.ts CHANGED
@@ -55,20 +55,22 @@ export async function runDuringPresentMode(
55
55
  fn: () => void | Promise<void>,
56
56
  ): Promise<void> {
57
57
  const state = store.get(viewStateAtom);
58
- if (state.mode === "present") {
58
+ if (state.mode !== "edit") {
59
59
  await fn();
60
60
  return;
61
61
  }
62
62
 
63
63
  store.set(viewStateAtom, { ...state, mode: "present" });
64
- // Wait 100ms to allow the page to render
65
- await new Promise((resolve) => setTimeout(resolve, 100));
66
- // Wait 2 frames
67
- await new Promise((resolve) => requestAnimationFrame(resolve));
68
- await new Promise((resolve) => requestAnimationFrame(resolve));
69
- await fn();
70
- store.set(viewStateAtom, { ...state, mode: "edit" });
71
- return undefined;
64
+ try {
65
+ // Wait 100ms to allow the page to render
66
+ await new Promise((resolve) => setTimeout(resolve, 100));
67
+ // Wait 2 frames
68
+ await new Promise((resolve) => requestAnimationFrame(resolve));
69
+ await new Promise((resolve) => requestAnimationFrame(resolve));
70
+ await fn();
71
+ } finally {
72
+ store.set(viewStateAtom, state);
73
+ }
72
74
  }
73
75
 
74
76
  export const viewStateAtom = atom<ViewState>({
@@ -84,6 +84,7 @@ describe("createLazyRequests", () => {
84
84
  format: "ipynb",
85
85
  dependenciesAvailable: false,
86
86
  missingPackages: ["nbformat"],
87
+ missingSetup: [],
87
88
  },
88
89
  ],
89
90
  };
@@ -61,5 +61,6 @@ export type KnownStorageProtocol =
61
61
  | "http"
62
62
  | "file"
63
63
  | "gdrive"
64
+ | "hf"
64
65
  | "in-memory"
65
66
  | "github";
@@ -390,8 +390,9 @@ describe("downloadHTMLAsImage", () => {
390
390
  it("should download image without prepare function", async () => {
391
391
  vi.mocked(toPng).mockResolvedValue(mockDataUrl);
392
392
 
393
- await downloadHTMLAsImage({ element: mockElement, filename: "test" });
394
-
393
+ expect(
394
+ await downloadHTMLAsImage({ element: mockElement, filename: "test" }),
395
+ ).toBe(true);
395
396
  expect(toPng).toHaveBeenCalledWith(
396
397
  mockElement,
397
398
  expect.objectContaining({
@@ -452,8 +453,9 @@ describe("downloadHTMLAsImage", () => {
452
453
  it("should show error toast on failure", async () => {
453
454
  vi.mocked(toPng).mockRejectedValue(new Error("Failed"));
454
455
 
455
- await downloadHTMLAsImage({ element: mockElement, filename: "test" });
456
-
456
+ expect(
457
+ await downloadHTMLAsImage({ element: mockElement, filename: "test" }),
458
+ ).toBe(false);
457
459
  expect(toast).toHaveBeenCalledWith({
458
460
  title: "Failed to download as PNG",
459
461
  description: "Failed",
@@ -140,7 +140,7 @@ export async function downloadHTMLAsImage(opts: {
140
140
  element: HTMLElement;
141
141
  filename: string;
142
142
  prepare?: (element: HTMLElement) => () => void;
143
- }) {
143
+ }): Promise<boolean> {
144
144
  const { element, filename, prepare } = opts;
145
145
 
146
146
  // Capture current scroll position
@@ -156,6 +156,7 @@ export async function downloadHTMLAsImage(opts: {
156
156
  // Get screenshot
157
157
  const dataUrl = await toPng(element);
158
158
  downloadByURL(dataUrl, Filenames.toPNG(filename));
159
+ return true;
159
160
  } catch (error) {
160
161
  Logger.error("Error downloading as PNG", error);
161
162
  toast({
@@ -163,6 +164,7 @@ export async function downloadHTMLAsImage(opts: {
163
164
  description: prettyError(error),
164
165
  variant: "danger",
165
166
  });
167
+ return false;
166
168
  } finally {
167
169
  cleanup?.();
168
170
  if (document.body.classList.contains("printing")) {
@@ -0,0 +1,17 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ /**
4
+ * Quote one argument for a POSIX shell command.
5
+ *
6
+ * Mirrors Python's `shlex.quote` so copied commands cannot interpret argument
7
+ * contents as shell syntax.
8
+ */
9
+ export function shellQuote(value: string): string {
10
+ if (value === "") {
11
+ return "''";
12
+ }
13
+ if (/^[\w@%+=:,./-]+$/.test(value)) {
14
+ return value;
15
+ }
16
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
17
+ }