ac9e6999d366

load the Atlas picker's saves when it first opens and cut timeline tooltips to 120 characters, taking a big repo's page from 1.5 MB to a fraction (v3.0.188)

time
2026-10-02 01:43 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
lighter repo pages
parent
8a3ca16206b1
6 files package.json+1 −1public/js/kepr.js+26 −6src/pages/atlas.ts+18 −16src/pages/repo.ts+12 −2src/pages/timeline.ts+7 −1tests/pages.test.ts+18 −2
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.187",3+	"version": "3.0.188",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/js/kepr.js+26 −6
@@ -483,18 +483,38 @@483483 	panel.hidden = false;484484 }485485 486-// Kepr 2's Atlas picker: saves filtered as you type; a click outside or Escape closes it.486+// Kepr 2's Atlas picker: its saves arrive when it first opens, filtered as you type; a click487+// outside or Escape closes it.487488 function atlasPicker() {488489 	const picker = document.querySelector(".atlas-picker");489490 	if (!picker) return;490491 	const filter = picker.querySelector(".atlas-picker-filter");491-	const rows = [...picker.querySelectorAll(".atlas-picker-row")];492-	filter.addEventListener("input", () => {492+	const list = picker.querySelector(".atlas-picker-list");493+	let rows = [];494+	const narrow = () => {493495 		const query = filter.value.trim().toLowerCase();494-		for (const row of rows) row.hidden = query !== "" && !row.dataset.q.includes(query);495-	});496+		for (const row of rows) {497+			row.hidden = query !== "" && !row.textContent.toLowerCase().includes(query);498+		}499+	};500+	filter.addEventListener("input", narrow);501+	let loading = null;496502 	picker.addEventListener("toggle", () => {497-		if (picker.open) filter.focus();503+		if (!picker.open) return;504+		filter.focus();505+		if (loading) return;506+		loading = fetch(list.dataset.src)507+			.then((response) => (response.ok ? response.text() : Promise.reject()))508+			.then((text) => {509+				const template = document.createElement("template");510+				template.innerHTML = text;511+				list.replaceChildren(template.content);512+				rows = [...list.children];513+				narrow();514+			})515+			.catch(() => {516+				loading = null;517+			});498518 	});499519 	document.addEventListener("click", (event) => {500520 		if (!picker.contains(event.target)) picker.open = false;

view file

src/pages/atlas.ts+18 −16
@@ -135,14 +135,9 @@135135  * §21.5's Atlas at one save: the folder tree as `<details>`, so it opens and links without136136  * JavaScript. A row's detail panel is fetched from `atlasPanel` on hover or select (kepr.js), so137137  * a big repo's page carries rows only. `HEAD` (or the save shown) opens Kepr 2's picker of saves138- * to view it at instead.138+ * to view it at instead, its rows fetched from `atlasPickerRows` when first opened.139139  */140-export function atlasSection(141-	repo: PublicRepo,142-	saveId: string,143-	at: string | null,144-	saves: Save[],145-): Html {140+export function atlasSection(repo: PublicRepo, saveId: string, at: string | null): Html {146141 	const model = atlasModel(repo, saveId);147142 	const view = { repo: repo.settings.name, at, links: model.links };148143 	return html`<section class="repo-section" id="atlas">@@ -152,7 +147,7 @@152147 		"file",153148 	)}</span></summary>154149 <div class="atlas-bar">155-${picker(view, saves)}150+${picker(view)}156151 ${at && html`<a class="atlas-head-link mono" href="/${view.repo}#atlas">back to HEAD</a>`}157152 <button type="button" class="atlas-fold-btn mono" hidden>expand all</button>158153 <button type="button" class="atlas-search-btn mono" hidden>search</button>@@ -186,20 +181,27 @@186181 	return file ? filePanel(file, view) : null;187182 }188183 189-// Kepr 2's "explore through time": every save, newest first. The filter is kepr.js's.190-function picker(view: View, saves: Save[]): Html {184+// Kepr 2's "explore through time". Its rows arrive when first opened (kepr.js): a big repo's185+// every save was most of its page. Without JavaScript, History's save pages link to the Atlas.186+function picker(view: View): Html {187+	const query = view.at ? `?at=${view.at}` : "";191188 	return html`<details class="atlas-picker">192189 <summary class="atlas-ref-tab mono" title="explore through time">${view.at ?? "HEAD"}</summary>193190 <div class="atlas-picker-popover">194191 <input class="atlas-picker-filter mono" type="search" placeholder="search" aria-label="Search saves" hidden>195-<div class="atlas-picker-list">${saves.toReversed().map((save) => {196-		const message = save.message.split("\n")[0] as string;197-		return html`<a class="atlas-picker-row${save.id === view.at ? " active" : ""}" href="/${198-			view.repo199-		}?at=${save.id}#atlas" data-q="${`${save.id} ${message}`.toLowerCase()}"><span class="atlas-picker-id">${save.id}</span><span class="atlas-picker-msg">${message}</span><span class="atlas-picker-age">· ${relativeTime(save.created_at)}</span></a>`;200-	})}</div>192+<div class="atlas-picker-list" data-src="/${view.repo}/atlas/saves${query}"><a class="atlas-picker-row" href="#history">pick a save from the history</a></div>201193 </div>202194 </details>`;195+}196+197+/** The picker's rows: every save, newest first, `at` marked. kepr.js filters on their text. */198+export function atlasPickerRows(repo: string, saves: Save[], at: string | null): Html[] {199+	return saves.toReversed().map((save) => {200+		const message = save.message.split("\n")[0] as string;201+		return html`<a class="atlas-picker-row${save.id === at ? " active" : ""}" href="/${202+			repo203+		}?at=${save.id}#atlas"><span class="atlas-picker-id">${save.id}</span><span class="atlas-picker-msg">${message}</span><span class="atlas-picker-age">· ${relativeTime(save.created_at)}</span></a>`;204+	});203205 }204206 205207 function children(folder: AtlasFolder, view: View): Html[] {

view file

src/pages/repo.ts+12 −2
@@ -14,7 +14,7 @@1414 } from "../mirror/queries.ts";1515 import { visibleReleases } from "../releases/releases.ts";1616 import { setting } from "../server/db.ts";17-import { atlasPanel, atlasSection } from "./atlas.ts";17+import { atlasPanel, atlasPickerRows, atlasSection } from "./atlas.ts";1818 import { type Html, html } from "./html.ts";1919 import {2020 	cacheFor,@@ -61,6 +61,16 @@6161 			}),6262 		);6363 	});64+	// The Atlas picker's rows, fetched by kepr.js when it first opens (§21.5).65+	app.get("/:repo/atlas/saves", (c) => {66+		const repo = openPublicRepo(ctx, c.req.param("repo"));67+		const at = c.req.query("at");68+		const save = repo && at !== undefined ? saveByPrefix(repo.mirror, at) : null;69+		if (!repo || (at !== undefined && !save)) return notFound(c, ctx);70+		cacheFor(c, false);71+		const rows = atlasPickerRows(repo.settings.name, timeline(repo.mirror), save?.id ?? null);72+		return c.html(rows.map(String).join(""));73+	});6474 	// One Atlas row's detail panel, fetched by kepr.js on hover or select (§21.5).6575 	app.get("/:repo/atlas/panel", (c) => {6676 		const repo = openPublicRepo(ctx, c.req.param("repo"));@@ -162,7 +172,7 @@162172 		newer: pageNumber > 1 ? pageNumber - 1 : null,163173 		older: pageNumber * PAGE_SIZE < saves.length ? pageNumber + 1 : null,164174 	};165-	const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at, saves);175+	const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at);166176 	return html`<section class="repo-section">167177 <details class="repo-section-toggle" open>168178 <summary class="section-head"><span class="section-label">timeline</span></summary>

view file

src/pages/timeline.ts+7 −1
@@ -2,6 +2,8 @@22 import { type Html, html } from "./html.ts";33 import { formatDate } from "./layout.ts";44 5+const TIP_CHARS = 120;6+57 /**68  * §21.4's dotted timeline as SVG, so it draws without JavaScript (kepr.js swaps it for Kepr 2's79  * drifting canvas, reading each save from its dot): one dot per save on a dashed line, x by@@ -23,7 +25,11 @@2325 		const meta = [formatDate(save.created_at), save.agent, save.session]2426 			.filter(Boolean)2527 			.join(" · ");26-		const tip = `${save.id}\n${save.message.split("\n")[0]}\n${meta}`;28+		// The tip is 340 px wide; a whole message wrapped to many lines and was most of a big29+		// repo's page.30+		const line = save.message.split("\n")[0] as string;31+		const message = line.length > TIP_CHARS ? `${line.slice(0, TIP_CHARS - 1)}…` : line;32+		const tip = `${save.id}\n${message}\n${meta}`;2733 		return html`<a href="/${2834 			repo2935 		}/saves/${save.id}" class="dot d${level}" data-t="${save.created_at}"><title>${tip}</title><circle cx="${x.toFixed(3)}%" cy="28" r="${2.5 + level * 0.5}"/></a>`;

view file

tests/pages.test.ts+18 −2
@@ -158,6 +158,13 @@158158 		const dots = [...timelineSvg("demo", saves).value.matchAll(/cx="([\d.]+)%"/g)].map((m) => m[1]);159159 		expect(dots).toEqual(["50.000", "1.000", "99.000", "25.500"]);160160 	});161+162+	test("a dot's tooltip keeps a long message's first 120 characters", () => {163+		const long = { ...(saves[0] as Save), message: `${"x".repeat(150)}\nsecond line` };164+		const svg = timelineSvg("demo", [long]).value;165+		expect(svg).toContain(`\n${"x".repeat(119)}…\n`);166+		expect(svg).not.toContain("x".repeat(120));167+	});161168 });162169 163170 describe("the file viewer", () => {@@ -299,7 +306,11 @@299306 	test("can be viewed at an older save, and an unknown save is a 404", async () => {300307 		const page = await body(`/demo?at=${second().slice(0, 8)}`);301308 		expect(page).toContain(`title="explore through time">${second()}</summary>`);302-		expect(page).toContain(`<a class="atlas-picker-row active" href="/demo?at=${second()}#atlas"`);309+		const picker = await body(`/demo/atlas/saves?at=${second()}`);310+		expect(picker).toContain(311+			`<a class="atlas-picker-row active" href="/demo?at=${second()}#atlas"`,312+		);313+		expect(picker).toContain(`<a class="atlas-picker-row" href="/demo?at=${head()}#atlas"`);303314 		expect(page).toContain('<a class="atlas-head-link mono" href="/demo#atlas">back to HEAD</a>');304315 		expect(page).toContain(`href="/demo/files/src/main.ts?at=${second()}"`);305316 		const main = await panelOf("src/main.ts", second().slice(0, 8));@@ -312,7 +323,12 @@312323 	});313324 314325 	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>");326+		const page = await body("/demo");327+		expect(page).not.toContain("<template>");328+		expect(page).not.toContain('class="atlas-picker-row" href="/demo?at=');329+		expect(page).toContain('data-src="/demo/atlas/saves"');330+		expect((await get("/demo/atlas/saves?at=abcdef")).status).toBe(404);331+		expect((await get("/nobody/atlas/saves")).status).toBe(404);316332 		const panel = await get("/demo/atlas/panel?path=src/main.ts");317333 		expect(panel.status).toBe(200);318334 		expect(panel.headers.get("content-type")).toContain("text/html");

view file