diff --git a/architecture/webapp-frontend.md b/architecture/webapp-frontend.md index 5ebf598..b1b0f62 100644 --- a/architecture/webapp-frontend.md +++ b/architecture/webapp-frontend.md @@ -62,9 +62,9 @@ classDiagram +useConfig +useHub +useBrowse - +useBlobText (sha-keyed, immutable) + +useTextAt (any URL) → useBlobText (sha-keyed, immutable) } - note for hooks "TanStack Query wrappers over the viewer APIs" + note for hooks "TanStack Query wrappers over the viewer APIs. useTextAt fetches any URL and sniffs it — the Content-Length cheap-out lives here (HTTP), the byte decision in lib/sniff.ts (pure). A live path must not be cached immutable; a sha can be" class components { FileView FolderListing FileTree @@ -78,6 +78,7 @@ classDiagram class lib { +diff.ts splitLines lcsDiff diffText + +sniff.ts sniffBytes BlobText MAX_BYTES +utils.ts } note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package" @@ -92,6 +93,7 @@ classDiagram HubApp --> components components --> nav : linkProps navigate components --> lib : diffText + hooks --> lib : sniffBytes hooks --> api Browser --> hooks HubApp --> hooks diff --git a/internal/webapp/frontend/e2e/browse.spec.ts b/internal/webapp/frontend/e2e/browse.spec.ts index 9c7f9ca..c0dceef 100644 --- a/internal/webapp/frontend/e2e/browse.spec.ts +++ b/internal/webapp/frontend/e2e/browse.spec.ts @@ -564,3 +564,91 @@ test("the folder change feed carries the version controls too", async ({ page }) await page.waitForURL(new RegExp(`/${pid}/notes/.*\\?v=[0-9a-f]{64}$`)); await expect(page.locator(".vbanner")).toBeVisible(); }); + +// BEA-44: the viewer used to decide on the extension, so every extensionless +// file an agent wrote — Dockerfile, LICENSE, .bdriveignore — hit a dead +// "No preview" card. It decides on the bytes now, and renders PDFs. + +test("an extensionless UTF-8 file previews as text", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + await page.request.put(`/api/p/${pid}/upload/content?path=sniff/Dockerfile`, { + data: "FROM alpine\nRUN apk add --no-cache curl\n", + }); + await page.goto(`/${pid}/sniff/Dockerfile`); + await expect(page.locator("#content pre.plain")).toContainText("RUN apk add --no-cache curl"); +}); + +test("an unlisted extension previews as text too", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + await page.request.put(`/api/p/${pid}/upload/content?path=sniff/main.tf`, { + data: 'resource "aws_s3_bucket" "b" {}\n', + }); + await page.goto(`/${pid}/sniff/main.tf`); + await expect(page.locator("#content pre.plain")).toContainText("aws_s3_bucket"); +}); + +test("binary bytes get the no-preview card, never dumped into the page", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + await page.request.put(`/api/p/${pid}/upload/content?path=sniff/model.bin`, { + data: Buffer.from([0x89, 0x50, 0x00, 0x01, 0x02, 0xff, 0xfe]), + }); + await page.goto(`/${pid}/sniff/model.bin`); + const card = page.locator("#content .filecard"); + await expect(card).toContainText("No preview for this file type."); + await expect(page.locator("#content pre.plain")).toHaveCount(0); + await expect(card.getByRole("link", { name: "Download" })).toHaveAttribute( + "href", + /download\?path=sniff%2Fmodel\.bin$/, + ); +}); + +test("a text file past the 1 MB cap says so instead of loading it", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + await page.request.put(`/api/p/${pid}/upload/content?path=sniff/huge.dat`, { + data: "x".repeat((1 << 20) + 1024), + }); + await page.goto(`/${pid}/sniff/huge.dat`); + await expect(page.locator("#content .filecard")).toContainText(/Too large to preview \(1\.0 MB\)/); +}); + +test("a pdf renders in the browser's viewer, in the wide column", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + // Smallest thing Chromium's viewer accepts; the assertion is the frame, + // not the glyphs. + const pdf = + "%PDF-1.4\n1 0 obj<>endobj\n" + + "2 0 obj<>endobj\n" + + "3 0 obj<>endobj\n" + + "trailer<>\n%%EOF\n"; + await page.request.put(`/api/p/${pid}/upload/content?path=sniff/report.pdf`, { data: pdf }); + await page.goto(`/${pid}/sniff/report.pdf`); + const frame = page.locator("#content iframe.pdfview"); + await expect(frame).toBeVisible(); + await expect(frame).toHaveAttribute("src", /file\?path=sniff%2Freport\.pdf$/); + // No sandbox attribute: the PDF viewer is not this page's JS realm, and + // sandboxing without allow-same-origin breaks Firefox's pdf.js. + await expect(frame).not.toHaveAttribute("sandbox", /./); + await expect(page.locator(".page.wide")).toBeVisible(); +}); + +test("an old version of an extensionless file previews the same way", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + const url = `/api/p/${pid}/upload/content?path=sniff/LICENSE`; + await page.request.put(url, { data: "MIT License — the first draft.\n" }); + await page.request.put(url, { data: "Apache 2.0 — the second draft.\n" }); + await page.goto(`/${pid}/history/sniff/LICENSE`); + const older = page.locator(".hentry.add"); + await expect(older).toBeVisible(); + await older.getByRole("button", { name: /^Open .* as of/ }).click(); + await page.waitForURL(new RegExp(`/${pid}/sniff/LICENSE\\?v=[0-9a-f]{64}$`)); + await expect(page.locator("#content pre.plain")).toContainText("the first draft"); + // A bad sha still explains itself rather than previewing nothing. + await page.goto(`/${pid}/sniff/LICENSE?v=${"0".repeat(64)}`); + await expect(page.locator("#content .empty")).toContainText("That version isn't available."); +}); diff --git a/internal/webapp/frontend/src/apps/Browser.tsx b/internal/webapp/frontend/src/apps/Browser.tsx index 33b530a..84e4953 100644 --- a/internal/webapp/frontend/src/apps/Browser.tsx +++ b/internal/webapp/frontend/src/apps/Browser.tsx @@ -14,7 +14,7 @@ import { useHeat, useTree } from "../hooks/useBrowse"; import { useShares } from "../hooks/useHub"; import { urlForPath, urlForView, type Route } from "../router"; import { currentNavType, navigate, useLocationPath } from "../nav"; -import { HTML_EXT, copyText } from "../util"; +import { HTML_EXT, PDF_EXT, copyText } from "../util"; import { toast } from "../toast"; import { onSearchRequest } from "../search"; import { track } from "../analytics"; @@ -352,7 +352,8 @@ export default function Browser(props: { /> ); } else { - pageWidth = HTML_EXT.test(path) ? "wide" : "read"; + // A PDF page is unreadable squeezed into the 768px reading column. + pageWidth = HTML_EXT.test(path) || PDF_EXT.test(path) ? "wide" : "read"; pageClass = "markdown"; view = ( <> diff --git a/internal/webapp/frontend/src/components/FileView.tsx b/internal/webapp/frontend/src/components/FileView.tsx index 9927519..35345cf 100644 --- a/internal/webapp/frontend/src/components/FileView.tsx +++ b/internal/webapp/frontend/src/components/FileView.tsx @@ -3,7 +3,8 @@ import { useQuery } from "@tanstack/react-query"; import { getJSON } from "../api/http"; import type { HeatMap, Node, RenderDoc } from "../api/types"; import { heatTotal, heatText } from "../hooks/useBrowse"; -import { HTML_EXT, IMG_EXT, MD_EXT, TEXT_EXT, joinPath } from "../util"; +import { useTextAt } from "../hooks/useBlob"; +import { HTML_EXT, IMG_EXT, MD_EXT, PDF_EXT, TEXT_EXT, humanSize, joinPath } from "../util"; export function FileView(props: { apiBase: string; @@ -41,15 +42,69 @@ export function FileView(props: { /> ); } + if (PDF_EXT.test(path)) { + // The browser's own viewer, streaming — no byte cap needed, nothing is + // held in JS memory. Deliberately NOT sandboxed: the PDF viewer is not + // this page's JS realm, so it can't reach the hub API or its cookies, + // and sandbox without allow-same-origin breaks Firefox's pdf.js. + return