dc678fae81ff

fetch each Atlas panel on demand from /:repo/atlas/panel and keep the computed Atlas per repo and save, so a big repo's page carries rows only (v3.0.185)

time
2026-10-01 20:37 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
atlas speed
parent
77686535d05d
5 files package.json+1 −1public/js/kepr.js+31 −10src/pages/atlas.ts+88 −35src/pages/repo.ts+19 −1tests/pages.test.ts+33 −21
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.184",3+	"version": "3.0.185",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/js/kepr.js+31 −10
@@ -359,8 +359,8 @@359359 	mark();360360 }361361 362-// The Atlas (§21.5): each row's detail panel waits in the <template> after it; the filter narrows363-// by filename; which folders are open is remembered per repo in this browser.362+// The Atlas (§21.5): each row's detail panel is fetched on hover or select and kept for the page's363+// life; the filter narrows by filename; which folders are open is remembered per repo in this browser.364364 function atlas() {365365 	const root = document.querySelector(".atlas");366366 	const filter = document.querySelector(".atlas-filter");@@ -385,12 +385,32 @@385385 			remember(key, [...opened]);386386 		});387387 	}388+	const panels = new Map();389+	const panelFor = (path) => {390+		if (!panels.has(path)) {391+			const query = new URLSearchParams({ path });392+			if (root.dataset.at) query.set("at", root.dataset.at);393+			const text = fetch(`/${root.dataset.repo}/atlas/panel?${query}`)394+				.then((response) => (response.ok ? response.text() : Promise.reject()))395+				.catch(() => {396+					panels.delete(path);397+					return "";398+				});399+			panels.set(path, text);400+		}401+		return panels.get(path).then((text) => {402+			const template = document.createElement("template");403+			template.innerHTML = text;404+			return template.content;405+		});406+	};388407 	let selected = null;389-	const show = (row) => {408+	const show = async (row) => {390409 		selected?.classList.remove("selected");391410 		selected = row;392411 		row.classList.add("selected");393-		panel.replaceChildren(row.nextElementSibling.content.cloneNode(true));412+		const content = await panelFor(row.dataset.path ?? `${row.parentElement.dataset.path}/`);413+		if (selected === row) panel.replaceChildren(content);394414 	};395415 	for (const row of root.querySelectorAll(".atlas-row")) {396416 		row.addEventListener("pointerenter", (event) => {@@ -413,7 +433,7 @@413433 		const link = event.target.closest("a.atlas-jump");414434 		if (link && !modified(event) && jump(link.dataset.path)) event.preventDefault();415435 	});416-	const viewer = fileViewer(root, jump);436+	const viewer = fileViewer(root, jump, panelFor);417437 	for (const file of files) {418438 		file.addEventListener("click", (event) => {419439 			if (modified(event)) return;@@ -488,7 +508,7 @@488508 // Files open in a viewer over the page, as in Kepr 2's Atlas: the file page's content with the489509 // row's detail panel beside it, and controls for text size, wrapping, raw, download and the490510 // neighbouring files. Escape closes it; the arrow keys step through the files.491-function fileViewer(root, jump) {511+function fileViewer(root, jump, panelFor) {492512 	let [row, size, wrap] = [null, 12, false];493513 	const overlay = element("div", "fv-overlay");494514 	overlay.tabIndex = -1;@@ -512,9 +532,8 @@512532 		row = file;513533 		const rows = [...root.querySelectorAll(".atlas-file")].filter((each) => !each.hidden);514534 		const index = rows.indexOf(file);515-		const detail = file.nextElementSibling.content.cloneNode(true);516-		const name = detail.querySelector(".atlas-info-name").textContent;517-		const path = detail.querySelector(".atlas-info-path").textContent;535+		const path = file.dataset.path;536+		const name = path.split("/").pop();518537 		const raw = new URL(file.href);519538 		raw.pathname = raw.pathname.replace(/^(\/[^/]+)\/files\//, "$1/raw/");520539 		const button = (text, action, className = "fv-ctrl-btn") => {@@ -531,7 +550,9 @@531550 		};532551 		const source = element("div", "fv-source", "loading…");533552 		const panel = element("aside", "fv-detail");534-		panel.append(detail);553+		panelFor(path).then((content) => {554+			if (row === file) panel.replaceChildren(content);555+		});535556 		const header = element("div", "fv-header");536557 		const title = element("div", "fv-title");537558 		title.append(element("span", "fv-name", name), element("span", "fv-path mono", path));

view file

src/pages/atlas.ts+88 −35
@@ -1,6 +1,13 @@11 import { describeFile, describeFolder, firstSentence } from "../describe/describe.ts";22 import { isTestPath, type Outline, outline, repoIndex, resolveImports } from "../describe/links.ts";3-import { filesAt, fileTouches, objectBytes, type Save, type Touches } from "../mirror/queries.ts";3+import {4+	filesAt,5+	fileTouches,6+	objectBytes,7+	type Save,8+	type Touches,9+	tarballGeneration,10+} from "../mirror/queries.ts";411 import { isText } from "./files.ts";512 import { type Html, html } from "./html.ts";613 import {@@ -67,18 +74,21 @@6774 	return facts;6875 }6976 70-/**71- * §21.5's Atlas at one save: the folder tree as `<details>`, so it opens and links without72- * JavaScript. Each row is followed by a `<template>` holding its detail panel, which the browser73- * island shows on hover or select. `HEAD` (or the save shown) opens Kepr 2's picker of saves to74- * view it at instead.75- */76-export function atlasSection(77-	repo: PublicRepo,78-	saveId: string,79-	at: string | null,80-	saves: Save[],81-): Html {77+/** The tree at one save with each file's facts and the links between files. */78+type AtlasModel = { root: AtlasFolder; links: Links; count: number };79+80+// Building a model reads every file's facts and every save that touched each path: about 165 ms81+// warm for a 550-file repo on Tylee. Panels are fetched one at a time on hover, so the model is82+// kept, keyed by everything that changes what it holds: the save, and the strips and hidden paths83+// (`tarballGeneration`).84+// ponytail: the newest MODELS models in insertion order, not an LRU; enough for a few repos at once.85+const MODELS = 16;86+const models = new Map<string, AtlasModel>();87+88+function atlasModel(repo: PublicRepo, saveId: string): AtlasModel {89+	const key = `${repo.settings.name} ${saveId} ${tarballGeneration(repo.mirror)}`;90+	const cached = models.get(key);91+	if (cached) return cached;8292 	const touches = fileTouches(repo.mirror, saveId);8393 	const foldersTouches = folderTouches(touches);8494 	const root: AtlasFolder = { name: "", path: "", folders: [], files: [], saves: [] };@@ -105,11 +115,30 @@105115 			saves: touches.byPath.get(entry.path) ?? [],106116 		});107117 	}108-	const view = { repo: repo.settings.name, at, links: linksOf(allFiles(root)) };118+	const model = { root, links: linksOf(allFiles(root)), count: entries.length };119+	if (models.size >= MODELS) models.delete(models.keys().next().value as string);120+	models.set(key, model);121+	return model;122+}123+124+/**125+ * §21.5's Atlas at one save: the folder tree as `<details>`, so it opens and links without126+ * JavaScript. A row's detail panel is fetched from `atlasPanel` on hover or select (kepr.js), so127+ * a big repo's page carries rows only. `HEAD` (or the save shown) opens Kepr 2's picker of saves128+ * to view it at instead.129+ */130+export function atlasSection(131+	repo: PublicRepo,132+	saveId: string,133+	at: string | null,134+	saves: Save[],135+): Html {136+	const model = atlasModel(repo, saveId);137+	const view = { repo: repo.settings.name, at, links: model.links };109138 	return html`<section class="repo-section" id="atlas">110139 <details class="repo-section-toggle" open>111140 <summary class="section-head"><span class="section-label">atlas</span><span class="section-count">${plural(112-		entries.length,141+		model.count,113142 		"file",114143 	)}</span></summary>115144 <div class="atlas-bar">@@ -119,14 +148,34 @@119148 <button type="button" class="atlas-search-btn mono" hidden>search</button>120149 <input class="atlas-filter mono" type="search" placeholder="filter by filename" aria-label="Filter files by name" hidden>121150 </div>122-<div class="atlas" data-repo="${view.repo}">123-<div class="atlas-tree">${children(root, view)}</div>151+<div class="atlas" data-repo="${view.repo}"${at && html` data-at="${at}"`}>152+<div class="atlas-tree">${children(model.root, view)}</div>124153 <aside class="atlas-panel" hidden><p class="atlas-hint mono">hover or select a row for details</p></aside>125154 </div>126155 </details>127156 </section>`;128157 }129158 159+/** One row's detail panel: a file's, or a folder's when `path` ends in a slash; null if absent. */160+export function atlasPanel(161+	repo: PublicRepo,162+	saveId: string,163+	at: string | null,164+	path: string,165+): Html | null {166+	const model = atlasModel(repo, saveId);167+	const view = { repo: repo.settings.name, at, links: model.links };168+	const parts = path.replace(/\/$/, "").split("/");169+	let folder: AtlasFolder | undefined = model.root;170+	for (const name of path.endsWith("/") ? parts : parts.slice(0, -1)) {171+		folder = folder?.folders.find((f) => f.name === name);172+	}173+	if (!folder) return null;174+	if (path.endsWith("/")) return folderPanel(folder, view);175+	const file = folder.files.find((f) => f.path === path);176+	return file ? filePanel(file, view) : null;177+}178+130179 // Kepr 2's "explore through time": every save, newest first. The filter is kepr.js's.131180 function picker(view: View, saves: Save[]): Html {132181 	return html`<details class="atlas-picker">@@ -153,13 +202,14 @@153202 }154203 155204 function fileRow(file: AtlasFile, view: View): Html {156-	const { description } = file;157-	const base = markdownBase(view, file.path);158205 	const href = `/${view.repo}/files/${pathUrl(file.path)}${view.at ? `?at=${view.at}` : ""}`;159206 	return html`<a class="atlas-row atlas-file" href="${160207 		href161-	}" data-name="${file.name.toLowerCase()}" data-path="${file.path}">${row(file.name, description, base, file.size, file.saves)}</a>162-<template>${panel(208+	}" data-name="${file.name.toLowerCase()}" data-path="${file.path}">${row(file.name, file.description, markdownBase(view, file.path), file.size, file.saves)}</a>`;209+}210+211+function filePanel(file: AtlasFile, view: View): Html {212+	return panel(163213 		file.name,164214 		file.path,165215 		[@@ -167,10 +217,10 @@167217 			["lines", file.lines === null ? "binary" : file.lines.toLocaleString("en-US")],168218 			...saveFacts(file.saves, view),169219 		],170-		description,171-		base,220+		file.description,221+		markdownBase(view, file.path),172222 		fileMap(file, view),173-	)}</template>`;223+	);174224 }175225 176226 // Kepr 2's map of the repo, only where a file has something to say: what it offers, what it@@ -226,20 +276,25 @@226276 }227277 228278 function folderRow(folder: AtlasFolder, view: View): Html {229-	const description = describeFolder(folder.files);230-	const base = markdownBase(view, `${folder.path}/`);231279 	const files = allFiles(folder);232280 	const size = files.reduce((sum, f) => sum + f.size, 0);233-	const lines = files.reduce((sum, f) => sum + (f.lines ?? 0), 0);234281 	return html`<details class="atlas-folder" data-path="${folder.path}">235282 <summary class="atlas-row">${row(236283 		`${folder.name}/`,237-		description,238-		base,284+		describeFolder(folder.files),285+		markdownBase(view, `${folder.path}/`),239286 		size,240287 		folder.saves,241288 	)}</summary>242-<template>${panel(289+<div class="atlas-children">${children(folder, view)}</div>290+</details>`;291+}292+293+function folderPanel(folder: AtlasFolder, view: View): Html {294+	const files = allFiles(folder);295+	const size = files.reduce((sum, f) => sum + f.size, 0);296+	const lines = files.reduce((sum, f) => sum + (f.lines ?? 0), 0);297+	return panel(243298 		`${folder.name}/`,244299 		folder.path,245300 		[@@ -248,12 +303,10 @@248303 			["files", files.length.toLocaleString("en-US")],249304 			...saveFacts(folder.saves, view),250305 		],251-		description,252-		base,306+		describeFolder(folder.files),307+		markdownBase(view, `${folder.path}/`),253308 		[["used by", targets(folderUsers(folder, files, view), view)]],254-	)}</template>255-<div class="atlas-children">${children(folder, view)}</div>256-</details>`;309+	);257310 }258311 259312 function row(

view file

src/pages/repo.ts+19 −1
@@ -1,4 +1,5 @@11 import type { Hono } from "hono";2+import { isSafePath } from "../bucket/repo.ts";23 import { describeFile } from "../describe/describe.ts";34 import {45 	fileAt,@@ -13,7 +14,7 @@1314 } from "../mirror/queries.ts";1415 import { visibleReleases } from "../releases/releases.ts";1516 import { setting } from "../server/db.ts";16-import { atlasSection } from "./atlas.ts";17+import { atlasPanel, atlasSection } from "./atlas.ts";1718 import { type Html, html } from "./html.ts";1819 import {1920 	cacheFor,@@ -59,6 +60,23 @@5960 ${details(repoDetails(repo))}`,6061 			}),6162 		);63+	});64+	// One Atlas row's detail panel, fetched by kepr.js on hover or select (§21.5).65+	app.get("/:repo/atlas/panel", (c) => {66+		const repo = openPublicRepo(ctx, c.req.param("repo"));67+		const at = c.req.query("at");68+		const path = c.req.query("path") ?? "";69+		const save = repo && (at === undefined ? null : saveByPrefix(repo.mirror, at));70+		const saveId = save?.id ?? (repo && headId(repo.mirror));71+		const panel =72+			repo &&73+			saveId &&74+			(at === undefined || save) &&75+			isSafePath(path.replace(/\/$/, "")) &&76+			atlasPanel(repo, saveId, save?.id ?? null, path);77+		if (!panel) return notFound(c, ctx);78+		cacheFor(c, save !== null && save !== undefined);79+		return c.html(String(panel));6280 	});6381 }6482 

view file

tests/pages.test.ts+33 −21
@@ -220,11 +220,9 @@220220 });221221 222222 describe("the Atlas", () => {223-	// The detail panel markup waiting in the <template> after a file's row.224-	const panelOf = (page: string, name: string) =>225-		new RegExp(`data-name="${name}"[^>]*>.*?</a>\\n<template>(.*?)</template>`, "s").exec(226-			page,227-		)?.[1] ?? "";223+	// A row's detail panel, fetched on hover or select; a folder's path ends in a slash.224+	const panelOf = (path: string, at?: string) =>225+		body(`/demo/atlas/panel?${new URLSearchParams(at ? { path, at } : { path })}`);228226 	const rowOf = (page: string, name: string) =>229227 		new RegExp(`data-name="${name}"[^>]*>(.*?)</a>`, "s").exec(page)?.[1] ?? "";230228 @@ -266,40 +264,35 @@266264 		expect(page).toContain(267265 			'<a class="atlas-row atlas-file" href="/demo/files/src/main.ts" data-name="main.ts" data-path="src/main.ts">',268266 		);269-		expect(panelOf(page, "util.ts")).toContain("<p>The <em>one</em> helper. Deep down.</p>");270-		expect(panelOf(page, "util.ts")).toContain("<dt>exports</dt><dd>one</dd>");271-		expect(panelOf(page, "main.ts")).not.toContain("atlas-map");267+		const util = await panelOf("src/lib/deep/util.ts");268+		expect(util).toContain("<p>The <em>one</em> helper. Deep down.</p>");269+		expect(util).toContain("<dt>exports</dt><dd>one</dd>");270+		expect(await panelOf("src/main.ts")).not.toContain("atlas-map");272271 	});273272 274273 	test("the panel counts only real saves, crediting a checkpoint's change to the next save", async () => {275274 		const page = await body("/demo");276-		const main = panelOf(page, "main.ts");275+		const main = await panelOf("src/main.ts");277276 		expect(main).toContain("<dt>lines</dt><dd>2</dd>");278277 		expect(main).toContain("<dt>saves</dt><dd>2</dd>");279278 		expect(main).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${second()}">`);280279 		expect(main).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);281-		const guide = panelOf(page, "guide.md");280+		const guide = await panelOf("docs/guide.md");282281 		expect(guide).toContain("<dt>saves</dt><dd>2</dd>");283282 		expect(guide).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);284-		expect(panelOf(page, "logo.png")).toContain("<dt>lines</dt><dd>binary</dd>");283+		expect(await panelOf("docs/logo.png")).toContain("<dt>lines</dt><dd>binary</dd>");285284 		expect(page).not.toContain(fx.saveIds[1] as string);286285 	});287286 288287 	test("a folder's panel sums its files and counts the saves that touched anything under it", async () => {289-		const page = await body("/demo");290-		const folderPanel = (path: string) =>291-			new RegExp(292-				`data-path="${path}">\\n<summary.*?</summary>\\n<template>(.*?)</template>`,293-				"s",294-			).exec(page)?.[1] ?? "";295-		const docs = folderPanel("docs");288+		const docs = await panelOf("docs/");296289 		expect(docs).toContain('<p class="atlas-info-path mono">docs</p>');297290 		expect(docs).toContain("<dt>lines</dt><dd>3</dd>");298291 		expect(docs).toContain("<dt>files</dt><dd>2</dd>");299292 		expect(docs).toContain("<dt>saves</dt><dd>2</dd>");300293 		expect(docs).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${first()}">`);301294 		expect(docs).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);302-		const src = folderPanel("src");295+		const src = await panelOf("src/");303296 		expect(src).toContain("<dt>saves</dt><dd>3</dd>");304297 	});305298 @@ -309,12 +302,31 @@309302 		expect(page).toContain(`<a class="atlas-picker-row active" href="/demo?at=${second()}#atlas"`);310303 		expect(page).toContain('<a class="atlas-head-link mono" href="/demo#atlas">back to HEAD</a>');311304 		expect(page).toContain(`href="/demo/files/src/main.ts?at=${second()}"`);312-		expect(panelOf(page, "main.ts")).toContain("<dt>saves</dt><dd>1</dd>");313-		expect(panelOf(page, "main.ts")).not.toContain(head());305+		const main = await panelOf("src/main.ts", second().slice(0, 8));306+		expect(main).toContain("<dt>saves</dt><dd>1</dd>");307+		expect(main).not.toContain(head());314308 		const oldest = await body(`/demo?at=${first()}`);315309 		expect(oldest).not.toContain('data-name="main.ts"');316310 		expect((await get("/demo?at=abcdef")).status).toBe(404);317311 		expect((await get("/demo?at=../x")).status).toBe(404);312+	});313+314+	test("the page carries no panels; each is fetched alone, and anything unknown is a 404", async () => {315+		expect(await body("/demo")).not.toContain("<template>");316+		const panel = await get("/demo/atlas/panel?path=src/main.ts");317+		expect(panel.status).toBe(200);318+		expect(panel.headers.get("content-type")).toContain("text/html");319+		for (const query of [320+			"path=nope.ts",321+			"path=src/main.ts&at=abcdef",322+			"path=../secret.txt",323+			"path=",324+			"",325+			"path=nope/",326+		]) {327+			expect(`${query} ${(await get(`/demo/atlas/panel?${query}`)).status}`).toBe(`${query} 404`);328+		}329+		expect((await get("/nobody/atlas/panel?path=README.md")).status).toBe(404);318330 	});319331 });320332 

view file