Skip to content

Commit dc57e9f

Browse files
authored
Bound file previews and offer download for unpreviewable files (#10277)
## 📝 Summary Open every selected file in FileViewer while preventing oversized files from being fully read, base64-encoded, and shipped through JSON. The `/api/files/file_details` endpoint bounds a read only when the caller passes `maxBytes`, so the file preview stays capped at 10 MiB while every other consumer (AI attachments, ACP reads, vimrc) reads in full as before. Metadata lookup is split from content retrieval, and reads stop at the limit without opening oversized files. The frontend classifies each response into text, CSV, media, or unsupported, and renders file metadata with a download action instead of mounting an empty editor or dumping base64 for oversized or unsupported files. Native downloads use the streaming GET endpoint; the explicit WASM download stays full-content. Closes MO-6275 <img width="510" height="389" alt="Screenshot 2026-07-22 at 1 27 35 PM" src="https://github.com/user-attachments/assets/c6486781-ef62-4c33-8f00-6ab1868aef24" /> <img width="510" height="389" alt="Screenshot 2026-07-22 at 1 27 45 PM" src="https://github.com/user-attachments/assets/158fe326-c37f-44f6-89e6-3dd0aacdd933" />
1 parent 7fd4f02 commit dc57e9f

16 files changed

Lines changed: 628 additions & 91 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)