eb54516e9dc9
split the repo workspace into overview, settings, builds and archives tabs (v3.0.165)
- time
- 2026-09-30 23:27 UTC · 1 week ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- operator console
- parent
- 5487c6f1524a
↓ kepr-eb54516e.tar.gzatlas at this save
files changed6 files
package.json+1 −1src/admin/repos.ts+77 −30tests/console.test.ts+19 −11tests/hidden.test.ts+1 −1tests/repoworkspace.test.ts+34 −8tests/triage.test.ts+1 −1package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.164",3+ "version": "3.0.165",44 "private": true,55 "type": "module",66 "scripts": {src/admin/repos.ts+77 −30
@@ -28,6 +28,8 @@2828 const MAX_LANGUAGES = 5;2929 const MAX_LANGUAGE = 30;3030 const MAX_HIDDEN = 100;31+const TABS = ["overview", "settings", "builds", "archives"] as const;32+type Tab = (typeof TABS)[number];3133 3234 /** Shown languages as typed, comma-separated; blanks between commas are dropped. */3335 function languageNames(value: string): string[] {@@ -39,7 +41,10 @@3941 4042 /** §24's Repos section: visibility (§20), card settings, texts, builds, sync status and "sync now". */4143 export function registerRepos(app: Hono, ctx: AdminContext): void {42- app.get("/admin/repos", (c) => c.html(reposPage(c, ctx, "")));44+ app.get("/admin/repos", (c) => {45+ const tab = TABS.find((t) => t === c.req.query("tab")) ?? "overview";46+ return c.html(reposPage(c, ctx, "", c.req.query("repo"), tab));47+ });4348 4449 app.post("/admin/sync", async (c) => {4550 await syncAll(ctx.sync);@@ -66,7 +71,7 @@6671 hidden_paths: hidden.join("\n"),6772 };6873 const refuse = (error: string) =>69- c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name), 400);74+ c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name, "settings"), 400);7075 const texts = (["description", "details"] as const).map((field): [RepoText, string] => [7176 field,7277 (fields[field] ?? "").replace(/\r\n/g, "\n").trim(),@@ -95,7 +100,7 @@95100 updateRepo(ctx.kepr, repo.name, edit);96101 for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);97102 if (edit.hidden_paths !== repo.hidden_paths) await applyHidden(ctx, repo.name, hidden);98- return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);103+ return c.redirect(`/admin/repos?repo=${repo.name}&tab=settings&saved=1`, 303);99104 });100105 101106 // A build's displayed notes and hiding live in kepr.db; the record in the bucket is untouched.@@ -108,13 +113,13 @@108113 if (notes.length > MAX_CHARS) {109114 const limit = MAX_CHARS.toLocaleString("en-US");110115 const error = `${repo.name} ${release.version}: notes are over ${limit} characters.`;111- return c.html(reposPage(c, ctx, error, repo.name), 400);116+ return c.html(reposPage(c, ctx, error, repo.name, "builds"), 400);112117 }113118 // Notes equal to the record's are no override, so a later record edit would show through.114119 const override = notes === release.recordNotes ? null : notes;115120 setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override);116121 return c.redirect(117- `/admin/repos?repo=${repo.name}&saved=1#${repo.name}-${release.version}`,122+ `/admin/repos?repo=${repo.name}&tab=builds&saved=1#${repo.name}-${release.version}`,118123 303,119124 );120125 });@@ -126,9 +131,10 @@126131 await dropTarballs(ctx.sync.bucket, repo.name);127132 } catch (error) {128133 if (!(error instanceof BucketWriteError)) throw error;129- return c.html(reposPage(c, ctx, `${repo.name}: ${error.message}`, repo.name), 503);134+ const message = `${repo.name}: ${error.message}`;135+ return c.html(reposPage(c, ctx, message, repo.name, "archives"), 503);130136 }131- return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);137+ return c.redirect(`/admin/repos?repo=${repo.name}&tab=archives&saved=1`, 303);132138 });133139 134140 app.post("/admin/releases/:repo/:version/delete", async (c) => {@@ -136,7 +142,10 @@136142 if (!found) return c.notFound();137143 const { repo, mirror, release } = found;138144 const refuse = (status: 400 | 503, error: string) =>139- c.html(reposPage(c, ctx, `${repo.name} ${release.version}: ${error}`, repo.name), status);145+ c.html(146+ reposPage(c, ctx, `${repo.name} ${release.version}: ${error}`, repo.name, "builds"),147+ status,148+ );140149 if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");141150 try {142151 await deleteRelease(ctx.sync.bucket, ctx.kepr, mirror, repo.name, release.version);@@ -144,7 +153,7 @@144153 if (error instanceof BucketWriteError) return refuse(503, error.message);145154 throw error;146155 }147- return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#builds`, 303);156+ return c.redirect(`/admin/repos?repo=${repo.name}&tab=builds&saved=1`, 303);148157 });149158 150159 app.post("/admin/releases/:repo/:version/:platform/delete", async (c) => {@@ -154,7 +163,13 @@154163 const { repo, mirror, release } = found;155164 const refuse = (status: 400 | 503, error: string) =>156165 c.html(157- reposPage(c, ctx, `${repo.name} ${release.version} ${platform}: ${error}`, repo.name),166+ reposPage(167+ c,168+ ctx,169+ `${repo.name} ${release.version} ${platform}: ${error}`,170+ repo.name,171+ "builds",172+ ),158173 status,159174 );160175 if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");@@ -173,7 +188,7 @@173188 throw error;174189 }175190 return c.redirect(176- `/admin/repos?repo=${repo.name}&saved=1#${repo.name}-${release.version}`,191+ `/admin/repos?repo=${repo.name}&tab=builds&saved=1#${repo.name}-${release.version}`,177192 303,178193 );179194 });@@ -206,7 +221,13 @@206221 return repo && mirror && release ? { repo, mirror, release } : null;207222 }208223 209-function reposPage(c: Context, ctx: AdminContext, error: string, chosen = c.req.query("repo")) {224+function reposPage(225+ c: Context,226+ ctx: AdminContext,227+ error: string,228+ chosen: string | undefined,229+ tab: Tab,230+) {210231 const repos = allRepos(ctx.kepr);211232 const selected = repos.find((repo) => repo.name === chosen) ?? repos[0];212233 const body = html`${notice(error)}@@ -224,7 +245,7 @@224245 ${repo.last_sync_error && html`<span class="issues-error">${repo.last_sync_error}</span>`}</a>`,225246 )}226247 </nav>227-${selected && repoForm(c, ctx, selected)}248+${selected && repoForm(c, ctx, selected, tab)}228249 </div>`;229250 return adminPage(c, ctx, "repos", body);230251 }@@ -257,29 +278,47 @@257278 `;258279 }259280 260-function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo) {261- const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));262- const own = repoText(ctx.kepr, repo.name);263- const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);264- const input = (name: keyof RepoEdit, label: string) =>265- html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>266-<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;281+function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo, tab: Tab) {267282 const synced =268283 repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;284+ const link = (t: Tab) =>285+ html`<a class="issues-tab${t === tab ? " active" : ""}" href="/admin/repos?repo=${repo.name}&tab=${t}"${286+ t === tab ? html` aria-current="page"` : ""287+ }>${t}</a>`;288+ const content = {289+ overview: () => overviewTab(ctx, repo),290+ settings: () => settingsTab(c, ctx, repo),291+ builds: () => buildsTab(c, ctx, repo),292+ archives: () => archivesTab(c, repo),293+ }[tab]();269294 return html`<article class="issue-post" id="repo-editor">270295 <p class="issue-post-head mono">271296 <span class="issue-author">272297 ${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>273298 <span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>274-${workspace(ctx, repo)}299+<nav class="issues-bar mono" aria-label="${repo.name} workspace">${TABS.map(link)}</nav>300+${content}301+</article>`;302+}303+304+function overviewTab(ctx: AdminContext, repo: AdminRepo) {305+ const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));306+ return html`${workspace(ctx, repo)}275307 ${276308 repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`277309 }278310 ${279311 quarantined.length > 0 &&280312 html`<p class="issues-error mono">${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}</p>`313+}`;281314 }282-<form class="issues-form" method="post" action="/admin/repos/${repo.name}">315+316+function settingsTab(c: Context, ctx: AdminContext, repo: AdminRepo) {317+ const own = repoText(ctx.kepr, repo.name);318+ const input = (name: keyof RepoEdit, label: string) =>319+ html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>320+<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;321+ return html`<form class="issues-form" method="post" action="/admin/repos/${repo.name}">283322 ${formField(c)}284323 <label class="issues-label">285324 <input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>@@ -299,19 +338,27 @@299338 <p class="issue-meta mono">One path per line, such as notes.md or docs/private. Hidden from every page,300339 the stats and tarballs, and passed to Koh for .kohignore when there is a write key.</p>301340 <button class="issues-submit" type="submit">save ${repo.name}</button>302-</form>341+</form>`;342+}343+344+function buildsTab(c: Context, ctx: AdminContext, repo: AdminRepo) {345+ const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);346+ return html`<section class="repo-section" id="builds">303347 ${304- releases.length > 0 &&305- html`<section class="repo-section" id="builds"><h2 class="section-label mono">builds</h2>306-${releases.map((release) => releaseForm(c, repo.name, release))}</section>`348+ releases.length > 0349+ ? releases.map((release) => releaseForm(c, repo.name, release))350+ : html`<p class="muted mono save-empty">no builds yet</p>`351+}</section>`;307352 }308-<section class="repo-section"><h2 class="section-label mono">archives</h2>353+354+function archivesTab(c: Context, repo: AdminRepo) {355+ return html`<section class="repo-section">309356 <form class="issues-form" method="post" action="/admin/repos/${repo.name}/tarballs/delete">310357 ${formField(c)}311-<p class="issue-meta mono">Tarballs are kept in the bucket once downloaded, and rebuilt on the next download.</p>358+<p class="issue-meta mono">Tarballs are kept in the bucket once downloaded, and rebuilt on the next download.359+Each one is listed under <a href="/admin/storage">storage</a>.</p>312360 <button class="issues-submit" type="submit">delete kept tarballs</button>313-</form></section>314-</article>`;361+</form></section>`;315362 }316363 317364 function textArea(repo: string, name: RepoText, label: string, rows: number, value: string) {tests/console.test.ts+19 −11
@@ -76,7 +76,7 @@7676 afterAll(() => setSystemTime());7777 7878 test("every repo the sync found is listed, private, with its last sync", async () => {79- const page = await (await browser.get("/admin/repos")).text();79+ const page = await (await browser.get("/admin/repos?tab=settings")).text();8080 expect(page).toContain('id="repo-app"');8181 expect(page).toContain('id="repo-other"');8282 expect(page.match(/action="\/admin\/repos\/[^/"]+"/g)?.length).toBe(1);@@ -85,12 +85,12 @@8585 });8686 8787 test("repo selection focuses one settings form without a search form", async () => {88- const page = await (await browser.get("/admin/repos?repo=other")).text();88+ const page = await (await browser.get("/admin/repos?repo=other&tab=settings")).text();8989 expect(page).toContain('action="/admin/repos/other"');9090 expect(page).toContain('id="repo-app"');9191 expect(page).not.toContain('type="search"');9292 const saved = await publish("other", {});93- expect(saved.headers.get("location")).toBe("/admin/repos?repo=other&saved=1#repo-editor");93+ expect(saved.headers.get("location")).toBe("/admin/repos?repo=other&tab=settings&saved=1");9494 expect(await (await browser.get("/admin/repos?repo=other&saved=1")).text()).toContain(9595 "Changes saved.",9696 );@@ -136,7 +136,9 @@136136 expect(await text("/")).not.toContain("tools");137137 expect(await stat()).toEqual(["1", "1", "0", "0"]);138138 // Settings survive the flip.139- expect(await (await browser.get("/admin/repos")).text()).toContain('value="tools"');139+ expect(await (await browser.get("/admin/repos?tab=settings")).text()).toContain(140+ 'value="tools"',141+ );140142 });141143 142144 test("sync now reads the bucket at once and records the time", async () => {@@ -285,7 +287,7 @@285287 });286288 287289 test("every version is listed with its download counts and notes", async () => {288- const page = await (await operator.get("/admin/repos?repo=app")).text();290+ const page = await (await operator.get("/admin/repos?repo=app&tab=builds")).text();289291 expect(page).toContain('id="app-1.1.0"');290292 expect(page).toContain('id="app-1.0.0"');291293 expect(page.indexOf('id="app-1.1.0"')).toBeLessThan(page.indexOf('id="app-1.0.0"'));@@ -295,10 +297,14 @@295297 296298 test("edited notes replace the record's everywhere; the record's own text clears the edit", async () => {297299 const saved = await edit("1.1.0", { notes: "Better <notes>." });298- expect(saved.headers.get("location")).toBe("/admin/repos?repo=app&saved=1#app-1.1.0");300+ expect(saved.headers.get("location")).toBe(301+ "/admin/repos?repo=app&tab=builds&saved=1#app-1.1.0",302+ );299303 expect(JSON.parse(await text("/api/app/releases/latest")).notes).toBe("Better <notes>.");300304 expect(await text("/app/releases")).toContain("Better <notes>.");301- expect(await (await operator.get("/admin/repos?repo=app")).text()).toContain("(edited)");305+ expect(await (await operator.get("/admin/repos?repo=app&tab=builds")).text()).toContain(306+ "(edited)",307+ );302308 expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);303309 const row = inst.kepr.query("SELECT notes FROM release_settings WHERE version = '1.1.0'").get();304310 expect(row).toEqual({ notes: null });@@ -312,7 +318,7 @@312318 expect(await text("/api/app/releases/history")).not.toContain("1.1.0");313319 expect(await text("/app/releases")).not.toContain("1.1.0");314320 expect((await inst.app.request("/app/releases/1.1.0/linux-x86_64")).status).toBe(404);315- const page = await (await operator.get("/admin/repos?repo=app")).text();321+ const page = await (await operator.get("/admin/repos?repo=app&tab=builds")).text();316322 expect(page).toContain('id="app-1.1.0"');317323 expect(page).toContain('value="1" checked> hidden');318324 expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);@@ -355,7 +361,7 @@355361 356362 test("the newest build goes, latest falls back, and a sync does not bring it back", async () => {357363 const response = await remove("1.1.0");358- expect(response.headers.get("location")).toBe("/admin/repos?repo=app&saved=1#builds");364+ expect(response.headers.get("location")).toBe("/admin/repos?repo=app&tab=builds&saved=1");359365 expect(await Bun.file(join(bucketDir, "app/releases/1.1.0/release.json")).exists()).toBe(false);360366 expect(await Bun.file(join(bucketDir, "app/releases/1.1.0/linux-x86_64/app")).exists()).toBe(361367 false,@@ -364,7 +370,7 @@364370 expect(JSON.parse(await text("/api/app/releases/latest")).version).toBe("1.0.0");365371 expect(inst.kepr.query("SELECT * FROM downloads WHERE version = '1.1.0'").all()).toEqual([]);366372 await operator.post("/admin/sync", {});367- const page = await (await operator.get("/admin/repos?repo=app")).text();373+ const page = await (await operator.get("/admin/repos?repo=app&tab=builds")).text();368374 expect(page).not.toContain('id="app-1.1.0"');369375 expect(page).toContain('id="app-1.0.0"');370376 });@@ -411,7 +417,9 @@411417 412418 test("the file leaves the record, the bucket, the counts and the public routes", async () => {413419 const response = await removeFile("macos-aarch64");414- expect(response.headers.get("location")).toBe("/admin/repos?repo=app&saved=1#app-1.0.0");420+ expect(response.headers.get("location")).toBe(421+ "/admin/repos?repo=app&tab=builds&saved=1#app-1.0.0",422+ );415423 const files = (await record()).files.map((file: { platform: string }) => file.platform);416424 expect(files).toEqual(["linux-x86_64"]);417425 const gone = join(bucketDir, "app/releases/1.0.0/macos-aarch64/app");tests/hidden.test.ts+1 −1
@@ -67,7 +67,7 @@6767 expect(await Bun.file(join(repos.bucketDir, ".kepr/hidden/app")).text()).toBe(6868 "tool.py\nprivate\n",6969 );70- expect(await (await browser.get("/admin/repos?repo=app")).text()).toContain(70+ expect(await (await browser.get("/admin/repos?repo=app&tab=settings")).text()).toContain(7171 ">tool.py\nprivate</textarea>",7272 );7373 });tests/repoworkspace.test.ts+34 −8
@@ -14,7 +14,8 @@1414 let inst: Instance;1515 let browser: Browser;1616 let bucketDir: string;17- const workspace = async () => (await browser.get("/admin/repos?repo=app")).text();17+ const workspace = async (tab = "") =>18+ (await browser.get(`/admin/repos?repo=app${tab && `&tab=${tab}`}`)).text();1819 const save = (fields: Record<string, string>) =>1920 browser.post("/admin/repos/app", { public: "1", ...fields });2021 @@ -56,6 +57,29 @@5657 expect(page).toMatch(/<strong>2<\/strong><span class="mono">downloads<\/span>/);5758 });5859 60+ test("splits into overview, settings, builds and archives tabs; overview is the default", async () => {61+ const tabs = ["overview", "settings", "builds", "archives"];62+ const marks: Record<string, string> = {63+ overview: 'class="admin-vitals"',64+ settings: 'name="hidden_paths"',65+ builds: 'action="/admin/releases/app/1.1.0"',66+ archives: 'action="/admin/repos/app/tarballs/delete"',67+ };68+ for (const tab of tabs) {69+ const page = await workspace(tab);70+ expect(page).toContain(`href="/admin/repos?repo=app&tab=${tab}" aria-current="page"`);71+ for (const other of tabs) {72+ expect(page.includes(marks[other] as string)).toBe(other === tab);73+ }74+ }75+ expect(await workspace()).toContain(marks.overview as string);76+ expect(await workspace("nowhere")).toContain(marks.overview as string);77+ const saved = await save({});78+ expect(saved.headers.get("location")).toBe("/admin/repos?repo=app&tab=settings&saved=1");79+ const refused = await save({ languages: "a, b, c, d, e, f" });80+ expect(await refused.text()).toContain(marks.settings as string);81+ });82+5983 test("lists the detected languages with their lines", async () => {6084 const page = await workspace();6185 expect(page).toContain('TypeScript <span class="muted">2 lines</span>');@@ -63,10 +87,12 @@6387 });6488 6589 test("edits the description and details; blank hands them back to the README", async () => {66- expect(await workspace()).toMatch(/<textarea[^>]*name="description"[^>]*><\/textarea>/);90+ expect(await workspace("settings")).toMatch(91+ /<textarea[^>]*name="description"[^>]*><\/textarea>/,92+ );6793 const saved = await save({ description: "Own *words*", details: "## Usage\r\nRun it." });6894 expect(saved.status).toBe(303);69- const page = await workspace();95+ const page = await workspace("settings");7096 expect(page).toContain("Own *words*</textarea>");7197 expect(page).toContain("## Usage\nRun it.</textarea>");7298 const repo = await (await inst.app.request("/app")).text();@@ -86,7 +112,7 @@86112 inst.kepr.run(87113 "INSERT INTO release_settings (repo, version, hidden) VALUES ('app', '1.0.0', 1)",88114 );89- const page = await workspace();115+ const page = await workspace("builds");90116 expect(page).toContain('action="/admin/releases/app/1.1.0"');91117 expect(page).toContain("<span>shown</span><span>2 downloads</span>");92118 expect(page).toContain("<span>hidden</span><span>0 downloads</span>");@@ -100,7 +126,7 @@100126 const shown = await header();101127 expect(shown).toContain('<span class="lang-badge">Zig</span>');102128 expect(shown).toContain('<span class="lang-badge">TypeScript</span>');103- expect(await workspace()).toContain('value="Zig, TypeScript"');129+ expect(await workspace("settings")).toContain('value="Zig, TypeScript"');104130 expect((await save({ languages: "" })).status).toBe(303);105131 const detected = await header();106132 expect(detected).not.toContain('<span class="lang-badge">Zig</span>');@@ -118,7 +144,7 @@118144 expect(response.status).toBe(400);119145 expect(await response.text()).toContain(error);120146 }121- expect(await workspace()).not.toContain('value="Zig');147+ expect(await workspace("settings")).not.toContain('value="Zig');122148 });123149 124150 test("deletes the repo's kept tarballs from the bucket", async () => {@@ -128,9 +154,9 @@128154 const kept = new Bun.Glob("**/*.tar.gz");129155 const keptIn = () => Array.fromAsync(kept.scan(join(bucketDir, ".kepr", "tarballs", "app")));130156 expect(await keptIn()).toHaveLength(1);131- expect(await workspace()).toContain('action="/admin/repos/app/tarballs/delete"');157+ expect(await workspace("archives")).toContain('action="/admin/repos/app/tarballs/delete"');132158 const deleted = await browser.post("/admin/repos/app/tarballs/delete", {});133- expect(deleted.headers.get("location")).toBe("/admin/repos?repo=app&saved=1#repo-editor");159+ expect(deleted.headers.get("location")).toBe("/admin/repos?repo=app&tab=archives&saved=1");134160 expect(await keptIn()).toEqual([]);135161 expect((await inst.app.request(head ?? "")).status).toBe(200);136162 });tests/triage.test.ts+1 −1
@@ -114,7 +114,7 @@114114 issues_paused: "1",115115 });116116 expect(pause.status).toBe(303);117- expect(await (await operator.get("/admin/repos?repo=app")).text()).toMatch(117+ expect(await (await operator.get("/admin/repos?repo=app&tab=settings")).text()).toMatch(118118 /name="issues_paused" value="1" checked/,119119 );120120 const form = await inst.app.request("/app/issues/new");