dec9767f3870

add the save page: facts, flag reason, parent past checkpoints, files changed with line stats and highlighted unified diffs from a small Myers diff (v3.0.14)

time
2026-09-26 18:26 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
a6176ba16faf
9 files package.json+1 −1public/css/kepr.css+173 −1src/mirror/queries.ts+21 −0src/pages/diff.ts+118 −0src/pages/save.ts+210 −0src/server/app.ts+2 −0tests/diff.test.ts+82 −0tests/pages.test.ts+57 −0tests/sync.test.ts+19 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.13",3+	"version": "3.0.14",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+173 −1
@@ -863,6 +863,171 @@863863 	font-style: italic;864864 }865865 866+/* ----- save page ---------------------------------------------------------- */867+868+.save-id {869+	margin: 12px 0 0;870+	font-size: 0.75rem;871+	color: var(--text-secondary);872+}873+.save-flag {874+	margin: 12px 0 0;875+	padding: 8px 12px;876+	border: 1px solid #6a3a2a;877+	border-left: 2px solid #c45c5c;878+	background: rgba(196, 92, 92, 0.08);879+	color: var(--text-heading);880+}881+.save-flag-label {882+	margin-right: 8px;883+	font-size: 0.625rem;884+	letter-spacing: 0.12em;885+	text-transform: uppercase;886+	color: #c45c5c;887+}888+.save-message-head {889+	font-size: 2.25rem;890+	font-weight: 400;891+	line-height: 1.3;892+	margin: 12px 0 8px;893+	color: var(--text-heading);894+	overflow-wrap: anywhere;895+	animation: fadeUp 280ms ease both;896+}897+.save-message-body {898+	margin: 0 0 12px;899+	white-space: pre-wrap;900+	color: var(--text-secondary);901+}902+.save-facts {903+	display: grid;904+	grid-template-columns: max-content 1fr;905+	gap: 2px 16px;906+	margin: 16px 0;907+	font-size: 0.75rem;908+}909+.save-facts dt {910+	color: var(--text-faint);911+}912+.save-facts dd {913+	margin: 0;914+	overflow-wrap: anywhere;915+}916+.save-facts a,917+.save-links a,918+.diff-view a {919+	color: var(--accent);920+}921+.save-links {922+	display: flex;923+	flex-wrap: wrap;924+	gap: 8px 20px;925+	margin: 0;926+	font-size: 0.75rem;927+}928+.save-empty {929+	font-size: 0.75rem;930+}931+.change-row,932+.diff-head {933+	display: grid;934+	grid-template-columns: 1fr auto;935+	gap: 12px;936+	padding: 7px 8px;937+	border-bottom: 1px solid var(--border);938+	font-family: var(--mono);939+	font-size: 0.8125rem;940+}941+.change-row:hover {942+	background: var(--accent-tint-04);943+	text-decoration: none;944+}945+.change-path {946+	min-width: 0;947+	overflow-wrap: anywhere;948+}949+.change-added .change-path {950+	color: var(--sage);951+}952+.change-removed .change-path {953+	color: var(--text-muted);954+	text-decoration: line-through;955+}956+.change-modified .change-path {957+	color: var(--accent-bright);958+}959+.change-stat {960+	font-size: 0.75rem;961+	white-space: nowrap;962+}963+.diff-add {964+	color: var(--sage);965+}966+.diff-del {967+	color: #c45c5c;968+}969+.diff-note {970+	color: var(--text-faint);971+	font-size: 0.6875rem;972+}973+.diff-file {974+	margin: 0 0 20px;975+}976+.diff-head {977+	cursor: pointer;978+	list-style: none;979+	border: 1px solid var(--border);980+	background: var(--bg-surface);981+}982+.diff-head::-webkit-details-marker {983+	display: none;984+}985+.diff-file > .file-view {986+	border-top: 0;987+}988+.diff-file > .diff-note,989+.diff-view {990+	margin: 8px 0 0;991+	font-size: 0.6875rem;992+}993+.code.diff {994+	padding: 4px 0;995+}996+.diff .dl {997+	display: grid;998+	grid-template-columns: 3.5em 3.5em 1.5em 1fr;999+	padding-right: 24px;1000+}1001+.diff .dh {1002+	display: block;1003+	padding: 4px 1em;1004+	color: var(--text-faint);1005+	background: var(--bg-elevated);1006+}1007+.diff .ln {1008+	padding-right: 0.75em;1009+	text-align: right;1010+	color: var(--text-faint);1011+	user-select: none;1012+}1013+.diff .ds {1014+	text-align: center;1015+	color: var(--text-faint);1016+	user-select: none;1017+}1018+.diff .dl-add {1019+	background: rgba(122, 154, 104, 0.1);1020+}1021+.diff .dl-add .ds {1022+	color: var(--sage);1023+}1024+.diff .dl-del {1025+	background: rgba(196, 92, 92, 0.08);1026+}1027+.diff .dl-del .ds {1028+	color: #c45c5c;1029+}1030+8661031 /* ----- phone -------------------------------------------------------------- */8671032 8681033 @media (max-width: 768px) {@@ -905,6 +1070,12 @@9051070 	.code .line {9061071 		grid-template-columns: 4.5em 1fr;9071072 	}1073+	.save-message-head {1074+		font-size: 1.625rem;1075+	}1076+	.diff .dl {1077+		grid-template-columns: 2.75em 2.75em 1.25em 1fr;1078+	}9081079 	.code .ln {9091080 		padding-right: 1em;9101081 	}@@ -922,7 +1093,8 @@9221093 	.repo-tabs,9231094 	.install-lines,9241095 	.repo-section,925-	.repo-details {1096+	.repo-details,1097+	.save-message-head {9261098 		animation: none;9271099 	}9281100 }

view file

src/mirror/queries.ts+21 −0
@@ -60,6 +60,27 @@6060 	return rows.length === 1 ? asSave(rows[0] as Save) : null;6161 }6262 63+/** The nearest ancestor that is not a checkpoint: the parent a visitor can see (§21.4). */64+export function visibleParent(db: Database, save: Save): Save | null {65+	for (let id = save.parent; id !== ""; ) {66+		const parent = saveByPrefix(db, id);67+		if (!parent?.is_checkpoint) return parent;68+		id = parent.parent;69+	}70+	return null;71+}72+73+/** The reason a save is flagged, or null when it isn't: its newest flag or clear event wins (§10.1). */74+export function flagReason(db: Database, saveId: string): string | null {75+	const row = db76+		.query<{ kind: string; reason: string }, [string]>(77+			`SELECT kind, COALESCE(reason, '') AS reason FROM events78+			WHERE save_id = ? AND kind IN ('flag', 'clear') ORDER BY created_at DESC, id DESC LIMIT 1`,79+		)80+		.get(saveId);81+	return row?.kind === "flag" ? row.reason : null;82+}83+6384 export function filesAt(db: Database, saveId: string): FileEntry[] {6485 	return db6586 		.query<FileEntry, [string]>(

view file

src/pages/diff.ts+118 −0
@@ -0,0 +1,118 @@1+/**2+ * One edit line: `a` and `b` are its 0-based line numbers in the old and new text. A removed line's `b`3+ * (and an added line's `a`) is where it falls in the other text, which the hunk headers need.4+ */5+export type Edit = { kind: " " | "-" | "+"; a: number; b: number };6+7+export type Hunk = { header: string; edits: Edit[] };8+9+// Past this many differences the Myers trace (about D² numbers) stops paying for itself; the rest of10+// the change is shown as a plain replacement, which is still a correct diff, just not a minimal one.11+const MAX_DIFFERENCES = 2000;12+13+/** The line edits turning `a` into `b`: Myers' O(ND) diff between the common head and tail. */14+export function diffLines(a: string[], b: string[]): Edit[] {15+	let head = 0;16+	while (head < a.length && head < b.length && a[head] === b[head]) head++;17+	let endA = a.length;18+	let endB = b.length;19+	while (endA > head && endB > head && a[endA - 1] === b[endB - 1]) {20+		endA--;21+		endB--;22+	}23+	const edits: Edit[] = [];24+	for (let i = 0; i < head; i++) edits.push({ kind: " ", a: i, b: i });25+	edits.push(...(myers(a, b, head, endA, endB) ?? replacement(head, endA, endB)));26+	for (let i = 0; endA + i < a.length; i++) edits.push({ kind: " ", a: endA + i, b: endB + i });27+	return edits;28+}29+30+function replacement(start: number, endA: number, endB: number): Edit[] {31+	const edits: Edit[] = [];32+	for (let i = start; i < endA; i++) edits.push({ kind: "-", a: i, b: start });33+	for (let i = start; i < endB; i++) edits.push({ kind: "+", a: endA, b: i });34+	return edits;35+}36+37+function myers(a: string[], b: string[], start: number, endA: number, endB: number): Edit[] | null {38+	const n = endA - start;39+	const m = endB - start;40+	const max = n + m;41+	const v = new Int32Array(2 * max + 2);42+	// trace[d] is the furthest x per diagonal k before step d, kept only for k in [-d, d].43+	const trace: Int32Array[] = [];44+	for (let d = 0; d <= Math.min(max, MAX_DIFFERENCES); d++) {45+		trace.push(v.slice(max - d, max + d + 1));46+		for (let k = -d; k <= d; k += 2) {47+			const down = k === -d || (k !== d && (v[max + k - 1] as number) < (v[max + k + 1] as number));48+			let x = down ? (v[max + k + 1] as number) : (v[max + k - 1] as number) + 1;49+			let y = x - k;50+			while (x < n && y < m && a[start + x] === b[start + y]) {51+				x++;52+				y++;53+			}54+			v[max + k] = x;55+			if (x >= n && y >= m) return backtrack(trace, n, m, start);56+		}57+	}58+	return null;59+}60+61+function backtrack(trace: Int32Array[], n: number, m: number, start: number): Edit[] {62+	const edits: Edit[] = [];63+	let x = n;64+	let y = m;65+	for (let d = trace.length - 1; d > 0; d--) {66+		const v = trace[d] as Int32Array;67+		const at = (k: number) => v[k + d] as number;68+		const k = x - y;69+		const prevK = k === -d || (k !== d && at(k - 1) < at(k + 1)) ? k + 1 : k - 1;70+		const prevX = at(prevK);71+		const prevY = prevX - prevK;72+		while (x > prevX && y > prevY) {73+			x--;74+			y--;75+			edits.push({ kind: " ", a: start + x, b: start + y });76+		}77+		if (x === prevX) edits.push({ kind: "+", a: start + x, b: start + prevY });78+		else edits.push({ kind: "-", a: start + prevX, b: start + y });79+		x = prevX;80+		y = prevY;81+	}82+	while (x > 0) {83+		x--;84+		edits.push({ kind: " ", a: start + x, b: start + x });85+	}86+	return edits.reverse();87+}88+89+/** Groups edits into unified-diff hunks with `context` unchanged lines around each change. */90+export function hunks(edits: Edit[], context = 3): Hunk[] {91+	const out: Hunk[] = [];92+	let i = 0;93+	while (i < edits.length) {94+		if (edits[i]?.kind === " ") {95+			i++;96+			continue;97+		}98+		let last = i;99+		// Changes up to 2 × context unchanged lines apart share a hunk, as in GNU diff.100+		for (let j = i; j < edits.length && j - last <= 2 * context + 1; j++) {101+			if (edits[j]?.kind !== " ") last = j;102+		}103+		const lines = edits.slice(Math.max(0, i - context), Math.min(edits.length, last + context + 1));104+		out.push({ header: header(lines), edits: lines });105+		i = last + context + 1;106+	}107+	return out;108+}109+110+// GNU's convention: a side with no lines names the line before the hunk.111+function header(edits: Edit[]): string {112+	const first = edits[0] as Edit;113+	const oldCount = edits.filter((e) => e.kind !== "+").length;114+	const newCount = edits.filter((e) => e.kind !== "-").length;115+	const range = (start: number, count: number) =>116+		`${count === 0 ? start : start + 1}${count === 1 ? "" : `,${count}`}`;117+	return `@@ -${range(first.a, oldCount)} +${range(first.b, newCount)} @@`;118+}

view file

src/pages/save.ts+210 −0
@@ -0,0 +1,210 @@1+import type { Database } from "bun:sqlite";2+import type { Hono } from "hono";3+import {4+	type FileEntry,5+	filesAt,6+	flagReason,7+	objectBytes,8+	type Save,9+	saveByPrefix,10+	visibleParent,11+} from "../mirror/queries.ts";12+import { diffLines, type Edit, hunks } from "./diff.ts";13+import { isText } from "./files.ts";14+import { highlightLines } from "./highlight.ts";15+import { type Html, html } from "./html.ts";16+import {17+	cacheFor,18+	formatDate,19+	notFound,20+	openPublicRepo,21+	type PageContext,22+	page,23+	pathUrl,24+	plural,25+	relativeTime,26+} from "./layout.ts";27+28+// A side over the file viewer's 1 MB is not diffed; past 256 KB it is shown without highlighting.29+const DIFF_MAX_BYTES = 1024 * 1024;30+const 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.33+const COLLAPSE_LINES = 300;34+const PAGE_MAX_LINES = 5000;35+36+type Change = {37+	path: string;38+	status: "added" | "removed" | "modified";39+	before: FileEntry | null;40+	after: FileEntry | null;41+};42+43+type FileDiff = Change & {44+	kind: "text" | "binary" | "large";45+	oldText: string;46+	newText: string;47+	edits: Edit[];48+	added: number;49+	removed: number;50+};51+52+export 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+}84+85+function 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+}113+114+function 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+}126+127+function 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+}143+144+function lines(text: string): string[] {145+	return text === "" ? [] : text.replace(/\n$/, "").split("\n");146+}147+148+function 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+}152+153+function 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+}156+157+async 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+}191+192+async 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+}206+207+function 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+}

view file

src/server/app.ts+2 −0
@@ -6,6 +6,7 @@66 import { isSafePath } from "../bucket/repo.ts";77 import { registerFileRoutes } from "../pages/files.ts";88 import { registerRepoPage } from "../pages/repo.ts";9+import { registerSavePage } from "../pages/save.ts";910 import type { Config } from "./config.ts";1011 1112 const MAX_BODY_BYTES = 64 * 1024;@@ -48,5 +49,6 @@4849 	const ctx = { domain: config.domain, kepr, mirrorDir: join(config.dataDir, "mirror") };4950 	registerRepoPage(app, ctx);5051 	registerFileRoutes(app, ctx);52+	registerSavePage(app, ctx);5153 	return app;5254 }

view file

tests/diff.test.ts+82 −0
@@ -0,0 +1,82 @@1+import { describe, expect, test } from "bun:test";2+import { diffLines, hunks } from "../src/pages/diff.ts";3+4+// Longest common subsequence by dynamic programming: the minimal diff keeps exactly this many lines.5+function lcs(a: string[], b: string[]): number {6+	const row = new Array<number>(b.length + 1).fill(0);7+	for (const line of a) {8+		let diagonal = 0;9+		for (let j = 1; j <= b.length; j++) {10+			const above = row[j] as number;11+			row[j] = line === b[j - 1] ? diagonal + 1 : Math.max(above, row[j - 1] as number);12+			diagonal = above;13+		}14+	}15+	return row[b.length] as number;16+}17+18+// A small seeded generator, so every run tests the same inputs.19+function random(seed: number): () => number {20+	let s = seed;21+	return () => {22+		s = (s * 1103515245 + 12345) % 2147483648;23+		return s / 2147483648;24+	};25+}26+27+describe("diffLines", () => {28+	test("rebuilds both sides and keeps a longest common subsequence", () => {29+		const next = random(7);30+		for (let round = 0; round < 300; round++) {31+			const lines = (n: number) =>32+				Array.from({ length: n }, () => "abcd"[Math.floor(next() * 4)] as string);33+			const a = lines(Math.floor(next() * 14));34+			const b = lines(Math.floor(next() * 14));35+			const edits = diffLines(a, b);36+			expect(edits.filter((e) => e.kind !== "+").map((e) => a[e.a])).toEqual(a);37+			expect(edits.filter((e) => e.kind !== "-").map((e) => b[e.b])).toEqual(b);38+			for (const e of edits.filter((e) => e.kind === " ")) expect(a[e.a]).toBe(b[e.b] as string);39+			expect(edits.filter((e) => e.kind === " ").length).toBe(lcs(a, b));40+		}41+	});42+43+	test("an empty side is all additions or all removals", () => {44+		expect(diffLines([], ["x", "y"]).map((e) => e.kind)).toEqual(["+", "+"]);45+		expect(diffLines(["x"], []).map((e) => e.kind)).toEqual(["-"]);46+	});47+48+	test("past the difference cap, a plain replacement still rebuilds both sides", () => {49+		const a = Array.from({ length: 3000 }, (_, i) => `a${i}`);50+		const b = ["same", ...Array.from({ length: 3000 }, (_, i) => `b${i}`), "end"];51+		a.unshift("same");52+		a.push("end");53+		const edits = diffLines(a, b);54+		expect(edits.filter((e) => e.kind !== "+").map((e) => a[e.a])).toEqual(a);55+		expect(edits.filter((e) => e.kind !== "-").map((e) => b[e.b])).toEqual(b);56+	});57+});58+59+describe("hunks", () => {60+	test("match GNU diff -u's headers and grouping", () => {61+		const a = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);62+		const b = [...a];63+		b[1] = "changed 2";64+		b.splice(9, 1);65+		b.push("line 21");66+		expect(hunks(diffLines(a, b)).map((h) => h.header)).toEqual([67+			"@@ -1,5 +1,5 @@",68+			"@@ -7,7 +7,6 @@",69+			"@@ -18,3 +17,4 @@",70+		]);71+		// Changes six unchanged lines apart share one hunk; seven apart do not.72+		const six = [...a];73+		six[2] = "x";74+		six[9] = "y";75+		expect(hunks(diffLines(a, six)).length).toBe(1);76+		const seven = [...a];77+		seven[2] = "x";78+		seven[10] = "y";79+		expect(hunks(diffLines(a, seven)).length).toBe(2);80+		expect(hunks(diffLines([], ["new"]))[0]?.header).toBe("@@ -0,0 +1 @@");81+	});82+});

view file

tests/pages.test.ts+57 −0
@@ -266,6 +266,63 @@266266 	});267267 });268268 269+describe("the save page", () => {270+	test("shows the save's facts and diffs against the previous real save, skipping the checkpoint", async () => {271+		const response = await get(`/demo/saves/${head()}`);272+		expect(response.status).toBe(200);273+		expect(response.headers.get("cache-control")).toBe("public, max-age=60");274+		const page = await response.text();275+		expect(page).toContain('<h1 class="save-message-head">say hello</h1>');276+		expect(page).toContain(277+			`<dt>parent</dt><dd><a href="/demo/saves/${second()}">${second()}</a></dd>`,278+		);279+		expect(page).not.toContain(fx.saveIds[1] as string);280+		expect(page).not.toContain("save-flag");281+		// The guide's edit was first recorded by the checkpoint; it belongs to this save.282+		const rows = [283+			...page.matchAll(284+				/class="change-row change-(\w+)" href="#diff-\d"><span class="change-path">([^<]+)</g,285+			),286+		];287+		expect(rows.map((m) => [m[1], m[2]])).toEqual([288+			["modified", "docs/guide.md"],289+			["modified", "src/main.ts"],290+		]);291+		expect(page).toContain('<span class="diff-add">+1</span> <span class="diff-del">−1</span>');292+		expect(page).toContain('<span class="dh">@@ -1,2 +1,2 @@</span>');293+		expect(page).toContain(294+			'<span class="dl dl-del"><span class="ln">2</span><span class="ln"></span><span class="ds">-</span>',295+		);296+		expect(page).toContain('class="tk-');297+		expect(page).toContain(`href="/demo/archive/${head()}.tar.gz"`);298+		expect(page).toContain(`href="/demo?at=${head()}#atlas"`);299+	});300+301+	test("the first save diffs against nothing, notes binaries, and shows its flag and agent", async () => {302+		const page = await body(`/demo/saves/${first()}`);303+		expect(page).toContain('<span class="save-flag-label mono">flagged</span> holds a secret');304+		expect(page).toContain("<dt>parent</dt><dd>none, the first save</dd>");305+		expect(page).toContain(306+			'change-added" href="#diff-2"><span class="change-path">docs/logo.png</span><span class="change-stat mono"><span class="diff-note">binary</span>',307+		);308+		expect(page).toContain("binary file, not shown");309+		expect(page).toContain('<span class="dh">@@ -0,0 +1 @@</span>');310+		const second_ = await body(`/demo/saves/${second()}`);311+		expect(second_).toContain("<dt>agent</dt><dd>test-agent</dd><dt>session</dt><dd>fixture</dd>");312+		expect(second_).not.toContain("save-flag");313+	});314+315+	test("a prefix redirects, and a checkpoint, an unknown or a private save is a 404", async () => {316+		const short = await get(`/demo/saves/${head().slice(0, 6)}`);317+		expect(short.status).toBe(302);318+		expect(short.headers.get("location")).toBe(`/demo/saves/${head()}`);319+		expect((await get(`/demo/saves/${fx.saveIds[1]}`)).status).toBe(404);320+		expect((await get("/demo/saves/abcdef")).status).toBe(404);321+		expect((await get("/demo/saves/abc")).status).toBe(404);322+		expect((await get(`/hidden/saves/${head()}`)).status).toBe(404);323+	});324+});325+269326 describe("raw files", () => {270327 	test("are the exact object bytes, text as text/plain", async () => {271328 		const mirror = openMirror(mirrorDir, "demo");

view file

tests/sync.test.ts+19 −0
@@ -8,6 +8,7 @@88 import {99 	fileAt,1010 	filesAt,11+	flagReason,1112 	headId,1213 	history,1314 	objectBytes,@@ -92,6 +93,24 @@9293 	test("stores the flag event", () => {9394 		const flag = t.mirror().query("SELECT kind, save_id, reason FROM events").get();9495 		expect(flag).toEqual({ kind: "flag", save_id: first(), reason: "holds a secret" });96+	});97+98+	test("a save's flag state is its newest flag or clear event, by time and then ID", async () => {99+		expect(flagReason(t.mirror(), first())).toBe("holds a secret");100+		expect(flagReason(t.mirror(), head())).toBeNull();101+		const mirror = openMirror(await mkdtemp(join(tmpdir(), "kepr-flags-")), "flags");102+		const event = mirror.query(103+			"INSERT INTO events (id, kind, save_id, reason, created_at, machine) VALUES (?, ?, 'aaaaaaaaaaaa', ?, ?, 'm')",104+		);105+		event.run("000000000001", "flag", "first", 10);106+		expect(flagReason(mirror, "aaaaaaaaaaaa")).toBe("first");107+		event.run("000000000002", "clear", null, 20);108+		expect(flagReason(mirror, "aaaaaaaaaaaa")).toBeNull();109+		// Same second: the larger ID is the newer.110+		event.run("000000000003", "flag", "again", 20);111+		expect(flagReason(mirror, "aaaaaaaaaaaa")).toBe("again");112+		event.run("000000000000", "clear", null, 20);113+		expect(flagReason(mirror, "aaaaaaaaaaaa")).toBe("again");95114 	});96115 97116 	test("a strip reaches the mirror and drops the object", async () => {

view file