@marimo-team/islands 0.23.16-dev2 → 0.23.16-dev4

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.
@@ -9,7 +9,7 @@ import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
9
9
  import { dt as kioskModeAtom, ht as outputIsStale } from "./html-to-image-DDENewl2.js";
10
10
  import "./chunk-5FQGJX7Z-B9WoXK1R.js";
11
11
  import { u as createLucideIcon } from "./dist-B96tMAst.js";
12
- import { a as DEFAULT_DECK_VERTICAL_ALIGN, c as SlideSidebar, cn as Expand, ct as Panel, dn as Code, i as DEFAULT_DECK_TRANSITION, l as Slide, lt as PanelGroup, o as DEFAULT_SLIDE_TYPE, sn as EyeOff, t as useNotebookCodeAvailable, ut as PanelResizeHandle } from "./code-visibility-mgBayy67.js";
12
+ import { a as DEFAULT_DECK_VERTICAL_ALIGN, c as SlideSidebar, cn as EyeOff, ct as Panel, fn as Code, i as DEFAULT_DECK_TRANSITION, l as Slide, ln as Expand, lt as PanelGroup, o as DEFAULT_SLIDE_TYPE, t as useNotebookCodeAvailable, ut as PanelResizeHandle } from "./code-visibility-Bmrvzgy9.js";
13
13
  import { X as useDebouncedCallback } from "./input-DStQIuqD.js";
14
14
  import "./toDate-B3IRfHhw.js";
15
15
  import "./react-dom-BTJzcVJ9.js";
@@ -7310,7 +7310,7 @@ var ParkedPreviewContent = (e5) => {
7310
7310
  output: c,
7311
7311
  stale: u
7312
7312
  }), t[5] = n.id, t[6] = n.output, t[7] = n.status, t[8] = u, t[9] = f) : f = t[9], f;
7313
- }, reveal_component_default = ({ slideCells: e5, layout: o, setLayout: s, noOutputIds: l, activeIndex: u, onSlideChange: f, mode: co, configWidth: lo, isEditable: fo = false }) => {
7313
+ }, reveal_component_default = ({ slideCells: e5, layout: o, setLayout: s, noOutputIds: l, activeIndex: u, onSlideChange: f, mode: co, configWidth: lo, isEditable: po = false }) => {
7314
7314
  var _a2, _b2, _c;
7315
7315
  let go = (0, import_react.useRef)(null), _o = (0, import_react.useRef)(null), { width: Co, height: wo } = useSlideDimensions(go), Eo = useFullScreenElement() != null, Do = useAtomValue(kioskModeAtom), Oo = (0, import_react.useMemo)(() => Do ? [] : [ce], [Do]), [ko, Ao] = (0, import_react.useState)(() => /* @__PURE__ */ new Set()), jo = useNotebookCodeAvailable(e5), Mo = !isIslands() && jo, No = u == null ? void 0 : e5[u], Po = No ?? e5.at(0), { parkedPreviewCell: Fo, isHeldEdit: Io, isNoOutputPreview: Lo, heldEditCellId: Ro, heldShowsCode: zo, toggleHeldShowsCode: Bo } = useParkedPreview({
7316
7316
  activeCell: No,
@@ -7414,7 +7414,7 @@ var ParkedPreviewContent = (e5) => {
7414
7414
  useEventListener(document, "keydown", is, { capture: true });
7415
7415
  let os = Io ? null : Lo ? "Hidden as there is no output" : "Skipped in presentation", ss = Io ? zo : Vo(Fo == null ? void 0 : Fo.id), cs = parkedRendersSource({
7416
7416
  isNoOutputPreview: Lo,
7417
- isEditable: fo,
7417
+ isEditable: po,
7418
7418
  showCode: ss
7419
7419
  }), ls = /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
7420
7420
  ref: go,
@@ -7438,13 +7438,13 @@ var ParkedPreviewContent = (e5) => {
7438
7438
  children: Go.stacks.map((e6, t) => e6.subslides.length === 1 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SubslideView, {
7439
7439
  subslide: e6.subslides[0],
7440
7440
  resolveShowCode: Vo,
7441
- isEditable: fo,
7441
+ isEditable: po,
7442
7442
  slideConfigs: o.cells,
7443
7443
  contentStyle: Yo
7444
7444
  }, t) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ne, { children: e6.subslides.map((e7, t2) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SubslideView, {
7445
7445
  subslide: e7,
7446
7446
  resolveShowCode: Vo,
7447
- isEditable: fo,
7447
+ isEditable: po,
7448
7448
  slideConfigs: o.cells,
7449
7449
  contentStyle: Yo
7450
7450
  }, t2)) }, t))
@@ -7463,7 +7463,7 @@ var ParkedPreviewContent = (e5) => {
7463
7463
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ParkedPreviewContent, {
7464
7464
  cell: Fo,
7465
7465
  isNoOutputPreview: Lo,
7466
- isEditable: fo,
7466
+ isEditable: po,
7467
7467
  showCode: ss
7468
7468
  })
7469
7469
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.16-dev2",
3
+ "version": "0.23.16-dev4",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -9,6 +9,7 @@ import {
9
9
  type TableFeature,
10
10
  type Updater,
11
11
  } from "@tanstack/react-table";
12
+ import { browserLocale } from "@/core/i18n/locale";
12
13
  import type { DataType } from "@/core/kernel/messages";
13
14
  import { logNever } from "@/utils/assertNever";
14
15
  import {
@@ -40,7 +41,7 @@ export const ColumnFormattingFeature: TableFeature = {
40
41
  return {
41
42
  enableColumnFormatting: true,
42
43
  onColumnFormattingChange: makeStateUpdater("columnFormatting", table),
43
- locale: navigator.language,
44
+ locale: browserLocale(),
44
45
  } as ColumnFormattingOptions;
45
46
  },
46
47
 
@@ -0,0 +1,27 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { getFileRenderMode } from "../file-render-mode";
5
+
6
+ describe("getFileRenderMode", () => {
7
+ it.each([
8
+ ["text/plain", false, "text"],
9
+ ["text/x-rust", false, "text"],
10
+ ["application/json", false, "text"],
11
+ ["application/xml", false, "text"],
12
+ ["text/csv", false, "csv"],
13
+ ["image/png", true, "media"],
14
+ ["video/mp4", true, "media"],
15
+ ["application/pdf", true, "media"],
16
+ ["application/x-apple-diskimage", true, "unsupported"],
17
+ ["application/zip", true, "unsupported"],
18
+ ["application/octet-stream", false, "unsupported"],
19
+ [null, false, "text"],
20
+ [null, true, "unsupported"],
21
+ ] as const)(
22
+ "maps %s with isBase64=%s to %s",
23
+ (mimeType, isBase64, expected) => {
24
+ expect(getFileRenderMode(mimeType, isBase64)).toBe(expected);
25
+ },
26
+ );
27
+ });
@@ -0,0 +1,150 @@
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 { beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { MockRequestClient } from "@/__mocks__/requests";
8
+ import { TooltipProvider } from "@/components/ui/tooltip";
9
+ import { requestClientAtom } from "@/core/network/requests";
10
+ import type { FileDetailsResponse, FileInfo } from "@/core/network/types";
11
+ import { store } from "@/core/state/jotai";
12
+ import { FileViewer, MAX_FILE_PREVIEW_BYTES } from "../file-viewer";
13
+
14
+ vi.mock("@/plugins/impl/code/LazyAnyLanguageCodeMirror", () => ({
15
+ LazyAnyLanguageCodeMirror: ({
16
+ value,
17
+ onChange,
18
+ }: {
19
+ value?: string;
20
+ onChange?: (value: string) => void;
21
+ }) => (
22
+ <textarea
23
+ data-testid="code-editor"
24
+ value={value ?? ""}
25
+ onChange={(evt) => onChange?.(evt.target.value)}
26
+ />
27
+ ),
28
+ }));
29
+
30
+ vi.mock("@/core/wasm/utils", () => ({ isWasm: () => false }));
31
+
32
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
33
+ <Provider store={store}>
34
+ <TooltipProvider>{children}</TooltipProvider>
35
+ </Provider>
36
+ );
37
+
38
+ const file: FileInfo = {
39
+ id: "/workspace/installer.dmg",
40
+ path: "/workspace/installer.dmg",
41
+ name: "installer.dmg",
42
+ isDirectory: false,
43
+ isMarimoFile: false,
44
+ size: 20 * 1024 * 1024,
45
+ };
46
+
47
+ function renderViewer(response: FileDetailsResponse) {
48
+ const client = MockRequestClient.create({
49
+ sendFileDetails: vi.fn().mockResolvedValue(response),
50
+ });
51
+ store.set(requestClientAtom, client);
52
+ render(<FileViewer file={file} onOpenNotebook={vi.fn()} />, { wrapper });
53
+ return client;
54
+ }
55
+
56
+ describe("FileViewer bounded previews", () => {
57
+ beforeEach(() => {
58
+ store.set(requestClientAtom, null);
59
+ });
60
+
61
+ it("requests a bounded preview and shows oversized metadata", async () => {
62
+ const client = renderViewer({
63
+ file,
64
+ contents: null,
65
+ mimeType: "application/x-apple-diskimage",
66
+ isBase64: false,
67
+ isTooLarge: true,
68
+ });
69
+
70
+ expect(await screen.findByText(/too large to preview/i)).toBeVisible();
71
+ expect(screen.getByText("20 MB")).toBeVisible();
72
+ expect(screen.getByRole("button", { name: /download/i })).toBeVisible();
73
+ expect(screen.queryByTestId("code-editor")).not.toBeInTheDocument();
74
+ expect(client.sendFileDetails).toHaveBeenCalledWith({
75
+ path: file.path,
76
+ maxBytes: MAX_FILE_PREVIEW_BYTES,
77
+ });
78
+ });
79
+
80
+ it("shows unsupported metadata with a download action, not base64 text", async () => {
81
+ renderViewer({
82
+ file: { ...file, size: 4 },
83
+ contents: "AAEC",
84
+ mimeType: "application/x-apple-diskimage",
85
+ isBase64: true,
86
+ isTooLarge: false,
87
+ });
88
+
89
+ expect(await screen.findByText(/cannot be previewed/i)).toBeVisible();
90
+ expect(screen.getByRole("button", { name: /download/i })).toBeVisible();
91
+ expect(screen.queryByTestId("code-editor")).not.toBeInTheDocument();
92
+ });
93
+
94
+ it("continues to render small text files", async () => {
95
+ renderViewer({
96
+ file: { ...file, name: "notes.txt", size: 5 },
97
+ contents: "hello",
98
+ mimeType: "text/plain",
99
+ isBase64: false,
100
+ isTooLarge: false,
101
+ });
102
+
103
+ await waitFor(() => {
104
+ expect(screen.getByTestId("code-editor")).toBeInTheDocument();
105
+ });
106
+ expect(screen.queryByText(/too large to preview/i)).not.toBeInTheDocument();
107
+ });
108
+
109
+ it("preserves edits to an initially empty text file across remount", async () => {
110
+ const emptyFile: FileInfo = {
111
+ id: "/workspace/empty.txt",
112
+ path: "/workspace/empty.txt",
113
+ name: "empty.txt",
114
+ isDirectory: false,
115
+ isMarimoFile: false,
116
+ size: 0,
117
+ };
118
+ const response: FileDetailsResponse = {
119
+ file: emptyFile,
120
+ contents: "",
121
+ mimeType: "text/plain",
122
+ isBase64: false,
123
+ isTooLarge: false,
124
+ };
125
+ const client = MockRequestClient.create({
126
+ sendFileDetails: vi.fn().mockResolvedValue(response),
127
+ });
128
+ store.set(requestClientAtom, client);
129
+
130
+ const { unmount } = render(
131
+ <FileViewer file={emptyFile} onOpenNotebook={vi.fn()} />,
132
+ { wrapper },
133
+ );
134
+
135
+ const editor =
136
+ await screen.findByTestId<HTMLTextAreaElement>("code-editor");
137
+ fireEvent.change(editor, { target: { value: "draft" } });
138
+ unmount();
139
+
140
+ render(<FileViewer file={emptyFile} onOpenNotebook={vi.fn()} />, {
141
+ wrapper,
142
+ });
143
+
144
+ const reopened =
145
+ await screen.findByTestId<HTMLTextAreaElement>("code-editor");
146
+ await waitFor(() => {
147
+ expect(reopened.value).toBe("draft");
148
+ });
149
+ });
150
+ });
@@ -0,0 +1,51 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { DownloadIcon } from "lucide-react";
4
+ import { useLocale } from "react-aria";
5
+ import { Button } from "@/components/ui/button";
6
+ import type { FileInfo } from "@/core/network/types";
7
+ import { formatBytes } from "@/utils/formatting";
8
+
9
+ interface Props {
10
+ file: FileInfo;
11
+ mimeType: string;
12
+ message: string;
13
+ onDownload?: () => void;
14
+ }
15
+
16
+ export const FilePreviewMetadata = ({
17
+ file,
18
+ mimeType,
19
+ message,
20
+ onDownload,
21
+ }: Props) => {
22
+ const { locale } = useLocale();
23
+ return (
24
+ <div className="p-4 text-sm">
25
+ <div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
26
+ <span className="text-muted-foreground font-medium">Path</span>
27
+ <span className="font-mono text-xs break-all">{file.path}</span>
28
+ <span className="text-muted-foreground font-medium">Type</span>
29
+ <span>{mimeType}</span>
30
+ {file.size != null && (
31
+ <>
32
+ <span className="text-muted-foreground font-medium">Size</span>
33
+ <span>{formatBytes(file.size, locale)}</span>
34
+ </>
35
+ )}
36
+ </div>
37
+ <div className="mt-4 text-muted-foreground italic">{message}</div>
38
+ {onDownload && (
39
+ <Button
40
+ variant="outline"
41
+ size="sm"
42
+ className="mt-4"
43
+ onClick={onDownload}
44
+ >
45
+ <DownloadIcon className="h-3.5 w-3.5 mr-2" />
46
+ Download
47
+ </Button>
48
+ )}
49
+ </div>
50
+ );
51
+ };
@@ -0,0 +1,32 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { isMediaMime, MIME_TO_LANGUAGE } from "./renderers";
4
+
5
+ export type FileRenderMode = "text" | "csv" | "media" | "unsupported";
6
+
7
+ export function getFileRenderMode(
8
+ mimeType: string | null | undefined,
9
+ isBase64: boolean | undefined,
10
+ ): FileRenderMode {
11
+ const mime = mimeType || "text/plain";
12
+
13
+ if (isMediaMime(mime)) {
14
+ return "media";
15
+ }
16
+ if (isBase64) {
17
+ return "unsupported";
18
+ }
19
+ if (mime === "text/csv") {
20
+ return "csv";
21
+ }
22
+ if (
23
+ mime.startsWith("text/") ||
24
+ (mime !== "default" && mime in MIME_TO_LANGUAGE)
25
+ ) {
26
+ return "text";
27
+ }
28
+ if (mimeType == null) {
29
+ return "text";
30
+ }
31
+ return "unsupported";
32
+ }
@@ -25,11 +25,11 @@ import { copyToClipboard } from "@/utils/copy";
25
25
  import type { Base64String } from "@/utils/json/base64";
26
26
  import { downloadFile } from "./download";
27
27
  import { FilePreviewHeader } from "./file-header";
28
- import {
29
- FileContentRenderer,
30
- isMediaMime,
31
- MIME_TO_LANGUAGE,
32
- } from "./renderers";
28
+ import { FilePreviewMetadata } from "./file-preview-metadata";
29
+ import { getFileRenderMode } from "./file-render-mode";
30
+ import { FileContentRenderer } from "./renderers";
31
+
32
+ export const MAX_FILE_PREVIEW_BYTES = 10 * 1024 * 1024;
33
33
 
34
34
  interface Props {
35
35
  file: FileInfo;
@@ -50,9 +50,15 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
50
50
 
51
51
  const { data, isPending, error, setData, refetch } =
52
52
  useAsyncData(async () => {
53
- const details = await sendFileDetails({ path: file.path });
54
- const contents = details.contents || "";
55
- setInternalValue(unsavedContentsForFile.get(file.path) || contents);
53
+ const details = await sendFileDetails({
54
+ path: file.path,
55
+ maxBytes: MAX_FILE_PREVIEW_BYTES,
56
+ });
57
+ const mode = getFileRenderMode(details.mimeType, details.isBase64);
58
+ if (!details.isTooLarge && mode === "text") {
59
+ const contents = details.contents || "";
60
+ setInternalValue(unsavedContentsForFile.get(file.path) ?? contents);
61
+ }
56
62
  return details;
57
63
  }, [file.path]);
58
64
 
@@ -72,13 +78,19 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
72
78
  );
73
79
  };
74
80
 
75
- // On file change or unmount, save the unsaved contents
81
+ // On file change or unmount, save the unsaved contents. Only editable text
82
+ // previews participate in editor state; oversized and non-text responses
83
+ // carry no draft to preserve.
76
84
  // We use a ref for internalValue so we don't call this effect on each keystroke
77
85
  const internalValueRef = useRef<string>(internalValue);
78
86
  internalValueRef.current = internalValue;
79
87
  useEffect(() => {
80
88
  return () => {
81
- if (!data?.contents) {
89
+ if (
90
+ data?.contents == null ||
91
+ data.isTooLarge ||
92
+ getFileRenderMode(data.mimeType, data.isBase64) !== "text"
93
+ ) {
82
94
  return;
83
95
  }
84
96
  const draft = internalValueRef.current;
@@ -88,7 +100,13 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
88
100
  unsavedContentsForFile.set(file.path, draft);
89
101
  }
90
102
  };
91
- }, [file.path, data?.contents]);
103
+ }, [
104
+ file.path,
105
+ data?.contents,
106
+ data?.isTooLarge,
107
+ data?.mimeType,
108
+ data?.isBase64,
109
+ ]);
92
110
 
93
111
  if (error) {
94
112
  return <ErrorBanner error={error} />;
@@ -99,7 +117,10 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
99
117
  }
100
118
 
101
119
  const mimeType = data.mimeType || "text/plain";
102
- const isEditable = mimeType in MIME_TO_LANGUAGE;
120
+ const renderMode = getFileRenderMode(data.mimeType, data.isBase64);
121
+ const isText = renderMode === "text";
122
+ const isCsv = renderMode === "csv";
123
+ const isMedia = renderMode === "media";
103
124
  const isActiveNotebook =
104
125
  currentNotebookFilename &&
105
126
  data.file.isMarimoFile &&
@@ -108,22 +129,27 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
108
129
  // but this is an okay heuristic for now.
109
130
  file.path.endsWith(`/${currentNotebookFilename}`));
110
131
 
111
- if (!data.contents && !isEditable) {
112
- // Show details instead of contents
113
- return (
114
- <div className="grid grid-cols-2 gap-2 p-6">
115
- <div className="font-bold text-muted-foreground">Name</div>
116
- <div>{data.file.name}</div>
117
- <div className="font-bold text-muted-foreground">Type</div>
118
- <div>{mimeType}</div>
119
- </div>
120
- );
121
- }
122
-
123
132
  const handleDownload = async () => {
124
133
  await downloadFile(file.path, data.file.name);
125
134
  };
126
135
 
136
+ const saveKeymapExtension = keymap.of([
137
+ {
138
+ key: hotkeys.getHotkey("global.save").key,
139
+ stopPropagation: true,
140
+ run: () => {
141
+ if (internalValue !== data.contents) {
142
+ void handleSaveFile();
143
+ return true;
144
+ }
145
+ return false;
146
+ },
147
+ },
148
+ ]);
149
+
150
+ // Defined before the metadata-only returns so refresh, download, and
151
+ // open-notebook stay available while edit-only copy/save are withheld from
152
+ // non-text previews.
127
153
  const header = (
128
154
  <FilePreviewHeader
129
155
  filename={data.file.name}
@@ -142,7 +168,7 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
142
168
  </Button>
143
169
  </Tooltip>
144
170
  )}
145
- {!isMediaMime(mimeType) && (
171
+ {isText && (
146
172
  <>
147
173
  <Tooltip content="Copy contents to clipboard">
148
174
  <Button
@@ -172,8 +198,23 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
172
198
  />
173
199
  );
174
200
 
175
- const isMedia = isMediaMime(mimeType);
176
- const isText = !isMedia && mimeType !== "text/csv";
201
+ if (data.isTooLarge || renderMode === "unsupported") {
202
+ return (
203
+ <>
204
+ {header}
205
+ <FilePreviewMetadata
206
+ file={data.file}
207
+ mimeType={mimeType}
208
+ message={
209
+ data.isTooLarge
210
+ ? "File is too large to preview."
211
+ : "This file type cannot be previewed."
212
+ }
213
+ onDownload={disableFileDownloads ? undefined : handleDownload}
214
+ />
215
+ </>
216
+ );
217
+ }
177
218
 
178
219
  const warningBanner = isText && isActiveNotebook && (
179
220
  <Alert variant="warning" className="rounded-none">
@@ -192,35 +233,20 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
192
233
  <FileContentRenderer
193
234
  mimeType={mimeType}
194
235
  contents={
195
- isMedia
196
- ? undefined
197
- : isText
198
- ? internalValue
199
- : (data.contents ?? undefined)
236
+ isText
237
+ ? internalValue
238
+ : isCsv
239
+ ? (data.contents ?? undefined)
240
+ : undefined
200
241
  }
201
242
  mediaSource={
202
- isMedia
243
+ isMedia && data.contents
203
244
  ? { base64: data.contents as Base64String, mime: mimeType }
204
245
  : undefined
205
246
  }
206
247
  readOnly={!isText}
207
- onChange={setInternalValue}
208
- extensions={[
209
- // Command S for save
210
- keymap.of([
211
- {
212
- key: hotkeys.getHotkey("global.save").key,
213
- stopPropagation: true,
214
- run: () => {
215
- if (internalValue !== data.contents) {
216
- handleSaveFile();
217
- return true;
218
- }
219
- return false;
220
- },
221
- },
222
- ]),
223
- ]}
248
+ onChange={isText ? setInternalValue : undefined}
249
+ extensions={isText ? [saveKeymapExtension] : []}
224
250
  />
225
251
  </>
226
252
  );