a6176ba16faf
give Atlas folder panels the full history facts and make the island ignore malformed remembered folders (v3.0.13)
- time
- 2026-09-26 18:19 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- kepr 3 implementation
- parent
- 08451dd8bbb6
↓ kepr-a6176ba1.tar.gzatlas at this save
files changed5 files
package.json+1 −1public/js/kepr.js+4 −2src/mirror/queries.ts+12 −15src/pages/atlas.ts+58 −37tests/pages.test.ts+24 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.12",3+ "version": "3.0.13",44 "private": true,55 "type": "module",66 "scripts": {public/js/kepr.js+4 −2
@@ -123,10 +123,12 @@123123 panel.hidden = false;124124 }125125 126-// Storage can be missing or refused (private windows, blocked site data); the tree then starts closed.126+// Storage can be missing, refused (private windows, blocked site data) or hold anything; only a list127+// of folder paths is trusted, and otherwise the tree starts closed.127128 function remembered(key) {128129 try {129- return JSON.parse(localStorage.getItem(key) ?? "[]");130+ const value = JSON.parse(localStorage.getItem(key) ?? "[]");131+ return Array.isArray(value) && value.every((path) => typeof path === "string") ? value : [];130132 } catch {131133 return [];132134 }src/mirror/queries.ts+12 −15
@@ -15,8 +15,8 @@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[] };18+/** The saves that touched each path up to one save, oldest first; `line` is every one of them in line order. */19+export type Touches = { line: Save[]; byPath: Map<string, Save[]> };2020 2121 export type RepoStats = { saves: number; lines: number; language: string; updated: number | null };2222 @@ -102,11 +102,11 @@102102 }103103 104104 /**105- * Each path's history on the line up to `saveId` (§21.5's detail panel). A save touched a path when105+ * Which saves touched each path on the line up to `saveId` (§21.5's detail panel). A save touched a path when106106 * the path was added, changed or removed against the save before it; checkpoints are skipped, so a107107 * change first recorded by a checkpoint counts at the next real save.108108 */109-export function fileHistories(db: Database, saveId: string): Map<string, FileHistory> {109+export function fileTouches(db: Database, saveId: string): Touches {110110 const rows = db111111 .query<Save & { path: string; n: number }, { at: string }>(112112 `WITH line AS (@@ -122,19 +122,16 @@122122 ORDER BY n`,123123 )124124 .all({ at: saveId });125- const histories = new Map<string, FileHistory>();125+ const saves = new Map<string, Save>();126+ const byPath = new Map<string, Save[]>();126127 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)];128+ const save = saves.get(row.id) ?? asSave(row);129+ saves.set(save.id, save);130+ const touched = byPath.get(path);131+ if (touched) touched.push(save);132+ else byPath.set(path, [save]);136133 }137- return histories;134+ return { line: [...saves.values()], byPath };138135 }139136 140137 function asSave(row: Save): Save {src/pages/atlas.ts+58 −37
@@ -1,11 +1,5 @@11 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";2+import { filesAt, fileTouches, objectBytes, type Save, type Touches } from "../mirror/queries.ts";93 import { isText } from "./files.ts";104 import { type Html, html } from "./html.ts";115 import {@@ -24,10 +18,18 @@2418 size: number;2519 lines: number | null;2620 description: string | null;27- history: FileHistory | undefined;21+ /** The saves that touched it, oldest first. */22+ saves: Save[];2823 };2924 30-type AtlasFolder = { name: string; path: string; folders: AtlasFolder[]; files: AtlasFile[] };25+type AtlasFolder = {26+ name: string;27+ path: string;28+ folders: AtlasFolder[];29+ files: AtlasFile[];30+ /** The saves that touched anything under it, removed files included, oldest first. */31+ saves: Save[];32+};3133 3234 type View = { repo: string; at: string | null };3335 @@ -59,8 +61,9 @@5961 * island shows on hover or select.6062 */6163 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 touches = fileTouches(repo.mirror, saveId);65+ const foldersTouches = folderTouches(touches);66+ const root: AtlasFolder = { name: "", path: "", folders: [], files: [], saves: [] };6467 const entries = filesAt(repo.mirror, saveId);6568 for (const entry of entries) {6669 const parts = entry.path.split("/");@@ -69,7 +72,7 @@6972 let child = folder.folders.find((f) => f.name === name);7073 if (!child) {7174 const path = folder.path ? `${folder.path}/${name}` : name;72- child = { name, path, folders: [], files: [] };75+ child = { name, path, folders: [], files: [], saves: foldersTouches.get(path) ?? [] };7376 folder.folders.push(child);7477 }7578 folder = child;@@ -80,7 +83,7 @@8083 path: entry.path,8184 size: entry.size,8285 ...factsOf(repo.mirror, entry.hash, name),83- history: histories.get(entry.path),86+ saves: touches.byPath.get(entry.path) ?? [],8487 });8588 }8689 const view = { repo: repo.settings.name, at };@@ -113,19 +116,18 @@113116 const { description } = file;114117 const base = markdownBase(view, file.path);115118 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>119+ return html`<a class="atlas-row atlas-file" href="${href}" data-name="${file.name.toLowerCase()}">${row(file.name, description, base, file.size, file.saves)}</a>118120 <template>${panel(119121 file.name,120122 file.path,121123 [122124 ["size", formatSize(file.size)],123125 ["lines", file.lines === null ? "binary" : file.lines.toLocaleString("en-US")],124- ...saveFacts(file.history, view),126+ ...saveFacts(file.saves, view),125127 ],126128 description,127129 base,128- )}${file.history && recent(file.history.recent, view)}</template>`;130+ )}${recent(file.saves, view)}</template>`;129131 }130132 131133 function folderRow(folder: AtlasFolder, view: View): Html {@@ -133,25 +135,21 @@133135 const base = markdownBase(view, `${folder.path}/`);134136 const files = allFiles(folder);135137 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- );138+ const lines = files.reduce((sum, f) => sum + (f.lines ?? 0), 0);142139 return html`<details class="atlas-folder" data-path="${folder.path}">143-<summary class="atlas-row">${row(`${folder.name}/`, description, base, size, last)}</summary>140+<summary class="atlas-row">${row(`${folder.name}/`, description, base, size, folder.saves)}</summary>144141 <template>${panel(145142 `${folder.name}/`,146143 folder.path,147144 [148145 ["size", formatSize(size)],146+ ["lines", lines.toLocaleString("en-US")],149147 ["files", files.length.toLocaleString("en-US")],150- ...(last ? ([["last save", saveLink(last, view)]] as [string, Html][]) : []),148+ ...saveFacts(folder.saves, view),151149 ],152150 description,153151 base,154- )}</template>152+ )}${recent(folder.saves, view)}</template>155153 <div class="atlas-children">${children(folder, view)}</div>156154 </details>`;157155 }@@ -161,8 +159,9 @@161159 description: string | null,162160 base: MarkdownBase,163161 size: number,164- last: Save | undefined,162+ saves: Save[],165163 ): Html {164+ const last = saves[saves.length - 1];166165 const meta = [formatSize(size), last && relativeTime(last.created_at)]167166 .filter(Boolean)168167 .join(" · ");@@ -182,12 +181,13 @@182181 ${description && html`<div class="markdown atlas-info-desc">${renderMarkdown(description, base)}</div>`}`;183182 }184183 185-function saveFacts(history: FileHistory | undefined, view: View): [string, Html | string][] {186- if (!history) return [];184+function saveFacts(saves: Save[], view: View): [string, Html | string][] {185+ const [first, last] = [saves[0], saves[saves.length - 1]];186+ if (!first || !last) return [];187187 return [188- ["first save", saveLink(history.first, view)],189- ["last save", saveLink(history.last, view)],190- ["saves", history.touches.toLocaleString("en-US")],188+ ["first save", saveLink(first, view)],189+ ["last save", saveLink(last, view)],190+ ["saves", saves.length.toLocaleString("en-US")],191191 ];192192 }193193 @@ -195,11 +195,32 @@195195 return html`<a href="/${view.repo}/saves/${save.id}">${save.id}</a> ${formatDate(save.created_at)}`;196196 }197197 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>`;198+function recent(saves: Save[], view: View): Html | null {199+ if (saves.length === 0) return null;200+ return html`<ul class="atlas-recent">${saves201+ .slice(-5)202+ .reverse()203+ .map(204+ (save) =>205+ 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>`,206+ )}</ul>`;207+}208+209+// Each folder's saves are the line's saves that touched any path under it, in line order.210+function folderTouches(touches: Touches): Map<string, Save[]> {211+ const ids = new Map<string, Set<string>>();212+ for (const [path, saves] of touches.byPath) {213+ const parts = path.split("/");214+ for (let depth = 1; depth < parts.length; depth++) {215+ const folder = parts.slice(0, depth).join("/");216+ const set = ids.get(folder) ?? new Set<string>();217+ for (const save of saves) set.add(save.id);218+ ids.set(folder, set);219+ }220+ }221+ return new Map(222+ [...ids].map(([folder, set]) => [folder, touches.line.filter((save) => set.has(save.id))]),223+ );203224 }204225 205226 function allFiles(folder: AtlasFolder): AtlasFile[] {tests/pages.test.ts+24 −0
@@ -229,6 +229,30 @@229229 expect(page).not.toContain(fx.saveIds[1] as string);230230 });231231 232+ test("a folder's panel sums its files and counts the saves that touched anything under it", async () => {233+ const page = await body("/demo");234+ const folderPanel = (path: string) =>235+ new RegExp(236+ `data-path="${path}">\\n<summary.*?</summary>\\n<template>(.*?)</template>`,237+ "s",238+ ).exec(page)?.[1] ?? "";239+ const docs = folderPanel("docs");240+ expect(docs).toContain('<p class="atlas-info-path mono">docs</p>');241+ expect(docs).toContain("<dt>lines</dt><dd>3</dd>");242+ expect(docs).toContain("<dt>files</dt><dd>2</dd>");243+ expect(docs).toContain("<dt>saves</dt><dd>2</dd>");244+ expect(docs).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${first()}">`);245+ expect(docs).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);246+ const src = folderPanel("src");247+ expect(src).toContain("<dt>saves</dt><dd>3</dd>");248+ const recentIds = [249+ ...(/<ul class="atlas-recent">(.*?)<\/ul>/s.exec(src)?.[1] ?? "").matchAll(250+ /saves\/([0-9a-f]+)/g,251+ ),252+ ].map((m) => m[1]);253+ expect(recentIds).toEqual([head(), second(), first()]);254+ });255+232256 test("can be viewed at an older save, and an unknown save is a 404", async () => {233257 const page = await body(`/demo?at=${second().slice(0, 8)}`);234258 expect(page).toContain(`at <a href="/demo/saves/${second()}">${second()}</a>`);