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
6 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 −1
package.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": {

view file

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}&amp;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) {

view file

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 &lt;notes&gt;.");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");

view file

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 	});

view file

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&amp;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 	});

view file

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");

view file