9148c9819b36

give release articles and notes fields their own id prefixes so no id on a repo's one-page console workspace can collide, completing one tab per repo (v3.0.199)

time
2026-10-03 04:38 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
lightning quick kepr
parent
10e65d3e89e7
4 files package.json+1 −1src/admin/repos.ts+6 −5tests/console.test.ts+12 −16tests/repoworkspace.test.ts+20 −3
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.198",3+	"version": "3.0.199",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/repos.ts+6 −5
@@ -117,7 +117,7 @@117117 		const override = notes === release.recordNotes ? null : notes;118118 		setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override);119119 		return c.redirect(120-			`/admin/repos?repo=${repo.name}&saved=1#${repo.name}-${release.version}`,120+			`/admin/repos?repo=${repo.name}&saved=1#release-${repo.name}-${release.version}`,121121 			303,122122 		);123123 	});@@ -189,7 +189,7 @@189189 			throw error;190190 		}191191 		return c.redirect(192-			`/admin/repos?repo=${repo.name}&saved=1#${repo.name}-${release.version}`,192+			`/admin/repos?repo=${repo.name}&saved=1#release-${repo.name}-${release.version}`,193193 			303,194194 		);195195 	});@@ -356,7 +356,8 @@356356 357357 function releaseForm(c: Context, repo: string, release: OperatorRelease) {358358 	const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);359-	const id = `${repo}-${release.version}`;359+	// Its own prefix: a version may be named like a settings field, which uses `<repo>-<name>`.360+	const id = `release-${repo}-${release.version}`;360361 	return html`<article class="issue-post" id="${id}">361362 <p class="issue-post-head mono">362363 ${/* this row stays one line: splitting its spans changes the HTML that tests/console.test.ts pins */ ""}${html`<span class="issue-author">${release.version}</span>`}${html`<span>${release.hidden ? "hidden" : "shown"}</span>`}${html`<span>${plural(total, "download")}</span>`}${release.downloads.map(@@ -365,9 +366,9 @@365366 </p>366367 <form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}">367368 ${formField(c)}368-<label class="issues-label" for="${id}-notes">notes369+<label class="issues-label" for="notes-${repo}-${release.version}">notes369370 ${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>370-<textarea class="issues-textarea" id="${id}-notes" name="notes" rows="3"371+<textarea class="issues-textarea" id="notes-${repo}-${release.version}" name="notes" rows="3"371372 maxlength="${MAX_CHARS}">${release.notes}</textarea>372373 ${373374 	// One line on purpose: Biome folds a hole-by-hole split back together, and breaking the

view file

tests/console.test.ts+12 −16
@@ -136,9 +136,7 @@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(140-			'value="tools"',141-		);139+		expect(await (await browser.get("/admin/repos")).text()).toContain('value="tools"');142140 	});143141 144142 	test("sync now reads the bucket at once and records the time", async () => {@@ -288,23 +286,21 @@288286 289287 	test("every version is listed with its download counts and notes", async () => {290288 		const page = await (await operator.get("/admin/repos?repo=app")).text();291-		expect(page).toContain('id="app-1.1.0"');292-		expect(page).toContain('id="app-1.0.0"');293-		expect(page.indexOf('id="app-1.1.0"')).toBeLessThan(page.indexOf('id="app-1.0.0"'));289+		expect(page).toContain('id="release-app-1.1.0"');290+		expect(page).toContain('id="release-app-1.0.0"');291+		expect(page.indexOf('id="release-app-1.1.0"')).toBeLessThan(292+			page.indexOf('id="release-app-1.0.0"'),293+		);294294 		expect(page).toContain('<span>1 download</span><span class="muted">linux-x86_64 1</span>');295295 		expect(page).toContain(">notes for 1.1.0</textarea>");296296 	});297297 298298 	test("edited notes replace the record's everywhere; the record's own text clears the edit", async () => {299299 		const saved = await edit("1.1.0", { notes: "Better <notes>." });300-		expect(saved.headers.get("location")).toBe(301-			"/admin/repos?repo=app&saved=1#app-1.1.0",302-		);300+		expect(saved.headers.get("location")).toBe("/admin/repos?repo=app&saved=1#release-app-1.1.0");303301 		expect(JSON.parse(await text("/api/app/releases/latest")).notes).toBe("Better <notes>.");304302 		expect(await text("/app/releases")).toContain("Better &lt;notes&gt;.");305-		expect(await (await operator.get("/admin/repos?repo=app")).text()).toContain(306-			"(edited)",307-		);303+		expect(await (await operator.get("/admin/repos?repo=app")).text()).toContain("(edited)");308304 		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);309305 		const row = inst.kepr.query("SELECT notes FROM release_settings WHERE version = '1.1.0'").get();310306 		expect(row).toEqual({ notes: null });@@ -319,7 +315,7 @@319315 		expect(await text("/app/releases")).not.toContain("1.1.0");320316 		expect((await inst.app.request("/app/releases/1.1.0/linux-x86_64")).status).toBe(404);321317 		const page = await (await operator.get("/admin/repos?repo=app")).text();322-		expect(page).toContain('id="app-1.1.0"');318+		expect(page).toContain('id="release-app-1.1.0"');323319 		expect(page).toContain('value="1" checked> hidden');324320 		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);325321 		expect(await text("/app/releases/latest/version.txt")).toBe("1.1.0");@@ -371,8 +367,8 @@371367 		expect(inst.kepr.query("SELECT * FROM downloads WHERE version = '1.1.0'").all()).toEqual([]);372368 		await operator.post("/admin/sync", {});373369 		const page = await (await operator.get("/admin/repos?repo=app")).text();374-		expect(page).not.toContain('id="app-1.1.0"');375-		expect(page).toContain('id="app-1.0.0"');370+		expect(page).not.toContain('id="release-app-1.1.0"');371+		expect(page).toContain('id="release-app-1.0.0"');376372 	});377373 378374 	test("the last build goes, and latest with it", async () => {@@ -418,7 +414,7 @@418414 	test("the file leaves the record, the bucket, the counts and the public routes", async () => {419415 		const response = await removeFile("macos-aarch64");420416 		expect(response.headers.get("location")).toBe(421-			"/admin/repos?repo=app&saved=1#app-1.0.0",417+			"/admin/repos?repo=app&saved=1#release-app-1.0.0",422418 		);423419 		const files = (await record()).files.map((file: { platform: string }) => file.platform);424420 		expect(files).toEqual(["linux-x86_64"]);

view file

tests/repoworkspace.test.ts+20 −3
@@ -83,9 +83,7 @@8383 	});8484 8585 	test("edits the description and details; blank hands them back to the README", async () => {86-		expect(await workspace()).toMatch(87-			/<textarea[^>]*name="description"[^>]*><\/textarea>/,88-		);86+		expect(await workspace()).toMatch(/<textarea[^>]*name="description"[^>]*><\/textarea>/);8987 		const saved = await save({ description: "Own *words*", details: "## Usage\r\nRun it." });9088 		expect(saved.status).toBe(303);9189 		const page = await workspace();@@ -184,4 +182,23 @@184182 	expect(columns.map((column) => column.name)).not.toContain("install1");185183 	expect(columns.map((column) => column.name)).not.toContain("sort_order");186184 	expect(db.query("SELECT name, public FROM repos").all()).toEqual([{ name: "app", public: 1 }]);185+});186+187+test("no two elements of a repo's workspace share an id, whatever its versions are called", async () => {188+	const repos = await makeRepos(["app"]);189+	const head = JSON.parse(repos.koh("app", "view", "--json")).head_id as string;190+	for (const version of ["category", "rename", "description", "1.0.0", "1.0.0-notes"]) {191+		await writeRelease(repos.bucketDir, "app", {192+			version,193+			save: head,194+			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],195+		});196+	}197+	const inst = await instance(repos.bucketDir);198+	const browser = new Browser(inst.app);199+	await browser.signIn();200+	const page = await (await browser.get("/admin/repos?repo=app")).text();201+	const ids = [...page.matchAll(/ id="([^"]+)"/g)].map((match) => match[1]);202+	expect(ids.length).toBeGreaterThan(10);203+	expect(ids.filter((id, i) => ids.indexOf(id) !== i)).toEqual([]);187204 });

view file