1import type { Database } from "bun:sqlite";2import type { Hono } from "hono";3import {4 type FileEntry,5 filesAt,6 flagReason,7 objectBytes,8 type Save,9 saveByPrefix,10 visibleParent,11} from "../mirror/queries.ts";12import { diffLines, type Edit, hunks } from "./diff.ts";13import { isText } from "./files.ts";14import { highlightLines } from "./highlight.ts";15import { type Html, html } from "./html.ts";16import {17 cacheFor,18 formatDate,19 notFound,20 openPublicRepo,21 type PageContext,22 page,23 pathUrl,24 plural,25 relativeTime,26} from "./layout.ts";272829const DIFF_MAX_BYTES = 1024 * 1024;30const HIGHLIGHT_MAX_BYTES = 256 * 1024;313233const COLLAPSE_LINES = 300;34const PAGE_MAX_LINES = 5000;3536type Change = {37 path: string;38 status: "added" | "removed" | "modified";39 before: FileEntry | null;40 after: FileEntry | null;41};4243type FileDiff = Change & {44 kind: "text" | "binary" | "large";45 oldText: string;46 newText: string;47 edits: Edit[];48 added: number;49 removed: number;50};5152export function registerSavePage(app: Hono, ctx: PageContext): void {53 app.get("/:repo/saves/:id", async (c) => {54 const repo = openPublicRepo(ctx, c.req.param("repo"));55 const id = c.req.param("id");56 const save = repo && saveByPrefix(repo.mirror, id);57 58 if (!repo || !save || save.is_checkpoint) return notFound(c, ctx);59 const name = repo.settings.name;60 if (save.id !== id) return c.redirect(`/${name}/saves/${save.id}`, 302);61 const parent = visibleParent(repo.mirror, save);62 const diffs = changes(repo.mirror, save, parent).map((change) => fileDiff(repo.mirror, change));63 64 cacheFor(c, false);65 const [title = "", ...rest] = save.message.split("\n");66 return c.html(67 page(ctx, {68 title: `${title} · ${name}`,69 description: `${save.id} · ${plural(diffs.length, "file")} changed`,70 path: `/${name}/saves/${save.id}`,71 body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a> / <a href="/${name}#history">history</a></p>72${saveHeader(repo.mirror, name, save, parent, title, rest.join("\n").trim())}73<section class="repo-section">74<details class="repo-section-toggle" open>75<summary class="section-head"><span class="section-label">files changed</span><span class="section-count">${plural(diffs.length, "file")}</span></summary>76${diffs.length === 0 ? html`<p class="muted mono save-empty">no files changed</p>` : diffs.map((d, i) => changeRow(d, i))}77</details>78</section>79<section class="repo-section">${await diffSections(name, save, parent, diffs)}</section>`,80 }),81 );82 });83}8485function saveHeader(86 db: Database,87 name: string,88 save: Save,89 parent: Save | null,90 title: string,91 body: string,92): Html {93 const reason = flagReason(db, save.id);94 const facts: [string, Html | string][] = [95 ["time", `${formatDate(save.created_at)} UTC · ${relativeTime(save.created_at)}`],96 ["machine", save.machine],97 ...(save.agent ? [["agent", save.agent] as [string, string]] : []),98 ...(save.session ? [["session", save.session] as [string, string]] : []),99 [100 "parent",101 parent102 ? html`<a href="/${name}/saves/${parent.id}">${parent.id}</a>`103 : "none, the first save",104 ],105 ];106 return html`<p class="save-id mono">${save.id}</p>107${reason !== null && html`<p class="save-flag"><span class="save-flag-label mono">flagged</span> ${reason || "no reason given"}</p>`}108<h1 class="save-message-head">${title}</h1>109${body && html`<p class="save-message-body">${body}</p>`}110<dl class="save-facts mono">${facts.map(([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`)}</dl>111<p class="save-links mono"><a href="/${name}/archive/${save.id}.tar.gz">↓ ${name}-${save.id.slice(0, 8)}.tar.gz</a><a href="/${name}?at=${save.id}#atlas">atlas at this save</a></p>`;112}113114function changes(db: Database, save: Save, parent: Save | null): Change[] {115 const before = new Map(parent ? filesAt(db, parent.id).map((f) => [f.path, f]) : []);116 const after = new Map(filesAt(db, save.id).map((f) => [f.path, f]));117 const paths = [...new Set([...before.keys(), ...after.keys()])].sort();118 return paths.flatMap((path) => {119 const old = before.get(path) ?? null;120 const now = after.get(path) ?? null;121 if (old && now && old.hash === now.hash && old.executable === now.executable) return [];122 const status = !old ? "added" : !now ? "removed" : "modified";123 return [{ path, status, before: old, after: now }];124 });125}126127function fileDiff(db: Database, change: Change): FileDiff {128 const empty = { oldText: "", newText: "", edits: [], added: 0, removed: 0 };129 const sides = [change.before, change.after];130 if (sides.some((f) => f && f.size > DIFF_MAX_BYTES))131 return { ...change, ...empty, kind: "large" };132 const [oldBytes, newBytes] = sides.map((f) => (f && objectBytes(db, f.hash)) ?? new Uint8Array());133 if (!isText(oldBytes as Uint8Array) || !isText(newBytes as Uint8Array)) {134 return { ...change, ...empty, kind: "binary" };135 }136 const oldText = new TextDecoder().decode(oldBytes);137 const newText = new TextDecoder().decode(newBytes);138 const edits = diffLines(lines(oldText), lines(newText));139 const added = edits.filter((e) => e.kind === "+").length;140 const removed = edits.filter((e) => e.kind === "-").length;141 return { ...change, kind: "text", oldText, newText, edits, added, removed };142}143144function lines(text: string): string[] {145 return text === "" ? [] : text.replace(/\n$/, "").split("\n");146}147148function stat(d: FileDiff): Html {149 if (d.kind !== "text") return html`<span class="diff-note">${d.kind}</span>`;150 return html`<span class="diff-add">+${d.added}</span> <span class="diff-del">−${d.removed}</span>`;151}152153function changeRow(d: FileDiff, index: number): Html {154 return html`<a class="change-row change-${d.status}" href="#diff-${index}"><span class="change-path">${d.path}</span><span class="change-stat mono">${stat(d)}</span></a>`;155}156157async function diffSections(158 name: string,159 save: Save,160 parent: Save | null,161 diffs: FileDiff[],162): Promise<Html[]> {163 let shown = 0;164 const sections: Html[] = [];165 for (const [index, d] of diffs.entries()) {166 const at = d.status === "removed" ? parent?.id : save.id;167 const view = `/${name}/files/${pathUrl(d.path)}?at=${at}`;168 const hunkList = hunks(d.edits);169 const length = hunkList.reduce((sum, h) => sum + h.edits.length + 1, 0);170 const mode =171 d.before && d.after && d.before.executable !== d.after.executable172 ? html`<p class="diff-note mono">${d.after.executable ? "now executable" : "no longer executable"}</p>`173 : "";174 let content: Html | string = "";175 if (d.kind === "binary") content = html`<p class="diff-note mono">binary file, not shown</p>`;176 else if (d.kind === "large") content = html`<p class="diff-note mono">over 1 MB, not shown</p>`;177 else if (shown + length > PAGE_MAX_LINES) {178 content = html`<p class="diff-note mono">not shown here: too long for one page</p>`;179 } else if (length > 0) {180 shown += length;181 content = await diffBody(d, hunkList);182 }183 sections.push(html`<details class="diff-file change-${d.status}" id="diff-${index}"${length <= COLLAPSE_LINES && " open"}>184<summary class="diff-head"><span class="change-path">${d.path}</span><span class="change-stat mono">${stat(d)}</span></summary>185${mode}${content}186<p class="diff-view mono"><a href="${view}">view file${d.status === "removed" && " at the parent"}</a></p>187</details>`);188 }189 return sections;190}191192async function diffBody(d: FileDiff, hunkList: ReturnType<typeof hunks>): Promise<Html> {193 const [oldLines, newLines] = await Promise.all([194 highlighted(d.oldText, d.path),195 highlighted(d.newText, d.path),196 ]);197 const row = (e: Edit) => {198 const code = e.kind === "-" ? oldLines[e.a] : newLines[e.b];199 const kind = e.kind === "+" ? "add" : e.kind === "-" ? "del" : "ctx";200 return html`<span class="dl dl-${kind}"><span class="ln">${e.kind !== "+" && e.a + 1}</span><span class="ln">${e.kind !== "-" && e.b + 1}</span><span class="ds">${e.kind}</span><span class="lc">${code}</span></span>`;201 };202 return html`<div class="file-view"><pre class="code diff"><code>${hunkList.map(203 (h) => html`<span class="dh">${h.header}</span>${h.edits.map(row)}`,204 )}</code></pre></div>`;205}206207function highlighted(text: string, path: string): Promise<Html[]> | Html[] {208 if (text.length > HIGHLIGHT_MAX_BYTES) return lines(text).map((line) => html`${line}`);209 return highlightLines(text, path);210}