Public: anyone can read this; the crew can change it.

The all-flows list is keyboard-first: j/k move between rows, enter opens one #10641

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10407-the-all-flows-list-is-keyboard-first-j-k → main 6 commits fork

  • Merged. scout/10407-the-all-flows-list-is-keyboard-first-j-k was merged into main as ee6ec44.

Review

Sign in to review this change.
2 files changed +63 −0
src/web/pages/flows-all.ts +1
@@ -38,6 +38,7 @@ export function allFlowsPage(ctx: Ctx): Response {
3838 const rows = shown.map((l) =>
3939 row({
4040 href: l.href,
41 nav: true,
4142 title: html`${l.repo} · ${l.kind}`,
4243 meta: html`${l.label} · ${l.at === null ? "never moved" : time(l.at)}`,
4344 trail: badge(l.state, { tone: TONE[l.health] }),
test/keys-flows.test.ts added +62
@@ -0,0 +1,62 @@
1 /** The all-flows list joins the shell's j/k row traversal (#10407): one data-nav per linked row, none on the empty page. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createUser } from "../src/core/users.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9
10 let dataDir: string;
11 let app: App;
12 let servers: Servers;
13 let cookie = "";
14
15 beforeAll(async () => {
16 dataDir = await mkdtemp(join(tmpdir(), "forge-keys-flows-"));
17 app = createApp({ dataDir, port: 0, forgeRepo: "acme/forge" });
18 servers = await serve(app);
19 const made = await createUser(app.db, {
20 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
21 password: "a-good-passphrase", adminIfFirst: true,
22 });
23 if (!made.ok) throw new Error(made.error);
24 const alice = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!.id;
25 const up = app.db.query("INSERT INTO repo_upstream (repo_id, url, branch, enabled, checked_at, behind, error, by_id, updated_at) VALUES (?1, ?2, 'main', 1, '2026-11-08T00:00:00Z', 0, '', ?3, '2026-11-08T00:00:00Z')");
26 for (const i of [1, 2, 3]) {
27 app.db.query("INSERT INTO repo (id, owner_id, name) VALUES (?1, ?2, ?3)").run(`r${i}`, alice, `repo${i}`);
28 up.run(`r${i}`, `https://example.com/${i}.git`, alice);
29 }
30 const res = await fetch(`${servers.url}/signin`, {
31 method: "POST",
32 redirect: "manual",
33 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
34 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
35 });
36 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
37 });
38
39 afterAll(async () => {
40 await servers.stop();
41 app.close();
42 await rm(dataDir, { recursive: true, force: true });
43 });
44
45 const get = async (q = "") => (await fetch(`${servers.url}/admin/flows${q}`, { headers: { cookie } })).text();
46 const NAV = /class="row"[^>]*data-nav tabindex="-1"/;
47
48 test("each flow line is one data-nav row whose first link is its repository's flows", async () => {
49 const text = await get();
50 const rows = text.split("<li").filter((li) => NAV.test(li));
51 expect(rows.length).toBe(3);
52 for (const [i, n] of [1, 2, 3].entries()) {
53 expect(rows[i]).toContain(`href="/alice/repo${n}/flows"`);
54 expect(rows[i]!.match(/<a href="([^"]+)"/)![1]).toBe(`/alice/repo${n}/flows`);
55 }
56 });
57
58 test("the empty list draws no flow data-nav row", async () => {
59 const text = await get("?kind=scan");
60 expect(text).toContain("No flow lines match.");
61 expect(text).not.toMatch(NAV);
62 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs