import type { Database } from "bun:sqlite"; import type { Hono } from "hono"; import { isText } from "../describe/describe.ts"; import { type FileEntry, filesAt, flagReason, objectBytes, type Save, saveByPrefix, visibleParent, } from "../mirror/queries.ts"; import { diffLines, type Edit, hunks } from "./diff.ts"; import { highlightLines } from "./highlight.ts"; import { type Html, html } from "./html.ts"; import { cacheFor, formatDate, notFound, openPublicRepo, type PageContext, page, pathUrl, plural, relativeTime, } from "./layout.ts"; // A side over the file viewer's 1 MB is not diffed; past 256 KB it is shown without highlighting. const DIFF_MAX_BYTES = 1024 * 1024; const HIGHLIGHT_MAX_BYTES = 256 * 1024; // A file's diff longer than this starts collapsed; once the page has shown this many lines, the // remaining files are listed without their diffs so one huge save can't make a huge page. const COLLAPSE_LINES = 300; const PAGE_MAX_LINES = 5000; type Change = { path: string; status: "added" | "removed" | "modified"; before: FileEntry | null; after: FileEntry | null; }; type FileDiff = Change & { kind: "text" | "binary" | "large"; oldText: string; newText: string; edits: Edit[]; added: number; removed: number; }; export function registerSavePage(app: Hono, ctx: PageContext): void { app.get("/:repo/saves/:id", async (c) => { const repo = openPublicRepo(ctx, c.req.param("repo")); const id = c.req.param("id"); const save = repo && saveByPrefix(repo.mirror, id); // Checkpoints never appear (§21.4), so a link to one finds nothing, like an unknown ID. if (!repo || !save || save.is_checkpoint) return notFound(c, ctx); const name = repo.settings.name; if (save.id !== id) return c.redirect(`/${name}/saves/${save.id}`, 302); const parent = visibleParent(repo.mirror, save); const diffs = changes(repo.mirror, save, parent).map((change) => fileDiff(repo.mirror, change)); // Not a year like other per-save content: a flag or a strip can arrive for this save later. cacheFor(c, false); const [title = "", ...rest] = save.message.split("\n"); return c.html( page(ctx, { title: `${title} · ${name}`, description: `${save.id} · ${plural(diffs.length, "file")} changed`, path: `/${name}/saves/${save.id}`, body: html`
${saveHeader(repo.mirror, name, save, parent, title, rest.join("\n").trim())}no files changed
` : diffs.map((d, i) => changeRow(d, i)) }${save.id}
${ reason !== null && html`flagged ${reason || "no reason given"}
` }↓ ${name}-${save.id.slice(0, 8)}.tar.gzatlas at this save
`; } function changes(db: Database, save: Save, parent: Save | null): Change[] { const before = new Map(parent ? filesAt(db, parent.id).map((f) => [f.path, f]) : []); const after = new Map(filesAt(db, save.id).map((f) => [f.path, f])); const paths = [...new Set([...before.keys(), ...after.keys()])].sort(); return paths.flatMap((path) => { const old = before.get(path) ?? null; const now = after.get(path) ?? null; if (old && now && old.hash === now.hash && old.executable === now.executable) return []; const status = !old ? "added" : !now ? "removed" : "modified"; return [{ path, status, before: old, after: now }]; }); } function fileDiff(db: Database, change: Change): FileDiff { const empty = { oldText: "", newText: "", edits: [], added: 0, removed: 0 }; const sides = [change.before, change.after]; if (sides.some((f) => f && f.size > DIFF_MAX_BYTES)) return { ...change, ...empty, kind: "large" }; const [oldBytes, newBytes] = sides.map((f) => (f && objectBytes(db, f.hash)) ?? new Uint8Array()); if (!isText(oldBytes as Uint8Array) || !isText(newBytes as Uint8Array)) { return { ...change, ...empty, kind: "binary" }; } const oldText = new TextDecoder().decode(oldBytes); const newText = new TextDecoder().decode(newBytes); const edits = diffLines(compared(oldText), compared(newText)); const added = edits.filter((e) => e.kind === "+").length; const removed = edits.filter((e) => e.kind === "-").length; return { ...change, kind: "text", oldText, newText, edits, added, removed }; } function lines(text: string): string[] { return text === "" ? [] : text.replace(/\n$/, "").split("\n"); } // A last line without its newline differs from the same line with one, as in GNU diff: a newline // can never occur inside a split line, so it marks the ending for the comparison alone. function compared(text: string): string[] { const split = lines(text); if (split.length > 0 && !text.endsWith("\n")) split[split.length - 1] += "\n"; return split; } function stat(d: FileDiff): Html { if (d.kind !== "text") return html`${d.kind}`; return html`+${d.added} −${d.removed}`; } function changeRow(d: FileDiff, index: number): Html { return html`${d.path}${stat(d)}`; } async function diffSections( name: string, save: Save, parent: Save | null, diffs: FileDiff[], ): Promise { let shown = 0; const sections: Html[] = []; for (const [index, d] of diffs.entries()) { const at = d.status === "removed" ? parent?.id : save.id; const view = `/${name}/files/${pathUrl(d.path)}?at=${at}`; const hunkList = hunks(d.edits); const length = hunkList.reduce((sum, h) => sum + h.edits.length + 1, 0); const mode = d.before && d.after && d.before.executable !== d.after.executable ? html`${ d.after.executable ? "now executable" : "no longer executable" }
` : ""; let content: Html | string = ""; if (d.kind === "binary") content = html`binary file, not shown
`; else if (d.kind === "large") content = html`over 1 MB, not shown
`; else if (shown + length > PAGE_MAX_LINES) { content = html`not shown here: too long for one page
`; } else if (length > 0) { shown += length; content = await diffBody(d, hunkList); } sections.push(html`${hunkList.map(
(h) => html`${h.header}${h.edits.map(row)}`,
)}