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
↓ kepr-dc678fae.tar.gzatlas at this save
files changed5 files
package.json+1 −1public/js/kepr.js+31 −10src/pages/atlas.ts+88 −35src/pages/repo.ts+19 −1tests/pages.test.ts+33 −21package.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": {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));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(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 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