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
↓ kepr-5e4eb28d.tar.gzatlas at this save
files changed11 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 −0package.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": {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;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. */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 {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+}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.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+}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>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 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");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");