save.ts

9.3 KB · raw

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";2728// A side over the file viewer's 1 MB is not diffed; past 256 KB it is shown without highlighting.29const DIFF_MAX_BYTES = 1024 * 1024;30const HIGHLIGHT_MAX_BYTES = 256 * 1024;31// A file's diff longer than this starts collapsed; once the page has shown this many lines, the32// remaining files are listed without their diffs so one huge save can't make a huge page.33const 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		// Checkpoints never appear (§21.4), so a link to one finds nothing, like an unknown ID.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		// Not a year like other per-save content: a flag or a strip can arrive for this save later.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(compared(oldText), compared(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}147148// A last line without its newline differs from the same line with one, as in GNU diff: a newline149// can never occur inside a split line, so it marks the ending for the comparison alone.150function compared(text: string): string[] {151	const split = lines(text);152	if (split.length > 0 && !text.endsWith("\n")) split[split.length - 1] += "\n";153	return split;154}155156function stat(d: FileDiff): Html {157	if (d.kind !== "text") return html`<span class="diff-note">${d.kind}</span>`;158	return html`<span class="diff-add">+${d.added}</span> <span class="diff-del">−${d.removed}</span>`;159}160161function changeRow(d: FileDiff, index: number): Html {162	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>`;163}164165async function diffSections(166	name: string,167	save: Save,168	parent: Save | null,169	diffs: FileDiff[],170): Promise<Html[]> {171	let shown = 0;172	const sections: Html[] = [];173	for (const [index, d] of diffs.entries()) {174		const at = d.status === "removed" ? parent?.id : save.id;175		const view = `/${name}/files/${pathUrl(d.path)}?at=${at}`;176		const hunkList = hunks(d.edits);177		const length = hunkList.reduce((sum, h) => sum + h.edits.length + 1, 0);178		const mode =179			d.before && d.after && d.before.executable !== d.after.executable180				? html`<p class="diff-note mono">${d.after.executable ? "now executable" : "no longer executable"}</p>`181				: "";182		let content: Html | string = "";183		if (d.kind === "binary") content = html`<p class="diff-note mono">binary file, not shown</p>`;184		else if (d.kind === "large") content = html`<p class="diff-note mono">over 1 MB, not shown</p>`;185		else if (shown + length > PAGE_MAX_LINES) {186			content = html`<p class="diff-note mono">not shown here: too long for one page</p>`;187		} else if (length > 0) {188			shown += length;189			content = await diffBody(d, hunkList);190		}191		sections.push(html`<details class="diff-file change-${d.status}" id="diff-${index}"${length <= COLLAPSE_LINES && " open"}>192<summary class="diff-head"><span class="change-path">${d.path}</span><span class="change-stat mono">${stat(d)}</span></summary>193${mode}${content}194<p class="diff-view mono"><a href="${view}">view file${d.status === "removed" && " at the parent"}</a></p>195</details>`);196	}197	return sections;198}199200async function diffBody(d: FileDiff, hunkList: ReturnType<typeof hunks>): Promise<Html> {201	const [oldLines, newLines] = await Promise.all([202		highlighted(d.oldText, d.path),203		highlighted(d.newText, d.path),204	]);205	// The last line of a side that doesn't end in a newline, or -1 when it does.206	const unterminated = (text: string, count: number) =>207		text === "" || text.endsWith("\n") ? -1 : count - 1;208	const oldEnd = unterminated(d.oldText, oldLines.length);209	const newEnd = unterminated(d.newText, newLines.length);210	const row = (e: Edit) => {211		const code = e.kind === "-" ? oldLines[e.a] : newLines[e.b];212		const kind = e.kind === "+" ? "add" : e.kind === "-" ? "del" : "ctx";213		const bare = (e.kind !== "+" && e.a === oldEnd) || (e.kind !== "-" && e.b === newEnd);214		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>${bare && html`<span class="dh">\\ No newline at end of file</span>`}`;215	};216	return html`<div class="file-view"><pre class="code diff"><code>${hunkList.map(217		(h) => html`<span class="dh">${h.header}</span>${h.edits.map(row)}`,218	)}</code></pre></div>`;219}220221function highlighted(text: string, path: string): Promise<Html[]> | Html[] {222	if (text.length > HIGHLIGHT_MAX_BYTES) return lines(text).map((line) => html`${line}`);223	return highlightLines(text, path);224}