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
↓ kepr-364bcfc8.tar.gzatlas at this save
files changed7 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 −0package.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": {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);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>`;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 }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">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 };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+});