Public: anyone can read this; the crew can change it.

A .patch or .diff file in a repository renders as a coloured diff, the same way a pull's diff does #10324

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10286-a-patch-or-diff-file-in-a-repository-ren → main 10 commits fork 3 threads · 3 unresolved

  • Merged. scout/10286-a-patch-or-diff-file-in-a-repository-ren was merged into main as 9c74c0a.

Review

Sign in to review this change.
10 files changed +315 −9
docs/features/file-media.md +8 −2
@@ -65,7 +65,7 @@ media type, for `<img>`, `<video>`, `<audio>`, `<object>` and `fetch()`.
6565 ## The kind registry
6666
6767 `fileKind(path, firstBytes)` answers `image | svg | pdf | audio | video |
68 csv | model3d | config | markdown | text | binary`. `RENDER_CAP` is the
68 csv | model3d | config | markdown | patch | text | binary`. `RENDER_CAP` is the
6969 largest file each kind draws inline; over it, the page offers Download.
7070
7171 `kinds.ts` is plain data with no web dependency: the editor and the
@@ -108,7 +108,13 @@ QuickTime files are inside, for browsers that refuse `video/quicktime`.
108108 mirrored origin), the extension decides (`claimedMedia`) and the media
109109 route confirms the bytes, serving a contradicted file as
110110 `application/octet-stream`, and the player shows the note.
111 - **Text kinds** (markdown, csv, config, text) and binary keep the page's
111 - **Patch** (`.patch`, `.diff`; `render/patch.ts`): text drawn with the pull
112 page's own diff (`parseUnifiedDiff` into `renderDiff`). A `git format-patch`
113 mail header (From, Subject, Date) shows above it as a plain list of rows.
114 Text with no `diff --git` file in it (a plain `diff -u`, notes) is not
115 parsed and falls back to the text view. The terminal's file view draws the
116 same parse with its pull diff (`tui/ui/diff.ts`); `v` shows the source.
117 - **Text kinds** (markdown, csv, config, patch, text) and binary keep the page's
112118 1 MB ceiling: over it the page says "over the render cap" and offers
113119 Download.
114120 - Over `RENDER_CAP` (500 MB audio, 4 GB video) the page offers Download.
src/tui/screens/workspace.ts +26 −6
@@ -47,6 +47,8 @@ import { stopMirroring } from "../../actions/mirror_target.ts";
4747 import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts";
4848 import { canAdmin, canWrite, DECISIONS_BY, getRepoById, type Repo } from "../../core/repos.ts";
4949 import type { TreeEntry } from "../../git/read.ts";
50 import { parseUnifiedDiff, type Diff } from "../../git/diff.ts";
51 import { renderDiff as renderPatch } from "../ui/diff.ts";
5052 import { fileKind, NO_BYTES } from "../../web/render/kinds.ts";
5153 import type { RepoFacts, RepoSource } from "../../view/source.ts";
5254 import { detectLanguage, highlightLines } from "../../web/highlight.ts";
@@ -284,7 +286,7 @@ export function buildLines(
284286 type TabMode = "view" | "edit";
285287 type FindMode = "off" | "find" | "replace";
286288 /** What a path is, as far as the file pane is concerned. */
287 type TabKind = "image" | "markdown" | "code";
289 type TabKind = "image" | "markdown" | "patch" | "code";
288290
289291 /** `.csv` and `.tsv`: files that read as a table. */
290292 function isTablePath(path: string): boolean {
@@ -318,6 +320,11 @@ function isMarkdownPath(path: string): boolean {
318320 return fileKind(name, NO_BYTES) === "markdown" || /^readme(\.|$)/i.test(name);
319321 }
320322
323 /** `.patch` and `.diff`: a file the pull page's diff draws. */
324 function isPatchPath(path: string): boolean {
325 return fileKind(path.slice(path.lastIndexOf("/") + 1), NO_BYTES) === "patch";
326 }
327
321328 class Tab {
322329 loading = true;
323330 /** Non-empty when there is a reason the bytes are not on screen. */
@@ -357,6 +364,8 @@ class Tab {
357364 /** The rendered document, and the width it was wrapped for. */
358365 doc: Line[] = [];
359366 docPlain: string[] = [];
367 /** The parsed diff of a patch file; null when the text is not a git diff. */
368 patch: Diff | null = null;
360369 docWidth = 0;
361370 /** `s` on a CSV/TSV: the aligned table, or the source behind it. */
362371 asTable = true;
@@ -374,12 +383,14 @@ class Tab {
374383
375384 get kind(): TabKind {
376385 if (this.image !== null) return "image";
386 if (isPatchPath(this.path)) return "patch";
377387 return isMarkdownPath(this.path) ? "markdown" : "code";
378388 }
379389
380390 /** True when the pane is showing prose rather than the bytes behind it. */
381391 get reading(): boolean {
382 return this.kind === "markdown" && this.rendered && this.mode === "view";
392 if (!this.rendered || this.mode !== "view") return false;
393 return this.kind === "markdown" || (this.kind === "patch" && this.patch !== null);
383394 }
384395
385396 /** True when the pane is showing a CSV/TSV as columns. */
@@ -403,7 +414,10 @@ class Tab {
403414 document(width: number, c: Tokens): void {
404415 if (this.docWidth === width && this.doc.length > 0) return;
405416 this.docWidth = width;
406 this.doc = renderMarkdown(this.plain.join("\n"), Math.max(8, width), c);
417 this.doc =
418 this.patch !== null
419 ? renderPatch(this.patch, Math.max(8, width), c)
420 : renderMarkdown(this.plain.join("\n"), Math.max(8, width), c);
407421 this.docPlain = this.doc.map((line) =>
408422 line.map((span) => span.text).join(""),
409423 );
@@ -440,7 +454,12 @@ class Tab {
440454 this.lines = [];
441455 return;
442456 }
443 const built = buildLines(new TextDecoder().decode(source), this.path, c);
457 const text = new TextDecoder().decode(source);
458 const built = buildLines(text, this.path, c);
459 if (isPatchPath(this.path)) {
460 const parsed = parseUnifiedDiff(text);
461 this.patch = parsed.files.length > 0 ? parsed : null;
462 } else this.patch = null;
444463 this.plain = built.plain;
445464 this.lines = built.lines;
446465 this.unread = 0;
@@ -1457,7 +1476,8 @@ export function workspaceScreen(
14571476 binding(["ctrl+c", "ctrl+x", "ctrl+v"], "copy / cut / paste"),
14581477 ];
14591478 const two =
1460 t !== undefined && (t.image !== null || isMarkdownPath(t.path));
1479 t !== undefined &&
1480 (t.image !== null || isMarkdownPath(t.path) || t.patch !== null);
14611481 return [
14621482 ...(t?.table == null
14631483 ? []
@@ -2257,7 +2277,7 @@ export function workspaceScreen(
22572277 t.showImage = !t.showImage;
22582278 return;
22592279 }
2260 if (t.kind !== "markdown" || t.mode === "edit") {
2280 if ((t.kind !== "markdown" && t.kind !== "patch") || t.mode === "edit") {
22612281 ctx.toast("nothing else to show for this file", "attn");
22622282 return;
22632283 }
src/web/render/kinds.ts +4
@@ -24,6 +24,7 @@ export type FileKind =
2424 | "archive"
2525 | "config"
2626 | "markdown"
27 | "patch"
2728 | "log"
2829 | "subtitles"
2930 | "text"
@@ -49,6 +50,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
4950 archive: 50 * MB,
5051 config: 1 * MB,
5152 markdown: 1 * MB,
53 patch: 1 * MB,
5254 log: 1 * MB,
5355 subtitles: 5 * MB,
5456 text: 1 * MB,
@@ -146,6 +148,8 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
146148 md: "markdown",
147149 markdown: "markdown",
148150 mdx: "markdown",
151 patch: "patch",
152 diff: "patch",
149153 log: "log",
150154 ansi: "log",
151155 srt: "subtitles",
src/web/render/patch.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * A `.patch` or `.diff` file drawn with the pull page's own diff markup
3 * (`renderDiff`). A `git format-patch` mail header (From, Subject, Date)
4 * shows above it as plain lines. Text with no `diff --git` file in it is not
5 * a diff this parser reads, and falls back to the source view.
6 */
7
8 import { parseUnifiedDiff } from "../../git/diff.ts";
9 import { renderDiff } from "../diff-view.ts";
10 import { html, type Html } from "../html.ts";
11 import type { Rendered, RenderInput } from "./renderers.ts";
12
13 const MAIL_FIELDS = ["From", "Subject", "Date"] as const;
14 type Field = (typeof MAIL_FIELDS)[number];
15 /** A header value longer than this is cut: a Subject is one line, not a page. */
16 export const VALUE_CAP = 500;
17
18 const row = (label: Field, value: string): Html =>
19 html`<div class="status-row"><dt class="muted">${label}</dt><dd>${value}</dd></div>`;
20
21 /** The mail fields before the first file, in the order the file gives them. */
22 function mailHeader(text: string): [Field, string][] {
23 const end = text.search(/^diff --git /m);
24 const head = (end < 0 ? text : text.slice(0, end)).split("\n");
outdated

Bug: line.slice(f.length + 2, 500) ends at index 500, not 500 characters after the label, so a Subject loses f.length+2 characters of the cap and the stated 500-char cap is wrong. Use line.slice(f.length + 2).slice(0, 500) (or .slice(f.length + 2, f.length + 2 + 500)). Add a test with a 600-char Subject.

25 if (!/^From [0-9a-f]{7,64} /.test(head[0] ?? "")) return [];
26 const out: [Field, string][] = [];
27 for (const line of head) {
28 for (const f of MAIL_FIELDS) if (line.startsWith(`${f}: `)) out.push([f, line.slice(f.length + 2).slice(0, VALUE_CAP)]);
29 if (line === "" && out.length > 0) break;
30 }
31 return out;
32 }
33
34 export function patch(input: RenderInput): Rendered {
35 const text = new TextDecoder().decode(input.body);
36 const diff = parseUnifiedDiff(text);
37 if (diff.files.length === 0) {
38 return { content: html``, lines: 0, fallback: "This is not a git unified diff, so it is shown as text." };
outdated

Principles 6/7: card patch-mail is a box around facts. Use a dl of rows; also add the escaped labels, not the raw 'From: ...' line.

39 }
40 const mail = mailHeader(text);
outdated

Hand-built HTML string joined and passed through raw(). Build it with the html` template and a row helper (as pages/maintenance.ts fact` does) so escaping is by construction, not by remembering escapeHtml on each value. Then drop the escapeHtml/raw imports.

41 const header = mail.length === 0 ? null : html`<dl class="status-rows">${mail.map(([f, v]) => row(f, v))}</dl>`;
42 return {
43 lines: text.split("\n").length,
44 content: html`${header}${renderDiff(diff)}`,
45 };
46 }
src/web/render/renderers.ts +2 −1
@@ -15,6 +15,7 @@ import { image } from "./image.ts";
1515 import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts";
1616 import { log, hasEscape } from "./log.ts";
1717 import { markdown } from "./markdown.ts";
18 import { patch } from "./patch.ts";
1819 import { pdf } from "./pdf.ts";
1920 import { sqlite } from "./sqlite.ts";
2021 import { subtitles } from "./subtitles.ts";
@@ -61,8 +62,8 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
6162 audio,
6263 video,
6364 config,
65 patch,
6466 log,
65
6667 subtitles,
6768 archive,
6869 };
test/blob-patch-page.test.ts added +62
@@ -0,0 +1,62 @@
1 /**
2 * A `.patch` committed to a repository, opened on its blob page over HTTP by
3 * an anonymous reader: the server-rendered page (no script needed) carries the
4 * mail header and the pull page's diff markup.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { seedAdmin } from "./helpers/admin.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 describe("a .patch on its blob page", () => {
20 let dataDir = "";
21 let app: App;
22 let servers: Servers;
23 let base = "";
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-blob-patch-"));
27 app = createApp({ dataDir, port: 0 });
28 servers = await serve(app);
29 base = servers.url;
30 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-patch-passphrase" });
31 if (!a.ok) throw new Error("signup failed");
32 const created = await createRepo(app, a.value, { name: "p", visibility: "public" });
33 if (!created.ok) throw new Error("repo failed");
34 const fixture = await Bun.file(new URL("./fixtures/format-patch.patch", import.meta.url)).text();
35 const made = await commitToBranch(app, a.value, created.value, {
36 message: "a patch",
37 files: [{ path: "fix.patch", content: fixture }],
38 });
39 if (!made.ok) throw new Error("commit failed");
40 }, 60_000);
41
42 afterAll(async () => {
43 await servers?.stop();
44 app?.close();
45 await rm(dataDir, { recursive: true, force: true });
46 });
47
48 test("the page answers with the header rows and both files drawn as a diff", async () => {
49 const res = await fetch(`${base}/alice/p/blob/main/fix.patch`);
50 const text = await res.text();
51 expect(res.status).toBe(200);
52 expect(text).toContain('<dt class="muted">Subject</dt><dd>[PATCH] Greet by name, and add a notes file</dd>');
53 expect(text).toContain('<dt class="muted">From</dt><dd>Ann Example &lt;[email protected]&gt;</dd>');
54 expect(text).toContain("greet.ts");
55 expect(text).toContain("notes.md");
56 expect(text).toMatch(/class="[^"]*\badd\b/);
57 expect(text).toMatch(/class="[^"]*\bdel\b/);
58 expect(text).not.toContain("<b>names");
59 expect(text).toContain("&lt;b&gt;names");
60 expect(text).not.toContain("is not a git unified diff");
61 });
62 });
test/fixtures/format-patch.patch added +32
@@ -0,0 +1,32 @@
1 From 0123456789abcdef0123456789abcdef01234567 Mon Sep 17 00:00:00 2001
2 From: Ann Example <[email protected]>
3 Date: Mon, 1 Jan 2024 00:00:00 +0000
4 Subject: [PATCH] Greet by name, and add a notes file
5
6 The greeting now says who it greets.
7 ---
8 greet.ts | 3 ++-
9 notes.md | 2 ++
10 2 files changed, 4 insertions(+), 1 deletion(-)
11
12 diff --git a/greet.ts b/greet.ts
13 index 1111111..2222222 100644
14 --- a/greet.ts
15 +++ b/greet.ts
16 @@ -1,3 +1,4 @@
17 -export function greet(): string {
18 - return "hello";
19 +export function greet(name: string): string {
20 + // <b>names</b> arrive escaped on the page
21 + return `hello, ${name}`;
22 }
23 diff --git a/notes.md b/notes.md
24 new file mode 100644
25 index 0000000..3333333
26 --- /dev/null
27 +++ b/notes.md
28 @@ -0,0 +1,2 @@
29 +# Notes
30 +Open this file on the forge to see it drawn as a diff.
31 --
32 2.43.0
test/render-kinds.test.ts +2
@@ -58,6 +58,8 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
5858 md: [enc("# a\n"), "markdown", "application/octet-stream"],
5959 markdown: [enc("# a\n"), "markdown", "application/octet-stream"],
6060 mdx: [enc("# a\n"), "markdown", "application/octet-stream"],
61 patch: [enc("diff --git a/a b/a\n"), "patch", "application/octet-stream"],
62 diff: [enc("diff --git a/a b/a\n"), "patch", "application/octet-stream"],
6163 log: [enc("ok\n"), "log", "application/octet-stream"],
6264 ansi: [enc("\x1b[31mok\n"), "log", "application/octet-stream"],
6365 srt: [enc("1\n00:00:01,000 --> 00:00:02,000\nHi\n"), "subtitles", "application/octet-stream"],
test/render-patch.test.ts added +92
@@ -0,0 +1,92 @@
1 /** .patch and .diff drawn as the pull page's diff. */
2 import { describe, expect, test } from "bun:test";
3 import { patch, VALUE_CAP } from "../src/web/render/patch.ts";
4 import { rendererFor } from "../src/web/render/renderers.ts";
5
6 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
7 const draw = (text: string) => patch({ path: "x.patch", size: text.length, body: enc(text), media: "/m" });
8
9 const DIFF = `diff --git a/one.txt b/one.txt
10 index 1111111..2222222 100644
11 --- a/one.txt
12 +++ b/one.txt
13 @@ -1,2 +1,2 @@
14 keep
15 -old line
16 +<img src=x onerror=alert(1)>
17 diff --git a/two.txt b/two.txt
18 new file mode 100644
19 index 0000000..3333333
20 --- /dev/null
21 +++ b/two.txt
22 @@ -0,0 +1 @@
23 +hello
24 `;
25 const MAIL = `From 0123456789abcdef0123456789abcdef01234567 Mon Sep 17 00:00:00 2001
26 From: Ann <[email protected]>
27 Date: Mon, 1 Jan 2024 00:00:00 +0000
28 Subject: [PATCH] <b>Fix</b> it
29
30 body text
31
32 ---
33 one.txt | 2 +-
34 1 file changed
35
36 ${DIFF}`;
37
38 describe("patch renderer", () => {
39 test("a two-file patch has both files, hunks and marked lines", () => {
40 const r = draw(DIFF);
41 const out = String(r.content);
42 expect(r.fallback).toBeUndefined();
43 expect(out).toContain("one.txt");
44 expect(out).toContain("two.txt");
45 expect(out).toContain("2</strong> files");
46 expect(out).toMatch(/class="[^"]*\badd\b/);
47 expect(out).toMatch(/class="[^"]*\bdel\b/);
48 });
49 test("markup in a line arrives escaped", () => {
50 const out = String(draw(DIFF).content);
51 expect(out).not.toContain("<img");
52 expect(out).toContain("&lt;img");
53 });
54 test("a format-patch header shows From and Subject, escaped", () => {
55 const out = String(draw(MAIL).content);
56 expect(out).toContain("<dt class=\"muted\">From</dt><dd>Ann &lt;[email protected]&gt;</dd>");
57 expect(out).toContain("<dt class=\"muted\">Subject</dt><dd>[PATCH] &lt;b&gt;Fix&lt;/b&gt; it</dd>");
58 expect(out).not.toContain("patch-mail");
59 expect(out).not.toContain("<b>Fix");
60 expect(out.indexOf("status-rows")).toBeLessThan(out.indexOf("one.txt"));
61 });
62 test("a 600-character Subject keeps exactly the first 500 characters of its value", () => {
63 const subject = "s".repeat(VALUE_CAP) + "TAIL".repeat(25);
64 const out = String(draw(MAIL.replace("[PATCH] <b>Fix</b> it", subject)).content);
65 expect(out).toContain(`<dt class="muted">Subject</dt><dd>${"s".repeat(VALUE_CAP)}</dd>`);
66 expect(out).not.toContain("TAIL");
67 });
68 test("a mail header with no diff after it falls back", () => {
69 expect(draw(MAIL.slice(0, MAIL.indexOf("diff --git"))).fallback).toBeDefined();
70 });
71 test("text that is not a diff falls back", () => {
72 expect(draw("just some notes\n").fallback).toBeDefined();
73 });
74 test("the blob page picks it for .patch and .diff only", () => {
75 for (const name of ["a.patch", "b.DIFF"])
76 expect(rendererFor(name, { kind: "text", body: enc(DIFF), size: DIFF.length } as never)).toBeDefined();
77 expect(rendererFor("a.txt", { kind: "text", body: enc(DIFF), size: DIFF.length } as never)).toBeUndefined();
78 });
79 });
80
81 describe("the committed fixture", () => {
82 test("test/fixtures/format-patch.patch draws its header and both files", async () => {
83 const text = await Bun.file(new URL("./fixtures/format-patch.patch", import.meta.url)).text();
84 const r = draw(text);
85 const out = String(r.content);
86 expect(r.fallback).toBeUndefined();
87 expect(out).toContain("Greet by name, and add a notes file");
88 expect(out).toContain("greet.ts");
89 expect(out).toContain("notes.md");
90 expect(out).not.toContain("<b>names");
91 });
92 });
test/tui-workspace.test.ts +41
@@ -309,6 +309,47 @@ describe("the terminal workspace", () => {
309309 }
310310 });
311311
312 test("a .patch opens as the diff a pull shows, v flips to the source, a non-diff stays text", async () => {
313 const made = await createRepo(forge, alice, {
314 name: "patches",
315 visibility: "public",
316 description: "diffs",
317 });
318 if (!made.ok) throw new Error("repo failed");
319 const patch = [
320 "diff --git a/one.txt b/one.txt",
321 "--- a/one.txt",
322 "+++ b/one.txt",
323 "@@ -1,2 +1,2 @@",
324 " keep",
325 "-old line",
326 "+new line",
327 "",
328 ].join("\n");
329 await commitToBranch(forge, alice, made.value, {
330 message: "seed",
331 files: [
332 { path: "fix.patch", content: patch },
333 { path: "notes.diff", content: "just some notes\n" },
334 ],
335 });
336 const h = boot();
337 try {
338 await h.settle();
339 await open(h, "patches");
340 await openFile(h, "fix.patch");
341 expect(h.text()).toContain("one.txt");
342 expect(h.text()).toContain("new line");
343 expect(h.text()).not.toContain("diff --git");
344 await h.press("v");
345 expect(h.text()).toContain("diff --git a/one.txt b/one.txt");
346 await openFile(h, "notes.diff");
347 expect(h.text()).toContain("just some notes");
348 } finally {
349 h.stop();
350 }
351 });
352
312353 test("a .srt opens as a Start/Text table sharing the web parser, and s flips to the source", async () => {
313354 const made = await createRepo(forge, alice, {
314355 name: "captions",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs