@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.
- package/dist/{chat-ui-DsZJj75A.js → chat-ui-DH3hwWoI.js} +2 -2
- package/dist/{common-BGCQJb-W.js → common-D8DGPf0N.js} +5 -5
- package/dist/{html-to-image-CZ1kLKkq.js → html-to-image-CxlSazqu.js} +2095 -2088
- package/dist/main.js +5 -5
- package/dist/{process-output-bVfbcx5_.js → process-output-B24cDGzV.js} +1 -1
- package/dist/{reveal-component-NNi374so.js → reveal-component-UfXDaVe-.js} +2 -2
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +5 -0
- package/src/components/databases/icons/huggingface.svg +8 -0
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +1 -1
- package/src/components/editor/actions/export-dialog/__tests__/export-command.test.ts +115 -0
- package/src/components/editor/actions/export-dialog/__tests__/export-dialog.test.tsx +458 -0
- package/src/components/editor/actions/export-dialog/__tests__/export-notebook.test.ts +178 -0
- package/src/components/editor/actions/export-dialog/__tests__/state.test.ts +295 -0
- package/src/components/editor/actions/export-dialog/__tests__/use-export-dialog.test.tsx +287 -0
- package/src/components/editor/actions/export-dialog/export-command.ts +149 -0
- package/src/components/editor/actions/export-dialog/export-dialog.tsx +194 -0
- package/src/components/editor/actions/export-dialog/export-notebook.ts +98 -0
- package/src/components/editor/actions/export-dialog/format-notice.tsx +166 -0
- package/src/components/editor/actions/export-dialog/format-options.tsx +531 -0
- package/src/components/editor/actions/export-dialog/state.ts +339 -0
- package/src/components/editor/actions/export-dialog/use-export-dialog.ts +372 -0
- package/src/components/editor/actions/pair-with-agent-commands.ts +1 -21
- package/src/components/editor/actions/useNotebookActions.tsx +72 -170
- package/src/components/editor/chrome/panels/outline/__tests__/useActiveOutline.test.ts +26 -0
- package/src/components/editor/connections/add-connection-dialog.tsx +1 -1
- package/src/components/editor/connections/quick-add-data-sources.tsx +14 -7
- package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +14 -0
- package/src/components/editor/connections/storage/__tests__/as-code.test.ts +20 -0
- package/src/components/editor/connections/storage/add-storage-form.tsx +10 -0
- package/src/components/editor/connections/storage/as-code.ts +19 -1
- package/src/components/editor/connections/storage/schemas.ts +19 -0
- package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +187 -0
- package/src/components/editor/controls/notebook-menu-dropdown.tsx +4 -2
- package/src/components/storage/__tests__/storage-snippets.test.ts +88 -0
- package/src/components/storage/components.tsx +2 -0
- package/src/components/storage/storage-snippets.ts +58 -0
- package/src/core/__tests__/mode.test.ts +85 -0
- package/src/core/dom/outline.ts +25 -2
- package/src/core/mode.ts +11 -9
- package/src/core/network/__tests__/requests-lazy.test.ts +1 -0
- package/src/core/storage/types.ts +1 -0
- package/src/utils/__tests__/download.test.tsx +6 -4
- package/src/utils/download.ts +3 -1
- 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
|
|
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
|
+
});
|
package/src/core/dom/outline.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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>({
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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",
|
package/src/utils/download.ts
CHANGED
|
@@ -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
|
+
}
|