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
↓ kepr-ac9e6999.tar.gzatlas at this save
files changed6 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 −2package.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": {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;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[] {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>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>`;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");