import type { Database } from "bun:sqlite"; import type { Hono } from "hono"; import { type FileEntry, filesAt, flagReason, objectBytes, type Save, saveByPrefix, visibleParent, } from "../mirror/queries.ts"; import { diffLines, type Edit, hunks } from "./diff.ts"; import { isText } from "./files.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())}
${plural( diffs.length, "file", )} ${ diffs.length === 0 ? html`

no files changed

` : diffs.map((d, i) => changeRow(d, i)) }
${await diffSections(name, save, parent, diffs)}
`, }), ); }); } function saveHeader( db: Database, name: string, save: Save, parent: Save | null, title: string, body: string, ): Html { const reason = flagReason(db, save.id); const facts: [string, Html | string][] = [ ["time", `${formatDate(save.created_at)} UTC · ${relativeTime(save.created_at)}`], ["machine", save.machine], ...(save.agent ? [["agent", save.agent] as [string, string]] : []), ...(save.session ? [["session", save.session] as [string, string]] : []), [ "parent", parent ? html`${parent.id}` : "none, the first save", ], ]; return html`

${save.id}

${ reason !== null && html`

flagged ${reason || "no reason given"}

` }

${title}

${body && html`

${body}

`}
${facts.map( ([term, value]) => html`
${term}
${value}
`, )}
`; } 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`
${ d.path }${stat(d)} ${mode}${content}

view file${d.status === "removed" && " at the parent"}

`); } return sections; } async function diffBody(d: FileDiff, hunkList: ReturnType): Promise { const [oldLines, newLines] = await Promise.all([ highlighted(d.oldText, d.path), highlighted(d.newText, d.path), ]); // The last line of a side that doesn't end in a newline, or -1 when it does. const unterminated = (text: string, count: number) => text === "" || text.endsWith("\n") ? -1 : count - 1; const oldEnd = unterminated(d.oldText, oldLines.length); const newEnd = unterminated(d.newText, newLines.length); const row = (e: Edit) => { const code = e.kind === "-" ? oldLines[e.a] : newLines[e.b]; const kind = e.kind === "+" ? "add" : e.kind === "-" ? "del" : "ctx"; const bare = (e.kind !== "+" && e.a === oldEnd) || (e.kind !== "-" && e.b === newEnd); return html`${e.kind !== "+" && e.a + 1}${e.kind !== "-" && e.b + 1}${e.kind}${code}${bare && html`\\ No newline at end of file`}`; }; return html`
${hunkList.map(
		(h) => html`${h.header}${h.edits.map(row)}`,
	)}
`; } function highlighted(text: string, path: string): Promise | Html[] { if (text.length > HIGHLIGHT_MAX_BYTES) return lines(text).map((line) => html`${line}`); return highlightLines(text, path); }