feat(files): a file that is not media opens in a viewer — an image, a PDF, text as text

- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
  image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
  table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
  as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
  a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
  thumbnail
This commit is contained in:
2026-10-12 15:28:31 +02:00
parent 6707550513
commit fb8fb980e5
26 changed files with 1090 additions and 48 deletions
@@ -402,6 +402,62 @@ describe("Files · a shared file's row", () => {
});
});
describe("Files · the viewer", () => {
const doc = (id: number, preview: string | null = "pdf") =>
file(id, {
job_kind: "upload",
display_name: `Doc ${id}`,
asset: { media_kind: "file", preview, container: "pdf", size_bytes: 10 },
} as never);
const viewer = () => screen.findByRole("dialog");
it("opens a file that is not media from its name, in the sandboxed preview", async () => {
mocks.files = [file(1), doc(2)];
mocks.folders = [YT];
mount();
fireEvent.click(screen.getByText("Doc 2"));
const dialog = await viewer();
expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(2));
});
it("puts Preview first in such a file's menu, and it opens the viewer", async () => {
mocks.files = [doc(2, null)];
mocks.folders = [YT];
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Doc 2")));
expect(menuRows()[0]).toBe("files.actions.view");
fireEvent.click(menuItem("files.actions.view"));
expect(await viewer()).toBeTruthy();
expect(screen.getByText("files.preview.none")).toBeTruthy();
});
it("steps through the list's files that are not media, in the list's order", async () => {
mocks.files = [doc(2), file(1), doc(3)];
mocks.folders = [YT];
mount();
fireEvent.click(screen.getByText("Doc 2"));
const dialog = await viewer();
fireEvent.keyDown(window, { key: "ArrowRight" });
await waitFor(() =>
expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(3)),
);
});
it("leaves Preview and Rename out of the viewer's own menu", async () => {
mocks.files = [doc(2)];
mocks.folders = [YT, OWN];
mount();
fireEvent.click(screen.getByText("Doc 2"));
const dialog = await viewer();
await openMenu(() =>
fireEvent.click(dialog.querySelector<HTMLElement>('button[aria-label="files.rowMenu"]')!),
);
expect(menuRows()).not.toContain("files.actions.view");
expect(menuRows()).not.toContain("files.actions.rename");
expect(menuRows()).toContain("files.actions.move");
});
});
describe("Files · the view", () => {
it("starts the list at the scroller's top, so its sticky header does not jump on the first scroll", () => {
mocks.files = [file(2)];