1import type { Database } from "bun:sqlite";2import type { Hono } from "hono";3import { isText } from "../describe/describe.ts";4import {5 type FileEntry,6 filesAt,7 flagReason,8 objectBytes,9 type Save,10 saveByPrefix,11 visibleParent,12} from "../mirror/queries.ts";13import { diffLines, type Edit, hunks } from "./diff.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="/${72 name73 }">${name}</a> / <a href="/${name}#history">history</a></p>74${saveHeader(repo.mirror, name, save, parent, title, rest.join("\n").trim())}75<section class="repo-section">76<details class="repo-section-toggle" open>77<summary class="section-head"><span class="section-label">files changed</span><span class="section-count">${plural(78 diffs.length,79 "file",80 )}</span></summary>81${82 diffs.length === 083 ? html`<p class="muted mono save-empty">no files changed</p>`84 : diffs.map((d, i) => changeRow(d, i))85}86</details>87</section>88<section class="repo-section">${await diffSections(name, save, parent, diffs)}</section>`,89 }),90 );91 });92}9394function saveHeader(95 db: Database,96 name: string,97 save: Save,98 parent: Save | null,99 title: string,100 body: string,101): Html {102 const reason = flagReason(db, save.id);103 const facts: [string, Html | string][] = [104 ["time", `${formatDate(save.created_at)} UTC · ${relativeTime(save.created_at)}`],105 ["machine", save.machine],106 ...(save.agent ? [["agent", save.agent] as [string, string]] : []),107 ...(save.session ? [["session", save.session] as [string, string]] : []),108 [109 "parent",110 parent111 ? html`<a href="/${name}/saves/${parent.id}">${parent.id}</a>`112 : "none, the first save",113 ],114 ];115 return html`<p class="save-id mono">${save.id}</p>116${117 reason !== null &&118 html`<p class="save-flag"><span class="save-flag-label mono">flagged</span> ${reason || "no reason given"}</p>`119}120<h1 class="save-message-head">${title}</h1>121${body && html`<p class="save-message-body">${body}</p>`}122<dl class="save-facts mono">${facts.map(123 ([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`,124 )}</dl>125<p class="save-links mono"><a href="/${126 name127 }/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>`;128}129130function changes(db: Database, save: Save, parent: Save | null): Change[] {131 const before = new Map(parent ? filesAt(db, parent.id).map((f) => [f.path, f]) : []);132 const after = new Map(filesAt(db, save.id).map((f) => [f.path, f]));133 const paths = [...new Set([...before.keys(), ...after.keys()])].sort();134 return paths.flatMap((path) => {135 const old = before.get(path) ?? null;136 const now = after.get(path) ?? null;137 if (old && now && old.hash === now.hash && old.executable === now.executable) return [];138 const status = !old ? "added" : !now ? "removed" : "modified";139 return [{ path, status, before: old, after: now }];140 });141}142143function fileDiff(db: Database, change: Change): FileDiff {144 const empty = { oldText: "", newText: "", edits: [], added: 0, removed: 0 };145 const sides = [change.before, change.after];146 if (sides.some((f) => f && f.size > DIFF_MAX_BYTES))147 return { ...change, ...empty, kind: "large" };148 const [oldBytes, newBytes] = sides.map((f) => (f && objectBytes(db, f.hash)) ?? new Uint8Array());149 if (!isText(oldBytes as Uint8Array) || !isText(newBytes as Uint8Array)) {150 return { ...change, ...empty, kind: "binary" };151 }152 const oldText = new TextDecoder().decode(oldBytes);153 const newText = new TextDecoder().decode(newBytes);154 const edits = diffLines(compared(oldText), compared(newText));155 const added = edits.filter((e) => e.kind === "+").length;156 const removed = edits.filter((e) => e.kind === "-").length;157 return { ...change, kind: "text", oldText, newText, edits, added, removed };158}159160function lines(text: string): string[] {161 return text === "" ? [] : text.replace(/\n$/, "").split("\n");162}163164165166function compared(text: string): string[] {167 const split = lines(text);168 if (split.length > 0 && !text.endsWith("\n")) split[split.length - 1] += "\n";169 return split;170}171172function stat(d: FileDiff): Html {173 if (d.kind !== "text") return html`<span class="diff-note">${d.kind}</span>`;174 return html`<span class="diff-add">+${d.added}</span> <span class="diff-del">−${d.removed}</span>`;175}176177function changeRow(d: FileDiff, index: number): Html {178 return html`<a class="change-row change-${179 d.status180 }" href="#diff-${index}"><span class="change-path">${d.path}</span><span class="change-stat mono">${stat(d)}</span></a>`;181}182183async function diffSections(184 name: string,185 save: Save,186 parent: Save | null,187 diffs: FileDiff[],188): Promise<Html[]> {189 let shown = 0;190 const sections: Html[] = [];191 for (const [index, d] of diffs.entries()) {192 const at = d.status === "removed" ? parent?.id : save.id;193 const view = `/${name}/files/${pathUrl(d.path)}?at=${at}`;194 const hunkList = hunks(d.edits);195 const length = hunkList.reduce((sum, h) => sum + h.edits.length + 1, 0);196 const mode =197 d.before && d.after && d.before.executable !== d.after.executable198 ? html`<p class="diff-note mono">${199 d.after.executable ? "now executable" : "no longer executable"200 }</p>`201 : "";202 let content: Html | string = "";203 if (d.kind === "binary") content = html`<p class="diff-note mono">binary file, not shown</p>`;204 else if (d.kind === "large") content = html`<p class="diff-note mono">over 1 MB, not shown</p>`;205 else if (shown + length > PAGE_MAX_LINES) {206 content = html`<p class="diff-note mono">not shown here: too long for one page</p>`;207 } else if (length > 0) {208 shown += length;209 content = await diffBody(d, hunkList);210 }211 sections.push(html`<details class="diff-file change-${212 d.status213 }" id="diff-${index}"${length <= COLLAPSE_LINES && " open"}>214<summary class="diff-head"><span class="change-path">${215 d.path216 }</span><span class="change-stat mono">${stat(d)}</span></summary>217${mode}${content}218<p class="diff-view mono"><a href="${219 view220 }">view file${d.status === "removed" && " at the parent"}</a></p>221</details>`);222 }223 return sections;224}225226async function diffBody(d: FileDiff, hunkList: ReturnType<typeof hunks>): Promise<Html> {227 const [oldLines, newLines] = await Promise.all([228 highlighted(d.oldText, d.path),229 highlighted(d.newText, d.path),230 ]);231 232 const unterminated = (text: string, count: number) =>233 text === "" || text.endsWith("\n") ? -1 : count - 1;234 const oldEnd = unterminated(d.oldText, oldLines.length);235 const newEnd = unterminated(d.newText, newLines.length);236 const row = (e: Edit) => {237 const code = e.kind === "-" ? oldLines[e.a] : newLines[e.b];238 const kind = e.kind === "+" ? "add" : e.kind === "-" ? "del" : "ctx";239 const bare = (e.kind !== "+" && e.a === oldEnd) || (e.kind !== "-" && e.b === newEnd);240 return html`<span class="dl dl-${241 kind242 }"><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>${bare && html`<span class="dh">\\ No newline at end of file</span>`}`;243 };244 return html`<div class="file-view"><pre class="code diff"><code>${hunkList.map(245 (h) => html`<span class="dh">${h.header}</span>${h.edits.map(row)}`,246 )}</code></pre></div>`;247}248249function highlighted(text: string, path: string): Promise<Html[]> | Html[] {250 if (text.length > HIGHLIGHT_MAX_BYTES) return lines(text).map((line) => html`${line}`);251 return highlightLines(text, path);252}