save.ts

9.4 KB · raw

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";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="/${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}163164// A last line without its newline differs from the same line with one, as in GNU diff: a newline165// can never occur inside a split line, so it marks the ending for the comparison alone.166function 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	// The last line of a side that doesn't end in a newline, or -1 when it does.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}