364bcfc8c156

turn the repo section into a selected-repo workspace with shown languages (v3.0.133)

time
2026-09-29 22:25 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
repo workspace
parent
3559470a0d0e
7 files package.json+1 −1public/css/kepr.css+9 −0src/admin/repos.ts+70 −2src/admin/store.ts+5 −1src/pages/repo.ts+5 −3src/server/db.ts+12 −1tests/repoworkspace.test.ts+122 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.132",3+	"version": "3.0.133",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+9 −0
@@ -2181,6 +2181,15 @@21812181 	font-weight: 400;21822182 	color: var(--accent);21832183 }2184+.admin-releases {2185+	margin: 12px 0 0;2186+	padding: 0;2187+	list-style: none;2188+	font-size: 0.6875rem;2189+}2190+.admin-releases li {2191+	padding: 4px 0;2192+}21842193 .admin-repos {21852194 	display: grid;21862195 	grid-template-columns: minmax(120px, 0.7fr) minmax(0, 1.3fr);

view file

src/admin/repos.ts+70 −2
@@ -1,15 +1,29 @@11 import type { Context, Hono } from "hono";22 import { syncAll } from "../bucket/sync.ts";3+import { operatorIssues } from "../issues/store.ts";4+import { languageLines, repoStats } from "../mirror/queries.ts";35 import { openMirror, quarantinedPaths } from "../mirror/store.ts";46 import { html } from "../pages/html.ts";57 import { formatDate, plural } from "../pages/layout.ts";8+import { operatorReleases, visibleReleases } from "../releases/releases.ts";9+import { repoText } from "../server/db.ts";610 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";711 import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";812 913 const MAX_FIELD = 500;1014 const HTTP_URL = /^https?:\/\/[^\s]+$/i;1115 const SORT_ORDER = /^-?\d{1,6}$/;16+const MAX_LANGUAGES = 5;17+const MAX_LANGUAGE = 30;1218 19+/** Shown languages as typed, comma-separated; blanks between commas are dropped. */20+function languageNames(value: string): string[] {21+	return value22+		.split(",")23+		.map((name) => name.trim())24+		.filter((name) => name !== "");25+}26+1327 /** §24's Repos section: visibility (§20), card settings, sync status and "sync now". */1428 export function registerRepos(app: Hono, ctx: AdminContext): void {1529 	app.get("/admin/repos", (c) => c.html(reposPage(c, ctx, "")));@@ -24,6 +38,7 @@2438 		if (!repo) return c.notFound();2539 		const fields = await textFields(c);2640 		const text = (name: string) => (fields[name] ?? "").trim();41+		const languages = languageNames(fields.languages ?? "");2742 		const edit: RepoEdit = {2843 			public: fields.public === "1" ? 1 : 0,2944 			category: text("category"),@@ -32,6 +47,8 @@3247 			install1: text("install1"),3348 			install2: text("install2"),3449 			install3: text("install3"),50+			// Joined with the separator `publicRepo` splits on, so a name never contains it.51+			languages: languages.join(", "),3552 		};3653 		const refuse = (error: string) => c.html(reposPage(c, ctx, `${repo.name}: ${error}`), 400);3754 		if (Object.values(fields).some((value) => value.length > MAX_FIELD)) {@@ -44,6 +61,13 @@4461 		if ([edit.install1, edit.install2, edit.install3].some((line) => /[\r\n]/.test(line))) {4562 			return refuse("an install line is one line.");4663 		}64+		if (languages.length > MAX_LANGUAGES) return refuse("up to five languages.");65+		if (languages.some((name) => name.length > MAX_LANGUAGE)) {66+			return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`);67+		}68+		if (languages.some((name) => /\p{C}/u.test(name))) {69+			return refuse("a language name is printable text.");70+		}4771 		updateRepo(ctx.kepr, repo.name, edit);4872 		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);4973 	});@@ -67,12 +91,53 @@6791 ${repo.last_sync_error && html`<span class="issues-error">${repo.last_sync_error}</span>`}</a>`,6892 )}6993 </nav>70-${selected && repoForm(c, selected, quarantinedPaths(openMirror(ctx.mirrorDir, selected.name)))}94+${selected && repoForm(c, ctx, selected)}7195 </div>`;7296 	return adminPage(c, ctx, "repos", body);7397 }7498 75-function repoForm(c: Context, repo: AdminRepo, quarantined: string[]) {99+/** The selected repo at a glance: its numbers, languages, description and releases (§24). */100+function workspace(ctx: AdminContext, repo: AdminRepo) {101+	const mirror = openMirror(ctx.mirrorDir, repo.name);102+	const stats = repoStats(mirror);103+	const releases = operatorReleases(ctx.kepr, mirror, repo.name);104+	const downloads = (release: (typeof releases)[number]) =>105+		release.downloads.reduce((sum, [, count]) => sum + count, 0);106+	const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name);107+	const vital = (label: string, value: string | number) =>108+		html`<div><strong>${String(value)}</strong><span class="mono">${label}</span></div>`;109+	const own = repoText(ctx.kepr, repo.name).description !== "";110+	return html`<div class="admin-vitals">111+${vital("saves", stats.saves)}112+${vital("lines", stats.lines)}113+${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")}114+${vital("open issues", open.length)}115+${vital(116+	"downloads",117+	releases.reduce((sum, release) => sum + downloads(release), 0),118+)}119+</div>120+<p class="issue-meta mono">detected languages:121+${languageLines(mirror).map(122+	([language, lines], i) =>123+		html`${i > 0 ? " · " : " "}${language} <span class="muted">${plural(lines, "line")}</span>`,124+)}</p>125+<p class="issue-meta mono">description: ${own ? "your own words" : "from the README"} ·126+<a href="/${repo.name}">edit on its page ↗</a></p>127+${128+	releases.length > 0 &&129+	html`<ul class="admin-releases mono">${releases.map(130+		(release) => html`<li><a href="/admin/releases#${repo.name}-${release.version}">131+${release.version}</a> <span class="muted">${release.hidden ? "hidden" : "shown"} · ${plural(132+			downloads(release),133+			"download",134+		)}</span></li>`,135+	)}</ul>`136+}`;137+}138+139+function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo) {140+	const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));76141 	const input = (name: keyof RepoEdit, label: string) =>77142 		html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>78143 <input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;@@ -83,6 +148,7 @@83148 <span class="issue-author">84149 ${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>85150 <span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>151+${workspace(ctx, repo)}86152 ${87153 	repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`88154 }@@ -101,6 +167,8 @@101167 ${input("install1", "install line 1")}102168 ${input("install2", "install line 2")}103169 ${input("install3", "install line 3")}170+${input("languages", "shown languages")}171+<p class="issue-meta mono">Up to five, separated by commas. Leave blank to show the detected one.</p>104172 <button class="issues-submit" type="submit">save ${repo.name}</button>105173 </form>106174 </article>`;

view file

src/admin/store.ts+5 −1
@@ -10,6 +10,8 @@1010 	install1: string;1111 	install2: string;1212 	install3: string;13+	/** Shown languages, joined by ", "; '' shows the detected one. */14+	languages: string;1315 	last_sync_at: number | null;1416 	last_sync_error: string | null;1517 };@@ -27,7 +29,8 @@2729 export function updateRepo(db: Database, name: string, edit: RepoEdit): void {2830 	db.query(2931 		`UPDATE repos SET public = ?, category = ?, homepage = ?, sort_order = ?, install1 = ?,30-		install2 = ?, install3 = ? WHERE name = ?`,32+		install2 = ?, install3 = ?, languages = ?33+		WHERE name = ?`,3134 	).run(3235 		edit.public,3336 		edit.category,@@ -36,6 +39,7 @@3639 		edit.install1,3740 		edit.install2,3841 		edit.install3,42+		edit.languages,3943 		name,4044 	);4145 }

view file

src/pages/repo.ts+5 −3
@@ -93,7 +93,7 @@9393 }9494 9595 function header(ctx: PageContext, repo: PublicRepo, intro: Html | null): Html {96-	const { name, homepage, installLines } = repo.settings;96+	const { name, homepage, installLines, languages } = repo.settings;9797 	const steal = `koh steal ${ctx.domain}/${name}`;9898 	const stats = repoStats(repo.mirror);9999 	const handle = setting(ctx.kepr, "handle");@@ -114,8 +114,10 @@114114 	html`<p class="repo-homepage"><a href="${homepage}">${homepage}</a></p>`115115 }116116 ${117-	stats.language &&118-	html`<div class="lang-badges"><span class="lang-badge">${stats.language}</span></div>`117+	(languages.length > 0 || stats.language) &&118+	html`<div class="lang-badges">${(languages.length > 0 ? languages : [stats.language]).map(119+		(language) => html`<span class="lang-badge">${language}</span>`,120+	)}</div>`119121 }120122 <p class="repo-meta">${meta.join(" · ")}</p>121123 <div class="repo-tabbar">

view file

src/server/db.ts+12 −1
@@ -17,6 +17,7 @@1717 	install1        TEXT NOT NULL DEFAULT '',1818 	install2        TEXT NOT NULL DEFAULT '',1919 	install3        TEXT NOT NULL DEFAULT '',20+	languages       TEXT NOT NULL DEFAULT '',2021 	last_sync_at    INTEGER,2122 	last_sync_error TEXT2223 );@@ -97,6 +98,8 @@9798 	category: string;9899 	homepage: string;99100 	installLines: string[];101+	/** The operator's shown languages (§24), or none for the detected one. */102+	languages: string[];100103 	description: string;101104 	details: string;102105 };@@ -110,6 +113,7 @@110113 	install1: string;111114 	install2: string;112115 	install3: string;116+	languages: string;113117 	description: string | null;114118 	details: string | null;115119 };@@ -120,6 +124,11 @@120124 	db.run("PRAGMA journal_mode = WAL");121125 	db.run("PRAGMA busy_timeout = 5000");122126 	db.run(SCHEMA);127+	// Added after the first kepr.db was made; SQLite has no ADD COLUMN IF NOT EXISTS.128+	const columns = db.query<{ name: string }, []>("PRAGMA table_info(repos)").all();129+	if (!columns.some((column) => column.name === "languages")) {130+		db.run("ALTER TABLE repos ADD COLUMN languages TEXT NOT NULL DEFAULT ''");131+	}123132 	return db;124133 }125134 @@ -156,7 +165,8 @@156165 export function publicRepo(db: Database, name: string): RepoSettings | null {157166 	const row = db158167 		.query<RepoRow, [string]>(159-			`SELECT name, category, homepage, install1, install2, install3, description, details168+			`SELECT name, category, homepage, install1, install2, install3, languages, description,169+			details160170 			FROM repos LEFT JOIN repo_text ON repo_text.repo = repos.name161171 			WHERE name = ? AND public = 1`,162172 		)@@ -168,6 +178,7 @@168178 		category: row.category,169179 		homepage: row.homepage,170180 		installLines,181+		languages: row.languages === "" ? [] : row.languages.split(", "),171182 		description: row.description ?? "",172183 		details: row.details ?? "",173184 	};

view file

tests/repoworkspace.test.ts+122 −0
@@ -0,0 +1,122 @@1+// Covers §24's selected-repo workspace: the summary, detected and shown languages, where the2+// description comes from, and the repo's releases.3+4+import { Database } from "bun:sqlite";5+import { beforeAll, describe, expect, test } from "bun:test";6+import { mkdtemp, writeFile } from "node:fs/promises";7+import { tmpdir } from "node:os";8+import { join } from "node:path";9+import { openKeprDb } from "../src/server/db.ts";10+import { Browser, type Instance, instance } from "./browser.ts";11+import { makeRepos, writeRelease } from "./fixture.ts";12+13+describe("the selected repo's workspace (§24)", () => {14+	let inst: Instance;15+	let browser: Browser;16+	const workspace = async () => (await browser.get("/admin/repos?repo=app")).text();17+	const save = (fields: Record<string, string>) =>18+		browser.post("/admin/repos/app", { public: "1", sort_order: "0", ...fields });19+20+	beforeAll(async () => {21+		const repos = await makeRepos(["app"]);22+		const work = join(repos.bucketDir, "..", "work", "app");23+		await writeFile(join(work, "main.ts"), "export const a = 1;\nexport const b = 2;\n");24+		await writeFile(join(work, "tool.py"), "print(1)\n");25+		repos.koh("app", "save", "add sources");26+		repos.koh("app", "offer");27+		const head = JSON.parse(repos.koh("app", "view", "--json")).head_id as string;28+		for (const version of ["1.0.0", "1.1.0"]) {29+			await writeRelease(repos.bucketDir, "app", {30+				version,31+				save: head,32+				files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],33+			});34+		}35+		inst = await instance(repos.bucketDir);36+		inst.kepr.run(37+			`INSERT INTO issues (repo, number, title, body, type, filed_at)38+			 VALUES ('app', 1, 'Broken', 'It broke', 'bug', 0)`,39+		);40+		browser = new Browser(inst.app);41+		await browser.signIn();42+		await save({});43+		await inst.app.request("/app/releases/1.1.0/linux-x86_64");44+		await inst.app.request("/app/releases/1.1.0/linux-x86_64");45+	});46+47+	test("summarises saves, lines, the newest version, open issues and downloads", async () => {48+		const page = await workspace();49+		expect(page).toContain('class="admin-vitals"');50+		expect(page).toMatch(/<strong>2<\/strong><span class="mono">saves<\/span>/);51+		expect(page).toMatch(/<strong>3<\/strong><span class="mono">lines<\/span>/);52+		expect(page).toMatch(/<strong>1\.1\.0<\/strong><span class="mono">newest version<\/span>/);53+		expect(page).toMatch(/<strong>1<\/strong><span class="mono">open issues<\/span>/);54+		expect(page).toMatch(/<strong>2<\/strong><span class="mono">downloads<\/span>/);55+	});56+57+	test("lists the detected languages with their lines", async () => {58+		const page = await workspace();59+		expect(page).toContain('TypeScript <span class="muted">2 lines</span>');60+		expect(page).toContain('Python <span class="muted">1 line</span>');61+	});62+63+	test("says where the description comes from and links to its page", async () => {64+		expect(await workspace()).toContain("description: from the README");65+		inst.kepr.run("INSERT INTO repo_text (repo, description) VALUES ('app', 'Own words')");66+		const page = await workspace();67+		expect(page).toContain("description: your own words");68+		expect(page).toContain('<a href="/app">edit on its page ↗</a>');69+		inst.kepr.run("DELETE FROM repo_text");70+	});71+72+	test("lists its releases with visibility and downloads, linking to the Releases section", async () => {73+		inst.kepr.run(74+			"INSERT INTO release_settings (repo, version, hidden) VALUES ('app', '1.0.0', 1)",75+		);76+		const page = await workspace();77+		expect(page).toContain('href="/admin/releases#app-1.1.0"');78+		expect(page).toMatch(/1\.1\.0[\s\S]*?shown · 2 downloads/);79+		expect(page).toMatch(/1\.0\.0[\s\S]*?hidden · 0 downloads/);80+		inst.kepr.run("DELETE FROM release_settings");81+	});82+83+	test("shown languages replace the header's detected language; blank brings it back", async () => {84+		const header = async () => (await inst.app.request("/app")).text();85+		expect(await header()).toContain('<span class="lang-badge">TypeScript</span>');86+		expect((await save({ languages: " Zig ,  TypeScript" })).status).toBe(303);87+		const shown = await header();88+		expect(shown).toContain('<span class="lang-badge">Zig</span>');89+		expect(shown).toContain('<span class="lang-badge">TypeScript</span>');90+		expect(await workspace()).toContain('value="Zig, TypeScript"');91+		expect((await save({ languages: "" })).status).toBe(303);92+		const detected = await header();93+		expect(detected).not.toContain('<span class="lang-badge">Zig</span>');94+		expect(detected).toContain('<span class="lang-badge">TypeScript</span>');95+	});96+97+	test("refuses more than five languages, a long name or a control character", async () => {98+		const refusals: [string, string][] = [99+			["a, b, c, d, e, f", "up to five languages."],100+			["x".repeat(31), "a language name is at most 30 characters."],101+			["Zig\u0007", "a language name is printable text."],102+		];103+		for (const [languages, error] of refusals) {104+			const response = await save({ languages });105+			expect(response.status).toBe(400);106+			expect(await response.text()).toContain(error);107+		}108+		expect(await workspace()).not.toContain('value="Zig');109+	});110+});111+112+test("an older kepr.db gains the shown-languages column when opened", async () => {113+	const dir = await mkdtemp(join(tmpdir(), "kepr-old-db-"));114+	const old = new Database(join(dir, "kepr.db"));115+	old.run("CREATE TABLE repos (name TEXT PRIMARY KEY, public INTEGER NOT NULL DEFAULT 0)");116+	old.run("INSERT INTO repos (name) VALUES ('app')");117+	old.close();118+	const db = openKeprDb(dir);119+	expect(db.query<{ languages: string }, []>("SELECT languages FROM repos").get()?.languages).toBe(120+		"",121+	);122+});

view file