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
5 files package.json+1 −1public/js/kepr.js+4 −2src/mirror/queries.ts+12 −15src/pages/atlas.ts+58 −37tests/pages.test.ts+24 −0
package.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": {

view file

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 	}

view file

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 {

view file

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[] {

view file

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>`);

view file