@marimo-team/islands 0.23.17-dev3 → 0.23.17-dev31

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 (76) hide show
  1. package/dist/{ConnectedDataExplorerComponent-f9vLjQiF.js → ConnectedDataExplorerComponent-BEb8RZam.js} +13 -13
  2. package/dist/{ErrorBoundary-DhLqwSWA.js → ErrorBoundary-8ZHte0h9.js} +3 -3
  3. package/dist/{ImageComparisonComponent-C3y7L3eJ.js → ImageComparisonComponent-fdY0LUuf.js} +38 -27
  4. package/dist/{any-language-editor-DifE-fl7.js → any-language-editor-CioNaP-T.js} +5 -5
  5. package/dist/{button-BSm8IXRU.js → button-8aS1QcHr.js} +4 -2
  6. package/dist/{capabilities-C6OQ5YBO.js → capabilities-D23lgbH_.js} +1 -1
  7. package/dist/{chat-ui-DsZJj75A.js → chat-ui-CnjFz0bt.js} +16 -16
  8. package/dist/{check-DP7U0H0G.js → check-CCvJTB2E.js} +1 -1
  9. package/dist/{common-D747zlkP.js → common-CaGHcf3w.js} +21 -21
  10. package/dist/{copy-DNq8q3zw.js → copy-D45ZUY3j.js} +2 -2
  11. package/dist/{dist-DIlxLFXG.js → dist-DgzzIPS4.js} +2 -2
  12. package/dist/{error-banner-B_GQfxxD.js → error-banner-DHfPl1w3.js} +2 -2
  13. package/dist/{esm-DzZVaNH4.js → esm-DJsE6wi-.js} +1 -1
  14. package/dist/{extends-w2_S44bV.js → extends-xQ_2Cylt.js} +4 -4
  15. package/dist/{formats-Dwq1THhr.js → formats-D10vOEsT.js} +3 -3
  16. package/dist/{glide-data-editor-B9K6-MJe.js → glide-data-editor-FiwJJzrV.js} +9 -9
  17. package/dist/{html-to-image-CZ1kLKkq.js → html-to-image-B8KJvlou.js} +2111 -2105
  18. package/dist/{input-C5K4VBvB.js → input-DtTqU0xh.js} +6 -6
  19. package/dist/{label-DjoYwqmj.js → label-5IC840tS.js} +2 -2
  20. package/dist/{loader-DX-IDS7n.js → loader-DP2JkPth.js} +1 -1
  21. package/dist/main.js +47 -44
  22. package/dist/{mermaid-hv6IckVw.js → mermaid-BNhC-PrB.js} +5 -5
  23. package/dist/{process-output-bVfbcx5_.js → process-output-CjS9wS4l.js} +3 -3
  24. package/dist/{reveal-component-CcSMtt1K.js → reveal-component-Cja7Z8N3.js} +11 -11
  25. package/dist/{spec-CQKL_D3i.js → spec-C7rMyZ_8.js} +4 -4
  26. package/dist/{strings-DIy9NAmg.js → strings-CITcoFNY.js} +4 -4
  27. package/dist/style.css +1 -1
  28. package/dist/{swiper-component-CEdAYjb7.js → swiper-component-dl1H9WIW.js} +2 -2
  29. package/dist/{toDate-CqhBWjvO.js → toDate-kxcuDOWd.js} +3 -3
  30. package/dist/{tooltip-f-qR0iUx.js → tooltip-BkSv4x8S.js} +3 -3
  31. package/dist/{types-DicH9Oi0.js → types-kNUyqkDD.js} +1 -1
  32. package/dist/{useAsyncData-CYOQb1EW.js → useAsyncData-Uo4VFafD.js} +1 -1
  33. package/dist/{useDateFormatter-Bt2qr_XS.js → useDateFormatter-ZCfHadYP.js} +2 -2
  34. package/dist/{useDeepCompareMemoize-CSHQeQEI.js → useDeepCompareMemoize-s3ZvmdK3.js} +1 -1
  35. package/dist/{useIframeCapabilities-kA09ykNq.js → useIframeCapabilities-pLC-ceDP.js} +1 -1
  36. package/dist/{useLifecycle-CMJGqBaY.js → useLifecycle-CLPBATCv.js} +3 -3
  37. package/dist/{useTheme-DYfby65G.js → useTheme-CEMsu1Js.js} +2 -2
  38. package/dist/{vega-component-C7nO5zcf.js → vega-component-DGkcoXu3.js} +10 -10
  39. package/dist/{zod-BUcyXSdR.js → zod-DdNi9290.js} +1 -1
  40. package/package.json +1 -1
  41. package/src/__mocks__/requests.ts +5 -0
  42. package/src/components/databases/icons/huggingface.svg +8 -0
  43. package/src/components/editor/actions/export-dialog/__tests__/export-dialog.test.tsx +35 -0
  44. package/src/components/editor/actions/export-dialog/__tests__/state.test.ts +61 -0
  45. package/src/components/editor/actions/export-dialog/export-dialog.tsx +27 -23
  46. package/src/components/editor/actions/export-dialog/format-notice.tsx +28 -0
  47. package/src/components/editor/actions/export-dialog/state.ts +23 -5
  48. package/src/components/editor/actions/export-dialog/use-export-dialog.ts +7 -15
  49. package/src/components/editor/chrome/panels/outline/__tests__/useActiveOutline.test.ts +26 -0
  50. package/src/components/editor/code/__tests__/readonly-python-code.test.tsx +114 -2
  51. package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +14 -0
  52. package/src/components/editor/connections/storage/__tests__/as-code.test.ts +20 -0
  53. package/src/components/editor/connections/storage/add-storage-form.tsx +10 -0
  54. package/src/components/editor/connections/storage/as-code.ts +19 -1
  55. package/src/components/editor/connections/storage/schemas.ts +19 -0
  56. package/src/components/storage/__tests__/storage-snippets.test.ts +88 -0
  57. package/src/components/storage/components.tsx +2 -0
  58. package/src/components/storage/storage-snippets.ts +58 -0
  59. package/src/core/__tests__/mode.test.ts +85 -0
  60. package/src/core/codemirror/code-lens/__tests__/extension.test.ts +2 -7
  61. package/src/core/codemirror/code-lens/extension.ts +0 -4
  62. package/src/core/codemirror/find-replace/search-highlight.ts +8 -3
  63. package/src/core/codemirror/theme/__tests__/light.test.ts +59 -123
  64. package/src/core/codemirror/theme/dark.ts +6 -5
  65. package/src/core/codemirror/theme/light.ts +6 -4
  66. package/src/core/config/feature-flag.tsx +0 -2
  67. package/src/core/dom/outline.ts +25 -2
  68. package/src/core/hotkeys/hotkeys.ts +2 -0
  69. package/src/core/mode.ts +11 -9
  70. package/src/core/network/__tests__/requests-lazy.test.ts +1 -0
  71. package/src/core/storage/types.ts +1 -0
  72. package/src/css/app/codemirror.css +11 -7
  73. package/src/css/globals.css +8 -0
  74. package/src/plugins/impl/image-comparison/ImageComparisonComponent.tsx +20 -1
  75. package/src/plugins/impl/image-comparison/__tests__/ImageComparisonComponent.test.tsx +58 -0
  76. package/src/stories/theme.stories.tsx +71 -1
@@ -1,13 +1,13 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { useAtom, useAtomValue, useSetAtom } from "jotai";
3
+ import { useAtom } from "jotai";
4
4
  import { useEffect, useRef, useState } from "react";
5
5
  import { toast } from "@/components/ui/use-toast";
6
6
  import {
7
7
  updateCellOutputsWithScreenshots,
8
8
  useEnrichCellOutputs,
9
9
  } from "@/core/export/hooks";
10
- import { runDuringPresentMode, viewStateAtom } from "@/core/mode";
10
+ import { runDuringPresentMode } 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";
@@ -21,6 +21,7 @@ import {
21
21
  withLoadingToast,
22
22
  } from "@/utils/download";
23
23
  import { Filenames } from "@/utils/filenames";
24
+ import { Logger } from "@/utils/Logger";
24
25
  import { Paths } from "@/utils/paths";
25
26
  import { getExportCommand } from "./export-command";
26
27
  import { exportNotebook } from "./export-notebook";
@@ -277,8 +278,6 @@ function useExportDialogAction({
277
278
  }) {
278
279
  const requests = useRequestClient();
279
280
  const takeScreenshots = useEnrichCellOutputs();
280
- const viewState = useAtomValue(viewStateAtom);
281
- const setViewState = useSetAtom(viewStateAtom);
282
281
  const [isExporting, setIsExporting] = useState(false);
283
282
  const dialogRef = useRef<HTMLDivElement>(null);
284
283
  const mountedRef = useRef(true);
@@ -302,15 +301,7 @@ function useExportDialogAction({
302
301
  const capturePNG = async () => {
303
302
  const capture = () =>
304
303
  captureCurrentAppView(dialogRef.current?.parentElement ?? null);
305
- if (viewState.mode !== "edit") {
306
- await capture();
307
- return;
308
- }
309
- try {
310
- await runDuringPresentMode(capture);
311
- } finally {
312
- setViewState(viewState);
313
- }
304
+ await runDuringPresentMode(capture);
314
305
  };
315
306
 
316
307
  const submit = async () => {
@@ -342,8 +333,9 @@ function useExportDialogAction({
342
333
  if (mountedRef.current) {
343
334
  onClose();
344
335
  }
345
- } catch {
346
- // Request and capture helpers report actionable errors to the user.
336
+ } catch (error) {
337
+ // Most helpers toast actionable errors, but not all (e.g. updateCellOutputs).
338
+ Logger.error("Export failed", error);
347
339
  } finally {
348
340
  if (mountedRef.current) {
349
341
  setIsExporting(false);
@@ -0,0 +1,26 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { describe, expect, it } from "vitest";
3
+ import { parseOutline } from "@/core/dom/outline";
4
+ import { findOutlineElements } from "../useActiveOutline";
5
+
6
+ describe("findOutlineElements", () => {
7
+ it.each([
8
+ "Design from the portfolio",
9
+ 'Decorative Art of "Spanish California"',
10
+ `The painter's "Study"`,
11
+ ])("finds an id-less heading named %s", (name) => {
12
+ const html = `<h3>${name}</h3>`;
13
+ document.body.innerHTML = html;
14
+
15
+ const outline = parseOutline({
16
+ mimetype: "text/html",
17
+ timestamp: 0,
18
+ channel: "output",
19
+ data: html,
20
+ });
21
+
22
+ expect(
23
+ findOutlineElements(outline?.items ?? []).map(([element]) => element),
24
+ ).toEqual([document.querySelector("h3")]);
25
+ });
26
+ });
@@ -1,13 +1,61 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { fireEvent, render, screen } from "@testing-library/react";
4
- import { beforeAll, describe, expect, it } from "vitest";
3
+ import { EditorView } from "@codemirror/view";
4
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
5
+ import {
6
+ afterAll,
7
+ beforeAll,
8
+ beforeEach,
9
+ describe,
10
+ expect,
11
+ it,
12
+ vi,
13
+ } from "vitest";
5
14
  import { SetupMocks } from "@/__mocks__/common";
6
15
  import { TooltipProvider } from "@/components/ui/tooltip";
7
16
  import { ReadonlyCode } from "../readonly-python-code";
8
17
 
18
+ const themeState = vi.hoisted(() => ({
19
+ value: "light" as "light" | "dark",
20
+ }));
21
+
22
+ let originalRangeGetClientRects: PropertyDescriptor | undefined;
23
+
24
+ vi.mock("@/theme/useTheme", async (importOriginal) => {
25
+ const actual = await importOriginal<typeof import("@/theme/useTheme")>();
26
+ return {
27
+ ...actual,
28
+ useTheme: () => ({ theme: themeState.value }),
29
+ };
30
+ });
31
+
9
32
  beforeAll(() => {
10
33
  SetupMocks.resizeObserver();
34
+ originalRangeGetClientRects = Object.getOwnPropertyDescriptor(
35
+ Range.prototype,
36
+ "getClientRects",
37
+ );
38
+ Object.defineProperty(Range.prototype, "getClientRects", {
39
+ configurable: true,
40
+ value: () => [],
41
+ });
42
+ });
43
+
44
+ afterAll(() => {
45
+ // Keep the shared JSDOM prototype unchanged for other test files.
46
+ if (originalRangeGetClientRects) {
47
+ Object.defineProperty(
48
+ Range.prototype,
49
+ "getClientRects",
50
+ originalRangeGetClientRects,
51
+ );
52
+ } else {
53
+ Reflect.deleteProperty(Range.prototype, "getClientRects");
54
+ }
55
+ });
56
+
57
+ beforeEach(() => {
58
+ themeState.value = "light";
11
59
  });
12
60
 
13
61
  /**
@@ -26,7 +74,71 @@ function isCollapsed(root: ParentNode) {
26
74
  return root.querySelector(".cm")?.classList.contains("opacity-20") ?? false;
27
75
  }
28
76
 
77
+ function getEditorIdentity(view: EditorView) {
78
+ return {
79
+ doc: view.state.doc,
80
+ dom: view.dom,
81
+ contentDOM: view.contentDOM,
82
+ scrollDOM: view.scrollDOM,
83
+ };
84
+ }
85
+
86
+ function expectEditorIdentity(
87
+ view: EditorView,
88
+ identity: ReturnType<typeof getEditorIdentity>,
89
+ ) {
90
+ expect(view.state.doc).toBe(identity.doc);
91
+ expect(view.dom).toBe(identity.dom);
92
+ expect(view.contentDOM).toBe(identity.contentDOM);
93
+ expect(view.scrollDOM).toBe(identity.scrollDOM);
94
+ }
95
+
29
96
  describe("ReadonlyCode", () => {
97
+ it("reconfigures the theme without replacing the editor", async () => {
98
+ let createdView: EditorView | undefined;
99
+ const onCreateEditor = (view: EditorView) => {
100
+ createdView = view;
101
+ };
102
+ const renderCode = (id: string) => (
103
+ <TooltipProvider>
104
+ <ReadonlyCode
105
+ id={id}
106
+ code={"line_1 = 1\nline_2 = 2\nline_3 = 3"}
107
+ showCopyCode={false}
108
+ onCreateEditor={onCreateEditor}
109
+ />
110
+ </TooltipProvider>
111
+ );
112
+
113
+ const { rerender } = render(renderCode("light"));
114
+ await waitFor(() => {
115
+ expect(createdView).toBeDefined();
116
+ });
117
+
118
+ const view = createdView;
119
+ if (!view) {
120
+ throw new Error("ReadonlyCode did not create an editor view");
121
+ }
122
+ const identity = getEditorIdentity(view);
123
+ expect(view.state.facet(EditorView.darkTheme)).toBe(false);
124
+
125
+ themeState.value = "dark";
126
+ rerender(renderCode("dark"));
127
+ await waitFor(() => {
128
+ expect(view.state.facet(EditorView.darkTheme)).toBe(true);
129
+ });
130
+ expect(createdView).toBe(view);
131
+ expectEditorIdentity(view, identity);
132
+
133
+ themeState.value = "light";
134
+ rerender(renderCode("light-again"));
135
+ await waitFor(() => {
136
+ expect(view.state.facet(EditorView.darkTheme)).toBe(false);
137
+ });
138
+ expect(createdView).toBe(view);
139
+ expectEditorIdentity(view, identity);
140
+ });
141
+
30
142
  it("starts collapsed when initiallyHideCode is true", () => {
31
143
  const { container } = renderReadonly({ initiallyHideCode: true });
32
144
  expect(isCollapsed(container)).toBe(true);
@@ -154,6 +154,20 @@ _creds = json.loads(_credentials_json)
154
154
  fs = GoogleDriveFileSystem(creds=_creds, token="service_account", use_listings_cache=False, skip_instance_cache=True)"
155
155
  `;
156
156
 
157
+ exports[`generateStorageCode > Hugging Face > default connection 1`] = `
158
+ "from huggingface_hub import HfApi
159
+
160
+ hf = HfApi()"
161
+ `;
162
+
163
+ exports[`generateStorageCode > Hugging Face > with token from secrets 1`] = `
164
+ "from huggingface_hub import HfApi
165
+ import os
166
+
167
+ _token = os.environ.get("HF_TOKEN")
168
+ hf = HfApi(token=_token)"
169
+ `;
170
+
157
171
  exports[`generateStorageCode > S3 > basic connection with all fields 1`] = `
158
172
  "from obstore.store import S3Store
159
173
 
@@ -309,6 +309,26 @@ describe("generateStorageCode", () => {
309
309
  });
310
310
  });
311
311
 
312
+ describe("Hugging Face", () => {
313
+ it("default connection", () => {
314
+ expect(
315
+ generateStorageCode({ type: "huggingface" }, { library: "fsspec" }),
316
+ ).toMatchSnapshot();
317
+ });
318
+
319
+ it("with token from secrets", () => {
320
+ expect(
321
+ generateStorageCode(
322
+ {
323
+ type: "huggingface",
324
+ token: prefixSecret("HF_TOKEN"),
325
+ },
326
+ { library: "fsspec" },
327
+ ),
328
+ ).toMatchSnapshot();
329
+ });
330
+ });
331
+
312
332
  describe("invalid cases", () => {
313
333
  it("throws for empty S3 bucket", () => {
314
334
  expect(() =>
@@ -17,6 +17,7 @@ import {
17
17
  CoreWeaveStorageSchema,
18
18
  GCSStorageSchema,
19
19
  GoogleDriveStorageSchema,
20
+ HuggingfaceStorageSchema,
20
21
  S3StorageSchema,
21
22
  type StorageConnection,
22
23
  } from "./schemas";
@@ -79,6 +80,15 @@ const STORAGE_PROVIDERS = [
79
80
  preferred: "fsspec",
80
81
  },
81
82
  },
83
+ {
84
+ name: "Hugging Face Hub",
85
+ schema: HuggingfaceStorageSchema,
86
+ protocol: "hf",
87
+ storageLibraries: {
88
+ libraries: ["huggingface_hub"],
89
+ preferred: "huggingface_hub",
90
+ },
91
+ },
82
92
  ] satisfies StorageProviderSchema[];
83
93
 
84
94
  const StorageProviderSelector: React.FC<{
@@ -10,7 +10,7 @@ import {
10
10
  StorageConnectionSchema,
11
11
  } from "./schemas";
12
12
 
13
- export type StorageLibrary = "obstore" | "fsspec";
13
+ export type StorageLibrary = "obstore" | "fsspec" | "huggingface_hub";
14
14
 
15
15
  export interface StorageCodeOptions {
16
16
  library: StorageLibrary;
@@ -20,6 +20,7 @@ export interface StorageCodeOptions {
20
20
  export const StorageLibraryDisplayNames: Record<StorageLibrary, string> = {
21
21
  obstore: "obstore",
22
22
  fsspec: "fsspec",
23
+ huggingface_hub: "huggingface_hub",
23
24
  };
24
25
 
25
26
  class SecretContainer {
@@ -245,6 +246,20 @@ function generateGDriveCode(
245
246
  return { imports, code };
246
247
  }
247
248
 
249
+ function generateHuggingfaceCode(
250
+ connection: Extract<StorageConnection, { type: "huggingface" }>,
251
+ secrets: SecretContainer,
252
+ ): { imports: Set<string>; code: string } {
253
+ const imports = new Set(["from huggingface_hub import HfApi"]);
254
+
255
+ if (!connection.token) {
256
+ return { imports, code: "hf = HfApi()" };
257
+ }
258
+
259
+ const token = secrets.print("token", connection.token);
260
+ return { imports, code: `hf = HfApi(token=${token})` };
261
+ }
262
+
248
263
  export function generateStorageCode(
249
264
  connection: StorageConnection,
250
265
  options: StorageCodeOptions,
@@ -273,6 +288,9 @@ export function generateStorageCode(
273
288
  isEmbedded: options.isEmbedded,
274
289
  });
275
290
  break;
291
+ case "huggingface":
292
+ result = generateHuggingfaceCode(connection, secrets);
293
+ break;
276
294
  default:
277
295
  assertNever(connection);
278
296
  }
@@ -220,12 +220,31 @@ export const GoogleDriveStorageSchema = z
220
220
  })
221
221
  .describe(FieldOptions.of({ direction: "two-columns" }));
222
222
 
223
+ export const HuggingfaceStorageSchema = z
224
+ .object({
225
+ type: z.literal("huggingface"),
226
+ token: z
227
+ .string()
228
+ .optional()
229
+ .describe(
230
+ FieldOptions.of({
231
+ label: "Access Token",
232
+ description:
233
+ "Leave empty to use the HF_TOKEN environment variable or cached login",
234
+ inputType: "password",
235
+ optionRegex: "(hf.?token|hugging.?face.?token|hub.?token)",
236
+ }),
237
+ ),
238
+ })
239
+ .describe(FieldOptions.of({ direction: "two-columns" }));
240
+
223
241
  export const StorageConnectionSchema = z.discriminatedUnion("type", [
224
242
  S3StorageSchema,
225
243
  GCSStorageSchema,
226
244
  AzureStorageSchema,
227
245
  CoreWeaveStorageSchema,
228
246
  GoogleDriveStorageSchema,
247
+ HuggingfaceStorageSchema,
229
248
  ]);
230
249
 
231
250
  export type StorageConnection = z.infer<typeof StorageConnectionSchema>;
@@ -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
+ });
@@ -103,7 +103,7 @@ describe("codeLensBundle", () => {
103
103
 
104
104
  beforeEach(() => {
105
105
  vi.useFakeTimers();
106
- mockFlags({ editor_code_lens: true, cache_panel: true });
106
+ mockFlags({ cache_panel: true });
107
107
  seedStore({});
108
108
  });
109
109
 
@@ -141,11 +141,6 @@ describe("codeLensBundle", () => {
141
141
  return [...v.dom.querySelectorAll<HTMLElement>(".mo-code-lens")];
142
142
  }
143
143
 
144
- it("returns an empty extension when the flag is off", () => {
145
- mockFlags({ editor_code_lens: false });
146
- expect(codeLensBundle(cellId("cell1"))).toEqual([]);
147
- });
148
-
149
144
  it("renders a lens at a dataframe declaration", async () => {
150
145
  seedStore({ tables: [DF_TABLE] });
151
146
  const v = await mount("df = load()\nx = 1");
@@ -293,7 +288,7 @@ describe("codeLensBundle", () => {
293
288
  );
294
289
 
295
290
  it("does not render cache lenses when the cache panel is disabled", async () => {
296
- mockFlags({ editor_code_lens: true, cache_panel: false });
291
+ mockFlags({ cache_panel: false });
297
292
  const v = await mount("@mo.cache\ndef f():\n return 1");
298
293
 
299
294
  expect(lenses(v)).toHaveLength(0);