diff --git a/apps/app/src/components/secondary-panel/FilePreview.test.tsx b/apps/app/src/components/secondary-panel/FilePreview.test.tsx index 0540b6917..40d23c1a7 100644 --- a/apps/app/src/components/secondary-panel/FilePreview.test.tsx +++ b/apps/app/src/components/secondary-panel/FilePreview.test.tsx @@ -740,6 +740,25 @@ describe("FilePreview", () => { }); }); + it("frames a pdf preview without a sandbox so Chromium's viewer loads", () => { + const path = "docs/handbook.pdf"; + const url = "/api/v1/threads/thread-1/host-files/content?path=" + path; + render( + , + ); + + const frame = screen.getByTitle(path); + expect(frame.getAttribute("src")).toBe(url); + // A sandboxed frame blocks the viewer's own scripts and the load fails + // with ERR_BLOCKED_BY_CLIENT, so the absence of the attribute is the + // behavior under test, not an oversight. + expect(frame.hasAttribute("sandbox")).toBe(false); + }); + it("reloads an HTML iframe only when the fetched source changes", () => { const path = "reports/preview.html"; const htmlPreviewUrl = diff --git a/apps/app/src/components/secondary-panel/FilePreview.tsx b/apps/app/src/components/secondary-panel/FilePreview.tsx index 179b1b4d6..7d3a85a89 100644 --- a/apps/app/src/components/secondary-panel/FilePreview.tsx +++ b/apps/app/src/components/secondary-panel/FilePreview.tsx @@ -52,6 +52,7 @@ export type FilePreviewState = | { kind: "not-found" } | { kind: "error"; message?: string } | { kind: "image"; url: string } + | { kind: "pdf"; url: string } | { kind: "video"; url: string } | ({ kind: "iframe" } & IframeFilePreviewTarget) | { @@ -146,6 +147,11 @@ interface FilePreviewVideoProps { title: string; } +interface FilePreviewPdfProps { + url: string; + title: string; +} + interface FilePreviewMessageProps { message: string; role?: "alert"; @@ -467,8 +473,14 @@ export function FilePreview({ // The code view owns its own scroller too: pierre's virtualizer needs the // scroll container to be the code viewport so it can render only the rows // near it, which the outer panel scroller (shared with the header) cannot be. + // Chromium's PDF viewer sizes itself to its frame and scrolls internally, so + // it needs the panel height rather than the content-height column. + const usesPdfPreviewLayout = state.kind === "pdf"; const usesFullHeightLayout = - usesIframeLayout || usesCsvPreviewLayout || usesCodeLayout; + usesIframeLayout || + usesCsvPreviewLayout || + usesCodeLayout || + usesPdfPreviewLayout; const usesContentHeightLayout = usesMarkdownPreviewLayout; // Establish a `@container/page` scope so MarkdownPreview's `100cqw`-based @@ -541,6 +553,9 @@ function FilePreviewBody({ if (state.kind === "image") { return ; } + if (state.kind === "pdf") { + return ; + } if (state.kind === "video") { return ; } @@ -1016,6 +1031,22 @@ function FilePreviewVideo({ url, title }: FilePreviewVideoProps) { ); } +// Chromium renders PDFs with its own viewer when a frame navigates to a +// response typed `application/pdf`, so this is an iframe over the URL the +// preview already carries — no PDF library, no new dependency. It takes no +// `sandbox`, unlike the HTML preview: the viewer is a privileged internal +// resource whose scripts a sandboxed frame blocks, and the load fails with +// ERR_BLOCKED_BY_CLIENT. That is not the same exposure the HTML preview +// guards against — Chromium hands these bytes to the viewer rather than +// parsing them as a document, so the file never executes in this origin. +function FilePreviewPdf({ url, title }: FilePreviewPdfProps) { + return ( +
+