From 951c6b9de258e8e8411cb9e6bed0db35f67b60a1 Mon Sep 17 00:00:00 2001 From: "Snow Lee (Sungwon)" Date: Thu, 30 Jul 2026 16:25:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(viewer):=20preview=20text=20by=20its=20byt?= =?UTF-8?q?es,=20not=20its=20extension=20=E2=80=94=20and=20render=20PDFs?= =?UTF-8?q?=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer decided what to preview from a filename regex, so every extensionless file an agent writes — Dockerfile, LICENSE, .bdriveignore — and every unlisted extension (main.tf, schema.graphql) hit a dead "No preview for this file type." card. That bites hardest in the core use case: an agent writes an artifact, a teammate opens the hub to read it, and the hub refuses. The unmatched path now fetches once and decides on the bytes. The logic already existed for the history diff — 1 MB cap, Content-Length cheap-out, 8 KB NUL scan, fatal UTF-8 decode — so the pure half moves to lib/sniff.ts (importable by node --test, no React Query) and both DiffView and FileView call it. Exactly one sniffer, per the spec. .pdf gets the browser's own viewer in an iframe, in the wide page column (768px is unreadable for a PDF page). No sandbox attribute, deliberately: the PDF viewer is not this page's JS realm, so it cannot reach the hub API or its cookies, and sandboxing without allow-same-origin breaks Firefox's pdf.js. Files that already previewed (md/html/img/.txt) are untouched and issue no extra fetch. BEA-44 --- architecture/webapp-frontend.md | 6 +- internal/webapp/frontend/e2e/browse.spec.ts | 88 +++++++++++++++++++ internal/webapp/frontend/src/apps/Browser.tsx | 5 +- .../frontend/src/components/FileView.tsx | 61 ++++++++++++- internal/webapp/frontend/src/hooks/useBlob.ts | 52 +++++------ .../webapp/frontend/src/lib/sniff.test.ts | 44 ++++++++++ internal/webapp/frontend/src/lib/sniff.ts | 23 +++++ internal/webapp/frontend/src/style.css | 2 +- internal/webapp/frontend/src/util.ts | 1 + .../{index-D89jvKKO.js => index-CJtVvPOm.js} | 28 +++--- ...{index-ozNOEdCg.css => index-ld3LwbeV.css} | 2 +- internal/webapp/static/index.html | 4 +- 12 files changed, 262 insertions(+), 54 deletions(-) create mode 100644 internal/webapp/frontend/src/lib/sniff.test.ts create mode 100644 internal/webapp/frontend/src/lib/sniff.ts rename internal/webapp/static/assets/{index-D89jvKKO.js => index-CJtVvPOm.js} (60%) rename internal/webapp/static/assets/{index-ozNOEdCg.css => index-ld3LwbeV.css} (98%) 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