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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10286-a-patch-or-diff-file-in-a-repository-ren → main 10 commits fork 3 threads · 3 unresolved
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()`. | ||
| 65 | 65 | ## The kind registry |
| 66 | 66 | |
| 67 | 67 | `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 | |
| 69 | 69 | largest file each kind draws inline; over it, the page offers Download. |
| 70 | 70 | |
| 71 | 71 | `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`. | ||
| 108 | 108 | mirrored origin), the extension decides (`claimedMedia`) and the media |
| 109 | 109 | route confirms the bytes, serving a contradicted file as |
| 110 | 110 | `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 | |
| 112 | 118 | 1 MB ceiling: over it the page says "over the render cap" and offers |
| 113 | 119 | Download. |
| 114 | 120 | - 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"; | ||
| 47 | 47 | import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts"; |
| 48 | 48 | import { canAdmin, canWrite, DECISIONS_BY, getRepoById, type Repo } from "../../core/repos.ts"; |
| 49 | 49 | 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"; | |
| 50 | 52 | import { fileKind, NO_BYTES } from "../../web/render/kinds.ts"; |
| 51 | 53 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| 52 | 54 | import { detectLanguage, highlightLines } from "../../web/highlight.ts"; |
| @@ -284,7 +286,7 @@ export function buildLines( | ||
| 284 | 286 | type TabMode = "view" | "edit"; |
| 285 | 287 | type FindMode = "off" | "find" | "replace"; |
| 286 | 288 | /** 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"; | |
| 288 | 290 | |
| 289 | 291 | /** `.csv` and `.tsv`: files that read as a table. */ |
| 290 | 292 | function isTablePath(path: string): boolean { |
| @@ -318,6 +320,11 @@ function isMarkdownPath(path: string): boolean { | ||
| 318 | 320 | return fileKind(name, NO_BYTES) === "markdown" || /^readme(\.|$)/i.test(name); |
| 319 | 321 | } |
| 320 | 322 | |
| 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 | ||
| 321 | 328 | class Tab { |
| 322 | 329 | loading = true; |
| 323 | 330 | /** Non-empty when there is a reason the bytes are not on screen. */ |
| @@ -357,6 +364,8 @@ class Tab { | ||
| 357 | 364 | /** The rendered document, and the width it was wrapped for. */ |
| 358 | 365 | doc: Line[] = []; |
| 359 | 366 | docPlain: string[] = []; |
| 367 | /** The parsed diff of a patch file; null when the text is not a git diff. */ | |
| 368 | patch: Diff | null = null; | |
| 360 | 369 | docWidth = 0; |
| 361 | 370 | /** `s` on a CSV/TSV: the aligned table, or the source behind it. */ |
| 362 | 371 | asTable = true; |
| @@ -374,12 +383,14 @@ class Tab { | ||
| 374 | 383 | |
| 375 | 384 | get kind(): TabKind { |
| 376 | 385 | if (this.image !== null) return "image"; |
| 386 | if (isPatchPath(this.path)) return "patch"; | |
| 377 | 387 | return isMarkdownPath(this.path) ? "markdown" : "code"; |
| 378 | 388 | } |
| 379 | 389 | |
| 380 | 390 | /** True when the pane is showing prose rather than the bytes behind it. */ |
| 381 | 391 | 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); | |
| 383 | 394 | } |
| 384 | 395 | |
| 385 | 396 | /** True when the pane is showing a CSV/TSV as columns. */ |
| @@ -403,7 +414,10 @@ class Tab { | ||
| 403 | 414 | document(width: number, c: Tokens): void { |
| 404 | 415 | if (this.docWidth === width && this.doc.length > 0) return; |
| 405 | 416 | 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); | |
| 407 | 421 | this.docPlain = this.doc.map((line) => |
| 408 | 422 | line.map((span) => span.text).join(""), |
| 409 | 423 | ); |
| @@ -440,7 +454,12 @@ class Tab { | ||
| 440 | 454 | this.lines = []; |
| 441 | 455 | return; |
| 442 | 456 | } |
| 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; | |
| 444 | 463 | this.plain = built.plain; |
| 445 | 464 | this.lines = built.lines; |
| 446 | 465 | this.unread = 0; |
| @@ -1457,7 +1476,8 @@ export function workspaceScreen( | ||
| 1457 | 1476 | binding(["ctrl+c", "ctrl+x", "ctrl+v"], "copy / cut / paste"), |
| 1458 | 1477 | ]; |
| 1459 | 1478 | const two = |
| 1460 | t !== undefined && (t.image !== null || isMarkdownPath(t.path)); | |
| 1479 | t !== undefined && | |
| 1480 | (t.image !== null || isMarkdownPath(t.path) || t.patch !== null); | |
| 1461 | 1481 | return [ |
| 1462 | 1482 | ...(t?.table == null |
| 1463 | 1483 | ? [] |
| @@ -2257,7 +2277,7 @@ export function workspaceScreen( | ||
| 2257 | 2277 | t.showImage = !t.showImage; |
| 2258 | 2278 | return; |
| 2259 | 2279 | } |
| 2260 | if (t.kind !== "markdown" || t.mode === "edit") { | |
| 2280 | if ((t.kind !== "markdown" && t.kind !== "patch") || t.mode === "edit") { | |
| 2261 | 2281 | ctx.toast("nothing else to show for this file", "attn"); |
| 2262 | 2282 | return; |
| 2263 | 2283 | } |
src/web/render/kinds.ts +4
| @@ -24,6 +24,7 @@ export type FileKind = | ||
| 24 | 24 | | "archive" |
| 25 | 25 | | "config" |
| 26 | 26 | | "markdown" |
| 27 | | "patch" | |
| 27 | 28 | | "log" |
| 28 | 29 | | "subtitles" |
| 29 | 30 | | "text" |
| @@ -49,6 +50,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 49 | 50 | archive: 50 * MB, |
| 50 | 51 | config: 1 * MB, |
| 51 | 52 | markdown: 1 * MB, |
| 53 | patch: 1 * MB, | |
| 52 | 54 | log: 1 * MB, |
| 53 | 55 | subtitles: 5 * MB, |
| 54 | 56 | text: 1 * MB, |
| @@ -146,6 +148,8 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 146 | 148 | md: "markdown", |
| 147 | 149 | markdown: "markdown", |
| 148 | 150 | mdx: "markdown", |
| 151 | patch: "patch", | |
| 152 | diff: "patch", | |
| 149 | 153 | log: "log", |
| 150 | 154 | ansi: "log", |
| 151 | 155 | 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"); | |
| 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: |
||
| 39 | } | |
| 40 | const mail = mailHeader(text); | |
outdated
Hand-built HTML string joined and passed through raw(). Build it with the html` |
||
| 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"; | ||
| 15 | 15 | import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts"; |
| 16 | 16 | import { log, hasEscape } from "./log.ts"; |
| 17 | 17 | import { markdown } from "./markdown.ts"; |
| 18 | import { patch } from "./patch.ts"; | |
| 18 | 19 | import { pdf } from "./pdf.ts"; |
| 19 | 20 | import { sqlite } from "./sqlite.ts"; |
| 20 | 21 | import { subtitles } from "./subtitles.ts"; |
| @@ -61,8 +62,8 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 61 | 62 | audio, |
| 62 | 63 | video, |
| 63 | 64 | config, |
| 65 | patch, | |
| 64 | 66 | log, |
| 65 | ||
| 66 | 67 | subtitles, |
| 67 | 68 | archive, |
| 68 | 69 | }; |
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 <[email protected]></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("<b>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 | ||
| 13 | ||
| 14 | ||
| 15 | ||
| 16 | @@ -1,3 +1,4 @@ | |
| 17 | -export function greet(): string { | |
| 18 | - return "hello"; | |
| 19 | ||
| 20 | ||
| 21 | ||
| 22 | } | |
| 23 | ||
| 24 | ||
| 25 | ||
| 26 | ||
| 27 | ||
| 28 | @@ -0,0 +1,2 @@ | |
| 29 | ||
| 30 | ||
| 31 | -- | |
| 32 | 2.43.0 |
test/render-kinds.test.ts +2
| @@ -58,6 +58,8 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 58 | 58 | md: [enc("# a\n"), "markdown", "application/octet-stream"], |
| 59 | 59 | markdown: [enc("# a\n"), "markdown", "application/octet-stream"], |
| 60 | 60 | 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"], | |
| 61 | 63 | log: [enc("ok\n"), "log", "application/octet-stream"], |
| 62 | 64 | ansi: [enc("\x1b[31mok\n"), "log", "application/octet-stream"], |
| 63 | 65 | 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("<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 <[email protected]></dd>"); | |
| 57 | expect(out).toContain("<dt class=\"muted\">Subject</dt><dd>[PATCH] <b>Fix</b> 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", () => { | ||
| 309 | 309 | } |
| 310 | 310 | }); |
| 311 | 311 | |
| 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 | ||
| 312 | 353 | test("a .srt opens as a Start/Text table sharing the web parser, and s flips to the source", async () => { |
| 313 | 354 | const made = await createRepo(forge, alice, { |
| 314 | 355 | name: "captions", |
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. Useline.slice(f.length + 2).slice(0, 500)(or.slice(f.length + 2, f.length + 2 + 500)). Add a test with a 600-char Subject.