Look at a file without downloading it first

Files could only hand you the bytes: double-clicking a picture put it on
disk and left you to find it. The viewer for this already existed --
images, PDFs and text, in the attachment preview in MessageView -- it was
just wired to one screen. It is now a component both screens use.

Two things it needed before it was any use on Files. The type detection
falls back to the file name: an upload carries whatever the browser
guessed, which for anything unusual is application/octet-stream, so the
old exact-type check saw nothing to show in a .md that had just been
uploaded. And text is read with fetch, which ignores Content-Disposition,
so Markdown previews even though the server will not serve it inline.

Whether we can show a file and whether the server will serve it inline
are separate questions, and lib/preview.ts answers them separately:
`openableInTab` mirrors the isInlineSafe allowlist in the blob route,
because navigating to a blob the server will not inline just starts a
download. SVG is left out of both -- it carries script, and how to show
one safely is its own question, not a detail of a file lister.

Printing goes with it. A picture or a text file prints from the dialog
with everything else dropped; a PDF prints itself from its own iframe,
since the page around it cannot paginate someone else's document. Hiding
`.app` alone was not enough there -- `#root` kept its height and printed a
blank first page, the same trap as the message card.
This commit is contained in:
2026-09-01 20:18:51 -07:00
parent 49f9a06009
commit 984f0474e3
6 changed files with 370 additions and 19 deletions
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { openableInTab, previewKind } from "@/lib/preview";
describe("previewKind", () => {
it("goes by the declared type when there is one", () => {
expect(previewKind("image/png", "photo.png")).toBe("image");
expect(previewKind("application/pdf", "invoice.pdf")).toBe("pdf");
expect(previewKind("text/plain", "notes.txt")).toBe("text");
expect(previewKind("text/markdown", "README.md")).toBe("text");
expect(previewKind("application/json", "data.json")).toBe("text");
expect(previewKind("image/png; charset=binary", "photo.png")).toBe("image");
});
it("falls back to the name when the type is a generic wrapper", () => {
// What an upload gets when the browser cannot guess -- files.ts stores
// `f.type || "application/octet-stream"`, so this is the common case for
// anything unusual, and it is what made the old exact-type check useless
// on real uploads.
expect(previewKind("application/octet-stream", "README.md")).toBe("text");
expect(previewKind("application/octet-stream", "shot.PNG")).toBe("image");
expect(previewKind("application/octet-stream", "report.pdf")).toBe("pdf");
expect(previewKind("", "notes.txt")).toBe("text");
expect(previewKind(null, "deploy.sh")).toBe("text");
expect(previewKind(undefined, undefined)).toBeNull();
});
it("does not let the name override a type the server was specific about", () => {
// A .txt served as a zip is a zip. Guessing from the name here would be
// taking the sender's word for the extension over the server's for the
// bytes.
expect(previewKind("application/zip", "archive.txt")).toBeNull();
expect(previewKind("video/mp4", "clip.txt")).toBeNull();
});
it("leaves SVG alone", () => {
// It carries script and the server refuses to serve it inline; it stays a
// download until that is decided deliberately.
expect(previewKind("image/svg+xml", "logo.svg")).toBeNull();
expect(previewKind("application/octet-stream", "logo.svg")).toBeNull();
});
it("has nothing to show for the rest", () => {
expect(previewKind("application/zip", "backup.zip")).toBeNull();
expect(previewKind("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "letter.docx")).toBeNull();
});
});
describe("openableInTab", () => {
/*
* This mirrors `isInlineSafe` in server/src/app.ts. If the two drift, the
* "open in a new tab" button silently starts downloading instead, because
* the server sends Content-Disposition: attachment for anything not on its
* list. These cases are the list.
*/
it("matches what the server will serve inline", () => {
expect(openableInTab("image/png")).toBe(true);
expect(openableInTab("video/mp4")).toBe(true);
expect(openableInTab("audio/mpeg")).toBe(true);
expect(openableInTab("application/pdf")).toBe(true);
expect(openableInTab("text/plain; charset=utf-8")).toBe(true);
expect(openableInTab("text/calendar")).toBe(true);
expect(openableInTab("text/vcard")).toBe(true);
});
it("refuses what the server will not", () => {
expect(openableInTab("image/svg+xml")).toBe(false);
expect(openableInTab("text/html")).toBe(false);
expect(openableInTab("text/markdown")).toBe(false);
expect(openableInTab("application/json")).toBe(false);
expect(openableInTab("application/octet-stream")).toBe(false);
expect(openableInTab(null)).toBe(false);
});
it("is narrower than what we can show ourselves", () => {
// Markdown is the case that proves the two questions are different: the
// dialog reads it with fetch, which ignores Content-Disposition.
expect(previewKind("text/markdown", "README.md")).toBe("text");
expect(openableInTab("text/markdown")).toBe(false);
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* What, if anything, we can show of a file without downloading it.
*
* Two questions, deliberately kept apart:
*
* - `previewKind` — can the app render it in a dialog? Text is answered with
* `fetch`, which ignores Content-Disposition, so this is free to say yes to
* anything text-shaped.
* - `openableInTab` — will the *server* hand it back inline? That mirrors
* `isInlineSafe` in `server/src/app.ts`, which is the security boundary:
* everything else is served as an attachment with a sandbox CSP. Navigating
* to a blob the server will not inline just starts a download, so the
* "open in a new tab" affordance has to ask this and not the other one.
*
* Keep the two in step by hand. They answer different questions and neither
* can be derived from the other.
*/
export type PreviewKind = "image" | "pdf" | "text";
/**
* Uploads arrive with whatever type the browser guessed, which for anything
* unusual is one of these -- `files.ts` stores `f.type || "application/octet-stream"`.
* A generic type is not evidence about the file, so fall through to the name.
*/
const GENERIC = new Set(["", "application/octet-stream", "binary/octet-stream", "application/unknown", "unknown/unknown"]);
const BY_EXTENSION: Array<[RegExp, PreviewKind]> = [
[/\.(png|jpe?g|gif|webp|avif|bmp|ico|heic|heif)$/i, "image"],
[/\.pdf$/i, "pdf"],
[/\.(txt|text|md|markdown|log|csv|tsv|json|ya?ml|toml|ini|cfg|conf|env|sh|bash|zsh|fish|ps1|bat|js|mjs|cjs|jsx|ts|tsx|css|scss|less|html?|xhtml|xml|sql|py|rb|rs|go|c|h|cc|cpp|hpp|java|kt|swift|php|pl|lua|r|diff|patch|gitignore|dockerfile|makefile)$/i, "text"],
];
function textish(type: string): boolean {
return (
type.startsWith("text/") ||
type.endsWith("+json") ||
type.endsWith("+xml") ||
/^application\/(json|xml|javascript|ecmascript|sql|toml|x-yaml|yaml|x-sh|x-shellscript|x-httpd-php)$/.test(type)
);
}
/**
* SVG is excluded on purpose, and stays excluded. It is a script carrier, the
* server refuses to serve it inline, and deciding how to show one safely is a
* question of its own rather than something to settle inside a file lister.
* An SVG falls through to a download, which is what it did before.
*/
export function previewKind(type: string | null | undefined, name: string | null | undefined): PreviewKind | null {
const t = (type ?? "").split(";")[0]!.trim().toLowerCase();
if (t && !GENERIC.has(t)) {
if (t === "image/svg+xml") return null;
if (t.startsWith("image/")) return "image";
if (t === "application/pdf") return "pdf";
if (textish(t)) return "text";
// The server was specific and it is not something we show. Guessing from
// the extension here would override a type the sender actually declared.
return null;
}
const n = name ?? "";
for (const [re, kind] of BY_EXTENSION) if (re.test(n)) return kind;
return null;
}
/** Mirrors `isInlineSafe` in `server/src/app.ts`; see the note at the top. */
export function openableInTab(type: string | null | undefined): boolean {
const t = (type ?? "").split(";")[0]!.trim().toLowerCase();
return (
(t.startsWith("image/") && t !== "image/svg+xml") ||
t.startsWith("video/") ||
t.startsWith("audio/") ||
t === "application/pdf" ||
t === "text/plain" ||
t === "text/calendar" ||
t === "text/vcard"
);
}
/**
* Past this, a text file is not read in a dialog -- it is downloaded and opened
* in something built for it. The number is about the browser, not the network:
* laying out a few million characters in one `<pre>` locks the tab up.
*/
export const TEXT_PREVIEW_MAX = 2 * 1024 * 1024;
/** A second guard for when the size was not known ahead of the fetch. */
export const TEXT_PREVIEW_CHARS = 400_000;