Public: anyone can read this; the crew can change it.
A source file over 1,500 lines shows its first 1,500, then "N more lines" with links to the whole file and the raw file #6804
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6783-a-source-file-over-1-500-lines-shows-its → main 4 commits fork 1 thread · 1 unresolved
6 files
changed +70
−19
docs/STATUS.md +2 −2
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | ## What works today |
| 2 | 2 | |
| 3 | - **A long file's page** draws its first 1,000 lines and says "Showing 1,000 of | |
| 4 | N lines", with *Show all lines* (`?all=1`) and the raw file. `?all=1` stops at | |
| 3 | - **A long file's page** draws its first 1,500 lines and says "N more | |
| 4 | lines", with *Show all lines* (`?all=1`) and the raw file. `?all=1` stops at | |
| 5 | 5 | 10,000 lines (it was 5,000 for every view before); past that, the raw file is |
| 6 | 6 | the way. Highlighted lines are cached by content hash (32 files, least |
| 7 | 7 | recently used out). A `#L…` anchor past the cut is redirected to `?all=1` by |
src/web/pages/browse.ts +1
| @@ -514,6 +514,7 @@ export async function browseBlob(ctx: Ctx): Promise<Response> { | ||
| 514 | 514 | path, |
| 515 | 515 | blob: blob.value, |
| 516 | 516 | view: blobView(ctx.url.searchParams.get("view")), |
| 517 | all: ctx.url.searchParams.get("all") === "1", | |
| 517 | 518 | }); |
| 518 | 519 | if (ctx.url.searchParams.get("fragment") === "1") |
| 519 | 520 | return fragment(pane, { request: ctx.request }); |
src/web/pages/repo.ts +3 −3
| @@ -93,7 +93,7 @@ const MAX_RENDER_BYTES = 1_000_000; | ||
| 93 | 93 | // A blob under the byte cap can still be pathological (a megabyte of newlines |
| 94 | 94 | // is ~490k lines). Bounding rendered lines bounds the response independently. |
| 95 | 95 | /** Lines a file page draws before it links the rest (`?all=1` draws every one). */ |
| 96 | const FIRST_LINES = 1_000; | |
| 96 | const FIRST_LINES = 1_500; | |
| 97 | 97 | const MAX_RENDER_LINES = 10_000; |
| 98 | 98 | |
| 99 | 99 | import { pathCrumbs, refMenu, refSetFrom, type RefSet } from "./refs.ts"; |
| @@ -670,7 +670,7 @@ export function blobBody( | ||
| 670 | 670 | ${when(truncated, () => |
| 671 | 671 | empty({ |
| 672 | 672 | icon: "file", |
| 673 | title: `Showing ${lines.length.toLocaleString()} of ${total.toLocaleString()} lines`, | |
| 673 | title: `${(total - lines.length).toLocaleString("en-US")} more lines`, | |
| 674 | 674 | note: all |
| 675 | 675 | ? "The rest is in the raw file." |
| 676 | 676 | : "The page draws the first lines so it opens at once. A link to a later line (#L…) lands once you show all lines.", |
| @@ -1012,7 +1012,7 @@ export function renderBlob( | ||
| 1012 | 1012 | view?: BlobView; |
| 1013 | 1013 | /** Told how long highlighting took, for Server-Timing. */ |
| 1014 | 1014 | highlighted?: (ms: number) => void; |
| 1015 | /** Draw every line instead of the first thousand. */ | |
| 1015 | /** Draw every line instead of the first 1,500. */ | |
| 1016 | 1016 | all?: boolean; |
| 1017 | 1017 | }, |
| 1018 | 1018 | ): Html { |
test/blob-long-file.test.ts +24 −10
| @@ -1,4 +1,4 @@ | ||
| 1 | /** A long file's page draws its first 1,000 lines and links the rest. */ | |
| 1 | /** A long file's page draws its first 1,500 lines and links the rest. */ | |
| 2 | 2 | import { describe, expect, test } from "bun:test"; |
| 3 | 3 | import { blobBody } from "../src/web/pages/repo.ts"; |
| 4 | 4 | import { highlightPrefix } from "../src/web/code.ts"; |
| @@ -16,13 +16,15 @@ const draw = (all = false) => | ||
| 16 | 16 | const rows = (h: string) => h.split('<tr id="L').length - 1; |
| 17 | 17 | |
| 18 | 18 | describe("long file page", () => { |
| 19 | test("default draws 1,000 of 7,000 lines, small and fast", () => { | |
| 19 | test("default draws 1,500 of 7,000 lines, small and fast", () => { | |
| 20 | 20 | const page = String(draw().content); |
| 21 | expect(rows(page)).toBe(1_000); | |
| 22 | expect(page).toContain("Showing 1,000 of 7,000 lines"); | |
| 21 | expect(rows(page)).toBe(1_500); | |
| 22 | expect(page).toContain("5,500 more lines"); | |
| 23 | 23 | expect(page).toContain("/alice/app/blob/main/src/big.ts?all=1"); |
| 24 | 24 | expect(page).toContain("/alice/app/raw/main/src/big.ts"); |
| 25 | expect(new TextEncoder().encode(page).length).toBeLessThan(400_000); | |
| 25 | // Worst case: every line is dense with token spans (~546 KB, ~25 KB | |
| 26 | // gzipped). Ordinary files weigh far less (hardening.test.ts holds 400 KB). | |
| 27 | expect(new TextEncoder().encode(page).length).toBeLessThan(600_000); | |
| 26 | 28 | expect(page).toContain('id="code-cut"'); |
| 27 | 29 | }); |
| 28 | 30 | |
| @@ -32,17 +34,29 @@ describe("long file page", () => { | ||
| 32 | 34 | expect(page).not.toContain("Show all lines"); |
| 33 | 35 | }); |
| 34 | 36 | |
| 37 | test("?all=1 past the 10,000-line ceiling says what is left and points to raw", () => { | |
| 38 | const huge = new TextEncoder().encode("x\n".repeat(12_000)); | |
| 39 | const page = String( | |
| 40 | blobBody("/alice/app", "main", "big.txt", { kind: "text", body: huge, size: huge.length }, undefined, true) | |
| 41 | .content, | |
| 42 | ); | |
| 43 | expect(rows(page)).toBe(10_000); | |
| 44 | expect(page).toContain("2,000 more lines"); | |
| 45 | expect(page).toContain("The rest is in the raw file."); | |
| 46 | expect(page).not.toContain("Show all lines"); | |
| 47 | }); | |
| 48 | ||
| 35 | 49 | test("a repeat view returns the cached highlight", () => { |
| 36 | expect(highlightPrefix(source, "src/big.ts", 1_000)).toBe( | |
| 37 | highlightPrefix(source, "src/big.ts", 1_000), | |
| 50 | expect(highlightPrefix(source, "src/big.ts", 1_500)).toBe( | |
| 51 | highlightPrefix(source, "src/big.ts", 1_500), | |
| 38 | 52 | ); |
| 39 | 53 | }); |
| 40 | 54 | |
| 41 | 55 | test("the prefix highlights like the whole file's first lines", () => { |
| 42 | 56 | const whole = highlightPrefix(source, "src/big.ts", 7_000); |
| 43 | const part = highlightPrefix(source, "src/big.ts", 1_000); | |
| 44 | expect(part.length).toBe(1_000); | |
| 45 | expect(String(part[999])).toBe(String(whole[999])); | |
| 57 | const part = highlightPrefix(source, "src/big.ts", 1_500); | |
| 58 | expect(part.length).toBe(1_500); | |
| 59 | expect(String(part[1_499])).toBe(String(whole[1_499])); | |
| 46 | 60 | }); |
| 47 | 61 | |
| 48 | 62 | test("an anchor past the cut is sent to the page that has the line", () => { |
test/blob-window.test.ts added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | /** A text file over 1,500 lines draws its first 1,500 and links the rest. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { blobBody } from "../src/web/pages/repo.ts"; | |
| 4 | import type { Blob } from "../src/view/source.ts"; | |
| 5 | ||
| 6 | const make = (n: number): Blob => { | |
| 7 | const body = new TextEncoder().encode( | |
| 8 | Array.from({ length: n }, (_, i) => `const v${i} = ${i};`).join("\n"), | |
| 9 | ); | |
| 10 | return { kind: "text", body, size: body.length }; | |
| 11 | }; | |
| 12 | const draw = (n: number, all = false) => | |
| 13 | String(blobBody("/a/b", "main", "x.ts", make(n), undefined, all).content); | |
| 14 | const rows = (h: string) => h.split('<tr id="L').length - 1; | |
| 15 | ||
| 16 | describe("blob window", () => { | |
| 17 | test("5,000 lines: 1,500 rows, a more-lines link, under 500 KB", () => { | |
| 18 | const page = draw(5_000); | |
| 19 | expect(rows(page)).toBe(1_500); | |
| 20 | expect(page).toContain("3,500 more lines"); | |
| 21 | expect(page).toContain("/a/b/blob/main/x.ts?all=1"); | |
| 22 | expect(page).toContain("/a/b/raw/main/x.ts"); | |
| 23 | expect(new TextEncoder().encode(page).length).toBeLessThan(500_000); | |
| 24 | }); | |
| 25 | test("?all=1 draws all 5,000", () => { | |
| 26 | const page = draw(5_000, true); | |
| 27 | expect(rows(page)).toBe(5_000); | |
| 28 | expect(page).not.toContain("more lines"); | |
| 29 | }); | |
| 30 | test("1,500 lines has no link", () => { | |
| 31 | const page = draw(1_500); | |
| 32 | expect(rows(page)).toBe(1_500); | |
| 33 | expect(page).not.toContain("more lines"); | |
| 34 | expect(page).not.toContain("?all=1"); | |
| 35 | }); | |
| 36 | }); |
test/hardening.test.ts +4 −4
| @@ -249,9 +249,9 @@ describe("running-forge attacks", () => { | ||
| 249 | 249 | |
| 250 | 250 | test("blob rendering caps line count (C-1)", async () => { |
| 251 | 251 | const html = await (await fetch(`${base}/t/r/blob/main/many.txt`)).text(); |
| 252 | expect(html).toContain('id="L1000"'); | |
| 253 | expect(html).not.toContain('id="L1001"'); // the first thousand only | |
| 254 | expect(html).toContain("Showing 1,000 of"); | |
| 252 | expect(html).toContain('id="L1500"'); | |
| 253 | expect(html).not.toContain('id="L1501"'); // the first 1,500 only | |
| 254 | expect(html).toContain("18,500 more lines"); | |
| 255 | 255 | expect(html).toContain("?all=1"); |
| 256 | 256 | expect(html.length).toBeLessThan(400_000); |
| 257 | 257 | // Even ?all=1 stops at a ceiling: the 20k-line file is not drawn whole. |
| @@ -260,7 +260,7 @@ describe("running-forge attacks", () => { | ||
| 260 | 260 | ).text(); |
| 261 | 261 | expect(all).toContain('id="L10000"'); |
| 262 | 262 | expect(all).not.toContain('id="L10001"'); |
| 263 | expect(all).toContain("Showing 10,000 of"); | |
| 263 | expect(all).toContain("10,000 more lines"); | |
| 264 | 264 | }); |
| 265 | 265 | |
| 266 | 266 | test("oversized blob is refused by stat, not read into memory (C-2)", async () => { |
new above
offline
Keep the size bound at 400_000, or justify the heavier page in the pull. Loosening the guard to fit the change hides a first-paint cost.