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

Iterations page: /:owner/:name/iterations with pins, tree layout, zoom and folds (Jay Moran) #9096

Merged opened this

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

Merged

scout/forge:scout/9052-iterations-page-owner-name-iterations-wi → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/9052-iterations-page-owner-name-iterations-wi was merged into main as 2eba073.

Review

Sign in to review this change.
11 files changed +947 −0
docs/design/areas/iterations.md added +12
@@ -0,0 +1,12 @@
1 # Iterations
2
3 A fork family's build pins (`/:owner/:name/iterations`) as a downward tree of runs, reached from the Builds page. `iterationsPage` in `src/web/pages/iterations.ts` draws it; `iterations_family.ts` holds the data (`iterationsOf`, `lanesOf`) and `iterations_script.ts` the rules (`ITERATIONS_CSS`) and the script. Without the script the runs stand side by side; with it each run hangs under the build it grew from.
4
5 - `.it-page` — the page below the repo header; carries `data-here` and `data-lanes` (the runs, as JSON) for the script.
6 - `.it-key` — the one-line key: solid line for fork of, thin for a branch (`.it-thin`), dashed (`.it-dash`) for built on a pin of; `.it-sp` the Collapse/Expand forks and zoom buttons (`.it-zc`), shown only once the script adds `.it-on` to `.it-page`.
7 - `.it-stage` — the graph pane: at most 60% of the viewport high, scrolling both ways; `.it-zoom` its sized box, `.it-tree` the scaled grid (`.it-placed` once the script has placed the runs).
8 - `.it-edges` — the SVG of lines from a build to the head of the run that grew from it.
9 - `.it-lane` — one run: `.it-lh` its head with `.it-nm` the fork's name (the script makes it a button that folds the fork) and `.it-ct` (a `btn` the script adds, folds the run); `.it-branch` a run that splits off a build; `.it-shut` and `.it-runshut` its folds, `.it-hid` a branch whose fork is folded.
10 - `.it-coll` — the one node a fold leaves (`.it-fcoll` a fork, `.it-rcoll` a run): a full-width, borderless row that is part of the card, not a `btn`; hidden until its fold is shut.
11 - `.it-rows` — a run's rows; `.it-sub` the title of a branch run.
12 - `.it-b` — a build row: `.it-l` label (monospace), `.it-d` date, `.it-n` the note's first line.
docs/features/iterations.md added +17
@@ -0,0 +1,17 @@
1 # Iterations
2
3 A fork family's build pins, drawn as a tree of runs. Read-only: pinning, notes and deletes stay on Builds.
4
5 ## On the web
6
7 Open `/:owner/:name/iterations`, or the **Iterations** button on a repository's Builds page (shown once it holds a pin). The page is not found when neither the repository nor any fork you may read holds a pin, and it is a 404 for a private repository you may not read.
8
9 - A **run** is a card: the fork's name, then one row per pin (label, date, first line of its note). A pin continues its repository's run, oldest first, unless it names a parent (`--parent`), in which case it hangs under that pin; a parent in another fork (`owner/name:label`) draws a dashed concept line.
10 - A fork carries its parent's pins; each is drawn once, in the parent.
11 - The family is the root you may read, and every fork below it you may read, at most 40 repositories. A private fork is simply absent for anyone who may not read it.
12 - With JavaScript: runs are placed under the build they grew from and joined by lines; **Collapse forks** / **Expand forks** fold every fork to its name; the fork name folds one fork, **fold run** folds a run to one node; **−**, **100%**, **+** and **Fit** zoom (Ctrl/⌘ + wheel too). Folds and zoom are remembered in this browser only. Without JavaScript the runs stand side by side and no button is shown.
13 - Pins are read from git and kept for 5 seconds per repository, so a new pin may take that long to appear.
14
15 ## In the terminal
16
17 Open a repository, then the palette (`ctrl+k`) and **Iterations**. The family's runs are an indented list, a run under the build it grew from, its builds beneath; `j`/`k` move, `esc` goes back. It shows exactly what the page shows for the same viewer.
src/http/server.ts +2
@@ -183,6 +183,7 @@ import {
183183 branchDelete,
184184 branchesPage,
185185 } from "../web/pages/branches.ts";
186 import { iterationsPage } from "../web/pages/iterations.ts";
186187 import { buildsPage, deletePinSubmit, newBuildPage, pinBuildSubmit, previewBuildSubmit } from "../web/pages/builds.ts";
187188 import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts";
188189 import {
@@ -1259,6 +1260,7 @@ export function buildRouter(): Router<Handler> {
12591260 // Build pins (`git/builds.ts`): the list, the form, and the writes behind them.
12601261 router.add("GET", "/:owner/:name/builds", buildsPage);
12611262 router.add("GET", "/:owner/:name/builds/new", newBuildPage);
1263 router.add("GET", "/:owner/:name/iterations", iterationsPage);
12621264 router.add("POST", "/:owner/:name/builds", pinBuildSubmit);
12631265 router.add("POST", "/:owner/:name/builds/preview", previewBuildSubmit);
12641266 router.add("POST", "/:owner/:name/builds/:label/delete", deletePinSubmit);
src/tui/screens/iterations.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * The Iterations command, a repository's: its fork family's build pins as the
3 * web page draws them (`iterationsOf`, `lanesOf`), as an indented list — a run
4 * under the build it grew from, its builds beneath. Read-only. Offered to, and
5 * read for, only someone who may read the repository; the family is what that
6 * viewer may read of it (`canRead`, `visibleForks`), so a guest sees nothing
7 * private.
8 */
9
10 import { canRead, type Repo } from "../../core/repos.ts";
11 import { iterationsOf, lanesOf, type IterationLane } from "../../web/pages/iterations_family.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { binding } from "../app/keys.ts";
14 import type { Command } from "../app/palette.ts";
15 import type { Session } from "../session.ts";
16 import { style } from "../term/style.ts";
17 import { truncate } from "../term/width.ts";
18 import { ListState } from "./common.ts";
19
20 export const NOT_FOUND_LINE = "No such repository";
21 export const NO_ITERATIONS_LINE = "No builds pinned in this family yet.";
22
23 export interface IterationLine {
24 readonly depth: number;
25 readonly text: string;
26 /** A run's head, as against one of its builds. */
27 readonly head: boolean;
28 }
29
30 /** The family's runs flattened to lines: a head per run, then a line per build. */
31 export function iterationLines(lanes: readonly IterationLane[]): IterationLine[] {
32 const laneOf = new Map<string, IterationLane>();
33 for (const l of lanes) for (const b of l.builds) laneOf.set(b.key, l);
34 const mainOf = new Map(lanes.filter((l) => l.main).map((l) => [l.repo, l]));
35 const depthOf = new Map<IterationLane, number>();
36 const depth = (l: IterationLane, guard = 0): number => {
37 const got = depthOf.get(l);
38 if (got !== undefined) return got;
39 const up = l.from !== null ? laneOf.get(l.from) : l.fromRepo !== null ? mainOf.get(l.fromRepo) : undefined;
40 const d = up === undefined || up === l || guard > lanes.length ? 0 : depth(up, guard + 1) + 1;
41 depthOf.set(l, d);
42 return d;
43 };
44 const out: IterationLine[] = [];
45 for (const l of lanes) {
46 const d = depth(l);
47 const n = l.builds.length;
48 out.push({ depth: d, head: true, text: `${l.title} ${n === 0 ? "no build pinned" : `${String(n)} ${n === 1 ? "build" : "builds"}`}` });
49 for (const b of l.builds) out.push({ depth: d + 1, head: false, text: `${b.label} ${b.at.slice(0, 10)}${b.note === "" ? "" : ` ${b.note}`}` });
50 }
51 return out;
52 }
53
54 export function iterationsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
55 let lines: IterationLine[] | undefined;
56 let hidden = false;
57 const state = new ListState();
58 const screen: Screen = {
59 title: "Iterations",
60 keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")],
61 async load() {
62 // Gated here as well as on the command: the screen is exported.
63 hidden = !canRead(f.db, repo, f.user);
64 const forks = hidden ? undefined : await iterationsOf(f.app, f.user, repo);
65 lines = forks === undefined ? [] : iterationLines(lanesOf(forks));
66 state.clamp(lines.length);
67 ctx.invalidate();
68 },
69 render(area, s) {
70 const c = ctx.theme.c;
71 const right = area.x + area.w;
72 const line = (y: number, x: number, text: string, fg = c.fg) => s.text(area.x + x, area.y + y, truncate(text, Math.max(1, area.w - x)), style(fg, c.bg), right);
73 if (hidden) return void line(0, 1, NOT_FOUND_LINE);
74 if (lines === undefined) return void line(0, 1, "Reading…");
75 if (lines.length === 0) return void line(0, 1, NO_ITERATIONS_LINE);
76 const from = state.window(area.h, lines.length);
77 for (let i = 0; i < area.h; i++) {
78 const l = lines[from + i];
79 if (l === undefined) break;
80 const picked = from + i === state.index;
81 s.text(area.x, area.y + i, picked ? ">" : " ", style(c.accent, c.bg), right);
82 line(i, 1 + l.depth * 2, l.text, picked || l.head ? c.fg : c.muted);
83 }
84 },
85 key(e) {
86 const n = lines?.length ?? 0;
87 if (e.key === "j" || e.key === "down") state.move(1, n);
88 else if (e.key === "k" || e.key === "up") state.move(-1, n);
89 else if (e.key === "esc" || e.key === "q") ctx.pop();
90 else return false;
91 ctx.invalidate();
92 return true;
93 },
94 };
95 return screen;
96 }
97
98 /** The palette's door on a repository screen, for whoever may read it. */
99 export function iterationsCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
100 if (repo === undefined || !canRead(f.db, repo, f.user)) return [];
101 return [
102 {
103 id: "iterations",
104 title: `${repo.ownerName}/${repo.name} — Iterations`,
105 hint: "its fork family's build pins as a tree of runs",
106 run: () => ctx.push(iterationsScreen(f, ctx, repo)),
107 },
108 ];
109 }
src/tui/screens/workspace.ts +2
@@ -134,6 +134,7 @@ import { deleteRepoCommands } from "./deleterepo.ts";
134134 import { grantsCommands } from "./grants.ts";
135135 import { visionCommands } from "./vision.ts";
136136 import { buildsCommands } from "./builds.ts";
137 import { iterationsCommands } from "./iterations.ts";
137138 import { labelsCommands } from "./labels.ts";
138139 import { appEnvCommands } from "./appenv.ts";
139140 import { autonomyCommands } from "./autonomy.ts";
@@ -1452,6 +1453,7 @@ export function workspaceScreen(
14521453 ...visionCommands(f, ctx, repo),
14531454 ...labelsCommands(f, ctx, repo),
14541455 ...buildsCommands(f, ctx, repo),
1456 ...iterationsCommands(f, ctx, repo),
14551457 ...appEnvCommands(f, ctx, repo),
14561458 ...autonomyCommands(f, ctx, repo),
14571459 ],
src/web/pages/builds.ts +1
@@ -90,6 +90,7 @@ export async function buildsPage(ctx: Ctx): Promise<Response> {
9090 {
9191 head: html`${icon("pin")}
9292 <h2>Builds</h2>
93 ${when(pins.length > 0, () => btn("Iterations", { href: `${base}/iterations`, size: "sm", variant: "ghost" }))}
9394 ${when(writer, () => btn("Pin a build", { href: `${base}/builds/new`, size: "sm", variant: "primary" }))}`,
9495 },
9596 )}
src/web/pages/iterations.ts added +81
@@ -0,0 +1,81 @@
1 /**
2 * Iterations (`/:owner/:name/iterations`): a fork family's build pins as
3 * cards in a downward tree, a card per run, with fork and concept lines,
4 * zoom, and folds. Reached from Builds; it is not found where the family
5 * (what the viewer may read of it) holds no pin. Read-only: pinning stays on
6 * Builds. The data is `iterations_family.ts`, the browser half
7 * `iterations_script.ts`.
8 */
9
10 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
11 import type { Ctx } from "../../http/context.ts";
12 import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette } from "../chrome.ts";
13 import { html, when, type Html } from "../html.ts";
14 import { page } from "../layout.ts";
15 import { iterationsOf, lanesOf, type IterationBuild, type IterationLane } from "./iterations_family.ts";
16 import { btn } from "../ui.ts";
17 import { ITERATIONS_CSS, ITERATIONS_JS } from "./iterations_script.ts";
18
19 const row = (b: IterationBuild): Html =>
20 html`<div class="it-b" data-key="${b.key}">
21 <span class="it-l mono">${b.label}</span><span class="it-d">${b.at.slice(0, 10)}</span>
22 ${when(b.note !== "", () => html`<span class="it-n">${b.note}</span>`)}
23 </div>`;
24
25 /** One run: its head, its rows, and the one node each fold leaves. */
26 function lane(l: IterationLane, branches: number): Html {
27 const keys = l.builds.map((b) => b.key).join(" ");
28 const n = l.builds.length;
29 const first = l.builds[0];
30 const last = l.builds.at(-1);
31 const count = `${String(n)} ${n === 1 ? "build" : "builds"}`;
32 // The fold buttons are made by the script; without it a head is a name.
33 const head = l.main
34 ? html`<span class="it-nm" data-act="fork"><i aria-hidden="true"></i><b class="mono">${l.title}</b></span>`
35 : html`<span class="it-sub mono">${l.title}</span>`;
36 return html`<section class="it-lane${l.main ? "" : " it-branch"}" data-lane="${l.id}" data-repo="${l.repo}">
37 <header class="it-lh">${head}</header>
38 <div class="it-rows">${l.builds.map(row)}${when(n === 0, () => html`<div class="it-b"><span class="it-n">No build pinned here</span></div>`)}</div>
39 ${when(l.main, () => html`<button type="button" class="it-coll it-fcoll" data-act="fork" data-keys="${keys}"><b>${count}</b><span>${branches > 0 ? `${String(branches)} ${branches === 1 ? "branch" : "branches"} · ` : ""}folded</span></button>`)}
40 ${when(first !== undefined && last !== undefined && n > 1, () => html`<button type="button" class="it-coll it-rcoll" data-act="run" data-keys="${keys}"><b class="mono">${first!.label} → ${last!.label}</b><span>${count} in a run</span></button>`)}
41 </section>`;
42 }
43
44 const ctl = (id: string, text: string, title: string, label?: string): Html =>
45 btn(text, { id, type: "button", size: "sm", title, ...(label === undefined ? {} : { label }) });
46
47 /** Shown by the script only (`#it-page.it-on`): each one does nothing without it. */
48 const CONTROLS = html`<span class="it-zc" role="group" aria-label="Forks">${ctl("it-fold-all", "Collapse forks", "Fold every fork to its name")}${ctl("it-open-all", "Expand forks", "Open every fork")}</span><span class="it-zc" role="group" aria-label="Zoom">${ctl("it-zout", "−", "Zoom out", "Zoom out")}${ctl("it-zval", "100%", "Back to 100%")}${ctl("it-zin", "+", "Zoom in", "Zoom in")}${ctl("it-zfit", "Fit", "Fit the whole graph in view")}</span>`;
49
50 // GET /:owner/:name/iterations
51 export async function iterationsPage(ctx: Ctx): Promise<Response> {
52 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
53 const repo = loadRepo(ctx);
54 if (!repo) return timePhase(ctx.request, "render", () => notFoundPage(ctx));
55 const forks = await timePhaseAsync(ctx.request, "graph", () => iterationsOf(ctx.app, ctx.auth.user, repo));
56 if (forks === undefined) return timePhase(ctx.request, "render", () => notFoundPage(ctx));
57 const base = repoBase(repo);
58 const lanes = lanesOf(forks);
59 const data = JSON.stringify(lanes.map((l) => ({ id: l.id, from: l.from, fromRepo: l.fromRepo, kind: l.kind, main: l.main })));
60 const body = html`${repoHeader(ctx, repo, "branches", { compact: true })}
61 <div class="it-page" id="it-page" data-here="${repo.ownerName}/${repo.name}" data-lanes="${data}">
62 <div class="page-head mt">
63 <h2>Iterations</h2>
64 <a href="${base}/builds">Builds</a>
65 </div>
66 <div class="it-key"><span><i></i>fork of</span><span><i class="it-thin"></i>branch</span><span><i class="it-dash"></i>built on a pin of</span><span class="it-sp">${CONTROLS}</span></div>
67 <div class="it-stage" id="it-stage"><div class="it-zoom" id="it-zoom"><div class="it-tree" id="it-grid">${lanes.map((l) => lane(l, l.main ? lanes.filter((x) => !x.main && x.repo === l.repo).length : 0))}</div></div></div>
68 </div>`;
69 return timePhase(ctx.request, "render", () =>
70 page({
71 ctx,
72 title: `Iterations · ${repo.name}`,
73 crumbs: crumbsFor(repo),
74 palette: repoPalette(ctx, repo),
75 body,
76 styleCss: ITERATIONS_CSS,
77 scriptJs: ITERATIONS_JS,
78 wide: true,
79 }),
80 );
81 }
src/web/pages/iterations_family.ts added +193
@@ -0,0 +1,193 @@
1 /**
2 * Iterations, the data: a fork family's build pins as plain data, and the
3 * downward tree's runs drawn from them. No HTML here; `iterations.ts` draws.
4 *
5 * A pin hangs under the parent its person named (`BuildPin.parent`: a label
6 * here, or `owner/name:label` for a concept edge to another pin of the
7 * family), else it continues its repository's run, oldest first. A fork
8 * carries its parent's pins; the family draws each once, in the parent.
9 */
10
11 import { canRead, getRepoById, visibleForks, type Repo } from "../../core/repos.ts";
12 import { listBuildPins, type BuildPin } from "../../git/builds.ts";
13 import { repoDir } from "../../git/repos.ts";
14 import type { App } from "../../app.ts";
15 import type { User } from "../../core/users.ts";
16
17 /** How many repositories a family draws. */
18 export const FAMILY_LIMIT = 40;
19
20 export interface IterationBuild {
21 /** `owner/name:label`. */
22 readonly key: string;
23 readonly label: string;
24 readonly oid: string;
25 /** ISO time it was pinned. */
26 readonly at: string;
27 /** The first line of its note. */
28 readonly note: string;
29 /** The key of the build it hangs under, when that is in the family. */
30 readonly parent: string | null;
31 readonly edge: "git" | "concept" | null;
32 }
33
34 export interface IterationFork {
35 /** `owner/name`. */
36 readonly repo: string;
37 readonly name: string;
38 /** The repository it was forked from, when that is in the family. */
39 readonly forkOf: string | null;
40 /** Oldest first. */
41 readonly builds: readonly IterationBuild[];
42 }
43
44 export interface IterationLane {
45 /** The repository for a fork's main run, else the key of the run's first build. */
46 readonly id: string;
47 readonly repo: string;
48 readonly title: string;
49 readonly main: boolean;
50 /** The build it grew from; null for a root. */
51 readonly from: string | null;
52 /** For a main run with no build to hang from, the repository it was forked from. */
53 readonly fromRepo: string | null;
54 readonly kind: "fork" | "branch" | "concept";
55 readonly builds: readonly IterationBuild[];
56 }
57
58 /** The repositories the viewer may read: up to the root, then every fork below, root first. */
59 export function familyRepos(app: App, viewer: User | undefined, repo: Repo, limit = FAMILY_LIMIT): Repo[] {
60 const db = app.db;
61 let root = repo;
62 for (let i = 0; i < limit && root.forkParentId !== null; i++) {
63 const up = getRepoById(db, root.forkParentId);
64 if (up === undefined || !canRead(db, up, viewer)) break;
65 root = up;
66 }
67 const repos: Repo[] = [root];
68 for (let i = 0; i < repos.length && repos.length < limit; i++)
69 repos.push(...visibleForks(db, repos[i]!.id, viewer).slice(0, limit - repos.length));
70 return repos;
71 }
72
73 /** A pin the fork inherited: the same label on the same commit in its parent. */
74 const copied = (pin: BuildPin, up: readonly BuildPin[]): boolean => up.some((p) => p.label === pin.label && p.oid === pin.oid);
75
76 /**
77 * A repository's pins are read from git, one process each, up to
78 * FAMILY_LIMIT at a time; a read is kept this long per repository so a reload
79 * or a second viewer costs no process. A new pin shows within this time.
80 */
81 export const PINS_TTL_MS = 5000;
82 const kept = new WeakMap<App, Map<string, { at: number; pins: Promise<BuildPin[]> }>>();
83
84 function pinsOf(app: App, repo: Repo, now: number): Promise<BuildPin[]> {
85 let mine = kept.get(app);
86 if (mine === undefined) kept.set(app, (mine = new Map()));
87 const hit = mine.get(repo.id);
88 if (hit !== undefined && now - hit.at < PINS_TTL_MS) return hit.pins;
89 const pins = listBuildPins(repoDir(app.reposRoot, repo.ownerName, repo.name));
90 mine.set(repo.id, { at: now, pins });
91 if (mine.size > 4 * FAMILY_LIMIT) for (const [id, v] of mine) if (now - v.at >= PINS_TTL_MS) mine.delete(id);
92 pins.catch(() => mine.delete(repo.id));
93 return pins;
94 }
95
96 /** The family's pins as fork cards; undefined when it holds none. */
97 export async function iterationsOf(app: App, viewer: User | undefined, repo: Repo, limit = FAMILY_LIMIT): Promise<IterationFork[] | undefined> {
98 const family = familyRepos(app, viewer, repo, limit);
99 const now = Date.now();
100 const names = new Map(family.map((r) => [r.id, `${r.ownerName}/${r.name}`]));
101 const pins = new Map<string, BuildPin[]>();
102 await Promise.all(
103 family.map(async (r) => pins.set(r.id, [...(await pinsOf(app, r, now))])),
104 );
105 for (const r of family) {
106 const up = r.forkParentId === null ? undefined : pins.get(r.forkParentId);
107 if (up !== undefined) pins.set(r.id, pins.get(r.id)!.filter((p) => !copied(p, up)));
108 }
109 if ([...pins.values()].every((p) => p.length === 0)) return undefined;
110 const known = new Set(family.flatMap((r) => pins.get(r.id)!.map((p) => `${names.get(r.id)}:${p.label}`)));
111 const forks: IterationFork[] = [];
112 for (const r of family) {
113 const name = names.get(r.id)!;
114 const builds: IterationBuild[] = [...pins.get(r.id)!]
115 .sort((a, b) => (a.pinnedAt < b.pinnedAt ? -1 : a.pinnedAt > b.pinnedAt ? 1 : a.label < b.label ? -1 : 1))
116 .map((p) => {
117 const key = `${name}:${p.label}`;
118 const named = p.parent === undefined ? undefined : p.parent.includes(":") ? p.parent : `${name}:${p.parent}`;
119 const ok = named !== undefined && named !== key && known.has(named);
120 return {
121 key,
122 label: p.label,
123 oid: p.oid,
124 at: p.pinnedAt,
125 note: (p.note.split("\n", 1)[0] ?? "").slice(0, 200),
126 parent: ok ? named : null,
127 edge: ok ? (p.parent!.includes(":") ? "concept" : "git") : null,
128 };
129 });
130 forks.push({ repo: name, name: r.name, forkOf: r.forkParentId === null ? null : (names.get(r.forkParentId) ?? null), builds });
131 }
132 // A fork is named by its repository name; its owner joins only when another fork shares that name.
133 const clash = (n: string) => forks.filter((f) => f.name === n).length > 1;
134 return forks.map((f) => (clash(f.name) ? { ...f, name: f.repo } : f));
135 }
136
137 /**
138 * The family as the tree's runs, in drawing order: depth first, and under a
139 * run the one that splits off nearest its foot first, so a line never
140 * crosses a run. A build continues its parent's run when the parent is that
141 * run's last build in the same repository; otherwise it starts a run.
142 */
143 export function lanesOf(forks: readonly IterationFork[]): IterationLane[] {
144 type Lane = { -readonly [K in keyof IterationLane]: IterationLane[K] } & { builds: IterationBuild[] };
145 const lanes: Lane[] = [];
146 const laneOf = new Map<string, Lane>();
147 const known = new Set(forks.flatMap((f) => f.builds.map((b) => b.key)));
148 for (const f of forks) {
149 let main: Lane | undefined;
150 const add = (l: Lane) => (lanes.push(l), l);
151 for (const b of f.builds) {
152 const from = b.parent !== null && known.has(b.parent) ? b.parent : null;
153 const at = from === null ? undefined : laneOf.get(from);
154 let lane: Lane;
155 if (at !== undefined && at.repo === f.repo && at.builds.at(-1)?.key === from) lane = at;
156 else if (from === null && main !== undefined) lane = main;
157 else if (main === undefined && (at === undefined || at.repo !== f.repo))
158 lane = main = add({
159 id: f.repo, repo: f.repo, title: f.name, main: true, from,
160 fromRepo: from === null ? f.forkOf : null,
161 kind: from !== null && b.edge === "concept" ? "concept" : "fork",
162 builds: [],
163 });
164 else
165 lane = add({
166 id: b.key, repo: f.repo, title: `${f.name} · branch`, main: false, from, fromRepo: null,
167 kind: at !== undefined && at.repo === f.repo ? "branch" : b.edge === "concept" ? "concept" : "fork",
168 builds: [],
169 });
170 lane.builds.push(b);
171 laneOf.set(b.key, lane);
172 }
173 if (main === undefined) add({ id: f.repo, repo: f.repo, title: f.name, main: true, from: null, fromRepo: f.forkOf, kind: "fork", builds: [] });
174 }
175 const childrenOf = (l: Lane) =>
176 lanes
177 .filter((c) => c !== l && ((c.from !== null && laneOf.get(c.from) === l) || (c.from === null && c.fromRepo === l.repo && l.main)))
178 .sort((a, b) => {
179 const at = (c: Lane) => (c.from === null ? -1 : l.builds.findIndex((x) => x.key === c.from));
180 return at(b) - at(a);
181 });
182 const out: Lane[] = [];
183 const seen = new Set<Lane>();
184 const visit = (l: Lane) => {
185 if (seen.has(l)) return;
186 seen.add(l);
187 out.push(l);
188 for (const c of childrenOf(l)) visit(c);
189 };
190 for (const l of lanes) if ((l.from === null || laneOf.get(l.from) === undefined) && (l.fromRepo === null || !lanes.some((x) => x.main && x.repo === l.fromRepo))) visit(l);
191 for (const l of lanes) visit(l);
192 return out;
193 }
src/web/pages/iterations_script.ts added +181
@@ -0,0 +1,181 @@
1 /**
2 * Iterations, the browser's half: the rules and the script. The server draws
3 * every card and row; the script places each run under the build it grew
4 * from, draws the lines, folds a fork or a run, and zooms. Folds and zoom are
5 * kept in this browser only. Without the script the runs stand side by side and no button is shown: the controls and the fold buttons are made or shown by it.
6 */
7
8 export const ITERATIONS_CSS = /* css */ `
9 .it-key{display:flex;gap:16px;color:var(--muted-fg);font-size:var(--fs-min);margin:4px 0 8px;flex-wrap:wrap;align-items:center}
10 .it-key i{display:inline-block;width:24px;height:0;border-top:2px solid var(--muted-fg);vertical-align:middle;margin-right:6px}
11 .it-key i.it-thin{border-top-width:1px}
12 .it-key i.it-dash{border-top-style:dashed}
13 .it-sp{margin-left:auto;display:none;gap:8px;align-items:center;flex-wrap:wrap}
14 .it-on .it-sp{display:flex}
15 .it-zc{display:inline-flex;gap:2px}
16 #it-zval{min-width:56px;font-variant-numeric:tabular-nums}
17 .it-stage{position:relative;padding:16px;border:1px solid var(--border);border-radius:var(--radius);overflow:auto;max-height:60vh}
18 #it-grid{--it-card:calc(50ch + 26px);font-family:var(--mono);font-size:var(--fs-min);transform-origin:0 0}
19 .it-tree{position:relative;display:flex;gap:32px;align-items:flex-start;width:max-content;min-width:100%}
20 .it-tree.it-placed{display:block}
21 .it-placed .it-lane{position:absolute}
22 .it-lane{width:var(--it-card);background:var(--card);border:1px solid var(--border);border-radius:var(--radius);z-index:1}
23 .it-lh{display:flex;align-items:center;gap:6px;padding:8px;border-bottom:1px solid var(--border);min-height:32px}
24 .it-nm{display:flex;gap:8px;align-items:baseline;min-width:0;padding:0;background:none;border:0;color:inherit;font:inherit;text-align:left;cursor:pointer}
25 .it-nm i{font-style:normal;color:var(--muted-fg);width:10px}
26 .it-nm i::before{content:"\\25BE"}
27 .it-lane.it-shut .it-nm i::before{content:"\\25B8"}
28 .it-nm b{overflow-wrap:anywhere}
29 .it-sub{color:var(--muted-fg);font-size:var(--fs-min)}
30 .it-ct{margin-left:auto;white-space:nowrap}
31 .it-ct::before{content:"fold run"}
32 .it-lane.it-runshut .it-ct::before{content:"open run"}
33 .it-rows{display:flex;flex-direction:column}
34 .it-b{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:8px;padding:8px 12px;border-top:1px solid var(--border)}
35 .it-rows > .it-b:first-child{border-top:0}
36 .it-l{font-weight:700;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:inherit}
37 .it-d{color:var(--muted-fg);font-size:var(--fs-min);text-align:right;white-space:nowrap}
38 .it-n{grid-column:1/-1;color:var(--muted-fg);font-size:var(--fs-min);overflow-wrap:anywhere}
39 .it-coll{display:none;flex-direction:column;gap:1px;width:100%;background:none;border:0;color:inherit;font:inherit;text-align:left;padding:8px 12px;cursor:pointer}
40 .it-coll span{color:var(--muted-fg);font-size:var(--fs-min)}
41 .it-lane.it-shut .it-fcoll,.it-lane.it-runshut:not(.it-shut) .it-rcoll{display:flex}
42 .it-lane.it-shut .it-rows,.it-lane.it-runshut .it-rows,.it-lane.it-shut .it-ct{display:none}
outdated

This rule looks mangled. It was meant to hide a folded fork's or run's rows and the fork-level .it-ct, but it sets margin-left:auto;white-space:nowrap instead. Nothing sets display:none on .it-rows, so after a fold both the rows and the .it-coll summary node show, and the fold only changes the toggle glyph. Fix: .it-lane.it-shut .it-rows,.it-lane.it-runshut .it-rows{display:none}, and drop .it-ct from the rule (the .it-ct rule on line 30 already has margin-left:auto;white-space:nowrap). .it-shut .it-ct should stay visible only if you want run-folding inside a folded fork. Otherwise hide it too. Add a test that asserts this rule in ITERATIONS_CSS.

43 .it-lane.it-shut .it-lh{border-bottom:0}
44 .it-lane.it-hid{display:none}
45 .it-edges{position:absolute;left:0;top:0;pointer-events:none;overflow:visible}
46 .it-edges path{fill:none;stroke:var(--muted-fg);stroke-width:2}
47 .it-edges path[data-kind="concept"]{stroke-dasharray:6 5}
48 .it-edges path[data-kind="branch"]{stroke-width:1.25}
49 `;
50
51 export const ITERATIONS_JS = `
52 (function () {
53 var root = document.getElementById('it-page'), stage = document.getElementById('it-stage'), box = document.getElementById('it-zoom'), grid = document.getElementById('it-grid');
54 if (!root || !stage || !box || !grid) return;
55 root.classList.add('it-on');
56 // The fold buttons exist only here: a fork's name becomes one, a run of several builds gets one.
57 grid.querySelectorAll('span.it-nm').forEach(function (s) {
58 var b = document.createElement('button'); b.type = 'button'; b.className = 'it-nm'; b.setAttribute('data-act', 'fork'); b.title = 'Fold this fork'; b.innerHTML = s.innerHTML; s.replaceWith(b);
59 });
60 grid.querySelectorAll('.it-lane').forEach(function (l) {
61 var h = l.querySelector('.it-lh');
62 if (!l.querySelector('.it-rcoll') || !h) return;
63 var b = document.createElement('button'); b.type = 'button'; b.className = 'btn it-ct'; b.setAttribute('data-size', 'sm'); b.setAttribute('data-variant', 'ghost'); b.setAttribute('data-act', 'run'); b.title = 'Fold this run'; h.appendChild(b);
64 });
65 var lanes = []; try { lanes = JSON.parse(root.getAttribute('data-lanes') || '[]') || []; } catch (e) {}
66 var NS = 'http://www.w3.org/2000/svg', W = 220, G = 36, PAD = 16, Z = 1, ZKEY = 'forge:iterations-zoom', FKEY = 'forge:iterations-folds:' + root.getAttribute('data-here');
67 var STEPS = [0.25, 0.33, 0.5, 0.67, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 2];
68 try { Z = Math.min(2, Math.max(0.2, Number(localStorage.getItem(ZKEY)) || 1)); } catch (e) {}
69 var els = {}; grid.querySelectorAll('.it-lane').forEach(function (el) { els[el.getAttribute('data-lane')] = el; });
70 var byId = {}; lanes.forEach(function (l) { byId[l.id] = l; });
71 function seen(el) { return el && el.offsetParent !== null; }
72 function anchor(key) {
73 var rows = grid.querySelectorAll('.it-b[data-key]');
74 for (var i = 0; i < rows.length; i++) if (rows[i].getAttribute('data-key') === key && seen(rows[i])) return rows[i];
75 var holds = grid.querySelectorAll('[data-keys]');
76 for (var k = 0; k < holds.length; k++) if ((' ' + holds[k].getAttribute('data-keys') + ' ').indexOf(' ' + key + ' ') >= 0 && seen(holds[k])) return holds[k];
77 return null;
78 }
79 function parentOf(l) {
80 if (l.from) { var a = anchor(l.from); return a ? { a: a, lane: a.closest('.it-lane') } : null; }
81 if (l.fromRepo) { var m = byId[l.fromRepo] && els[l.fromRepo]; return m && seen(m) ? { a: m.querySelector('.it-lh'), lane: m } : null; }
82 return null;
83 }
84 function visible() { return lanes.filter(function (l) { return seen(els[l.id]); }); }
85 function save() {
86 var s = { shut: [], run: [] };
87 Object.keys(els).forEach(function (id) { if (els[id].classList.contains('it-shut')) s.shut.push(id); if (els[id].classList.contains('it-runshut')) s.run.push(id); });
88 try { if (s.shut.length || s.run.length) localStorage.setItem(FKEY, JSON.stringify(s)); else localStorage.removeItem(FKEY); } catch (e) {}
89 }
90 function place() {
91 var one = grid.querySelector('.it-lane'); if (one && one.offsetWidth) W = one.offsetWidth;
92 var hid = {};
93 grid.querySelectorAll('.it-lane.it-shut').forEach(function (el) { hid[el.getAttribute('data-repo')] = true; });
94 grid.querySelectorAll('.it-lane.it-branch').forEach(function (el) { el.classList.toggle('it-hid', !!hid[el.getAttribute('data-repo')]); });
95 var pos = {};
96 visible().forEach(function (l, i) {
97 var y = PAD, p = parentOf(l);
98 if (p && p.lane && pos[p.lane.getAttribute('data-lane')]) y = pos[p.lane.getAttribute('data-lane')].y + p.a.offsetTop + p.a.offsetHeight + 10;
99 pos[l.id] = { x: PAD + i * (W + G), y: y };
100 els[l.id].style.left = pos[l.id].x + 'px'; els[l.id].style.top = y + 'px';
101 });
102 var w = 0, h = 0;
103 grid.querySelectorAll('.it-lane').forEach(function (el) { if (!seen(el)) return; w = Math.max(w, el.offsetLeft + el.offsetWidth); h = Math.max(h, el.offsetTop + el.offsetHeight); });
104 grid.style.width = (w + PAD) + 'px'; grid.style.height = (h + PAD) + 'px';
105 lines(); fit();
106 }
107 function lines() {
108 var old = grid.querySelector('svg.it-edges'); if (old) old.remove();
109 var svg = document.createElementNS(NS, 'svg'); svg.setAttribute('class', 'it-edges');
110 var o = grid.getBoundingClientRect();
111 function R(el) { var r = el.getBoundingClientRect(); return { l: (r.left - o.left) / Z, t: (r.top - o.top) / Z, r: (r.right - o.left) / Z, b: (r.bottom - o.top) / Z, cx: ((r.left + r.right) / 2 - o.left) / Z, cy: ((r.top + r.bottom) / 2 - o.top) / Z }; }
112 visible().forEach(function (l) {
113 var p = parentOf(l); if (!p) return;
114 var a = R(p.a), h = R(els[l.id].querySelector('.it-lh')), sx, sy, ex = h.cx, ey = h.t, d;
115 if (h.cx > a.r) { sx = a.r; sy = a.cy; } else if (h.cx < a.l) { sx = a.l; sy = a.cy; } else { sx = a.cx; sy = a.b; }
116 if (sx === a.cx) { if (ey < sy) ey = h.b; var my = (sy + ey) / 2; d = 'M' + sx + ',' + sy + ' C' + sx + ',' + my + ' ' + ex + ',' + my + ' ' + ex + ',' + ey; }
117 else if (ey > sy + 8) { var dir = ex > sx ? 1 : -1, r = Math.min(12, Math.abs(ex - sx) / 2, (ey - sy) / 2); d = 'M' + sx + ',' + sy + ' H' + (ex - dir * r) + ' Q' + ex + ',' + sy + ' ' + ex + ',' + (sy + r) + ' V' + ey; }
118 else { ey = h.b; d = 'M' + sx + ',' + sy + ' C' + ex + ',' + sy + ' ' + ex + ',' + sy + ' ' + ex + ',' + ey; }
119 var path = document.createElementNS(NS, 'path'); path.setAttribute('d', d); path.setAttribute('data-kind', l.kind || 'fork'); svg.appendChild(path);
120 });
121 svg.setAttribute('width', grid.scrollWidth); svg.setAttribute('height', grid.scrollHeight);
122 grid.insertBefore(svg, grid.firstChild);
123 }
124 // Zoom: the grid is scaled, its box takes the scaled size so the stage scrolls the right distance.
125 function fit() {
126 grid.style.transform = Z === 1 ? '' : 'scale(' + Z + ')';
127 box.style.width = Math.ceil(grid.offsetWidth * Z) + 'px'; box.style.height = Math.ceil(grid.offsetHeight * Z) + 'px';
128 var v = document.getElementById('it-zval'); if (v) v.textContent = Math.round(Z * 100) + '%';
129 }
130 function setZoom(z, px, py) {
131 z = Math.min(2, Math.max(0.2, Math.round(z * 100) / 100));
132 if (z === Z) return;
133 if (px === undefined) { px = stage.clientWidth / 2; py = stage.clientHeight / 2; }
134 var gx = (stage.scrollLeft + px) / Z, gy = (stage.scrollTop + py) / Z;
135 Z = z;
136 try { if (Z === 1) localStorage.removeItem(ZKEY); else localStorage.setItem(ZKEY, String(Z)); } catch (e) {}
137 fit(); lines();
138 stage.scrollLeft = gx * Z - px; stage.scrollTop = gy * Z - py;
139 }
140 function step(dir) {
141 var next = dir > 0 ? STEPS.find(function (s) { return s > Z + 0.001; }) : STEPS.slice().reverse().find(function (s) { return s < Z - 0.001; });
142 if (next !== undefined) setZoom(next);
143 }
144 function on(id, f) { var b = document.getElementById(id); if (b) b.addEventListener('click', f); }
145 on('it-zin', function () { step(1); });
146 on('it-zout', function () { step(-1); });
147 on('it-zval', function () { setZoom(1); });
148 on('it-zfit', function () {
149 var w = grid.offsetWidth, h = grid.offsetHeight; if (!w || !h) return;
150 var cs = getComputedStyle(stage), padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight), padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
151 var tall = parseFloat(cs.maxHeight); if (!(tall > 0)) tall = window.innerHeight * 0.6;
152 setZoom(Math.floor(Math.min(1, (stage.clientWidth - padX - 2) / w, (tall - padY - 2) / h) * 100) / 100);
153 stage.scrollLeft = 0; stage.scrollTop = 0;
154 });
155 stage.addEventListener('wheel', function (e) {
156 if (!e.ctrlKey && !e.metaKey) return;
157 e.preventDefault();
158 var r = stage.getBoundingClientRect();
159 setZoom(Z * Math.exp(-e.deltaY / 300), e.clientX - r.left, e.clientY - r.top);
160 }, { passive: false });
161 // Folds: a fork to its name, a run to one node.
162 grid.addEventListener('click', function (ev) {
163 var t = ev.target.closest && ev.target.closest('[data-act]'); if (!t) return;
164 var el = t.closest('.it-lane'); if (!el) return;
165 el.classList.toggle(t.getAttribute('data-act') === 'fork' ? 'it-shut' : 'it-runshut');
166 save(); place();
167 });
168 function all(shut) {
169 Object.keys(els).forEach(function (id) { if (byId[id] && byId[id].main) els[id].classList.toggle('it-shut', shut); els[id].classList.remove('it-runshut'); });
170 save(); place();
171 }
172 on('it-fold-all', function () { all(true); });
173 on('it-open-all', function () { all(false); });
174 try {
175 var s = JSON.parse(localStorage.getItem(FKEY) || 'null');
176 if (s) { (s.shut || []).forEach(function (id) { if (els[id]) els[id].classList.add('it-shut'); }); (s.run || []).forEach(function (id) { if (els[id]) els[id].classList.add('it-runshut'); }); }
177 } catch (e) {}
178 grid.classList.add('it-placed'); place();
179 if (document.fonts && document.fonts.ready) document.fonts.ready.then(place);
180 })();
181 `;
test/iterations.test.ts added +233
@@ -0,0 +1,233 @@
1 /**
2 * Iterations (`/:owner/:name/iterations`): a fork family's pins as a tree of
3 * runs. A pin hangs under the parent it names, else continues its
4 * repository's run; a fork's inherited pins are drawn once, in the parent; a
5 * family with no pin, or a private one to a stranger, is not found; every
6 * note is escaped; the page's script parses; Builds links to it.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { pinBuild } from "../src/actions/builds.ts";
14 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
15 import { getRepo, type Repo } from "../src/core/repos.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { promote } from "./helpers/members.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { FAMILY_LIMIT, familyRepos, iterationsOf, lanesOf, type IterationBuild, type IterationFork } from "../src/web/pages/iterations_family.ts";
20 import { ITERATIONS_CSS, ITERATIONS_JS } from "../src/web/pages/iterations_script.ts";
21
22 const build = (repo: string, label: string, parent: string | null = null, edge: IterationBuild["edge"] = parent === null ? null : "git"): IterationBuild => ({
23 key: `${repo}:${label}`,
24 label,
25 oid: "a".repeat(40),
26 at: "2026-01-01T00:00:00Z",
27 note: "",
28 parent,
29 edge,
30 });
31
32 describe("lanesOf", () => {
33 test("a pin continues its repository's run; a second child of a build starts a branch run under it", () => {
34 const forks: IterationFork[] = [
35 { repo: "a/g", name: "g", forkOf: null, builds: [build("a/g", "v1"), build("a/g", "v2", "a/g:v1"), build("a/g", "side", "a/g:v1")] },
36 ];
37 const lanes = lanesOf(forks);
38 expect(lanes.map((l) => [l.id, l.main, l.builds.map((b) => b.label)])).toEqual([
39 ["a/g", true, ["v1", "v2"]],
40 ["a/g:side", false, ["side"]],
41 ]);
42 expect(lanes[1]!.from).toBe("a/g:v1");
43 expect(lanes[1]!.kind).toBe("branch");
44 });
45
46 test("a fork with no parent pin hangs under its upstream's head; a concept edge keeps its kind", () => {
47 const forks: IterationFork[] = [
48 { repo: "a/g", name: "g", forkOf: null, builds: [build("a/g", "v1")] },
49 { repo: "b/g", name: "b/g", forkOf: "a/g", builds: [build("b/g", "x1")] },
50 { repo: "c/g", name: "c/g", forkOf: "a/g", builds: [build("c/g", "y1", "a/g:v1", "concept")] },
51 ];
52 const lanes = lanesOf(forks);
53 const b = lanes.find((l) => l.id === "b/g")!;
54 expect([b.from, b.fromRepo, b.kind]).toEqual([null, "a/g", "fork"]);
55 const c = lanes.find((l) => l.id === "c/g")!;
56 expect([c.from, c.kind]).toEqual(["a/g:v1", "concept"]);
57 });
58
59 test("a fork with no builds still has a card; a parent cycle ends", () => {
60 const forks: IterationFork[] = [
61 { repo: "a/g", name: "g", forkOf: null, builds: [build("a/g", "v1", "a/g:v2"), build("a/g", "v2", "a/g:v1")] },
62 { repo: "b/g", name: "b/g", forkOf: "a/g", builds: [] },
63 ];
64 expect(lanesOf(forks).map((l) => l.id).sort()).toEqual(["a/g", "b/g"]);
65 });
66 });
67
68 describe("iterations page", () => {
69 let dataDir: string;
70 let app: App;
71 let servers: Servers;
72 let base: string;
73 let alice: User;
74 let bob: User;
75 let carol: User;
76 const game = (): Repo => getRepo(app.db, "alice", "game")!;
77 const get = (path: string, cookie = "") => fetch(`${base}${path}`, { headers: { cookie } });
78 const signIn = async (username: string) =>
79 ((await fetch(`${base}/signin`, { method: "POST", body: new URLSearchParams({ username, password: "a-good-passphrase" }), redirect: "manual" })).headers.get("set-cookie") ?? "").split(";")[0]!;
80
81 beforeAll(async () => {
82 dataDir = await mkdtemp(join(tmpdir(), "forge-iterations-"));
83 app = createApp({ dataDir, port: 0 });
84 servers = await serve(app);
85 base = servers.url;
86 for (const name of ["alice", "bob", "carol"]) {
87 const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: true });
88 if (!made.ok) throw new Error(made.error);
89 if (name === "alice") alice = made.value;
90 if (name === "bob") bob = made.value;
91 if (name === "carol") carol = made.value;
92 }
93 promote(app.db, bob);
94 promote(app.db, carol);
95 for (const name of ["game", "secret", "empty"]) {
96 const made = await createRepo(app, alice, { name, visibility: name === "secret" ? "private" : "public" });
97 if (!made.ok) throw new Error(String(made.error));
98 }
99 const commit = async (repo: Repo, who: User, message: string) => {
100 const c = await commitToBranch(app, who, repo, { branch: "main", message, files: [{ path: `${message}.txt`, content: `${message}\n` }] });
101 if (!c.ok) throw new Error(JSON.stringify(c.error));
102 return c.value.commitOid;
103 };
104 const pin = async (repo: Repo, who: User, label: string, oid: string, extra: { parent?: string; note?: string } = {}) => {
105 const p = await pinBuild(app, who, repo, { label, oid, ...extra });
106 if (!p.ok) throw new Error(JSON.stringify(p.error));
107 };
108 const one = await commit(game(), alice, "one");
109 const two = await commit(game(), alice, "two");
110 await pin(game(), alice, "v1", one, { note: "first <b>playable</b>" });
111 await pin(game(), alice, "v2", two, { parent: "v1" });
112 await pin(game(), alice, "side", two, { parent: "v1" });
113 const forked = await forkRepo(app, bob, game(), "game");
114 if (!forked.ok) throw new Error(String(forked.error));
115 const mine = getRepo(app.db, "bob", "game")!;
116 await pin(mine, bob, "b1", await commit(mine, bob, "bob one"), { parent: "alice/game:v2" });
117 const hush = await forkRepo(app, carol, game(), "hush", { visibility: "private" });
118 if (!hush.ok) throw new Error(String(hush.error));
119 await pin(hush.value, carol, "hush-pin", await commit(hush.value, carol, "hush one"), { parent: "alice/game:v1" });
120 const secret = getRepo(app.db, "alice", "secret")!;
121 await pin(secret, alice, "s1", await commit(secret, alice, "s"));
122 });
123
124 afterAll(async () => {
125 servers.stop();
126 app.close();
127 await rm(dataDir, { recursive: true, force: true });
128 });
129
130 test("the family is drawn from any member, each pin once, with the runs as data", async () => {
131 for (const path of ["/alice/game/iterations", "/bob/game/iterations"]) {
132 const res = await get(path);
133 expect(res.status).toBe(200);
134 const text = await res.text();
135 expect(text).toContain("Iterations");
136 for (const key of ["alice/game:v1", "alice/game:v2", "alice/game:side", "bob/game:b1"]) expect(text).toContain(`data-key="${key}"`);
137 expect(text.split('data-key="alice/game:v1"').length).toBe(2);
138 expect(text.split('data-key="bob/game:v1"').length).toBe(1);
139 expect(text).toContain("alice/game:side");
140 }
141 });
142
143 test("the markup the script relies on is there for a folded run, and no button waits for the script", async () => {
144 const text = await (await get("/alice/game/iterations")).text();
145 expect(text).toContain('data-lane="alice/game" data-repo="alice/game"');
146 // Pins made in the same second sort by label.
147 const keys = "alice/game:side alice/game:v1 alice/game:v2";
148 expect(text).toContain(`data-act="fork" data-keys="${keys}"`);
149 expect(text).toContain(`data-act="run" data-keys="${keys}"`);
150 expect(text).toContain('data-lanes="[{');
151 expect(text).toContain('data-here="alice/game"');
152 // Names and the controls are drawn inert; the script turns them on.
153 expect(text).toContain('<span class="it-nm" data-act="fork">');
154 expect(text).not.toContain('<button type="button" class="it-nm"');
155 expect(text).toContain('id="it-zfit"');
156 expect(ITERATIONS_JS).toContain("classList.add('it-on')");
157 expect(ITERATIONS_CSS).toMatch(/\.it-sp\{[^}]*display:none/);
158 expect(ITERATIONS_CSS).toContain(".it-on .it-sp{display:flex}");
159 });
160
161 test("a fold hides its rows and shows the one node it leaves", () => {
162 const rule = (selector: string) => ITERATIONS_CSS.split("\n").find((l) => l.startsWith(selector) || l.includes(`,${selector}`)) ?? "";
163 for (const shut of [".it-lane.it-shut .it-rows", ".it-lane.it-runshut .it-rows", ".it-lane.it-shut .it-ct"]) expect(rule(shut)).toMatch(/\{display:none\}$/);
164 expect(rule(".it-lane.it-shut .it-fcoll")).toMatch(/\{display:flex\}$/);
165 expect(rule(".it-lane.it-runshut:not(.it-shut) .it-rcoll")).toMatch(/\{display:flex\}$/);
166 expect(rule(".it-coll{")).toContain("display:none");
167 });
168
169 test("a private fork and its pin are drawn to its owner and never to a guest", async () => {
170 const guest = await (await get("/alice/game/iterations")).text();
171 expect(guest).not.toContain("hush");
172 expect(guest).not.toContain("hush-pin");
173 expect((await get("/carol/hush/iterations")).status).toBe(404);
174 const mine = await (await get("/carol/hush/iterations", await signIn("carol"))).text();
175 expect(mine).toContain('data-key="carol/hush:hush-pin"');
176 // What it carried from alice/game is drawn once, in alice/game.
177 expect(mine.split('data-key="alice/game:v1"').length).toBe(2);
178 expect(mine.split("carol/hush:v1").length).toBe(1);
179 });
180
181 test("a family is capped at the limit, root first", () => {
182 expect(FAMILY_LIMIT).toBe(40);
183 const all = familyRepos(app, carol, game());
184 expect(all[0]!.name).toBe("game");
185 expect(all.length).toBe(3);
186 expect(familyRepos(app, carol, game(), 2).length).toBe(2);
187 expect(familyRepos(app, bob, game()).length).toBe(2);
188 expect(familyRepos(app, undefined, game(), 5).map((r) => r.name)).toEqual(["game", "game"]);
189 });
190
191 test("a read of a family's pins is kept for a few seconds", async () => {
192 const made = await createRepo(app, alice, { name: "kept", visibility: "public" });
193 if (!made.ok) throw new Error(String(made.error));
194 const c = await commitToBranch(app, alice, made.value, { branch: "main", message: "k", files: [{ path: "k.txt", content: "k\n" }] });
195 if (!c.ok) throw new Error(JSON.stringify(c.error));
196 const first = await pinBuild(app, alice, made.value, { label: "k1", oid: c.value.commitOid });
197 if (!first.ok) throw new Error(JSON.stringify(first.error));
198 expect((await iterationsOf(app, undefined, made.value))![0]!.builds.length).toBe(1);
199 const second = await pinBuild(app, alice, made.value, { label: "k2", oid: c.value.commitOid });
200 if (!second.ok) throw new Error(JSON.stringify(second.error));
201 expect((await iterationsOf(app, undefined, made.value))![0]!.builds.length).toBe(1);
202 });
203
204 test("a note is escaped", async () => {
205 const text = await (await get("/alice/game/iterations")).text();
206 expect(text).toContain("first &lt;b&gt;playable&lt;/b&gt;");
207 expect(text).not.toContain("<b>playable</b>");
208 });
209
210 test("the page keeps the security floor, loosens no frames, and ships a script that parses", async () => {
211 const res = await get("/alice/game/iterations");
212 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
213 expect(res.headers.get("referrer-policy")).toBeTruthy();
214 expect(res.headers.get("content-security-policy") ?? "").not.toContain("frame-src http");
215 expect(() => new Function(ITERATIONS_JS)).not.toThrow();
216 });
217
218 test("a family with no pin is not found", async () => {
219 expect((await get("/alice/empty/iterations")).status).toBe(404);
220 expect((await get("/alice/nope/iterations")).status).toBe(404);
221 });
222
223 test("a private family is not found to a stranger and drawn to its owner", async () => {
224 expect((await get("/alice/secret/iterations")).status).toBe(404);
225 const cookie = await signIn("alice");
226 expect((await get("/alice/secret/iterations", cookie)).status).toBe(200);
227 });
228
229 test("Builds links to Iterations when it holds a pin", async () => {
230 expect(await (await get("/alice/game/builds")).text()).toContain('href="/alice/game/iterations"');
231 expect(await (await get("/alice/empty/builds")).text()).not.toContain("/iterations");
232 });
233 });
test/tui-iterations.test.ts added +116
@@ -0,0 +1,116 @@
1 /** The terminal's Iterations screen: a family's pins as an indented list, for whoever may read it; nothing private to a guest. */
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 { pinBuild } from "../src/actions/builds.ts";
7 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { getRepo, type Repo } from "../src/core/repos.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import { promote } from "./helpers/members.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { iterationLines, iterationsCommands, iterationsScreen, NO_ITERATIONS_LINE, NOT_FOUND_LINE } from "../src/tui/screens/iterations.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { lanesOf } from "../src/web/pages/iterations_family.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17 import { settleApp } from "./helpers/tui.ts";
18
19 quietLogs();
20
21 let dir: string;
22 let app: App;
23 let alice: User;
24 let carol: User;
25 let game: Repo;
26 let empty: Repo;
27 const guest = { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test" };
28
29 beforeAll(async () => {
30 dir = await mkdtemp(join(tmpdir(), "forge-tui-iterations-"));
31 app = createApp({ dataDir: dir, port: 0 });
32 const mkUser = async (username: string) => {
33 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase", adminIfFirst: true });
34 if (!made.ok) throw new Error(made.error);
35 return made.value;
36 };
37 alice = await mkUser("alice");
38 const bob = await mkUser("bob");
39 carol = await mkUser("carol");
40 promote(app.db, bob);
41 promote(app.db, carol);
42 const commit = async (repo: Repo, who: User, message: string) => {
43 const c = await commitToBranch(app, who, repo, { branch: repo.defaultBranch, message, files: [{ path: `${message}.txt`, content: `${message}\n` }] });
44 if (!c.ok) throw new Error(JSON.stringify(c.error));
45 return c.value.commitOid;
46 };
47 const pin = async (repo: Repo, who: User, label: string, oid: string, parent?: string) => {
48 const p = await pinBuild(app, who, repo, { label, oid, ...(parent === undefined ? {} : { parent }) });
49 if (!p.ok) throw new Error(JSON.stringify(p.error));
50 };
51 const made = await createRepo(app, alice, { name: "game", visibility: "public" });
52 const quiet = await createRepo(app, alice, { name: "empty", visibility: "public" });
53 if (!made.ok || !quiet.ok) throw new Error("repos");
54 game = made.value;
55 empty = quiet.value;
56 await pin(game, alice, "v1", await commit(game, alice, "one"));
57 await pin(game, alice, "v2", await commit(game, alice, "two"), "v1");
58 const hush = await forkRepo(app, carol, game, "hush", { visibility: "private" });
59 if (!hush.ok) throw new Error(String(hush.error));
60 await pin(hush.value, carol, "hidden-pin", await commit(hush.value, carol, "h"), "alice/game:v2");
61 const open = await forkRepo(app, bob, game, "open");
62 if (!open.ok) throw new Error(String(open.error));
63 await pin(open.value, bob, "shown-pin", await commit(open.value, bob, "o"), "alice/game:v1");
64 }, 60_000);
65
66 afterAll(async () => {
67 app.close();
68 await rm(dir, { recursive: true, force: true });
69 });
70
71 async function drive(repo: Repo, user: User | undefined): Promise<string> {
72 const terminal = headlessTerminal(100, 30);
73 const { app: tui, session } = createForgeApp(app, user ? { ...guest, user } : guest, { terminal, exitHooks: false });
74 tui.start();
75 tui.ctx.push(iterationsScreen(session, tui.ctx, repo));
76 await settleApp(tui);
77 const text = tui.screen.toText();
78 tui.stop?.();
79 return text;
80 }
81
82 test("iterationLines indents a run under the one it grew from", () => {
83 const b = (label: string, parent: string | null) => ({ key: `a/g:${label}`, label, oid: "a".repeat(40), at: "2026-01-02T00:00:00Z", note: "", parent, edge: parent === null ? null : ("git" as const) });
84 const lines = iterationLines(lanesOf([{ repo: "a/g", name: "g", forkOf: null, builds: [b("v1", null), b("v2", "a/g:v1"), b("side", "a/g:v1")] }]));
85 expect(lines.map((l) => [l.depth, l.head, l.text.split(" ")[0]])).toEqual([
86 [0, true, "g"],
87 [1, false, "v1"],
88 [1, false, "v2"],
89 [1, true, "g · branch"],
90 [2, false, "side"],
91 ]);
92 });
93
94 test("a reader sees the family's runs and builds; a guest sees what is public only", async () => {
95 const mine = await drive(game, carol);
96 for (const word of ["v1", "v2", "shown-pin", "hidden-pin"]) expect(mine).toContain(word);
97 const seen = await drive(game, undefined);
98 for (const word of ["v1", "v2", "shown-pin"]) expect(seen).toContain(word);
99 expect(seen).not.toContain("hidden-pin");
100 expect(seen).not.toContain("hush");
101 });
102
103 test("a family with no pin says so", async () => {
104 expect(await drive(empty, undefined)).toContain(NO_ITERATIONS_LINE);
105 });
106
107 test("a private repository reads as not found to a guest, and is not offered", async () => {
108 const hush = getRepo(app.db, "carol", "hush")!;
109 const text = await drive(hush, undefined);
110 expect(text).toContain(NOT_FOUND_LINE);
111 expect(text).not.toContain("hidden-pin");
112 const terminal = headlessTerminal(100, 30);
113 const { app: tui, session } = createForgeApp(app, guest, { terminal, exitHooks: false });
114 expect(iterationsCommands(session, tui.ctx, hush)).toEqual([]);
115 expect(iterationsCommands(session, tui.ctx, game).map((c) => c.id)).toEqual(["iterations"]);
116 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs