5e4eb28dcc4a

add the Atlas to the repo page: folder tree with described rows, per-file history panels and ?at= views at any save (v3.0.10)

time
2026-09-26 18:12 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
532072a50935
11 files package.json+1 −1public/css/kepr.css+174 −0src/describe/describe.ts+10 −10src/mirror/queries.ts+41 −0src/pages/atlas.ts+219 −0src/pages/files.ts+1 −1src/pages/markdown.ts+7 −0src/pages/repo.ts+10 −2tests/describe.test.ts+11 −10tests/fixture.ts+7 −2tests/pages.test.ts+78 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.9",3+	"version": "3.0.10",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+174 −0
@@ -496,6 +496,167 @@496496 	color: var(--accent);497497 }498498 499+/* ----- atlas -------------------------------------------------------------- */500+501+.atlas-where {502+	margin: 10px 0 8px;503+	font-size: 0.625rem;504+	color: var(--text-faint);505+	letter-spacing: 0.04em;506+}507+.atlas-where a {508+	color: var(--accent);509+}510+.atlas-filter {511+	width: 100%;512+	margin: 0 0 8px;513+	padding: 7px 10px;514+	background: transparent;515+	border: 1px solid var(--border);516+	border-radius: 3px;517+	color: var(--text);518+	font-size: 0.6875rem;519+}520+.atlas-filter:focus {521+	outline: none;522+	border-color: var(--accent-dim);523+}524+.atlas-filter::placeholder {525+	color: var(--text-faint);526+}527+.atlas {528+	display: grid;529+	grid-template-columns: minmax(0, 1fr);530+	gap: 0 20px;531+}532+.atlas:has(> .atlas-panel:not([hidden])) {533+	grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.75fr);534+}535+.atlas [hidden] {536+	display: none;537+}538+.atlas-folder > summary {539+	list-style: none;540+}541+.atlas-folder > summary::-webkit-details-marker {542+	display: none;543+}544+.atlas-children {545+	padding-left: 14px;546+	border-left: 1px solid var(--border);547+	margin-left: 6px;548+}549+.atlas-row {550+	position: relative;551+	display: grid;552+	grid-template-columns: minmax(90px, auto) 1fr auto;553+	align-items: baseline;554+	gap: 0 12px;555+	padding: 6px 8px;556+	border-bottom: 1px solid rgba(42, 32, 24, 0.62);557+	cursor: pointer;558+	transition: background 100ms;559+}560+.atlas-row:hover,561+.atlas-row:focus-visible,562+.atlas-row.selected {563+	outline: none;564+	background: var(--accent-tint-04);565+	text-decoration: none;566+}567+.atlas-row.selected {568+	box-shadow: inset 2px 0 0 var(--accent);569+}570+.atlas-name {571+	color: var(--text-heading);572+	font-size: 0.875rem;573+	white-space: nowrap;574+}575+.atlas-folder > summary .atlas-name {576+	color: var(--accent);577+}578+.atlas-desc {579+	min-width: 0;580+	color: var(--text-muted);581+	font-size: 0.8125rem;582+	overflow: hidden;583+	text-overflow: ellipsis;584+	white-space: nowrap;585+}586+.atlas-meta {587+	font-size: 0.625rem;588+	color: var(--text-faint);589+	white-space: nowrap;590+}591+.atlas-panel {592+	position: sticky;593+	top: 16px;594+	align-self: start;595+	max-height: calc(100vh - 32px);596+	overflow-y: auto;597+	padding: 4px 0 12px 16px;598+	border-left: 1px solid var(--border);599+}600+.atlas-hint {601+	margin: 0;602+	font-size: 0.625rem;603+	color: var(--text-faint);604+}605+.atlas-info-name {606+	margin: 0;607+	font-weight: 400;608+	font-size: 1.375rem;609+	color: var(--text-heading);610+	overflow-wrap: anywhere;611+}612+.atlas-info-path {613+	margin: 0 0 12px;614+	font-size: 0.625rem;615+	color: var(--text-faint);616+	overflow-wrap: anywhere;617+}618+.atlas-facts {619+	display: grid;620+	grid-template-columns: auto 1fr;621+	gap: 3px 12px;622+	margin: 0 0 12px;623+	font-size: 0.625rem;624+}625+.atlas-facts dt {626+	color: var(--text-faint);627+}628+.atlas-facts dd {629+	margin: 0;630+	color: var(--text);631+}632+.atlas-facts a,633+.atlas-recent a {634+	color: var(--accent);635+}636+.atlas-info-desc {637+	font-size: 0.8125rem;638+	color: var(--text-muted);639+}640+.atlas-recent {641+	list-style: none;642+	margin: 12px 0 0;643+	padding: 10px 0 0;644+	border-top: 1px solid var(--border);645+	font-size: 0.75rem;646+}647+.atlas-recent li {648+	margin-bottom: 6px;649+	line-height: 1.5;650+}651+.atlas-recent .mono,652+.atlas-when {653+	font-size: 0.625rem;654+}655+.atlas-when {656+	color: var(--text-faint);657+	white-space: nowrap;658+}659+499660 /* ----- README and rendered Markdown --------------------------------------- */500661 501662 .repo-details {@@ -727,6 +888,19 @@727888 	}728889 	.history-id {729890 		grid-column: 1 / -1;891+	}892+	.atlas-row {893+		grid-template-columns: 1fr auto;894+	}895+	.atlas-desc {896+		grid-column: 1 / -1;897+		grid-row: 2;898+	}899+	.atlas:has(> .atlas-panel:not([hidden])) {900+		grid-template-columns: minmax(0, 1fr);901+	}902+	.atlas-panel {903+		display: none;730904 	}731905 	.code .line {732906 		grid-template-columns: 4.5em 1fr;

view file

src/describe/describe.ts+10 −10
@@ -20,7 +20,7 @@2020 2121 const SHELL_SHEBANG = /^#!.*\b(ba|z|da|k)?sh\b/;2222 const MODULE_FILES = ["mod.zig", "mod.rs", "index.ts", "__init__.py"];23-const README = /^readme(\.md|\.markdown)?$/i;23+const README = /^readme\.(md|markdown)$/i;2424 2525 /**2626  * A license header is the first comment block when it carries an SPDX tag, a copyright line or@@ -55,15 +55,15 @@5555 	return null;5656 }5757 58-/** A folder's description: its README's first paragraph, else its module file's header. */59-export function describeFolder(children: { name: string; text: string }[]): string | null {60-	const readme = children.find((child) => README.test(child.name));61-	if (readme) return firstParagraph(readme.text);62-	for (const moduleFile of MODULE_FILES) {63-		const child = children.find((c) => c.name === moduleFile);64-		if (child) return describeFile(child.name, child.text);65-	}66-	return null;58+/** A folder's description: its README's, else its module file's, given each child's own. */59+export function describeFolder(60+	children: { name: string; description: string | null }[],61+): string | null {62+	const readme = children.find((child) => README.test(child.name))?.description;63+	const moduleFile = MODULE_FILES.map((name) => children.find((c) => c.name === name)).find(64+		Boolean,65+	);66+	return readme ?? moduleFile?.description ?? null;6767 }6868 6969 /** The first sentence of the first paragraph, with hard-wrapped lines joined: the row's one-liner. */

view file

src/mirror/queries.ts+41 −0
@@ -15,10 +15,15 @@1515 1616 export type FileEntry = { path: string; hash: string; executable: boolean; size: number };1717 18+/** A file's story up to one save: first and last save that changed it, how many did, newest five. */19+export type FileHistory = { first: Save; last: Save; touches: number; recent: Save[] };20+1821 export type RepoStats = { saves: number; lines: number; language: string; updated: number | null };1922 2023 const SAVE_COLUMNS = `id, COALESCE(parent, '') AS parent, message, created_at, machine,2124 	COALESCE(agent, '') AS agent, COALESCE(session, '') AS session, is_checkpoint = 1 AS is_checkpoint`;25+const LINE_COLUMNS =26+	"line.id, line.parent, line.message, line.created_at, line.machine, line.agent, line.session, line.is_checkpoint, line.n";2227 const ID_PREFIX = /^[0-9a-f]{6,12}$/;2328 2429 // Checkpoints are working marks, not history (§21.4): every list and count below leaves them out.@@ -94,6 +99,42 @@9499 		lines: Number(mirrorState(db, "lines") ?? 0),95100 		language: mirrorState(db, "language") ?? "",96101 	};102+}103+104+/**105+ * Each path's history on the line up to `saveId` (§21.5's detail panel). A save touched a path when106+ * the path was added, changed or removed against the save before it; checkpoints are skipped, so a107+ * change first recorded by a checkpoint counts at the next real save.108+ */109+export function fileHistories(db: Database, saveId: string): Map<string, FileHistory> {110+	const rows = db111+		.query<Save & { path: string; n: number }, { at: string }>(112+			`WITH line AS (113+				SELECT ${SAVE_COLUMNS}, LAG(id) OVER (ORDER BY rowid) AS prev, rowid AS n FROM saves114+				WHERE ${NOT_CHECKPOINT} AND rowid <= (SELECT rowid FROM saves WHERE id = $at)115+			)116+			SELECT ${LINE_COLUMNS}, f.path FROM line JOIN save_files f ON f.save_id = line.id117+			LEFT JOIN save_files p ON p.save_id = line.prev AND p.path = f.path118+			WHERE p.hash IS NOT f.hash OR p.executable IS NOT f.executable119+			UNION ALL120+			SELECT ${LINE_COLUMNS}, p.path FROM line JOIN save_files p ON p.save_id = line.prev121+			WHERE NOT EXISTS (SELECT 1 FROM save_files f WHERE f.save_id = line.id AND f.path = p.path)122+			ORDER BY n`,123+		)124+		.all({ at: saveId });125+	const histories = new Map<string, FileHistory>();126+	for (const { path, n: _, ...row } of rows) {127+		const save = asSave(row);128+		const known = histories.get(path);129+		if (!known) {130+			histories.set(path, { first: save, last: save, touches: 1, recent: [save] });131+			continue;132+		}133+		known.last = save;134+		known.touches++;135+		known.recent = [save, ...known.recent.slice(0, 4)];136+	}137+	return histories;97138 }98139 99140 function asSave(row: Save): Save {

view file

src/pages/atlas.ts+219 −0
@@ -0,0 +1,219 @@1+import { describeFile, describeFolder, firstSentence } from "../describe/describe.ts";2+import {3+	type FileHistory,4+	fileHistories,5+	filesAt,6+	objectBytes,7+	type Save,8+} from "../mirror/queries.ts";9+import { isText } from "./files.ts";10+import { type Html, html } from "./html.ts";11+import {12+	formatDate,13+	formatSize,14+	type PublicRepo,15+	pathUrl,16+	plural,17+	relativeTime,18+} from "./layout.ts";19+import { type MarkdownBase, renderLine, renderMarkdown } from "./markdown.ts";20+21+type AtlasFile = {22+	name: string;23+	path: string;24+	size: number;25+	lines: number | null;26+	description: string | null;27+	history: FileHistory | undefined;28+};29+30+type AtlasFolder = { name: string; path: string; folders: AtlasFolder[]; files: AtlasFile[] };31+32+type View = { repo: string; at: string | null };33+34+type Facts = { lines: number | null; description: string | null };35+36+// Measured: Koh 3's vendored 9 MB sqlite3.c cost every Atlas view ~25 ms to inflate and scan. An37+// object never changes, so what it says about itself is kept for the life of the process, keyed by38+// hash and file name (the name picks the comment syntax).39+// ponytail: unbounded, one small entry per object viewed; an LRU if a bucket reaches millions.40+const known = new Map<string, Facts>();41+42+function factsOf(db: PublicRepo["mirror"], hash: string, name: string): Facts {43+	const key = `${hash} ${name}`;44+	const cached = known.get(key);45+	if (cached) return cached;46+	const bytes = objectBytes(db, hash);47+	const text = bytes && isText(bytes) ? new TextDecoder().decode(bytes) : null;48+	const facts =49+		text === null50+			? { lines: null, description: null }51+			: { lines: countLines(text), description: describeFile(name, text) };52+	known.set(key, facts);53+	return facts;54+}55+56+/**57+ * §21.5's Atlas at one save: the folder tree as `<details>`, so it opens and links without58+ * JavaScript. Each row is followed by a `<template>` holding its detail panel, which the browser59+ * island shows on hover or select.60+ */61+export function atlasSection(repo: PublicRepo, saveId: string, at: string | null): Html {62+	const histories = fileHistories(repo.mirror, saveId);63+	const root: AtlasFolder = { name: "", path: "", folders: [], files: [] };64+	const entries = filesAt(repo.mirror, saveId);65+	for (const entry of entries) {66+		const parts = entry.path.split("/");67+		let folder = root;68+		for (const name of parts.slice(0, -1)) {69+			let child = folder.folders.find((f) => f.name === name);70+			if (!child) {71+				const path = folder.path ? `${folder.path}/${name}` : name;72+				child = { name, path, folders: [], files: [] };73+				folder.folders.push(child);74+			}75+			folder = child;76+		}77+		const name = parts[parts.length - 1] as string;78+		folder.files.push({79+			name,80+			path: entry.path,81+			size: entry.size,82+			...factsOf(repo.mirror, entry.hash, name),83+			history: histories.get(entry.path),84+		});85+	}86+	const view = { repo: repo.settings.name, at };87+	const where = at88+		? html`at <a href="/${view.repo}/saves/${at}">${at}</a> · <a href="/${view.repo}#atlas">back to HEAD</a>`89+		: "HEAD";90+	return html`<section class="repo-section" id="atlas">91+<details class="repo-section-toggle" open>92+<summary class="section-head"><span class="section-label">atlas</span><span class="section-count">${plural(entries.length, "file")}</span></summary>93+<p class="atlas-where mono">${where}</p>94+<input class="atlas-filter mono" type="search" placeholder="filter by filename" aria-label="Filter files by name" hidden>95+<div class="atlas" data-repo="${view.repo}">96+<div class="atlas-tree">${children(root, view)}</div>97+<aside class="atlas-panel" hidden><p class="atlas-hint mono">hover or select a row for details</p></aside>98+</div>99+</details>100+</section>`;101+}102+103+function children(folder: AtlasFolder, view: View): Html[] {104+	const byName = (a: { name: string }, b: { name: string }) =>105+		a.name.toLowerCase().localeCompare(b.name.toLowerCase()) || a.name.localeCompare(b.name);106+	return [107+		...folder.folders.sort(byName).map((child) => folderRow(child, view)),108+		...folder.files.sort(byName).map((file) => fileRow(file, view)),109+	];110+}111+112+function fileRow(file: AtlasFile, view: View): Html {113+	const { description } = file;114+	const base = markdownBase(view, file.path);115+	const href = `/${view.repo}/files/${pathUrl(file.path)}${view.at ? `?at=${view.at}` : ""}`;116+	const last = file.history?.last;117+	return html`<a class="atlas-row atlas-file" href="${href}" data-name="${file.name.toLowerCase()}">${row(file.name, description, base, file.size, last)}</a>118+<template>${panel(119+		file.name,120+		file.path,121+		[122+			["size", formatSize(file.size)],123+			["lines", file.lines === null ? "binary" : file.lines.toLocaleString("en-US")],124+			...saveFacts(file.history, view),125+		],126+		description,127+		base,128+	)}${file.history && recent(file.history.recent, view)}</template>`;129+}130+131+function folderRow(folder: AtlasFolder, view: View): Html {132+	const description = describeFolder(folder.files);133+	const base = markdownBase(view, `${folder.path}/`);134+	const files = allFiles(folder);135+	const size = files.reduce((sum, f) => sum + f.size, 0);136+	const last = files137+		.map((f) => f.history?.last)138+		.reduce<Save | undefined>(139+			(a, b) => (b && (!a || b.created_at > a.created_at) ? b : a),140+			undefined,141+		);142+	return html`<details class="atlas-folder" data-path="${folder.path}">143+<summary class="atlas-row">${row(`${folder.name}/`, description, base, size, last)}</summary>144+<template>${panel(145+		`${folder.name}/`,146+		folder.path,147+		[148+			["size", formatSize(size)],149+			["files", files.length.toLocaleString("en-US")],150+			...(last ? ([["last save", saveLink(last, view)]] as [string, Html][]) : []),151+		],152+		description,153+		base,154+	)}</template>155+<div class="atlas-children">${children(folder, view)}</div>156+</details>`;157+}158+159+function row(160+	name: string,161+	description: string | null,162+	base: MarkdownBase,163+	size: number,164+	last: Save | undefined,165+): Html {166+	const meta = [formatSize(size), last && relativeTime(last.created_at)]167+		.filter(Boolean)168+		.join(" · ");169+	return html`<span class="atlas-name">${name}</span><span class="atlas-desc">${description && renderLine(firstSentence(description), base)}</span><span class="atlas-meta mono">${meta}</span>`;170+}171+172+function panel(173+	name: string,174+	path: string,175+	facts: [string, Html | string][],176+	description: string | null,177+	base: MarkdownBase,178+): Html {179+	return html`<h3 class="atlas-info-name">${name}</h3>180+<p class="atlas-info-path mono">${path}</p>181+<dl class="atlas-facts mono">${facts.map(([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`)}</dl>182+${description && html`<div class="markdown atlas-info-desc">${renderMarkdown(description, base)}</div>`}`;183+}184+185+function saveFacts(history: FileHistory | undefined, view: View): [string, Html | string][] {186+	if (!history) return [];187+	return [188+		["first save", saveLink(history.first, view)],189+		["last save", saveLink(history.last, view)],190+		["saves", history.touches.toLocaleString("en-US")],191+	];192+}193+194+function saveLink(save: Save, view: View): Html {195+	return html`<a href="/${view.repo}/saves/${save.id}">${save.id}</a> ${formatDate(save.created_at)}`;196+}197+198+function recent(saves: Save[], view: View): Html {199+	return html`<ul class="atlas-recent">${saves.map(200+		(save) =>201+			html`<li><a class="mono" href="/${view.repo}/saves/${save.id}">${save.id}</a> <span>${save.message.split("\n")[0]}</span> <span class="atlas-when mono">${relativeTime(save.created_at)}</span></li>`,202+	)}</ul>`;203+}204+205+function allFiles(folder: AtlasFolder): AtlasFile[] {206+	return [...folder.files, ...folder.folders.flatMap(allFiles)];207+}208+209+// Relative links in a description resolve from the file's own folder, as in a README.210+function markdownBase(view: View, path: string): MarkdownBase {211+	return { repo: view.repo, dir: path.split("/").slice(0, -1).join("/"), at: view.at };212+}213+214+function countLines(text: string): number {215+	if (text === "") return 0;216+	let count = 0;217+	for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) count++;218+	return text.endsWith("\n") ? count : count + 1;219+}

view file

src/pages/files.ts+1 −1
@@ -69,7 +69,7 @@6969 	return file && bytes ? { repo: name, path, at: at && saveId, file, bytes } : null;7070 }7171 72-function isText(bytes: Uint8Array): boolean {72+export function isText(bytes: Uint8Array): boolean {7373 	if (bytes.subarray(0, 8000).includes(0)) return false;7474 	try {7575 		// Streaming mode tolerates only a character cut at the 1 MB mark; invalid bytes still throw.

view file

src/pages/markdown.ts+7 −0
@@ -54,3 +54,10 @@5454 		.join("");5555 	return { html: trusted(md.renderer.renderInline(inline.children, md.options, base)), text };5656 }57+58+/** One line of Markdown with links and images flattened away, for a row that is itself a link. */59+export function renderLine(source: string, base: MarkdownBase): Html {60+	const tokens = md.parseInline(source, base)[0]?.children ?? [];61+	const kept = tokens.filter((token) => !/^(link_open|link_close|image)$/.test(token.type));62+	return trusted(md.renderer.renderInline(kept, md.options, base));63+}

view file

src/pages/repo.ts+10 −2
@@ -6,9 +6,11 @@66 	history,77 	objectBytes,88 	repoStats,9+	saveByPrefix,910 	timeline,1011 } from "../mirror/queries.ts";1112 import { setting } from "../server/db.ts";13+import { atlasSection } from "./atlas.ts";1214 import { type Html, html } from "./html.ts";1315 import {1416 	cacheFor,@@ -30,6 +32,10 @@3032 	app.get("/:repo", (c) => {3133 		const repo = openPublicRepo(ctx, c.req.param("repo"));3234 		if (!repo) return notFound(c, ctx);35+		// `?at=<id>` shows the Atlas at that save (§21.5); the rest of the page stays at HEAD.36+		const at = c.req.query("at");37+		const atSave = at === undefined ? null : saveByPrefix(repo.mirror, at);38+		if (at !== undefined && !atSave) return notFound(c, ctx);3339 		const pageNumber = Math.max(1, Number.parseInt(c.req.query("page") ?? "1", 10) || 1);3440 		const readme = readmeAtHead(repo);3541 		const name = repo.settings.name;@@ -42,7 +48,7 @@4248 				description: intro?.text ?? "",4349 				path: `/${name}`,4450 				body: html`${header(ctx, repo, intro?.html ?? null)}45-${headId(repo.mirror) && sections(repo, pageNumber)}51+${headId(repo.mirror) && sections(repo, pageNumber, atSave?.id ?? null)}4652 ${readme && details(renderMarkdown(readme, { repo: name, dir: "", at: null }))}`,4753 			}),4854 		);@@ -81,8 +87,9 @@8187 </header>`;8288 }8389 84-function sections(repo: PublicRepo, pageNumber: number): Html {90+function sections(repo: PublicRepo, pageNumber: number, at: string | null): Html {8591 	const name = repo.settings.name;92+	const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at);8693 	const saves = timeline(repo.mirror);8794 	const rows = history(repo.mirror, PAGE_SIZE, (pageNumber - 1) * PAGE_SIZE);8895 	const count = plural(saves.length, "save");@@ -93,6 +100,7 @@93100 ${timelineSvg(name, saves)}94101 </details>95102 </section>103+${atlas}96104 <section class="repo-section" id="history">97105 <details class="repo-section-toggle" open>98106 <summary class="section-head"><span class="section-label">history</span><span class="section-count">${count}</span></summary>

view file

tests/describe.test.ts+11 −10
@@ -117,23 +117,24 @@117117 });118118 119119 describe("folders", () => {120+	const child = (name: string, text: string) => ({ name, description: describeFile(name, text) });121+120122 	test("a README's first paragraph wins over the module file", () => {121123 		const children = [122-			{ name: "mod.zig", text: "//! From the module.\n" },123-			{ name: "README.md", text: "# Pages\n\nFrom the readme.\n" },124+			child("mod.zig", "//! From the module.\n"),125+			child("README.md", "# Pages\n\nFrom the readme.\n"),124126 		];125127 		expect(describeFolder(children)).toBe("From the readme.");126128 	});127129 128130 	test("else the module file's header, else nothing", () => {129-		expect(describeFolder([{ name: "mod.rs", text: "//! Rust module.\n" }])).toBe("Rust module.");130-		expect(describeFolder([{ name: "index.ts", text: "// TS module.\nx();\n" }])).toBe(131-			"TS module.",132-		);133-		expect(describeFolder([{ name: "__init__.py", text: '"""Py package."""\n' }])).toBe(134-			"Py package.",135-		);136-		expect(describeFolder([{ name: "other.ts", text: "// Not a module file.\n" }])).toBeNull();131+		expect(describeFolder([child("mod.rs", "//! Rust module.\n")])).toBe("Rust module.");132+		expect(describeFolder([child("index.ts", "// TS module.\nx();\n")])).toBe("TS module.");133+		expect(describeFolder([child("__init__.py", '"""Py package."""\n')])).toBe("Py package.");134+		expect(135+			describeFolder([child("README.md", "# Only a title\n"), child("mod.zig", "//! Mod.\n")]),136+		).toBe("Mod.");137+		expect(describeFolder([child("other.ts", "// Not a module file.\n")])).toBeNull();137138 	});138139 });139140 

view file

tests/fixture.ts+7 −2
@@ -34,7 +34,7 @@3434 /**3535  * Builds a genuine Koh 3 folder remote with koh3 itself, under a throwaway HOME. The line is:3636  * "add docs, a secret and a deep helper" (flagged) → a second later "add readme and entry point"37- * (agent and session) → a checkpoint → "say hello" (head).37+ * (agent and session) → a checkpoint holding an edit to the guide → "say hello" (head).3838  */3939 export async function makeKohBucket(repo = "demo"): Promise<Fixture> {4040 	const root = await mkdtemp(join(tmpdir(), "kepr-fixture-"));@@ -52,7 +52,10 @@5252 	await writeFile(join(repoDir, "docs/guide.md"), "# Guide\n\nRead me.\n");5353 	await writeFile(join(repoDir, "docs/logo.png"), PNG);5454 	await writeFile(join(repoDir, "secret.txt"), "hunter2\n");55-	await writeFile(join(repoDir, "src/lib/deep/util.ts"), "export const one = 1;\n");55+	await writeFile(56+		join(repoDir, "src/lib/deep/util.ts"),57+		"// The *one* helper. Deep down.\nexport const one = 1;\n",58+	);5659 	await writeFile(join(repoDir, "vendor/big.c"), "int x;\n".repeat(100));5760 	koh("save", "add docs, a secret and a deep helper");5861 	const first = JSON.parse(koh("log", "--json")).head_id as string;@@ -62,6 +65,8 @@6265 	await writeFile(join(repoDir, "README.md"), README);6366 	await writeFile(join(repoDir, "src/main.ts"), "// Entry point.\nconsole.log('hi');\n");6467 	koh("save", "add readme and entry point", "--agent", "test-agent", "--session", "fixture");68+	// The guide's edit is first recorded by the checkpoint; the Atlas must credit it to "say hello".69+	await writeFile(join(repoDir, "docs/guide.md"), "# Guide\n\nRead me. It is short.\n");6570 	koh("checkpoint", "before the second change");6671 	await writeFile(join(repoDir, "src/main.ts"), "// Entry point.\nconsole.log('hello');\n");6772 	koh("save", "say hello");

view file

tests/pages.test.ts+78 −0
@@ -161,6 +161,84 @@161161 	});162162 });163163 164+describe("the Atlas", () => {165+	// The detail panel markup waiting in the <template> after a file's row.166+	const panelOf = (page: string, name: string) =>167+		new RegExp(`data-name="${name}">.*?</a>\\n<template>(.*?)</template>`, "s").exec(page)?.[1] ??168+		"";169+	const rowOf = (page: string, name: string) =>170+		new RegExp(`data-name="${name}">(.*?)</a>`, "s").exec(page)?.[1] ?? "";171+172+	test("sits between the timeline and History", async () => {173+		const page = await body("/demo");174+		const atlas = page.indexOf('id="atlas"');175+		expect(page.indexOf('class="timeline"')).toBeLessThan(atlas);176+		expect(atlas).toBeLessThan(page.indexOf('id="history"'));177+	});178+179+	test("is a folder tree, folders first, then files, alphabetical", async () => {180+		const names = [...(await body("/demo")).matchAll(/class="atlas-name">([^<]*)</g)].map(181+			(m) => m[1],182+		);183+		expect(names).toEqual([184+			"docs/",185+			"guide.md",186+			"logo.png",187+			"src/",188+			"lib/",189+			"deep/",190+			"util.ts",191+			"main.ts",192+			"vendor/",193+			"big.c",194+			".kohignore",195+			"README.md",196+			"secret.txt",197+		]);198+	});199+200+	test("rows carry the first sentence of the file's own comment, rendered, and link to the file", async () => {201+		const page = await body("/demo");202+		expect(rowOf(page, "util.ts")).toContain(203+			'<span class="atlas-desc">The <em>one</em> helper.</span>',204+		);205+		expect(rowOf(page, "main.ts")).toContain("Entry point.");206+		expect(rowOf(page, "guide.md")).toContain('<span class="atlas-desc">Read me.</span>');207+		expect(rowOf(page, "secret.txt")).toContain('<span class="atlas-desc"></span>');208+		expect(page).toContain(209+			'<a class="atlas-row atlas-file" href="/demo/files/src/main.ts" data-name="main.ts">',210+		);211+		expect(panelOf(page, "util.ts")).toContain("<p>The <em>one</em> helper. Deep down.</p>");212+	});213+214+	test("the panel counts only real saves, crediting a checkpoint's change to the next save", async () => {215+		const page = await body("/demo");216+		const main = panelOf(page, "main.ts");217+		expect(main).toContain("<dt>lines</dt><dd>2</dd>");218+		expect(main).toContain("<dt>saves</dt><dd>2</dd>");219+		expect(main).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${second()}">`);220+		expect(main).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);221+		expect(main.indexOf(head())).toBeLessThan(main.lastIndexOf(second()));222+		const guide = panelOf(page, "guide.md");223+		expect(guide).toContain("<dt>saves</dt><dd>2</dd>");224+		expect(guide).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);225+		expect(panelOf(page, "logo.png")).toContain("<dt>lines</dt><dd>binary</dd>");226+		expect(page).not.toContain(fx.saveIds[1] as string);227+	});228+229+	test("can be viewed at an older save, and an unknown save is a 404", async () => {230+		const page = await body(`/demo?at=${second().slice(0, 8)}`);231+		expect(page).toContain(`at <a href="/demo/saves/${second()}">${second()}</a>`);232+		expect(page).toContain(`href="/demo/files/src/main.ts?at=${second()}"`);233+		expect(panelOf(page, "main.ts")).toContain("<dt>saves</dt><dd>1</dd>");234+		expect(panelOf(page, "main.ts")).not.toContain(head());235+		const oldest = await body(`/demo?at=${first()}`);236+		expect(oldest).not.toContain('data-name="main.ts"');237+		expect((await get("/demo?at=abcdef")).status).toBe(404);238+		expect((await get("/demo?at=../x")).status).toBe(404);239+	});240+});241+164242 describe("raw files", () => {165243 	test("are the exact object bytes, text as text/plain", async () => {166244 		const mirror = openMirror(mirrorDir, "demo");

view file