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

Iterations is Jay's actual page, with his name on it: the landed version is a smaller rewrite #9138

Merged opened this in answer to

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

Merged

scout/forge:scout/9133-iterations-is-jay-s-actual-page-with-his → main 7 commits fork 3 threads · 3 unresolved

  • Merged. scout/9133-iterations-is-jay-s-actual-page-with-his was merged into main as 79380c9.

Review

Sign in to review this change.
22 files changed +1849 −520
docs/design/areas/iterations.md +16 −9
@@ -1,12 +1,19 @@
11 # Iterations
22
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; `src/core/iterations.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.
3 A fork family's build pins (`/:owner/:name/iterations`), after Jay Moran's page: fork cards in columns by fork depth, or (`?layout=tree`) a downward tree of runs, and under them a player and an info panel. `iterationsPage` in `src/web/pages/iterations.ts` draws it from the data in `src/core/iterations.ts` (kept five seconds per repository, `src/core/ttl_cache.ts`); `ITERATIONS_CSS` there holds the page's rules, and `INFO_CSS` in `src/web/pages/build_info.ts` the player's and info panel's. Notes is `data-notes` on `.it-page`, not a class. The picker, Showcased and Edit display selections wait for displays.
44
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.
5 - `.it-page` — the page below the repo header; carries the Notes state the rules below read.
6 - `.it-checks` — the head's controls: the layout switch (`.it-seg`, Columns / Tree) and Notes.
7 - `.it-key` — the one-line key under the head: a solid line for fork of, a thin one (`.it-thin`) for a branch, a dashed one (`.it-dash`) for built on a pin of; `.it-sp` holds Collapse / Expand forks, zoom with Fit (`.it-zc`), and in the tree Align and Reset.
8 - `.it-stage` — the graph pane: at most 60% of the viewport high, scrolling both ways; `.it-zoom` its sized box.
9 - `.it-grid` — the columns, one per fork depth; `.it-col` is one column. `.it-tree` is the tree's scaled canvas.
10 - `.it-edges` — the SVG of lines between cards, drawn by the page's script.
11 - `.it-fork` — one fork's card; `.it-head` its name, `.it-rows` its builds; `.it-shut` folds it to its name.
12 - `.it-lane` — one run of the tree, placed by the script and dragged by hand (`.it-dragging`); `.it-lh` its head with `.it-nm` the fork's name (folds the fork) and `.it-ct` (folds the run); `.it-branch` a run that splits off a build with `.it-sub` its title; `.it-runshut` and `.it-hid` its folds.
13 - `.it-coll` — the one node a fold leaves (`.it-fcoll` a fork, `.it-rcoll` a run): a borderless row inside the card, hidden until its fold is shut.
14 - `.it-b` — one build row: `.it-l` label, `.it-d` date, `.it-n` the note line (the notes' first line). `.it-sel` is picked, `.it-tip` is a branch with no pin.
15 - `.it-pane` — the player and the info panel side by side; one column on a phone.
16 - `.it-player` — the player: `.it-bar` its title row, `.it-empty` a message or form in its place, `.it-foot` the build's line.
17 - `.it-info` — the info panel's sections; `.it-notes` the Notes section, `.it-body` its text, `.it-acts` the Actions row, `.it-cmd` the read-only link or commands in an action's dialog; `.it-dlg` marks those dialogs for the script.
18 - `.it-notelist` — the notes as one list; `.it-note` one note, `.it-note-human`, `.it-note-agent` and `.it-note-pin` by who wrote it.
19 - `.it-edit` — a section's quiet Edit link; `.it-ed` the form it opens in place, `.it-ed-foot` its Cancel and Save, `.it-saved` the line that says it saved or why not, `.it-none` the empty Notes line.
docs/features/iterations.md +15 −8
@@ -1,20 +1,27 @@
11 # Iterations
22
3 A fork family's build pins, drawn as a tree of runs. Read-only: pinning, notes and deletes stay on Builds.
3 A fork family's build pins, after Jay Moran's design: fork cards in columns, or a downward tree of runs, with a player and an info panel under the graph. Pinning and deleting stay on Builds; notes can be added from the panel.
44
55 ## On the web
66
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.
7 Open `/:owner/:name/iterations`, or the **Iterations** button on Builds (shown once the repository holds a pin). It is not found when neither the repository nor any fork you may read holds a pin, and for a private repository you may not read.
88
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.
9 - **Columns** (default): a card per fork, in a column by fork depth. **Tree** (`?layout=tree`): a card per run, each hung under the build it grew from, joined by lines.
10 - A pin hangs under the parent it names (`--parent`; `owner/name:label` for a concept edge, a dashed line), else the nearest pin in its history, else it continues its repository's run. A fork carries its parent's pins; each is drawn once, in the parent. The family is the root you may read and every fork below it you may read.
11 - **Tree**: drag a run anywhere; **Align** keeps the order you dragged and snaps columns and spacing to a grid; **Reset** puts every run back. Positions are kept in this browser only.
12 - **−**, **100%**, **+**, **Fit** zoom (Ctrl/⌘ + wheel too). **Collapse forks** / **Expand forks** fold every fork to its name; the fork name folds one fork, **fold run** folds a run. Folds and zoom are per browser; a fork that gained or lost a pin since it was folded opens again.
13 - **Notes** hides the note lines. Click a build: its info panel (pin, commit, branch, lineage, notes, actions: copy link, clone, fork, delete pin for a writer) and its player. The player opens a build preview for an app; a repository that is no app plays nothing. `?build=owner/name:label` links to one.
14 - A writer adds a note in the panel (`POST /:owner/:name/builds/:label/note`); it is saved to `refs/forge/notes`.
15
16 Not here yet, as they wait for displays and the arcade: the Showcased / display picker, Edit display selections, captured pages (Build and play, Download) and screenshots.
1417
1518 ## In the terminal
1619
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.
20 Open a repository, then the palette (`ctrl+k`) and **Iterations**. The family's runs are an indented list; `j`/`k` move, `esc` goes back.
21
22 ## Builds
23
24 `/:owner/:name/builds` is Jay's lineage too: the family's pins as one list, each indented under the pin it grew from (the parent its person named, else the nearest pin in its history), a pin from another repository marked `owner/name:` with a dotted concept edge. The Pin a build form suggests a label (`iter-N` for an iteration branch or a fork's first pin) and offers every pin of the family as **Parent**; a pin made there lands on Iterations with it picked. The demo options wait for demos.
1825
1926 ## Over the API
2027
src/actions/builds.ts +4
@@ -5,6 +5,7 @@
55 * is what the feed and webhooks read.
66 */
77
8 import { forget } from "../core/ttl_cache.ts";
89 import type { App } from "../app.ts";
910 import { commitIdentity } from "../core/commit_identity.ts";
1011 import { copyNoteToIntent } from "../core/intent_notes.ts";
@@ -65,6 +66,7 @@ export async function pinBuild(
6566 tagger: who,
6667 });
6768 if (!pinned.ok) return pinned;
69 forget(app);
6870 app.bus.append({
6971 kind: "build.pinned",
7072 repoId: repo.id,
@@ -95,6 +97,7 @@ export async function deletePin(
9597 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
9698 const gone = await gitDeletePin(dir, label);
9799 if (!gone.ok) return gone;
100 forget(app);
98101 // Its notes stay: they are append-only (`git/builds.ts`).
99102 app.bus.append({
100103 kind: "build.deleted",
@@ -126,6 +129,7 @@ export async function notePin(
126129 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
127130 const noted = await notePinBuild(dir, label, text, who, commitIdentity(app.config, writer.value));
128131 if (!noted.ok) return noted;
132 forget(app);
129133 const { pin, note, commit } = noted.value;
130134 app.bus.append({
131135 kind: "build.noted",
src/actions/pulls.ts +17 −2
@@ -64,6 +64,7 @@ import { approveGuest } from "../origins/guest.ts";
6464 import { grantRun } from "../core/run_grant.ts";
6565 import { commitIdentity } from "../core/commit_identity.ts";
6666 import { getUserById, getUserByName, type User } from "../core/users.ts";
67 import { coAuthorLines, messagesBetween } from "../git/coauthors.ts";
6768 import { carriedSummary, resolveOid, type MergeStrategy } from "../git/merge.ts";
6869 import { landingKind, thanksNote } from "../core/landing_kind.ts";
6970 import { repoDir } from "../git/repos.ts";
@@ -477,15 +478,29 @@ export async function mergePull(
477478 const requester =
478479 asked === null ? undefined : getUserByName(app.db, asked);
479480 const from = pullSourceOf(app.db, pull.id);
481 const baseDir = repoDir(app.reposRoot, repo.ownerName, repo.name);
482 const at = await pullDir(app.db, app.reposRoot, repo, pull);
483 // A squash folds the pull's commits into one: the people they credit as
484 // `Co-authored-by` stay credited on the commit that lands.
485 const kept =
486 strategy === "squash"
487 ? coAuthorLines(
488 await messagesBetween(
489 at,
490 (await resolveOid(baseDir, `refs/heads/${pull.baseBranch}`)) ?? pull.baseOid,
491 (getPull(app.db, repo.id, pull.number) ?? pull).headOid,
492 ),
493 [lander.email, ...(requester ? [commitIdentity(app.config, requester).email] : [])],
494 )
495 : [];
480496 const trailers = [
481497 landingTrailers(
482498 requester ? commitIdentity(app.config, requester) : null,
483499 lander,
484500 ),
501 ...kept,
485502 ...(from === undefined ? [] : [sourceTrailers(from, ownHost(app.config))]),
486503 ].join("\n");
487 const baseDir = repoDir(app.reposRoot, repo.ownerName, repo.name);
488 const at = await pullDir(app.db, app.reposRoot, repo, pull);
489504 const carriedBefore =
490505 strategy === "merge" && pull.crossRepo
491506 ? await carriedSummary(
src/core/iterations.ts +210 −111
@@ -1,151 +1,175 @@
11 /**
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.
2 * A fork family's build pins as data: the builds (`buildOf`) with what a
3 * page, the terminal and the API draw from them, grouped as fork cards
4 * (`iterationsOf`) and as the tree's runs (`lanesOf`). What a repository
5 * holds is read from git once and kept briefly (`ttl_cache.ts`); a build's
6 * refs are read per build, so they are kept too.
97 */
108
11 import { canRead, getRepoById, visibleForks, type Repo } from "./repos.ts";
12 import { listBuildPins, type BuildPin } from "../git/builds.ts";
9 import { getAppByRepo } from "./apps.ts";
10 import { buildLineage, familyRepos, type Viewing } from "./lineage.ts";
11 import { canWrite, getRepo, type Repo } from "./repos.ts";
12 import { remembered } from "./ttl_cache.ts";
13 import { listBranches } from "../git/branches.ts";
14 import { refsContaining, splitPinNote, wholeNotes, type BuildNote, type BuildPin } from "../git/builds.ts";
1315 import { repoDir } from "../git/repos.ts";
14 import type { App } from "../app.ts";
15 import type { User } from "./users.ts";
16 import { isMirror } from "../origins/write-through.ts";
1617
17 /** How many repositories a family draws. */
18 export const FAMILY_LIMIT = 40;
18 /** How a build plays: a build preview of its commit, or nothing for a repository that is no app. */
19 export type Plays = "env" | "none";
1920
21 /** One build, as its row, player and info panel draw it. */
2022 export interface IterationBuild {
21 /** `owner/name:label`. */
2223 readonly key: string;
24 readonly repo: string;
2325 readonly label: string;
2426 readonly oid: string;
25 /** ISO time it was pinned. */
27 /** Branches whose tip is this commit, else the branch it was pinned from. */
28 readonly branch: string;
2629 readonly at: string;
27 /** The first line of its note. */
30 readonly by: string;
31 /** Its first note's first line, else its tag message's: the one-line summary; empty when it is only the label. */
2832 readonly note: string;
29 /** The key of the build it hangs under, when that is in the family. */
33 /** Its notes, ranked: people's first (`git/builds.ts`). */
34 readonly notes: readonly BuildNote[];
35 /** Its tag message's note, whole; empty when it is only the label. */
36 readonly tag: string;
37 /** What a fork of its repository carries of its commit: a branch that holds it, else whether a tag does. */
38 readonly forkHas: { readonly branch: string | null; readonly tagged: boolean };
39 /** The pin it hangs from and how: `owner/name:label`. */
3040 readonly parent: string | null;
3141 readonly edge: "git" | "concept" | null;
42 readonly plays: Plays;
43 readonly page: string;
44 /** Whether its build preview may be opened: a person who writes an app. */
45 readonly preview: boolean;
46 /** Whether its notes may be edited: a person who writes its repository, not a mirror. */
47 readonly canNote: boolean;
3248 }
3349
34 export interface IterationFork {
50 const BRANCH_LIMIT = 30;
51 /** What every build of one repository is drawn from, read once. */
52 export interface RepoFacts {
53 readonly repo: Repo;
3554 /** `owner/name`. */
55 readonly name: string;
56 readonly dir: string;
57 /** Its branches, up to `BRANCH_LIMIT`; none when not asked for. */
58 readonly branches: readonly { readonly name: string; readonly oid: string }[];
59 readonly isApp: boolean;
60 /** Whether the viewer is a person who writes it. */
61 readonly writer: boolean;
62 }
63
64 /** `r`'s facts as `ctx`'s viewer sees them; its branches only when `withBranches`. */
65 export async function repoFactsOf(ctx: Viewing, r: Repo, withBranches: boolean): Promise<RepoFacts> {
66 const dir = repoDir(ctx.app.reposRoot, r.ownerName, r.name);
67 const listed = withBranches ? await remembered(ctx.app, `branches:${r.id}`, () => listBranches(dir, r.defaultBranch)) : undefined;
68 const user = ctx.auth.user;
69 return {
70 repo: r,
71 name: `${r.ownerName}/${r.name}`,
72 dir,
73 branches: listed?.ok ? listed.value.branches.slice(0, BRANCH_LIMIT) : [],
74 isApp: getAppByRepo(ctx.app.db, r.id) !== undefined,
75 writer: user !== undefined && user.kind !== "agent" && canWrite(ctx.app.db, r, user),
76 };
77 }
78
79 /** One pin, as its row, player and info panel draw it. */
80 export async function buildOf(
81 ctx: Viewing,
82 rf: RepoFacts,
83 pin: BuildPin,
84 parent: string | null,
85 edge: "git" | "concept" | null,
86 ): Promise<IterationBuild> {
87 const { repo: r, name, dir } = rf;
88 const plays: Plays = rf.isApp ? "env" : "none";
89 const at = rf.branches.filter((b) => b.oid === pin.oid).map((b) => b.name);
90 const from = /^branch (.+)$/.exec(pin.source)?.[1];
91 // A branch at the commit first, then the default branch, then any branch that holds it.
92 const holds = await remembered(ctx.app, `refs:${r.id}:${pin.oid}`, () => refsContaining(dir, pin.oid));
93 const onBranch = at[0] ?? (holds.branches.includes(r.defaultBranch) ? r.defaultBranch : holds.branches[0]) ?? null;
94 return {
95 key: `${name}:${pin.label}`,
96 repo: name,
97 label: pin.label,
98 oid: pin.oid,
99 branch: at.length > 0 ? at.join(", ") : (from ?? ""),
100 at: pin.pinnedAt,
101 by: pin.pinnedBy,
102 note: splitPinNote(pin.label, pin.note).note,
103 notes: pin.notes ?? [],
104 tag: wholeNotes(pin.label, pin.tagNote ?? pin.note),
105 forkHas: { branch: onBranch, tagged: holds.tags.length > 0 },
106 parent,
107 edge,
108 plays,
109 page: "/",
110 preview: plays === "env" && rf.writer,
111 canNote: rf.writer && !isMirror(ctx.app, r),
112 };
113 }
114
115 /** One fork's card. */
116 export interface IterationFork {
36117 readonly repo: string;
37118 readonly name: string;
119 /** Its column: fork depth, a concept edge counting as one more step. */
120 readonly depth: number;
38121 /** The repository it was forked from, when that is in the family. */
39122 readonly forkOf: string | null;
40 /** Oldest first. */
123 /** Its builds, oldest first. */
41124 readonly builds: readonly IterationBuild[];
125 /** Branches with no pin at their tip. */
126 readonly tips: readonly string[];
42127 }
43128
129 export interface IterationEdge {
130 readonly from: string;
131 readonly to: string;
132 readonly kind: "fork" | "concept";
133 }
134
135 export interface Iterations {
136 readonly forks: readonly IterationFork[];
137 readonly edges: readonly IterationEdge[];
138 }
139
140 /**
141 * One column of the downward tree (records/design/1101-forgej-graph-layout,
142 * B): a fork's main run, or a run that splits off a build. Its builds run
143 * top to bottom; it hangs under the build it grew from.
144 */
44145 export interface IterationLane {
45 /** The repository for a fork's main run, else the key of the run's first build. */
146 /** The fork's repository for its main run, else the key of the run's first build. */
46147 readonly id: string;
47148 readonly repo: string;
48149 readonly title: string;
49150 readonly main: boolean;
50 /** The build it grew from; null for a root. */
151 /** The build it grew from, `owner/name:label`; null for a root. */
51152 readonly from: string | null;
52153 /** For a main run with no build to hang from, the repository it was forked from. */
53154 readonly fromRepo: string | null;
54 readonly kind: "fork" | "branch" | "concept";
155 readonly kind: "fork" | "branch" | "concept" | null;
55156 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));
157 readonly tips: readonly string[];
135158 }
136159
137160 /**
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.
161 * The family as the downward tree's runs, in the order the tree draws them:
162 * depth first, and under each run the one that splits off nearest its foot
163 * first, so a line never crosses a run. A build continues its parent's run
164 * when the parent is that run's last build in the same repository;
165 * otherwise it starts a run of its own.
142166 */
143167 export function lanesOf(forks: readonly IterationFork[]): IterationLane[] {
144 type Lane = { -readonly [K in keyof IterationLane]: IterationLane[K] } & { builds: IterationBuild[] };
168 type Lane = { id: string; repo: string; title: string; main: boolean; from: string | null; fromRepo: string | null; kind: IterationLane["kind"]; builds: IterationBuild[]; tips: readonly string[] };
145169 const lanes: Lane[] = [];
146170 const laneOf = new Map<string, Lane>();
147171 const known = new Set(forks.flatMap((f) => f.builds.map((b) => b.key)));
148 for (const f of forks) {
172 for (const f of [...forks].sort((a, b) => a.depth - b.depth)) {
149173 let main: Lane | undefined;
150174 const add = (l: Lane) => (lanes.push(l), l);
151175 for (const b of f.builds) {
@@ -156,22 +180,35 @@ export function lanesOf(forks: readonly IterationFork[]): IterationLane[] {
156180 else if (from === null && main !== undefined) lane = main;
157181 else if (main === undefined && (at === undefined || at.repo !== f.repo))
158182 lane = main = add({
159 id: f.repo, repo: f.repo, title: f.name, main: true, from,
183 id: f.repo,
184 repo: f.repo,
185 title: f.name,
186 main: true,
187 from,
160188 fromRepo: from === null ? f.forkOf : null,
161 kind: from !== null && b.edge === "concept" ? "concept" : "fork",
189 kind: from === null ? (f.forkOf === null ? null : "fork") : b.edge === "concept" ? "concept" : "fork",
162190 builds: [],
191 tips: f.tips,
163192 });
164193 else
165194 lane = add({
166 id: b.key, repo: f.repo, title: `${f.name} · branch`, main: false, from, fromRepo: null,
195 id: b.key,
196 repo: f.repo,
197 title: `${f.name} · branch`,
198 main: false,
199 from,
200 fromRepo: null,
167201 kind: at !== undefined && at.repo === f.repo ? "branch" : b.edge === "concept" ? "concept" : "fork",
168202 builds: [],
203 tips: [],
169204 });
170205 lane.builds.push(b);
171206 laneOf.set(b.key, lane);
172207 }
173 if (main === undefined) add({ id: f.repo, repo: f.repo, title: f.name, main: true, from: null, fromRepo: f.forkOf, kind: "fork", builds: [] });
208 if (main === undefined)
209 add({ id: f.repo, repo: f.repo, title: f.name, main: true, from: null, fromRepo: f.forkOf, kind: f.forkOf === null ? null : "fork", builds: [], tips: f.tips });
174210 }
211 // Depth first from the roots; under a run, the child that starts lowest comes first.
175212 const childrenOf = (l: Lane) =>
176213 lanes
177214 .filter((c) => c !== l && ((c.from !== null && laneOf.get(c.from) === l) || (c.from === null && c.fromRepo === l.repo && l.main)))
@@ -191,3 +228,65 @@ export function lanesOf(forks: readonly IterationFork[]): IterationLane[] {
191228 for (const l of lanes) visit(l);
192229 return out;
193230 }
231
232 /** The family's pins as fork cards and the lines between them; undefined when it holds no pin. */
233 export async function iterationsOf(ctx: Viewing, repo: Repo): Promise<Iterations | undefined> {
234 const here = `${repo.ownerName}/${repo.name}`;
235 const lineage = await buildLineage(ctx, repo);
236 if (lineage.length === 0) return undefined;
237 const family = familyRepos(ctx, repo);
238 const byName = new Map(family.map((r) => [`${r.ownerName}/${r.name}`, r]));
239 const byId = new Map(family.map((r) => [r.id, r]));
240 // A concept edge may name a pin outside the family: its repository is a card too.
241 const outside: Repo[] = [];
242 for (const n of lineage) {
243 const name = n.from ?? here;
244 if (byName.has(name)) continue;
245 const [owner = "", rname = ""] = name.split("/");
246 const r = getRepo(ctx.app.db, owner, rname);
247 if (r === undefined) continue;
248 byName.set(name, r);
249 outside.push(r);
250 }
251 const forks: IterationFork[] = [];
252 for (const r of [...outside, ...family]) {
253 const name = `${r.ownerName}/${r.name}`;
254 const own = lineage.filter((n) => (n.from ?? here) === name).sort((a, b) => (a.pin.pinnedAt < b.pin.pinnedAt ? -1 : 1));
255 const rf = await repoFactsOf(ctx, r, byId.has(r.id));
256 const branches = rf.branches;
257 const builds: IterationBuild[] = [];
258 for (const n of own) builds.push(await buildOf(ctx, rf, n.pin, n.parent ?? null, n.edge ?? null));
259 const pinned = new Set(own.map((n) => n.pin.oid));
260 const parent = r.forkParentId === null ? undefined : byId.get(r.forkParentId);
261 forks.push({
262 repo: name,
263 name: r.name,
264 depth: 0,
265 forkOf: parent === undefined ? null : `${parent.ownerName}/${parent.name}`,
266 builds,
267 // Branches with no build pinned, beside the builds a fork has; never its default branch.
268 tips: own.length === 0 ? [] : branches.filter((b) => !pinned.has(b.oid) && b.name !== r.defaultBranch).map((b) => b.name),
269 });
270 }
271
272 // Fork lines, then a dashed line where a pin names a pin in another repository.
273 const edges: IterationEdge[] = [];
274 for (const f of forks) if (f.forkOf !== null) edges.push({ from: f.forkOf, to: f.repo, kind: "fork" });
275 for (const f of forks)
276 for (const b of f.builds) {
277 if (b.edge !== "concept" || b.parent === null) continue;
278 const from = b.parent.slice(0, b.parent.lastIndexOf(":"));
279 if (from !== f.repo && byName.has(from) && !edges.some((e) => e.kind === "concept" && e.from === from && e.to === f.repo))
280 edges.push({ from, to: f.repo, kind: "concept" });
281 }
282 // Columns: each card one step right of every card it hangs from. Bounded, so a cycle ends.
283 const depth = new Map(forks.map((f) => [f.repo, 0]));
284 for (let i = 0; i < forks.length; i++)
285 for (const e of edges) {
286 const d = (depth.get(e.from) ?? 0) + 1;
287 if (d > (depth.get(e.to) ?? 0) && d < forks.length) depth.set(e.to, d);
288 }
289 // A fork is named by its repository name; its owner joins only when another fork shares that name.
290 const clash = (n: string) => forks.filter((f) => f.name === n).length > 1;
291 return { forks: forks.map((f) => ({ ...f, name: clash(f.name) ? f.repo : f.name, depth: depth.get(f.repo) ?? 0 })), edges };
292 }
src/core/lineage.ts added +195
@@ -0,0 +1,195 @@
1 /**
2 * The fork family a viewer may read and its pins as a lineage: a pin's
3 * parent is the one its person named, else the nearest pin in its history —
4 * read from git (`for-each-ref --merged`), so the tree follows the work even
5 * across merges and squashes that a strict commit graph would split. A
6 * fork's first pins hang from its parent repository's, which its objects
7 * share. A repository's pins are kept briefly (`ttl_cache.ts`).
8 */
9
10 import { canRead, getRepo, getRepoById, visibleForks, type Repo } from "./repos.ts";
11 import { remembered } from "./ttl_cache.ts";
12 import type { User } from "./users.ts";
13 import { isAncestor, isCopiedPin, listBuildPins, pinsMergedInto, type BuildPin } from "../git/builds.ts";
14 import { repoDir } from "../git/repos.ts";
15 import type { Ctx } from "../http/context.ts";
16
17 /** Who is looking, and on what: a page's `Ctx`, or the terminal's session. */
18 export type Viewing = Pick<Ctx, "app"> & { readonly auth: { readonly user: User | undefined } };
19
20 /** One pin in the tree: where it hangs and how deep. */
21 export interface BuildNode {
22 readonly pin: BuildPin;
23 /** The repository it lives in, `owner/name`, when not this one. */
24 readonly from: string | undefined;
25 /** Its parent's key, `owner/name:label`. */
26 readonly parent: string | undefined;
27 /** How it hangs: by history (`git`) or by a person's concept edge. */
28 readonly edge: "git" | "concept" | undefined;
29 readonly depth: number;
30 }
31
32 /** One repository of the family, read once. */
33 interface Member {
34 readonly repo: Repo;
35 readonly name: string;
36 readonly dir: string;
37 readonly pins: BuildPin[];
38 /** Per pin label: the labels of this repository's pins its commit contains. */
39 readonly merged: Map<string, Set<string>>;
40 }
41
42 /** How many repositories a family view reads. */
43 export const FAMILY_LIMIT = 40;
44
45 function member(ctx: Viewing, repo: Repo): Promise<Member> {
46 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
47 return remembered(ctx.app, `member:${repo.id}`, async () => {
48 const pins = await listBuildPins(dir);
49 const merged = new Map(await Promise.all(pins.map(async (p) => [p.label, await pinsMergedInto(dir, p.oid)] as const)));
50 return { repo, name: `${repo.ownerName}/${repo.name}`, dir, pins, merged };
51 });
52 }
53
54 /** The nearest of `found` in `m`: the one whose commit contains most of them. */
55 function nearestOf(m: Member, found: readonly BuildPin[]): BuildPin | undefined {
56 const reach = (p: BuildPin) => found.filter((q) => m.merged.get(p.label)?.has(q.label)).length;
57 return [...found].sort(
58 (a, b) => reach(b) - reach(a) || (a.pinnedAt < b.pinnedAt ? 1 : -1),
59 )[0];
60 }
61
62 /** The fork family the viewer may read: up to the root, then every fork below it, root first. */
63 export function familyRepos(ctx: Viewing, repo: Repo): Repo[] {
64 const db = ctx.app.db;
65 const viewer = ctx.auth.user;
66 let root = repo;
67 for (let i = 0; i < FAMILY_LIMIT && root.forkParentId !== null; i++) {
68 const up = getRepoById(db, root.forkParentId);
69 if (up === undefined || !canRead(db, up, viewer)) break;
70 root = up;
71 }
72 const repos: Repo[] = [root];
73 for (let i = 0; i < repos.length && repos.length < FAMILY_LIMIT; i++)
74 repos.push(...visibleForks(db, repos[i]!.id, viewer).slice(0, FAMILY_LIMIT - repos.length));
75 return repos;
76 }
77
78 /**
79 * The whole family's pins as one forest: this repository, the forks above
80 * it to the root, and every fork below the root that the viewer may read.
81 * A pin hangs under the parent its person named — here, or in another
82 * repository by a concept edge — else under the nearest pin in its own
83 * repository's history, else under the nearest pin in the history of the
84 * repositories it was forked from, nearest first.
85 */
86 export async function buildLineage(ctx: Viewing, repo: Repo): Promise<BuildNode[]> {
87 const db = ctx.app.db;
88 const viewer = ctx.auth.user;
89 const repos = familyRepos(ctx, repo);
90 const members = new Map<string, Member>();
91 for (const m of await Promise.all(repos.map((r) => member(ctx, r)))) members.set(m.name, m);
92 // A fork carries its parent's pins; the family draws each once, in the parent.
93 const byId = new Map([...members.values()].map((m) => [m.repo.id, m]));
94 for (const m of [...members.values()]) {
95 const up = m.repo.forkParentId === null ? undefined : byId.get(m.repo.forkParentId);
96 if (up !== undefined) members.set(m.name, { ...m, pins: m.pins.filter((p) => !isCopiedPin(p, up.pins)) });
97 }
98 if ([...members.values()].every((m) => m.pins.length === 0)) return [];
99
100 // A concept edge may reach outside the family: that repository's pin is
101 // drawn as the root it hangs from.
102 for (const m of [...members.values()])
103 for (const p of m.pins) {
104 const at = p.parent?.lastIndexOf(":") ?? -1;
105 if (at === -1) continue;
106 const [owner = "", name = ""] = p.parent!.slice(0, at).split("/");
107 const key = `${owner}/${name}`;
108 if (members.has(key) || members.size >= FAMILY_LIMIT) continue;
109 const other = getRepo(db, owner, name);
110 if (other === undefined || !canRead(db, other, viewer)) continue;
111 const full = await member(ctx, other);
112 const wanted = full.pins.filter((q) => q.label === p.parent!.slice(at + 1));
113 members.set(key, { ...full, pins: wanted });
114 }
115
116 const keyOf = (m: Member, label: string) => `${m.name}:${label}`;
117 const nodes: {
118 pin: BuildPin;
119 m: Member;
120 parent: string | undefined;
121 edge: "git" | "concept" | undefined;
122 }[] = [];
123 const known = new Set<string>();
124 for (const m of members.values()) for (const p of m.pins) known.add(keyOf(m, p.label));
125
126 for (const m of members.values())
127 for (const pin of m.pins) {
128 const self = keyOf(m, pin.label);
129 // 1. The parent its person named.
130 if (pin.parent !== undefined) {
131 const named = pin.parent.includes(":") ? pin.parent : keyOf(m, pin.parent);
132 if (named !== self && known.has(named)) {
133 nodes.push({ pin, m, parent: named, edge: pin.parent.includes(":") ? "concept" : "git" });
134 continue;
135 }
136 }
137 // 2. The nearest pin in its own history. A pin on the same commit
138 // made later is its child, never its parent.
139 const own = m.merged.get(pin.label) ?? new Set<string>();
140 const near = nearestOf(
141 m,
142 m.pins.filter(
143 (p) => p.label !== pin.label && own.has(p.label) && !(p.oid === pin.oid && p.pinnedAt >= pin.pinnedAt),
144 ),
145 );
146 if (near !== undefined) {
147 nodes.push({ pin, m, parent: keyOf(m, near.label), edge: "git" });
148 continue;
149 }
150 // 3. The nearest pin of the repositories it was forked from: a fork
151 // shares their objects, so git can say what its history holds.
152 let hung: string | undefined;
153 let up = m.repo.forkParentId === null ? undefined : getRepoById(db, m.repo.forkParentId);
154 for (let i = 0; up !== undefined && i < FAMILY_LIMIT && hung === undefined; i++) {
155 const um = members.get(`${up.ownerName}/${up.name}`);
156 if (um !== undefined) {
157 const found: BuildPin[] = [];
158 for (const p of um.pins) if (await isAncestor(m.dir, p.oid, pin.oid)) found.push(p);
159 const best = nearestOf(um, found);
160 if (best !== undefined) hung = keyOf(um, best.label);
161 }
162 up = up.forkParentId === null ? undefined : getRepoById(db, up.forkParentId);
163 }
164 nodes.push({ pin, m, parent: hung, edge: hung === undefined ? undefined : "git" });
165 }
166
167 // Children under parents, oldest first at every level.
168 const children = new Map<string | undefined, typeof nodes>();
169 for (const n of nodes) {
170 const list = children.get(n.parent) ?? [];
171 list.push(n);
172 children.set(n.parent, list);
173 }
174 const here = `${repo.ownerName}/${repo.name}`;
175 const out: BuildNode[] = [];
176 const seen = new Set<string>();
177 const walk = (parent: string | undefined, depth: number) => {
178 const list = (children.get(parent) ?? []).sort((a, b) => (a.pin.pinnedAt < b.pin.pinnedAt ? -1 : 1));
179 for (const n of list) {
180 const key = keyOf(n.m, n.pin.label);
181 if (seen.has(key)) continue;
182 seen.add(key);
183 out.push({
184 pin: n.pin,
185 from: n.m.name === here ? undefined : n.m.name,
186 parent: n.parent,
187 edge: n.edge,
188 depth,
189 });
190 walk(key, depth + 1);
191 }
192 };
193 walk(undefined, 0);
194 return out;
195 }
src/core/ttl_cache.ts added +32
@@ -0,0 +1,32 @@
1 /**
2 * A short memory of slow reads (git processes), per app and key. A read is
3 * kept `TTL_MS`; asked again within it, the same promise answers, so a
4 * reload, a second viewer or a loop of requests costs no further process.
5 * A write that makes a read stale calls `forget`.
6 */
7
8 import type { App } from "../app.ts";
9
10 export const TTL_MS = 5000;
11 const MAX_KEPT = 2000;
12 const kept = new WeakMap<App, Map<string, { at: number; value: Promise<unknown> }>>();
13
14 export function remembered<T>(app: App, key: string, make: () => Promise<T>): Promise<T> {
15 let mine = kept.get(app);
16 if (mine === undefined) kept.set(app, (mine = new Map()));
17 const now = Date.now();
18 const hit = mine.get(key);
19 if (hit !== undefined && now - hit.at < TTL_MS) return hit.value as Promise<T>;
20 if (mine.size >= MAX_KEPT) for (const [k, v] of mine) if (now - v.at >= TTL_MS) mine.delete(k);
21 if (mine.size >= MAX_KEPT) mine.clear();
22 const value = make();
23 mine.set(key, { at: now, value });
24 const own = mine;
25 value.catch(() => own.get(key)?.value === value && own.delete(key));
26 return value;
27 }
28
29 /** Drop what is kept for `app`: a pin, a note or a deletion has just changed it. */
30 export function forget(app: App): void {
31 kept.get(app)?.clear();
32 }
src/git/builds.ts +59
@@ -470,3 +470,62 @@ async function listTagPins(dir: string, label?: string): Promise<BuildPin[]> {
470470 }
471471 return pins;
472472 }
473
outdated

Add a blank line before this function's doc comment. Remove the stray blank line at the file's end.

474 /** A pin's note as its notes, whole: a first line that only repeats the label is dropped. */
475 export function wholeNotes(label: string, note: string): string {
476 const at = note.indexOf("\n");
477 const first = (at === -1 ? note : note.slice(0, at)).trim();
478 return first === label ? (at === -1 ? "" : note.slice(at + 1).trim()) : note.trim();
479 }
480
481 /** A pin's note split at its first line: the pin note, then the dev notes. */
482 export function splitPinNote(label: string, note: string): { readonly note: string; readonly body: string } {
483 const at = note.indexOf("\n");
484 const first = (at === -1 ? note : note.slice(0, at)).trim();
485 return { note: first === label ? "" : first, body: at === -1 ? "" : note.slice(at + 1).trim() };
486 }
487
488 /** One pin by label, or undefined. */
489 export async function readBuildPin(dir: string, label: string): Promise<BuildPin | undefined> {
490 if (!isPinLabel(label)) return undefined;
491 return (await listBuildPins(dir, label))[0];
492 }
493
494 /**
495 * Whether `pin` is the copy of a parent's pin a fork carries
496 * (`copyForgeRefs`): the same label, commit, pinner and time. A family or
497 * a display drawing both shows the parent's alone.
498 */
499 export function isCopiedPin(pin: BuildPin, parentPins: readonly BuildPin[]): boolean {
500 return parentPins.some((p) => p.label === pin.label && p.oid === pin.oid && p.pinnedAt === pin.pinnedAt && p.pinnedBy === pin.pinnedBy);
501 }
502
503 /** The labels of the pins whose commit `oid` contains, itself included. */
504 export async function pinsMergedInto(dir: string, oid: string): Promise<Set<string>> {
505 if (!isSafeOid(oid)) return new Set();
506 const listed = await runGit(
507 ["for-each-ref", `--merged=${oid}`, "--format=%(refname:strip=2)", BUILDS_PREFIX.slice(0, -1)],
508 { cwd: dir },
509 );
510 if (!listed.ok) return new Set();
511 return new Set(listed.value.split("\n").filter((l) => l !== ""));
512 }
513
514 /** The branches and tags whose history holds `oid`: what a fork, which copies those, carries of it. */
515 export async function refsContaining(dir: string, oid: string): Promise<{ readonly branches: readonly string[]; readonly tags: readonly string[] }> {
516 if (!isSafeOid(oid)) return { branches: [], tags: [] };
517 const listed = await runGit(["for-each-ref", `--contains=${oid}`, "--format=%(refname)", "refs/heads", "refs/tags"], { cwd: dir });
518 if (!listed.ok) return { branches: [], tags: [] };
519 const refs = listed.value.split("\n").filter((l) => l !== "");
520 return {
521 branches: refs.filter((r) => r.startsWith("refs/heads/")).map((r) => r.slice("refs/heads/".length)),
522 tags: refs.filter((r) => r.startsWith("refs/tags/")).map((r) => r.slice("refs/tags/".length)),
523 };
524 }
525
526 /** Whether `ancestor` is in `oid`'s history, read in `dir`. */
527 export async function isAncestor(dir: string, ancestor: string, oid: string): Promise<boolean> {
528 if (!isSafeOid(ancestor) || !isSafeOid(oid)) return false;
529 const r = await runGitCapture(["merge-base", "--is-ancestor", ancestor, oid], { cwd: dir });
530 return r.code === 0;
531 }
src/git/coauthors.ts added +43
@@ -0,0 +1,43 @@
1 /**
2 * Co-authors a landing keeps: a squash folds a pull's commits into one, and
3 * the `Co-authored-by` trailers they carried must survive it, or the people
4 * they credit read as strangers to the change.
5 */
6
7 import { runGitCapture } from "./env.ts";
8 import { isSafeOid } from "./guard.ts";
9
10 /** At most this many co-authors ride a landing. */
11 export const COAUTHORS_MAX = 20;
12
13 const TRAILER = /^co-authored-by:[ \t]*([^<>\s][^<>\r\n]{0,99}?)[ \t]*<([^<>\s@]{1,100}@[^<>\s@]{1,200})>[ \t]*$/i;
14
15 /**
16 * The `Co-authored-by: Name <email>` lines in commit `messages`, as written
17 * canonically, one per person (by email), first seen first. A line that is
18 * not exactly name and address is dropped; `skip` lists emails already
19 * credited on the landing.
20 */
21 export function coAuthorLines(messages: readonly string[], skip: readonly string[] = []): string[] {
22 const seen = new Set(skip.map((e) => e.toLowerCase()));
23 const out: string[] = [];
24 for (const message of messages)
25 for (const line of message.split("\n")) {
26 const m = TRAILER.exec(line.replace(/\r$/, ""));
27 if (m === null) continue;
28 const email = m[2]!.toLowerCase();
29 if (seen.has(email)) continue;
30 seen.add(email);
31 out.push(`Co-authored-by: ${m[1]!} <${m[2]!}>`);
32 if (out.length >= COAUTHORS_MAX) return out;
33 }
34 return out;
35 }
36
37 /** The commit messages of `base..head` in `dir`, newest first; none when git cannot say. */
38 export async function messagesBetween(dir: string, base: string, head: string): Promise<string[]> {
39 if (!isSafeOid(base) || !isSafeOid(head)) return [];
40 const r = await runGitCapture(["log", "--format=%B%x00", "-n", "200", `${base}..${head}`], { cwd: dir });
41 if (r.code !== 0) return [];
42 return r.stdout.split("\0").filter((m) => m.trim() !== "");
43 }
src/http/api/iterations.ts +1 −1
@@ -16,7 +16,7 @@ export function iterationRoutes(router: Router<Handler>): void {
1616 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
1717 const repo = loadRepo(ctx);
1818 if (!repo) return notFound();
19 const forks = (await iterationsOf(ctx.app, ctx.auth.user, repo)) ?? [];
19 const forks = (await iterationsOf(ctx, repo))?.forks ?? [];
2020 const res = json({ forks, lanes: lanesOf(forks) });
2121 res.headers.set("cache-control", "private, no-cache");
2222 res.headers.set("vary", "authorization, cookie");
src/http/mcp/tools/iterations.ts +1 −1
@@ -14,7 +14,7 @@ export const tool: Tool = {
1414 async run(ctx, user, args) {
1515 const repo = repoArg(ctx, user, { repo: `${str(args["owner"])}/${str(args["name"])}` });
1616 if (isAnswer(repo)) return repo;
17 const forks = (await iterationsOf(ctx.app, user, repo)) ?? [];
17 const forks = (await iterationsOf({ app: ctx.app, auth: { user } }, repo))?.forks ?? [];
1818 return good({ forks, lanes: lanesOf(forks) });
1919 },
2020 };
src/http/server.ts +2 −1
@@ -183,7 +183,7 @@ import {
183183 branchDelete,
184184 branchesPage,
185185 } from "../web/pages/branches.ts";
186 import { iterationsPage } from "../web/pages/iterations.ts";
186 import { iterationsPage, noteSubmit } from "../web/pages/iterations.ts";
187187 import { buildsPage, deletePinSubmit, newBuildPage, pinBuildSubmit, previewBuildSubmit } from "../web/pages/builds.ts";
188188 import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts";
189189 import {
@@ -1264,6 +1264,7 @@ export function buildRouter(): Router<Handler> {
12641264 router.add("POST", "/:owner/:name/builds", pinBuildSubmit);
12651265 router.add("POST", "/:owner/:name/builds/preview", previewBuildSubmit);
12661266 router.add("POST", "/:owner/:name/builds/:label/delete", deletePinSubmit);
1267 router.add("POST", "/:owner/:name/builds/:label/note", noteSubmit);
12671268 router.add("GET", "/:owner/:name/waves.atom", wavesAtom);
12681269 router.add("GET", "/:owner/:name/waves", wavesPage);
12691270 router.add("GET", "/:owner/:name/waves/:version", wavePage);
src/tui/screens/iterations.ts +2 −2
@@ -61,8 +61,8 @@ export function iterationsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
6161 async load() {
6262 // Gated here as well as on the command: the screen is exported.
6363 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));
64 const forks = hidden ? undefined : await iterationsOf({ app: f.app, auth: { user: f.user } }, repo);
65 lines = forks === undefined ? [] : iterationLines(lanesOf(forks.forks));
6666 state.clamp(lines.length);
6767 ctx.invalidate();
6868 },
src/web/pages/build_info.ts added +284
@@ -0,0 +1,284 @@
1 /**
2 * One build's player and info panel, as the Iterations page draws them: the
3 * facts a build is drawn from (`buildOf`), the player (`playerOf`), the info
4 * panel with its Notes and Actions (`infoOf`), their style, and the script
5 * that runs the panel's dialogs and its notes edited in place (`INFO_JS`).
6 * Captured pages, screenshots and displays are not on this forge yet: a
7 * build plays as a build preview, or not at all.
8 */
9
10 import { holdsNotes, NOTES_MAX, type BuildNote } from "../../git/builds.ts";
11 import type { Ctx } from "../../http/context.ts";
12 import { html, raw, when, type Html } from "../html.ts";
13 import { repoDir } from "../../git/repos.ts";
14 import { renderMarkdown } from "../markdown.ts";
15 import { btn } from "../ui.ts";
16 import type { IterationBuild } from "../../core/iterations.ts";
17
18
19
20 /** "Oct 24", read in UTC so every viewer sees one date. */
21 export const shortDate = (iso: string): string =>
22 new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
23
24
25 /** What a build's info panel says it plays. */
26 export function playsText(b: IterationBuild): string {
27 if (b.plays === "env") return "build preview";
28 return "nothing: not an app";
29 }
30
31 export function playerOf(b: IterationBuild): Html {
32 const title = html`<b>${b.repo.split("/")[1]} ${b.label}</b>`;
33 const foot = html`<div class="it-foot">${b.note === "" ? null : html`${b.note} · `}${b.repo} · ${b.branch === "" ? null : html`${b.branch} @ `}<span class="mono">${b.oid.slice(0, 7)}</span> · ${shortDate(b.at)}</div>`;
34 const body =
35 b.plays === "none"
36 ? html`<div class="it-empty">Nothing to play: ${b.repo} is not an app.</div>`
37 : b.preview
38 ? html`<div class="it-empty"><form method="post" action="/${b.repo}/builds/preview">
39 <input type="hidden" name="sha" value="${b.oid}" />
40 ${btn("Open build preview", { variant: "primary", size: "sm" })}
41 </form></div>`
42 : html`<div class="it-empty">Opening a build preview requires write access.</div>`;
43 return html`<div class="it-bar">${title}</div>${body}${foot}`;
44 }
45
46 /** A button that opens one of the build's dialogs; nothing happens until the dialog is confirmed. */
47 export const ask = (id: string, label: string): Html =>
48 html`<button type="button" class="btn" data-size="sm" data-ask="it-dlg-${id}">${label}</button>`;
49
50 /** A dialog that says what will happen, with Cancel and the one button that does it. */
51 export const dialog = (id: string, title: string, body: Html, confirm: Html): Html =>
52 html`<dialog class="dialog it-dlg" id="it-dlg-${id}" aria-labelledby="it-dlg-${id}-title">
53 <div class="dialog-head"><h2 id="it-dlg-${id}-title">${title}</h2></div>
54 <div class="dialog-body">${body}</div>
55 <div class="dialog-foot"><button type="button" class="btn" data-cancel>Cancel</button>${confirm}</div>
56 </dialog>`;
57
58 /** What the fork dialog says a fork carries of the build's commit. */
59 function forkCarries(b: IterationBuild): string {
60 const sha7 = b.oid.slice(0, 7);
61 if (b.forkHas.branch !== null) return `${b.label}'s commit ${sha7} is on branch ${b.forkHas.branch}.`;
62 return `${b.label}'s commit ${sha7} is on no branch; the copied pin keeps it.`;
63 }
64
65 /** A quiet Edit link at the right of a section's heading, Forgejo style. */
66 const editLink = (hide = false, label = "Edit"): Html =>
67 html`<button type="button" class="it-edit" data-edit ${hide ? raw("hidden") : null}>${label}</button>`;
68
69 /** One note: its author and day, then its first line as a title; the rest opens on a click. */
70 function noteItem(cls: string, who: Html, text: string): Html {
71 const at = text.indexOf("\n");
72 const first = at === -1 ? text : text.slice(0, at);
73 const more = at === -1 ? "" : text.slice(at + 1).trim();
74 return more === ""
75 ? html`<div class="it-note ${cls}">${who} · ${first}</div>`
76 : html`<details class="it-note ${cls}"><summary>${who} · ${first}</summary><div class="prose it-body">${renderMarkdown(more)}</div></details>`;
77 }
78
79 /** A build's notes as one list: people's notes first, highlighted, then agents', then the pin's own tag note. */
80 export function noteListOf(b: { readonly notes: readonly BuildNote[]; readonly tag: string; readonly by: string; readonly at: string }): Html | null {
81 const items = b.notes.map((n) =>
82 noteItem(`it-note-${n.kind}`, html`<b>${n.by}</b>${n.kind === "agent" ? html` <i>agent</i>` : null} · ${shortDate(n.at)}`, n.text),
83 );
84 if (b.tag !== "") items.push(noteItem("it-note-pin", html`<b>${b.by}</b> <i>pin</i> · ${shortDate(b.at)}`, b.tag));
85 return items.length === 0 ? null : html`${items}`;
86 }
87
88 /** The Notes section: one list, people's notes first; for a writer, Add a note opens a box with Save and Cancel, in place. */
89 function notesOf(b: IterationBuild, back: string): Html {
90 const list = noteListOf(b);
91 const empty = list === null;
92 const view = html`<div class="it-notelist" data-view ${empty ? raw("hidden") : null}>${list}</div>`;
93 if (!b.canNote) return html`<section class="it-notes" data-section="notes"><h4>Notes</h4>${empty ? html`<p>None</p>` : view}</section>`;
94 return html`<section class="it-notes" data-section="notes" data-field="notes" data-empty="${empty ? "1" : "0"}"><h4>Notes${editLink(empty, "Add a note")}</h4>
95 ${view}
96 <p class="muted it-none" data-none ${empty ? null : raw("hidden")}>None. ${editLink(false, "Add a note")}</p>
97 <form class="it-ed" method="post" action="/${b.repo}/builds/${encodeURIComponent(b.label)}/note" hidden>
98 <input type="hidden" name="back" value="${back}" />
99 <textarea class="input" name="notes" rows="5" maxlength="${String(NOTES_MAX)}" aria-label="Your note: the first line is its title" placeholder="The first line is its title"></textarea>
100 <div class="it-ed-foot"><button type="button" class="btn" data-size="sm" data-cancel-edit>Cancel</button>${btn("Save note", { variant: "primary", size: "sm" })}</div>
101 </form>
102 <p class="it-saved" data-saved role="status" hidden></p></section>`;
103 }
104
105 export function infoOf(ctx: Ctx, b: IterationBuild, forkOf: string | null, link: string, back: string): Html {
outdated

refsContaining runs git per build, and listBranches per repo, on every request, with no cache. Bound this or compute it only for the picked build.

106 const name = b.repo.split("/")[1] ?? b.repo;
107 const lineage = [
108 ...(forkOf === null ? [] : [`fork of ${forkOf}`]),
109 ...(b.parent === null ? [] : [`${b.edge === "concept" ? "built on" : "after"} ${b.parent}`]),
110 ];
111 // The forge's refs ride along: pins, and their notes when the repository holds any (a missing ref fails the fetch).
112 const [owner = "", rname = ""] = b.repo.split("/");
113 const notes = holdsNotes(repoDir(ctx.app.reposRoot, owner, rname)) ? " 'refs/forge/notes:refs/forge/notes'" : "";
114 const clone = `git clone ${ctx.url.origin}/${b.repo}.git\ngit -C ${name} fetch origin 'refs/builds/*:refs/builds/*'${notes}\ngit -C ${name} checkout ${b.oid}`;
115 const fact = (label: string, value: Html | string) => html`<dt>${label}</dt><dd>${value}</dd>`;
116 return html`<h3>${name} ${b.label}</h3>
117 <section data-section="build"><h4>Build</h4><dl>
118 ${fact("Pin", `refs/builds/${b.label}`)}
119 ${fact("Commit", html`<a class="mono" href="/${b.repo}/commit/${b.oid}">${b.oid.slice(0, 12)}</a>`)}
120 ${fact("Branch", b.branch === "" ? "None" : b.branch)}
121 ${fact("Repository", html`<a href="/${b.repo}">${b.repo}</a>`)}
122 ${fact("Lineage", lineage.length === 0 ? "first pin" : lineage.join(" · "))}
123 ${fact("Pinned", `${b.by} · ${b.at.slice(0, 10)}`)}
124 ${fact("Plays", playsText(b))}
125 </dl></section>
126 ${notesOf(b, back)}
127 <section data-section="actions"><h4>Actions</h4><div class="it-acts">
128 ${ask("link", "Copy link")}
129 ${ask("clone", "Clone")}
130 ${ask("fork", "Fork")}
131 ${when(b.canNote, () => ask("delete", "Delete pin"))}
132 </div></section>
133 ${dialog(
134 "link",
135 "Copy link",
136 html`<p>The link to ${name} ${b.label} on this page.</p><input class="input mono it-cmd" id="it-link" type="text" value="${link}" readonly aria-label="Link" />`,
137 html`<button type="button" class="btn" data-variant="primary" data-copy="it-link" data-done>Copy link</button>`,
138 )}
139 ${dialog(
140 "clone",
141 "Clone",
142 html`<p>Run these on your machine to get ${name} ${b.label}. Nothing runs on the forge.</p><textarea class="input mono it-cmd" id="it-clone" rows="3" readonly aria-label="Commands">${clone}</textarea>`,
143 html`<button type="button" class="btn" data-variant="primary" data-copy="it-clone" data-done>Copy commands</button>`,
144 )}
145 ${dialog(
146 "fork",
147 "Fork",
148 html`<form method="post" action="/${b.repo}/fork" id="it-fork-form">
149 <p>Fork ${b.repo} into your account as a new repository. It copies the branches and tags, and the build pins with their notes. ${forkCarries(b)}</p>
150 <input type="hidden" name="then" value="iterations" /><input type="hidden" name="back" value="${back}" />
151 <div class="field"><label for="it-fork-name">Repository name</label><input class="input mono" id="it-fork-name" name="name" value="${name}-${b.label}" required autocomplete="off" /></div>
152 </form>`,
153 html`<button type="submit" class="btn" data-variant="primary" form="it-fork-form">Fork repository</button>`,
154 )}
155 ${when(b.canNote, () =>
156 dialog(
157 "delete",
158 "Delete pin",
159 html`<form method="post" action="/${b.repo}/builds/${encodeURIComponent(b.label)}/delete" id="it-delete-form">
160 <input type="hidden" name="back" value="${back}" />
161 <p>Delete the pin ${b.label} from ${b.repo}. Its commit ${b.oid.slice(0, 7)} and its notes stay.</p>
162 </form>`,
163 html`<button type="submit" class="btn" data-variant="danger" form="it-delete-form">Delete pin</button>`,
164 ),
165 )}`;
166 }
167
168 /** The player's and the info panel's style. */
169 export const INFO_CSS = /* css */ `
170 .it-player,.it-info{background:var(--card);border:1px solid var(--border);border-radius:12px;overflow:hidden;min-width:0}
171 .it-bar{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--border)}
172 .it-sp{margin-left:auto;display:flex;gap:8px}
173 .it-empty{padding:40px 20px;text-align:center;color:var(--muted-fg)}
174 .it-empty form{display:inline-block}
175 .it-foot{padding:10px 14px;color:var(--muted-fg);font-size:13px;border-top:1px solid var(--border)}
176 .it-info h3{margin:0;padding:10px 14px;border-bottom:1px solid var(--border);font-size:15px}
177 .it-info section{padding:10px 14px;border-bottom:1px solid var(--border)}
178 .it-info section:last-child{border-bottom:0}
179 .it-info h4{display:flex;align-items:baseline;gap:8px;margin:0 0 6px;font-size:12.5px;color:var(--muted-fg);font-weight:600}
180 .it-info dl{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;margin:0;font-size:13px}
181 .it-info dt{color:var(--muted-fg)}
182 .it-info dd{margin:0;overflow-wrap:anywhere}
183 .it-info p{margin:0;font-size:13px}
184 .it-info .prose.it-body{padding:0;font-size:13px;line-height:1.5}
185 .it-edit{margin-left:auto;background:none;border:0;padding:0;color:var(--muted-fg);font:inherit;font-size:11.5px;font-weight:400;cursor:pointer}
186 .it-edit:hover,.it-edit:focus-visible{color:var(--accent);text-decoration:underline}
187 @media (forced-colors: active){.it-edit:focus-visible{outline:1px solid Highlight}}
188 .it-none .it-edit{margin-left:0;font-size:inherit}
189 .it-edit[hidden],.it-ed[hidden],.it-saved[hidden],.it-info [data-view][hidden],.it-none[hidden]{display:none}
190 .it-ed{display:grid;gap:8px}
191 .it-ed textarea{min-height:96px;resize:vertical;font-size:16px}
192 .it-notelist{display:grid;gap:4px;margin-bottom:6px}
193 .it-note{padding:5px 9px;border-radius:6px;font-size:13px;border-left:3px solid var(--border);overflow-wrap:anywhere}
194 .it-note summary{cursor:pointer}
195 .it-note .it-body{margin-top:4px}
196 .it-note-human{background:color-mix(in srgb,var(--accent) 10%,transparent);border-left-color:var(--accent)}
197 .it-note-agent,.it-note-pin{color:var(--muted-fg);font-size:12.5px}
198 .it-note i{font-style:normal;font-size:11.5px;color:var(--muted-fg)}
199 .it-ed-foot{display:flex;justify-content:flex-end;gap:8px}
200 .it-info .it-saved{margin-top:6px;font-size:11.5px;color:var(--muted-fg)}
201 .it-info .it-saved.it-bad{color:var(--danger)}
202 .it-acts{display:flex;flex-wrap:wrap;gap:8px}
203 .it-cmd{display:block;width:100%;font-size:11.5px;margin-top:8px;resize:none}
204 `;
205
206 /**
207 * The panel's script, as a string of browser code: panel(player, info, saved)
208 * runs the dialogs every action opens, Notes edited in
209 * place (saved(j) is told what was saved, to mend the page around the
210 * panel). It returns show(playerTpl, infoTpl),
211 * which swaps a build's player and panel in from their templates, and
212 * keep(tpl), which writes the panel as it stands back to its template.
213 */
214 export const INFO_JS = `
215 function panel(player, info, saved) {
216 document.addEventListener('click', function (ev) {
217 var t = ev.target; if (!t || !t.closest) return;
218 var a = t.closest('[data-ask]');
219 if (a) { var d = document.getElementById(a.getAttribute('data-ask')); if (d && d.showModal && !d.open) d.showModal(); return; }
220 var dlg = t.closest('dialog.it-dlg'); if (!dlg) return;
221 if (t.closest('[data-cancel]')) { dlg.close(); return; }
222 if (t.closest('[data-done]')) setTimeout(function () { if (dlg.open) dlg.close(); }, 900);
223 });
224 function shown(sec, editing) {
225 var empty = sec.getAttribute('data-empty') === '1';
226 var q = function (s) { return sec.querySelector(s); };
227 q('form.it-ed').hidden = !editing;
228 q('[data-view]').hidden = empty;
229 var none = q('[data-none]'); if (none) none.hidden = editing || !empty;
230 q('h4 .it-edit').hidden = editing || empty;
231 }
232 info.addEventListener('click', function (ev) {
233 var t = ev.target; if (!t || !t.closest) return;
234 var sec = t.closest('section[data-field]'); if (!sec) return;
235 // Only the panel's own Edit buttons: the Iterations page itself carries data-edit.
236 if (t.closest('button[data-edit]')) {
237 ev.preventDefault();
238 sec.querySelector('[data-saved]').hidden = true;
239 shown(sec, true);
240 var box = sec.querySelector('form.it-ed textarea'); if (box) box.focus();
241 } else if (t.closest('[data-cancel-edit]')) {
242 sec.querySelector('form.it-ed').reset();
243 shown(sec, false);
244 }
245 });
246 var tplNow = null;
247 function keep() {
248 if (!tplNow) return;
249 tplNow.content.replaceChildren();
250 for (var c = info.firstChild; c; c = c.nextSibling) tplNow.content.appendChild(c.cloneNode(true));
251 var s = tplNow.content.querySelectorAll('[data-saved]'); for (var m = 0; m < s.length; m++) s[m].hidden = true;
252 }
253 info.addEventListener('submit', function (ev) {
254 var form = ev.target; if (!form.classList || !form.classList.contains('it-ed')) return;
255 ev.preventDefault();
256 var sec = form.closest('section[data-field]'), said = sec.querySelector('[data-saved]');
257 var save = form.querySelector('button[type="submit"]'); if (save) save.disabled = true;
258 fetch(form.action, { method: 'POST', body: new FormData(form), headers: { accept: 'application/json' }, credentials: 'same-origin' }).then(function (r) {
259 return r.json().then(function (j) { return { ok: r.ok, j: j }; }, function () { return { ok: false, j: {} }; });
260 }).then(function (res) {
261 if (save) save.disabled = false;
262 if (!res.ok) { said.textContent = res.j.message || 'Not saved.'; said.classList.add('it-bad'); said.hidden = false; return; }
263 var j = res.j;
264 sec.querySelector('[data-view]').innerHTML = j.html;
265 sec.setAttribute('data-empty', j.html === '' ? '1' : '0');
266 var area = sec.querySelector('textarea[name="notes"]'); area.defaultValue = ''; area.value = '';
267 shown(sec, false);
268 said.textContent = 'Saved.'; said.classList.remove('it-bad'); said.hidden = false;
269 if (saved) saved(j);
270 keep();
271 }, function () { if (save) save.disabled = false; said.textContent = 'Not saved: the forge did not answer.'; said.classList.add('it-bad'); said.hidden = false; });
272 });
273 return {
274 show: function (p, f) {
275 tplNow = f;
276 player.replaceChildren(p.content.cloneNode(true));
277 info.replaceChildren(f.content.cloneNode(true));
278 var back = player.querySelector('input[name="back"]');
279 if (back) back.value = location.pathname + location.search;
280 },
281 keep: keep,
282 };
283 }
284 `;
src/web/pages/builds.ts +194 −108
@@ -1,24 +1,46 @@
11 /**
2 * Builds: the pins people made (`git/builds.ts`), a short form to make one,
3 * and "preview now" for any commit. A pin is a git ref, so pinning runs no
4 * check and wakes no agent.
2 * Builds: the pins people made, drawn as a lineage. A pin's parent is the
3 * one its person named, else the nearest pin in its history — read from git
4 * (`for-each-ref --merged`), so the tree follows the work even across
5 * merges and squashes that a strict commit graph would split. A fork's
6 * first pins hang from its parent repository's, which its objects share.
57 */
68
7 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
8 import { previewBuild } from "../../actions/build_preview.ts";
9 import { openEnvUrl } from "../../actions/apps.ts";
109 import { deletePin, pinBuild } from "../../actions/builds.ts";
10 import { previewBuild } from "../../actions/build_preview.ts";
11 import { getAppByRepo } from "../../core/apps.ts";
12 import { openEnvUrl } from "../../actions/apps.ts";
13 import { buildLineage, type BuildNode } from "../../core/lineage.ts";
1114 import { canWrite } from "../../core/repos.ts";
15 import {
16 isPinLabel,
17 listBuildPins,
18 type BuildPin,
19 } from "../../git/builds.ts";
1220 import { exactRefOid } from "../../git/branches.ts";
13 import { listBuildPins, type BuildPin } from "../../git/builds.ts";
1421 import { isSafeOid } from "../../git/guard.ts";
1522 import { repoDir } from "../../git/repos.ts";
23 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1624 import type { Ctx } from "../../http/context.ts";
1725 import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts";
18 import { field as formValue, readForm } from "../form.ts";
19 import { html, when } from "../html.ts";
26 import { backTo, field as formValue, readForm } from "../form.ts";
27 import { isLocalPath } from "../flash.ts";
28 import { html, when, type Html } from "../html.ts";
2029 import { flashTo, page } from "../layout.ts";
21 import { btn, card, field, icon, sha, time } from "../ui.ts";
30 import { btn, card, field, icon, selectField, sha, time } from "../ui.ts";
31
32 /**
33 * The label a new pin is offered. An iteration branch (`iter-3`) pinned
34 * for the first time offers its own name, and a fork's first pin is
35 * `iter-1`: each iteration is a checkpoint under the name it was made as.
36 * Otherwise the newest label, stepped on by one.
37 */
38 export function suggestLabel(pins: readonly BuildPin[], forked: boolean, source: string): string {
39 const branch = /^branch (iter-\d+[A-Za-z0-9._-]*)$/.exec(source)?.[1];
40 if (branch !== undefined && isPinLabel(branch) && !pins.some((p) => p.label === branch)) return branch;
41 if (pins.length === 0 && forked) return "iter-1";
42 return nextLabel(pins);
43 }
2244
2345 /** The newest label stepped on by one. */
2446 export function nextLabel(pins: readonly BuildPin[]): string {
@@ -31,75 +53,113 @@ export function nextLabel(pins: readonly BuildPin[]): string {
3153 return `${last}-2`;
3254 }
3355
34 /** Renders a page and names `session` and `render` in Server-Timing. */
35 function timedPage(ctx: Ctx, opts: Parameters<typeof page>[0]): Response {
36 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
37 const started = performance.now();
38 const response = page(opts);
39 notePhase(ctx.request, "render", performance.now() - started);
40 return response;
41 }
56 /** The lineage as a card: one line per pin, indented under its parent. */
57 const hidden = (error: string): boolean => error === "unauthorized" || error === "not_found" || error === "forbidden";
4258
43 // GET /:owner/:name/builds — the pins, newest first.
59 // GET /:owner/:name/builds — the pins as a lineage, each under the one it grew from.
4460 export async function buildsPage(ctx: Ctx): Promise<Response> {
61 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4562 const repo = loadRepo(ctx);
4663 if (!repo) return notFoundPage(ctx);
4764 const base = repoBase(repo);
48 const pins = await listBuildPins(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name));
65 const nodes = await buildLineage(ctx, repo);
4966 const writer = ctx.auth.user !== undefined && canWrite(ctx.app.db, repo, ctx.auth.user);
67 const isApp = getAppByRepo(ctx.app.db, repo.id) !== undefined;
5068 const body = html`${repoHeader(ctx, repo, "branches", { compact: true })}
51 <div class="mt">
52 ${card(
53 pins.length === 0
54 ? html`<p class="muted builds-none">No builds pinned yet.</p>`
55 : html`<ul class="list">
56 ${pins.map(
57 (p) => html`<li>
58 <div class="row build-row">
59 <span class="row-lead">${icon("pin")}</span>
60 <span class="row-main">
61 <span class="row-title mono">${p.label}</span>
62 <span class="row-meta"
63 >${sha(p.oid, `${base}/commit/${p.oid}`)} · ${p.source} · pinned ${time(p.pinnedAt)} by
64 ${p.pinnedBy}${when(p.parent, (parent) => html` · under ${parent}`)}${when(
65 p.note,
66 (note) => html` · ${note}`,
67 )}</span
68 >
69 </span>
70 <span class="row-trail">
71 ${when(writer, () =>
72 html`<form method="post" action="${base}/builds/preview" class="build-preview">
73 <input type="hidden" name="sha" value="${p.oid}" />
74 ${btn("Preview", { type: "submit", size: "sm", variant: "outline", icon: "eye" })}
75 </form>`,
76 )}
77 ${when(writer, () =>
78 btn("Delete", {
79 href: `/-/confirm?to=${encodeURIComponent(`${base}/builds/${encodeURIComponent(p.label)}/delete`)}`,
80 size: "sm",
81 variant: "danger",
82 label: `Delete pin ${p.label}`,
83 }),
84 )}
85 </span>
86 </div>
87 </li>`,
88 )}
89 </ul>`,
90 {
91 head: html`${icon("pin")}
92 <h2>Builds</h2>
93 ${when(pins.length > 0, () => btn("Iterations", { href: `${base}/iterations`, size: "sm", variant: "ghost" }))}
94 ${when(writer, () => btn("Pin a build", { href: `${base}/builds/new`, size: "sm", variant: "primary" }))}`,
95 },
96 )}
97 </div>`;
98 return timedPage(ctx, { ctx, title: `Builds · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body });
69 <div class="mt">${buildsCard(base, nodes, writer, writer && isApp, writer)}</div>`;
70 return timePhase(ctx.request, "render", () =>
71 page({ ctx, title: `Builds · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body, styleCss: BUILDS_CSS }),
72 );
9973 }
10074
75 // POST /:owner/:name/builds/:label/delete — behind /-/confirm.
76 export async function deletePinSubmit(ctx: Ctx): Promise<Response> {
77 const repo = loadRepo(ctx);
78 if (!repo) return notFoundPage(ctx);
79 if (!ctx.auth.user) return signInFirst(ctx);
80 const form = await readForm(ctx, "rw");
81 if (form instanceof Response) return form;
82 const gone = await deletePin(ctx.app, ctx.auth.user, repo, ctx.params.label ?? "");
83 if (!gone.ok && hidden(gone.error)) return notFoundPage(ctx);
84 const to = backTo(form, `${repoBase(repo)}/builds`);
85 return flashTo(ctx, to, gone.ok ? { message: `Deleted pin ${gone.value.label}.` } : { tone: "danger", message: "Delete failed." });
86 }
87
88 export function buildsCard(
89 base: string,
90 nodes: readonly BuildNode[],
91 canPin: boolean,
92 /** Whether each pin of this repository offers Preview now: a writer, on an app. */
93 canPreview = false,
94 /** Whether each pin of this repository offers Delete: a writer. */
95 canDelete = false,
96 ): Html {
97 return card(
98 nodes.length === 0
99 ? html`<p class="muted small builds-none">No pinned builds</p>`
100 : html`<ol class="builds">
101 ${nodes.map(
102 (n) => html`<li data-depth="${String(Math.min(n.depth, 8))}" class="build-row">
103 ${when(n.edge === "concept", () => html`<span class="build-concept" title="Concept edge: parent set by hand, not by commit history">┄</span>`)}
104 <span class="build-label mono">${n.from === undefined ? null : html`<span class="muted">${n.from}:</span>`}${n.pin.label}</span>
105 ${sha(n.pin.oid, `${n.from === undefined ? base : `/${n.from}`}/commit/${n.pin.oid}`)}
106 <span class="build-note truncate" title="${n.pin.note}">${n.pin.note === n.pin.label ? "" : n.pin.note}</span>
107 <span class="muted small build-meta"
108 >${n.pin.source} · ${n.pin.pinnedBy} · ${time(n.pin.pinnedAt)}</span
109 >
110 ${when(
111 canPreview && n.from === undefined,
112 () => html`<form method="post" action="${base}/builds/preview" class="build-preview">
113 <input type="hidden" name="sha" value="${n.pin.oid}" />
114 ${btn("Preview now", { size: "sm", variant: "ghost", icon: "eye" })}
115 </form>`,
116 )}
117 ${when(
118 canDelete && n.from === undefined,
119 () => btn("Delete", {
120 href: `/-/confirm?to=${encodeURIComponent(`${base}/builds/${encodeURIComponent(n.pin.label)}/delete`)}`,
121 size: "sm",
122 variant: "danger",
123 label: `Delete pin ${n.pin.label}`,
124 }),
125 )}
126 </li>`,
127 )}
128 </ol>`,
129 {
130 head: html`${icon("pin")}
131 <h2>Builds</h2>
132 <span class="spacer"></span>
133 ${when(nodes.length > 0, () => btn("Iterations", { href: `${base}/iterations?layout=tree`, size: "sm", variant: "ghost", icon: "map" }))}
134 ${when(canPin, () =>
135 html`${btn("Pin a build", {
136 href: `${base}/builds/new`,
137 size: "sm",
138 icon: "plus",
139 })}`,
140 )}`,
141 extra: "builds-card",
142 },
143 );
144 }
145
146 export const BUILDS_CSS = `
147 .builds{list-style:none;margin:0;padding:6px 12px}
148 .build-row{display:flex;align-items:center;gap:8px;min-height:30px;min-width:0}
149 ${[1, 2, 3, 4, 5, 6, 7, 8].map((d) => `.build-row[data-depth="${d}"]{padding-left:${d * 20}px}`).join("\n")}
150 .build-row + .build-row{border-top:1px solid var(--border)}
151 .build-label{font-weight:600;flex:none}
152 .build-concept{color:var(--muted-fg);flex:none}
153 .build-note{min-width:0;flex:1}
154 .build-meta{flex:none;margin-left:auto}
155 .build-preview{flex:none;margin:0}
156 .builds-none{padding:10px 14px;margin:0}
157 @media (max-width:640px){.build-meta{display:none}}
158 `;
159
101160 // GET /:owner/:name/builds/new?sha=&source= — the pin form.
102161 export async function newBuildPage(ctx: Ctx): Promise<Response> {
162 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
103163 const repo = loadRepo(ctx);
104164 if (!repo) return notFoundPage(ctx);
105165 if (!ctx.auth.user) return signInFirst(ctx);
@@ -107,26 +167,60 @@ export async function newBuildPage(ctx: Ctx): Promise<Response> {
107167 const base = repoBase(repo);
108168 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
109169 const pins = await listBuildPins(dir);
170 const family = await buildLineage(ctx, repo);
171 // From Pin a build with nothing chosen: the default branch's tip.
110172 const asked = ctx.url.searchParams.get("sha") ?? "";
111 const want = isSafeOid(asked) ? asked : ((await exactRefOid(dir, `refs/heads/${repo.defaultBranch}`)) ?? "");
112 const source = ctx.url.searchParams.get("source") ?? (isSafeOid(asked) ? "" : `branch ${repo.defaultBranch}`);
173 const want = isSafeOid(asked)
174 ? asked
175 : ((await exactRefOid(dir, `refs/heads/${repo.defaultBranch}`)) ?? "");
176 const source =
177 ctx.url.searchParams.get("source") ?? (isSafeOid(asked) ? "" : `branch ${repo.defaultBranch}`);
178 // From the build graph: back to it, not to Branches.
179 const askedBack = ctx.url.searchParams.get("back") ?? "";
180 const back = isLocalPath(askedBack) ? askedBack : undefined;
113181 const body = html`${repoHeader(ctx, repo, "branches", { compact: true })}
114182 <div class="mt">
115183 ${card(
116184 html`<form method="post" action="${base}/builds" class="stack">
117185 ${field({ label: "Commit", name: "sha", value: isSafeOid(want) ? want : "", required: true, mono: true, hint: "Full commit SHA." })}
118186 <input type="hidden" name="source" value="${source}" />
119 ${field({ label: "Label", name: "label", value: nextLabel(pins), required: true, mono: true, hint: "Letters, digits, dot, dash or underscore. Example: v3." })}
187 ${when(back, (b) => html`<input type="hidden" name="back" value="${b}" />`)}
188 ${field({ label: "Label", name: "label", value: suggestLabel(pins, repo.forkParentId !== null, source), required: true, mono: true, hint: "Letters, digits, dot, dash or underscore. Example: iter-3, v3." })}
120189 ${field({ label: "Note", name: "note", rows: 3, hint: "Optional." })}
190 ${selectField({
191 label: "Parent",
192 name: "parent",
193 hint: "Automatic: the nearest pin in the commit history. A pin in another repository creates a concept edge.",
194 options: [
195 { value: "", label: "Automatic", selected: true },
196 // Any pin of the family: one in another repository is a
197 // concept edge, an idea shared without history.
198 ...family.map((n) => {
199 const value = n.from === undefined ? n.pin.label : `${n.from}:${n.pin.label}`;
200 return { value, label: value };
201 }),
202 ],
203 })}
121204 <div class="bar">
122205 ${btn("Pin build", { variant: "primary", icon: "pin" })}
123 ${btn("Cancel", { href: `${base}/builds`, variant: "ghost" })}
206 ${btn("Cancel", { href: back ?? `${base}/branches`, variant: "ghost" })}
124207 </div>
125208 </form>`,
126 { head: html`${icon("pin")}<h2>Pin a build</h2>` },
209 {
210 head: html`${icon("pin")}
211 <h2>Pin a build</h2>`,
212 },
127213 )}
128214 </div>`;
129 return timedPage(ctx, { ctx, title: `Pin a build · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body });
215 return timePhase(ctx.request, "render", () =>
216 page({
217 ctx,
218 title: `Pin a build · ${repo.name}`,
219 crumbs: crumbsFor(repo),
220 palette: repoPalette(ctx, repo),
221 body,
222 }),
223 );
130224 }
131225
132226 const PIN_ERRORS: Record<string, string> = {
@@ -138,8 +232,6 @@ const PIN_ERRORS: Record<string, string> = {
138232 git_failed: "Pin failed: git refused the ref.",
139233 };
140234
141 const hidden = (error: string): boolean => error === "unauthorized" || error === "not_found" || error === "forbidden";
142
143235 // POST /:owner/:name/builds — pin it.
144236 export async function pinBuildSubmit(ctx: Ctx): Promise<Response> {
145237 const repo = loadRepo(ctx);
@@ -156,31 +248,17 @@ export async function pinBuildSubmit(ctx: Ctx): Promise<Response> {
156248 source: formValue(form, "source") || "commit",
157249 });
158250 if (!pinned.ok) {
159 if (hidden(pinned.error)) return notFoundPage(ctx);
251 if (pinned.error === "unauthorized" || pinned.error === "not_found" || pinned.error === "forbidden")
252 return notFoundPage(ctx);
160253 return flashTo(ctx, `${base}/builds/new?sha=${encodeURIComponent(formValue(form, "sha"))}`, {
161254 tone: "danger",
162255 message: PIN_ERRORS[pinned.error] ?? "Pin failed.",
163256 });
164257 }
165 return flashTo(ctx, `${base}/builds`, {
166 message: `Pinned ${pinned.value.label} at ${pinned.value.oid.slice(0, 7)}.`,
167 });
168 }
169
170 // POST /:owner/:name/builds/:label/delete — behind /-/confirm.
171 export async function deletePinSubmit(ctx: Ctx): Promise<Response> {
172 const repo = loadRepo(ctx);
173 if (!repo) return notFoundPage(ctx);
174 if (!ctx.auth.user) return signInFirst(ctx);
175 const form = await readForm(ctx, "rw");
176 if (form instanceof Response) return form;
177 const gone = await deletePin(ctx.app, ctx.auth.user, repo, ctx.params.label ?? "");
178 if (!gone.ok && hidden(gone.error)) return notFoundPage(ctx);
179 return flashTo(
180 ctx,
181 `${repoBase(repo)}/builds`,
182 gone.ok ? { message: `Deleted pin ${gone.value.label}.` } : { tone: "danger", message: "Delete failed." },
183 );
258 const said = `Pinned ${pinned.value.label} at ${pinned.value.oid.slice(0, 7)}.`;
259 // On Iterations, the new pin is picked.
260 const to = backTo(form, `${base}/iterations?build=${encodeURIComponent(`${repo.ownerName}/${repo.name}:${pinned.value.label}`)}`);
261 return flashTo(ctx, to, { message: said });
184262 }
185263
186264 const PREVIEW_ERRORS: Record<string, string> = {
@@ -190,36 +268,44 @@ const PREVIEW_ERRORS: Record<string, string> = {
190268 no_runtime: "Apps are disabled on this forge.",
191269 };
192270
193 // POST /:owner/:name/builds/preview — serve one commit for a while, then open it.
194271 export async function previewBuildSubmit(ctx: Ctx): Promise<Response> {
195272 const repo = loadRepo(ctx);
196273 if (!repo) return notFoundPage(ctx);
197274 if (!ctx.auth.user) return signInFirst(ctx);
198275 const form = await readForm(ctx, "rw");
199276 if (form instanceof Response) return form;
200 const oid = formValue(form, "sha");
201 const preview = await previewBuild(ctx.app, ctx.auth.user, repo, oid);
277 const preview = await previewBuild(ctx.app, ctx.auth.user, repo, formValue(form, "sha"));
202278 if (!preview.ok) {
203 if (hidden(preview.error)) return notFoundPage(ctx);
204 return flashTo(ctx, `${repoBase(repo)}/builds`, {
279 // A reader is told what a stranger is; an agent never holds a session.
280 if (preview.error === "unauthorized" || preview.error === "not_found" || preview.error === "forbidden")
281 return notFoundPage(ctx);
282 return flashTo(ctx, `${repoBase(repo)}/branches`, {
205283 tone: "danger",
206284 message: PREVIEW_ERRORS[preview.error] ?? "Preview failed.",
207285 });
208286 }
209 // Through the forge's own opener, so a private app's preview gets its grant.
210 const opened = openEnvUrl(ctx.app, ctx.auth.user, repo, preview.value.env, "/");
211 const url = opened.ok ? opened.value : preview.value.url;
287 const path = "/";
288 // Through the forge's own opener, so a private app's preview gets its
289 // grant; a public one's is the plain address.
290 const opened = openEnvUrl(ctx.app, ctx.auth.user, repo, preview.value.env, path);
291 const url = opened.ok
292 ? opened.value
293 : path === "/"
294 ? preview.value.url
295 : preview.value.url.replace(/\/$/, "") + path;
212296 // Not a redirect: a form's redirect to another origin is held to the
213 // page's CSP form-action. A script navigation is not; the proxy there
214 // shows a page that refreshes itself until the build is up.
297 // page's CSP form-action, which allows only the forge. A page that moves
298 // itself on by script is a navigation, and the app's own address opens. The proxy
299 // there shows a page that refreshes itself until the build is up.
300 const short = formValue(form, "sha").slice(0, 7);
215301 return page({
216302 ctx,
217 title: `Opening ${oid.slice(0, 7)} · ${repo.name}`,
303 title: `Opening ${short} · ${repo.name}`,
218304 crumbs: crumbsFor(repo),
219305 scriptJs: `location.replace(${JSON.stringify(url).replace(/</g, "\\u003c")});`,
220306 body: html`<div class="mt">
221307 ${card(
222 html`<p>Starting build <span class="mono">${oid.slice(0, 7)}</span>.</p>
308 html`<p>Starting build <span class="mono">${short}</span>.</p>
223309 <p><a class="btn" data-variant="primary" href="${url}">Open ${url}</a></p>`,
224310 { head: html`${icon("eye")}<h2>Opening preview</h2>` },
225311 )}
src/web/pages/iterations.ts +554 −45
@@ -1,70 +1,548 @@
11 /**
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 `core/iterations.ts`, the browser half
7 * `iterations_script.ts`.
2 * Iterations: a fork family's build pins as fork cards in columns by fork
3 * depth, or as a downward tree of runs (Tree) with drag, Align and Reset;
4 * zoom with Fit, Collapse and Expand forks, folds kept per browser, and
5 * Notes. A build picked plays in the player under the graph, beside its info
6 * panel (`build_info.ts`). The layout is
7 * records/design/1031-forgej-iterations/LAYOUT.md (iteration H) and
8 * records/design/1101-forgej-graph-layout (B, C, D).
9 *
10 * The cards, rows and folds are drawn here; the script opens folds, swaps
11 * the picked build's player and panel in from their templates, and draws the
12 * lines between cards. Displays (the picker, Showcased, Edit display
13 * selections) are not on this forge yet.
814 */
915
16 import { notePin } from "../../actions/builds.ts";
17 import { iterationsOf, lanesOf, type IterationBuild, type IterationFork, type IterationLane } from "../../core/iterations.ts";
1018 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
19 import { NOTES_MAX, splitPinNote, wholeNotes } from "../../git/builds.ts";
1120 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 "../../core/iterations.ts";
16 import { btn } from "../ui.ts";
17 import { ITERATIONS_CSS, ITERATIONS_JS } from "./iterations_script.ts";
21 import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts";
22 import { backTo, field, prefersJson, readForm } from "../form.ts";
23 import { html, raw, when, type Html } from "../html.ts";
24 import { flashTo, page } from "../layout.ts";
25 import { infoOf, INFO_CSS, INFO_JS, noteListOf, playerOf, shortDate } from "./build_info.ts";
1826
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>
27 export { shortDate };
28 export type { IterationBuild };
29
30
31 // ---- drawing ----
32
33 /** What a row needs to draw itself. */
34 interface Draw {
35 readonly index: Map<string, number>;
36 readonly selected: string | undefined;
37 }
38
39 function row(b: IterationBuild, d: Draw): Html {
40 return html`<button type="button" class="it-b${b.key === d.selected ? " it-sel" : ""}" data-i="${String(d.index.get(b.key)!)}" data-key="${b.key}">
41 <span class="it-l mono">${b.label}</span><span class="it-d">${shortDate(b.at)}</span>
2242 ${when(b.note !== "", () => html`<span class="it-n">${b.note}</span>`)}
23 </div>`;
43 </button>`;
44 }
2445
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>`)}
46 /** A card's rows, and a line for each branch with no pin at its tip. */
47 function rows(f: { readonly builds: readonly IterationBuild[]; readonly tips: readonly string[] }, d: Draw): Html {
48 return html`${f.builds.map((b) => row(b, d))}${f.tips.map(
49 (t) => html`<div class="it-b it-tip"><span class="it-l mono">${t}</span><span class="it-d">branch</span><span class="it-n">No pin</span></div>`,
50 )}`;
51 }
52
53 function card(f: IterationFork, d: Draw): Html {
54 return html`<section class="it-fork" data-repo="${f.repo}">
55 <button type="button" class="it-head"><b class="mono">${f.name}</b></button>
56 <div class="it-rows">${rows(f, d)}</div>
4157 </section>`;
4258 }
4359
44 const ctl = (id: string, text: string, title: string, label?: string): Html =>
45 btn(text, { id, type: "button", size: "sm", title, ...(label === undefined ? {} : { label }) });
60 /** One run of the downward tree: its head, its rows, and the one node it folds to. */
61 function lane(l: IterationLane, branches: number, d: Draw): Html {
62 const keys = l.builds.map((b) => b.key).join(" ");
63 const n = l.builds.length;
64 const head = l.main
65 ? html`<button type="button" class="it-nm" data-act="fork" title="Fold this fork"><i aria-hidden="true"></i><b class="mono">${l.title}</b></button>${when(
66 branches > 0 && n > 1,
67 () => html`<button type="button" class="it-ct" data-act="run" title="Fold this run"></button>`,
68 )}`
69 : html`<span class="it-sub mono">${l.title}</span>`;
70 const first = l.builds[0], last = l.builds.at(-1);
71 return html`<section class="it-lane${l.main ? "" : " it-branch"}" data-lane="${l.id}" data-repo="${l.repo}">
72 <header class="it-lh">${head}</header>
73 <div class="it-rows">${rows(l, d)}</div>
74 ${when(l.main, () => html`<button type="button" class="it-coll it-fcoll" data-act="fork" data-keys="${keys}"><b>${String(n)} ${n === 1 ? "build" : "builds"}</b><span>${branches > 0 ? `${String(branches)} ${branches === 1 ? "branch" : "branches"} · ` : ""}folded</span></button>`)}
75 ${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>${String(n)} builds in a run</span></button>`)}
76 </section>`;
77 }
4678
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>`;
79 export const ITERATIONS_CSS = INFO_CSS + /* css */ `
80 .it-seg{display:inline-flex;border:1px solid var(--border);border-radius:8px;overflow:hidden}
81 .it-seg a{padding:4px 10px;font-size:13px;color:var(--muted-fg);text-decoration:none}
82 .it-seg a[aria-current="page"]{background:var(--muted);color:var(--fg);font-weight:600}
83 .it-key .it-sp{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
84 .it-zc{display:inline-flex;gap:2px;margin-right:6px}
85 .it-zc .btn{min-width:30px;justify-content:center;font-variant-numeric:tabular-nums}
86 #it-zval{min-width:52px}
87 .it-zoom{position:relative}
88 #it-grid{transform-origin:0 0}
89 /* One card is as wide as a conventional commit subject: 50 characters of monospace, plus its padding. */
90 #it-grid{--it-card:calc(50ch + 26px);font-family:var(--mono);font-size:12.5px}
91 #it-grid .it-fork,#it-grid .it-lane,#it-grid .it-b,#it-grid .it-coll,#it-grid .it-head,#it-grid .it-nm,#it-grid .it-tip{font-family:var(--mono)}
92 .it-fork.it-shut .it-rows{display:none}
93 .it-fork.it-shut .it-head{border-bottom:0}
94 .it-key i.it-thin{border-top-width:1px}
95 .it-tree{position:relative;min-width:100%}
96 .it-lane{position:absolute;width:var(--it-card);background:var(--card);border:1px solid var(--border);border-radius:10px;z-index:1;cursor:grab;touch-action:none}
97 .it-lane.it-dragging{z-index:5;cursor:grabbing;box-shadow:0 0 0 2px var(--accent)}
98 .it-lh{display:flex;align-items:center;gap:6px;padding:6px 8px;border-bottom:1px solid var(--border);min-height:32px}
99 .it-nm{display:flex;gap:6px;align-items:baseline;min-width:0;padding:0;background:none;border:0;color:inherit;font:inherit;text-align:left;cursor:pointer}
100 .it-nm i{font-style:normal;color:var(--muted-fg);font-size:11.5px;width:10px}
101 .it-nm i::before{content:"\\25BE"}
102 .it-lane.it-shut .it-nm i::before{content:"\\25B8"}
103 .it-nm b{font-size:13px;overflow-wrap:anywhere}
104 .it-sub{color:var(--muted-fg);font-size:11.5px}
105 .it-ct{margin-left:auto;background:none;border:1px solid var(--border);border-radius:999px;color:var(--muted-fg);font:inherit;font-size:11.5px;padding:0 7px;cursor:pointer;white-space:nowrap}
106 .it-ct::before{content:"fold run"}
107 .it-lane.it-runshut .it-ct::before{content:"open run"}
108 .it-coll{display:none;flex-direction:column;gap:1px;width:100%;background:none;border:0;border-left:3px solid transparent;color:inherit;font:inherit;text-align:left;padding:8px 10px;cursor:pointer}
109 .it-coll b{font-size:13px}
110 .it-coll span{color:var(--muted-fg);font-size:11.5px}
111 .it-lane.it-shut .it-fcoll,.it-lane.it-runshut:not(.it-shut) .it-rcoll{display:flex}
112 .it-lane.it-shut .it-rows,.it-lane.it-runshut .it-rows,.it-lane.it-shut .it-ct{display:none}
113 .it-lane.it-shut .it-lh{border-bottom:0}
114 .it-lane.it-hid{display:none}
115 body.it-nosel{user-select:none;-webkit-user-select:none}
116 .it-checks{display:flex;gap:16px;align-items:center;flex-wrap:wrap}
117 .it-checks label{display:flex;gap:6px;align-items:center;cursor:pointer}
118 .it-key{display:flex;gap:16px;color:var(--muted-fg);font-size:12.5px;margin:4px 0 8px;flex-wrap:wrap}
119 .it-key i{display:inline-block;width:22px;height:0;border-top:2px solid var(--muted-fg);vertical-align:middle;margin-right:6px}
120 .it-key i.it-dash{border-top-style:dashed}
121 .it-stage{position:relative;padding:16px;border:1px solid var(--border);border-radius:12px;overflow:auto;max-height:60vh}
122 .it-grid{position:relative;display:grid;grid-auto-flow:column;grid-auto-columns:var(--it-card);column-gap:64px;align-items:start;width:max-content}
123 .it-col{display:flex;flex-direction:column;gap:18px}
124 .it-edges{position:absolute;left:0;top:0;pointer-events:none;overflow:visible}
125 .it-edges path{fill:none;stroke:var(--muted-fg);stroke-width:2}
126 .it-edges path[data-kind="concept"]{stroke-dasharray:6 5}
127 .it-edges path[data-kind="branch"]{stroke-width:1.25}
128 .it-fork{background:var(--card);border:1px solid var(--border);border-radius:10px;position:relative;z-index:1}
129 .it-head{display:flex;align-items:baseline;gap:8px;padding:8px 10px;width:100%;background:none;border:0;border-bottom:1px solid var(--border);color:inherit;font:inherit;text-align:left;cursor:pointer}
130 .it-head b{font-size:13px;min-width:0;overflow-wrap:anywhere}
131 .it-rows{display:flex;flex-direction:column}
132 .it-b{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:8px;padding:7px 10px;border:0;border-top:1px solid var(--border);border-left:3px solid transparent;background:none;color:inherit;font:inherit;text-align:left;width:100%;cursor:pointer}
133 .it-rows > .it-b:first-child{border-top:0}
134 .it-l{font-weight:700;font-size:13px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
135 .it-d{color:var(--muted-fg);font-size:12.5px;text-align:right;white-space:nowrap}
136 .it-n{grid-column:1/-1;color:var(--muted-fg);font-size:12.5px;overflow-wrap:anywhere}
137 .it-b.it-sel{background:var(--muted);border-left-color:var(--accent)}
138 .it-b.it-tip{opacity:.55;cursor:default}
139 .it-b.it-tip .it-l{font-weight:400}
140 .it-page[data-notes="0"] button.it-b .it-n,.it-page[data-notes="0"] .it-notes{display:none}
141 .it-pane{display:grid;grid-template-columns:320px minmax(0,1fr);gap:16px;margin-top:16px;align-items:start}
142 @media (max-width:760px){.it-pane{grid-template-columns:minmax(0,1fr)}}
143 `;
49144
50 // GET /:owner/:name/iterations
145 /**
146 * The lines between fork cards, from each card's head to the next: a
147 * string of browser code.
148 * `drawEdges(grid, edges)` redraws them over `grid`.
149 */
150 export const EDGES_JS = `
151 function drawEdges(grid, list, z) {
152 z = z || 1;
153 var NS = 'http://www.w3.org/2000/svg';
154 function head(repo) {
155 var cards = grid.querySelectorAll('.it-fork');
156 for (var i = 0; i < cards.length; i++) if (cards[i].getAttribute('data-repo') === repo) return cards[i].querySelector('.it-head');
157 return null;
158 }
159 var old = grid.querySelector('svg.it-edges'); if (old) old.remove();
160 var svg = document.createElementNS(NS, 'svg'); svg.setAttribute('class', 'it-edges');
161 var o = grid.getBoundingClientRect();
162 list.forEach(function (e) {
163 var a = head(e.from), b = head(e.to); if (!a || !b) return;
164 var ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
165 var x1 = (ra.right - o.left) / z, y1 = (ra.top - o.top + ra.height / 2) / z, x2 = (rb.left - o.left) / z, y2 = (rb.top - o.top + rb.height / 2) / z, mx = (x1 + x2) / 2;
166 var p = document.createElementNS(NS, 'path');
167 p.setAttribute('d', 'M' + x1 + ',' + y1 + ' C' + mx + ',' + y1 + ' ' + mx + ',' + y2 + ' ' + x2 + ',' + y2);
168 p.setAttribute('data-kind', e.kind);
169 svg.appendChild(p);
170 });
171 svg.setAttribute('width', grid.scrollWidth); svg.setAttribute('height', grid.scrollHeight);
172 grid.insertBefore(svg, grid.firstChild);
173 }
174 `;
175
176 /**
177 * Zoom for the graph: − and + step through fixed scales, the percentage goes
178 * back to 100%, Fit shows the whole graph in the stage, and ⌘ or Ctrl with
179 * the wheel (a trackpad pinch) zooms where the pointer is. The scale is kept
180 * in this browser. `zoom(stage, box, grid, redraw)` returns { z, fit } where
181 * z() is the scale and fit() resizes the scrolled box to the scaled graph.
182 */
183 export const ZOOM_JS = `
184 function zoom(stage, box, grid, redraw) {
185 var STEPS = [0.25, 0.33, 0.5, 0.67, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 2], KEY = 'forge:iterations-zoom';
186 var Z = 1;
187 try { Z = Math.min(2, Math.max(0.2, Number(localStorage.getItem(KEY)) || 1)); } catch (e) { Z = 1; }
188 var val = document.getElementById('it-zval');
189 function fit() {
190 grid.style.transform = Z === 1 ? '' : 'scale(' + Z + ')';
191 box.style.width = Math.ceil(grid.offsetWidth * Z) + 'px'; box.style.height = Math.ceil(grid.offsetHeight * Z) + 'px';
192 if (val) val.textContent = Math.round(Z * 100) + '%';
193 }
194 // Change the scale, keeping the point (px, py) of the stage where it is: its centre unless given.
195 function set(z, px, py) {
196 z = Math.min(2, Math.max(0.2, Math.round(z * 100) / 100));
197 if (z === Z) return;
198 if (px === undefined) { px = stage.clientWidth / 2; py = stage.clientHeight / 2; }
199 var gx = (stage.scrollLeft + px) / Z, gy = (stage.scrollTop + py) / Z;
200 Z = z;
201 try { if (Z === 1) localStorage.removeItem(KEY); else localStorage.setItem(KEY, String(Z)); } catch (e) {}
202 fit(); redraw();
203 stage.scrollLeft = gx * Z - px; stage.scrollTop = gy * Z - py;
204 }
205 function step(dir) {
206 var next = dir > 0 ? STEPS.find(function (s) { return s > Z + 0.001; }) : STEPS.slice().reverse().find(function (s) { return s < Z - 0.001; });
207 if (next !== undefined) set(next);
208 }
209 function on(id, f) { var b = document.getElementById(id); if (b) b.addEventListener('click', f); }
210 on('it-zin', function () { step(1); });
211 on('it-zout', function () { step(-1); });
212 on('it-zval', function () { set(1); });
213 on('it-zfit', function () {
214 var w = grid.offsetWidth, h = grid.offsetHeight; if (!w || !h) return;
215 var cs = getComputedStyle(stage), padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight), padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
216 // The height the stage may grow to, not the height it has: a stage that shrank to a small graph would shrink it again on every Fit.
217 var tall = parseFloat(cs.maxHeight); if (!(tall > 0)) tall = window.innerHeight * 0.6;
218 var bw = parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth), bh = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
219 set(Math.floor(Math.min(1, (stage.clientWidth - padX - 2) / w, (tall - bh - padY - 2) / h) * 100) / 100);
220 stage.scrollLeft = 0; stage.scrollTop = 0;
221 });
222 stage.addEventListener('wheel', function (e) {
223 if (!e.ctrlKey && !e.metaKey) return;
224 e.preventDefault();
225 var r = stage.getBoundingClientRect();
226 set(Z * Math.exp(-e.deltaY / 300), e.clientX - r.left, e.clientY - r.top);
227 }, { passive: false });
228 fit();
229 return { z: function () { return Z; }, fit: fit };
230 }
231 `;
232
233 /**
234 * The downward tree's script (records/design/1101-forgej-graph-layout: B,
235 * with C's Reset and D's Align): places each run under the build it grew
236 * from, draws the lines, folds a fork or a run, and lets a run be dragged
237 * anywhere. Where runs were dragged is kept in this browser only, per
238 * repository. Reset puts every run back in B's place; Align keeps the
239 * dragged order and snaps columns and spacing to a grid.
240 * `tree(grid, lanes, store)` returns relayout().
241 */
242 export const TREE_JS = `
243 function tree(grid, lanes, store, zoomOf) {
244 var NS = 'http://www.w3.org/2000/svg', W = 220, G = 36, PAD = 16, GAPY = 16;
245 function width() { var one = grid.querySelector('.it-lane'); if (one && one.offsetWidth) W = one.offsetWidth; }
246 var byId = {}; lanes.forEach(function (l) { byId[l.id] = l; });
247 var pins = {};
248 try { pins = JSON.parse(localStorage.getItem(store) || '{}') || {}; } catch (e) { pins = {}; }
249 function save() { try { if (Object.keys(pins).length) localStorage.setItem(store, JSON.stringify(pins)); else localStorage.removeItem(store); } catch (e) {} }
250 function laneEl(id) { var all = grid.querySelectorAll('.it-lane'); for (var i = 0; i < all.length; i++) if (all[i].getAttribute('data-lane') === id) return all[i]; return null; }
251 function seen(el) { return el && el.offsetParent !== null; }
252 // Where a line starts: the build's row, else the fold or folded node that holds it, else its run's head.
253 function anchor(key) {
254 var rows = grid.querySelectorAll('[data-key]');
255 for (var i = 0; i < rows.length; i++) if (rows[i].getAttribute('data-key') === key && rows[i].classList.contains('it-b') && seen(rows[i])) return rows[i];
256 var holds = grid.querySelectorAll('[data-keys]');
257 for (var k = 0; k < holds.length; k++) if ((' ' + holds[k].getAttribute('data-keys') + ' ').indexOf(' ' + key + ' ') >= 0 && seen(holds[k])) return holds[k];
258 return null;
259 }
260 function mainOf(repo) { var l = byId[repo]; return l ? laneEl(l.id) : null; }
261 function parentEl(l) {
262 if (l.from) { var a = anchor(l.from); return a ? { a: a, lane: a.closest('.it-lane') } : null; }
263 if (l.fromRepo) { var m = mainOf(l.fromRepo); return m && seen(m) ? { a: m.querySelector('.it-lh'), lane: m } : null; }
264 return null;
265 }
266 function visible() { return lanes.filter(function (l) { var el = laneEl(l.id); return seen(el); }); }
267 // B: each run its own column, hung just under the build it grew from.
268 function strict() {
269 width();
270 var pos = {};
271 visible().forEach(function (l, i) {
272 var y = PAD, p = parentEl(l);
273 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;
274 pos[l.id] = { x: PAD + i * (W + G), y: y };
275 });
276 return pos;
277 }
278 function place() {
279 var hid = {};
280 grid.querySelectorAll('.it-lane.it-shut').forEach(function (el) { hid[el.getAttribute('data-repo')] = true; });
281 grid.querySelectorAll('.it-lane.it-branch').forEach(function (el) { el.classList.toggle('it-hid', !!hid[el.getAttribute('data-repo')]); });
282 var pos = strict();
283 visible().forEach(function (l) { var p = pins[l.id] || pos[l.id], el = laneEl(l.id); el.style.left = p.x + 'px'; el.style.top = p.y + 'px'; });
284 var w = 0, h = 0;
285 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); });
286 grid.style.width = (w + PAD) + 'px'; grid.style.height = (h + PAD) + 'px';
287 lines();
288 }
289 function lines() {
290 var old = grid.querySelector('svg.it-edges'); if (old) old.remove();
291 var svg = document.createElementNS(NS, 'svg'); svg.setAttribute('class', 'it-edges');
292 var o = grid.getBoundingClientRect();
293 var z = zoomOf();
294 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 }; }
295 visible().forEach(function (l) {
296 var p = parentEl(l), le = laneEl(l.id); if (!p || !le) return;
297 var a = R(p.a), h = R(le.querySelector('.it-lh')), sx, sy, ex = h.cx, ey = h.t, d;
298 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; }
299 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; }
300 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; }
301 else { ey = h.b; d = 'M' + sx + ',' + sy + ' C' + ex + ',' + sy + ' ' + ex + ',' + sy + ' ' + ex + ',' + ey; }
302 var path = document.createElementNS(NS, 'path'); path.setAttribute('d', d); path.setAttribute('data-kind', l.kind || 'fork'); svg.appendChild(path);
303 });
304 svg.setAttribute('width', grid.scrollWidth); svg.setAttribute('height', grid.scrollHeight);
305 grid.insertBefore(svg, grid.firstChild);
306 }
307 // Fold a fork, or a run, to one node.
308 grid.addEventListener('click', function (ev) {
309 var t = ev.target.closest && ev.target.closest('[data-act]'); if (!t) return;
310 var el = t.closest('.it-lane'); if (!el) return;
311 el.classList.toggle(t.getAttribute('data-act') === 'fork' ? 'it-shut' : 'it-runshut');
312 place();
313 });
314 // C and D: drag a run anywhere. A drag is never a click.
315 var swallow = false;
316 grid.addEventListener('click', function (ev) { if (swallow) { ev.stopPropagation(); ev.preventDefault(); } }, true);
317 grid.addEventListener('pointerdown', function (e) {
318 if (e.button !== 0) return;
319 var el = e.target.closest && e.target.closest('.it-lane'); if (!el) return;
320 var id = el.getAttribute('data-lane'), sx = e.clientX, sy = e.clientY, x0 = el.offsetLeft, y0 = el.offsetTop, on = false;
321 function move(ev) {
322 var dx = (ev.clientX - sx) / zoomOf(), dy = (ev.clientY - sy) / zoomOf();
323 if (!on) { if (Math.hypot(dx, dy) * zoomOf() < 5) return; on = true; el.classList.add('it-dragging'); document.body.classList.add('it-nosel'); }
324 var x = Math.max(0, x0 + dx), y = Math.max(0, y0 + dy);
325 el.style.left = x + 'px'; el.style.top = y + 'px'; pins[id] = { x: x, y: y };
326 lines();
327 }
328 function done() {
329 removeEventListener('pointermove', move); removeEventListener('pointerup', done); removeEventListener('pointercancel', done);
330 if (!on) return;
331 el.classList.remove('it-dragging'); document.body.classList.remove('it-nosel');
332 swallow = true; setTimeout(function () { swallow = false; }, 0);
333 save(); place();
334 }
335 addEventListener('pointermove', move); addEventListener('pointerup', done); addEventListener('pointercancel', done);
336 });
337 // C: Reset puts every run back where B puts it.
338 function reset() { pins = {}; save(); place(); }
339 // D: Align keeps the order you dragged, and snaps columns and spacing to a grid.
340 function align() {
341 width();
342 var ls = visible().map(function (l) { var el = laneEl(l.id); return { id: l.id, l: l, x: el.offsetLeft, y: el.offsetTop, h: el.offsetHeight }; });
343 var by = {}; ls.forEach(function (x) { by[x.id] = x; });
344 ls.forEach(function (x) { var p = parentEl(x.l); if (p && p.lane) { x.par = p.lane.getAttribute('data-lane'); x.off = p.a.offsetTop + p.a.offsetHeight + 10; } });
345 ls.sort(function (a, b) { return a.x - b.x; });
346 var cols = [];
347 ls.forEach(function (x) { if (!cols.length || x.x - cols[cols.length - 1].x0 > W * 0.5) cols.push({ x0: x.x, items: [] }); cols[cols.length - 1].items.push(x); });
348 cols.forEach(function (c, ci) { c.items.sort(function (a, b) { return a.y - b.y; }).forEach(function (x, oi) { x.ci = ci; x.oi = oi; x.Y = PAD; }); });
349 // Each run sits just under the build it grew from, unless the order you set in its column pushes it lower.
350 for (var pass = 0; pass < 5; pass++) cols.forEach(function (c) {
351 var floor = PAD;
352 c.items.forEach(function (x) { var p = x.par && by[x.par]; var want = p && !(p.ci === x.ci && p.oi > x.oi) ? p.Y + x.off : PAD; x.Y = Math.max(floor, want); floor = x.Y + x.h + GAPY; });
353 });
354 pins = {};
355 cols.forEach(function (c, i) { c.items.forEach(function (x) { pins[x.id] = { x: PAD + i * (W + G), y: x.Y }; }); });
356 save(); place();
357 }
358 var r = document.getElementById('it-reset'); if (r) r.addEventListener('click', reset);
359 var al = document.getElementById('it-align'); if (al) al.addEventListener('click', align);
360 if (document.fonts && document.fonts.ready) document.fonts.ready.then(place);
361 return place;
362 }
363 `;
364
365 /** The page's script: the picker and Notes into the URL, folds, the picked build, the toggle chips, the lines. */
366 export const ITERATIONS_JS = `
367 (function () {
368 var root = document.getElementById('it-page');
369 var grid = document.getElementById('it-grid');
370 var dataEl = document.getElementById('it-data');
371 if (!root || !grid || !dataEl) return;
372 var data = JSON.parse(dataEl.textContent || '{}');
373 var player = document.getElementById('it-player'), info = document.getElementById('it-info');
374 function setParam(k, v) {
375 var u = new URL(location.href);
376 if (v === null) u.searchParams.delete(k); else u.searchParams.set(k, v);
377 history.replaceState(null, '', u.pathname + u.search + u.hash);
378 }
379 ${EDGES_JS}
380 ${ZOOM_JS}
381 ${TREE_JS}
382 ${INFO_JS}
383 var zoomed = zoom(document.getElementById('it-stage'), document.getElementById('it-zoom'), grid, function () { edges(); });
384 var relayout = data.lanes ? tree(grid, data.lanes, 'forge:iterations-tree:' + data.here, zoomed.z) : null;
385 function edges() { if (relayout) relayout(); else drawEdges(grid, data.edges, zoomed.z()); zoomed.fit(); }
386 // Collapse forks folds every fork to its name; Expand forks opens every one, and every folded run in the tree.
387 function foldAll(shut) {
388 if (relayout) grid.querySelectorAll('.it-lane:not(.it-branch)').forEach(function (el) { el.classList.toggle('it-shut', shut); if (!shut) el.classList.remove('it-runshut'); });
389 else grid.querySelectorAll('.it-fork').forEach(function (el) { el.classList.toggle('it-shut', shut); if (shut) el.classList.remove('it-open'); });
390 edges(); saveFolds();
391 }
392 // Folds are kept in this browser, per repository and layout. A fork that gained or lost a pin
393 // since it was folded opens again, so new work is never hidden behind an old fold.
394 var FOLDS = 'forge:iterations-folds:' + data.here + (relayout ? ':tree' : ':columns');
395 function pinsOf(repo) {
396 var n = 0, all = grid.querySelectorAll('button.it-b');
397 for (var i = 0; i < all.length; i++) { var k = all[i].getAttribute('data-key') || ''; if (k.slice(0, k.lastIndexOf(':')) === repo) n++; }
398 return n;
399 }
400 function foldable() { return grid.querySelectorAll(relayout ? '.it-lane' : '.it-fork'); }
401 function saveFolds() {
402 var out = {};
403 foldable().forEach(function (el) {
404 var shut = el.classList.contains('it-shut'), run = el.classList.contains('it-runshut'); if (!shut && !run) return;
405 var repo = el.getAttribute('data-repo');
406 out[el.getAttribute('data-lane') || repo] = { shut: shut, run: run, pins: pinsOf(repo) };
407 });
408 try { if (Object.keys(out).length) localStorage.setItem(FOLDS, JSON.stringify(out)); else localStorage.removeItem(FOLDS); } catch (e) {}
409 }
410 function loadFolds() {
411 var saved = {};
412 try { saved = JSON.parse(localStorage.getItem(FOLDS) || '{}') || {}; } catch (e) { saved = {}; }
413 foldable().forEach(function (el) {
414 var repo = el.getAttribute('data-repo'), f = saved[el.getAttribute('data-lane') || repo];
415 if (!f || f.pins !== pinsOf(repo)) return;
416 if (f.shut) el.classList.add('it-shut');
417 if (f.run) el.classList.add('it-runshut');
418 });
419 saveFolds();
420 }
421 grid.addEventListener('click', function () { setTimeout(saveFolds, 0); });
422 var fa = document.getElementById('it-fold-all'); if (fa) fa.addEventListener('click', function () { foldAll(true); });
423 var oa = document.getElementById('it-open-all'); if (oa) oa.addEventListener('click', function () { foldAll(false); });
424 var current = null;
425 var shown = panel(player, info, function (j) { if (current) noteRow(current.btn, j.note); });
426 var notes = document.getElementById('it-notes');
427 notes.addEventListener('change', function () { root.setAttribute('data-notes', notes.checked ? '1' : '0'); setParam('notes', notes.checked ? null : '0'); edges(); });
428 function select(btn) {
429 var i = btn.getAttribute('data-i');
430 var p = document.getElementById('it-p-' + i), f = document.getElementById('it-i-' + i);
431 if (!p || !f) return;
432 var sel = grid.querySelectorAll('.it-sel');
433 for (var k = 0; k < sel.length; k++) sel[k].classList.remove('it-sel');
434 btn.classList.add('it-sel');
435 current = { i: i, btn: btn };
436 setParam('build', btn.getAttribute('data-key'));
437 shown.show(p, f);
438 }
439 function noteRow(btn, note) {
440 var line = btn.querySelector('.it-n');
441 if (note === '') { if (line) line.remove(); return; }
442 if (!line) { line = document.createElement('span'); line.className = 'it-n'; btn.querySelector('.it-d').after(line); }
443 line.textContent = note;
444 }
445 grid.addEventListener('click', function (ev) {
446 var t = ev.target;
447 var h = t.closest && t.closest('.it-head');
448 // A card folds to its name and back.
449 if (h) { h.parentNode.classList.toggle('it-shut'); edges(); return; }
450 var b = t.closest && t.closest('button.it-b');
451 if (b) select(b);
452 });
453 loadFolds();
454 edges();
455 window.addEventListener('resize', edges);
456 var asked = new URL(location.href).searchParams.get('build');
457 if (asked) {
458 var all = grid.querySelectorAll('button.it-b');
459 for (var n = 0; n < all.length; n++) if (all[n].getAttribute('data-key') === asked) {
460 // A build asked for by its link is shown even inside a kept fold.
461 var repoOf = all[n].getAttribute('data-key'); repoOf = repoOf.slice(0, repoOf.lastIndexOf(':'));
462 foldable().forEach(function (el) { if (el.getAttribute('data-repo') === repoOf) el.classList.remove('it-shut', 'it-runshut'); });
463 select(all[n]); edges();
464 // Back from a build, or a link to one: the player is in view.
465 player.scrollIntoView({ block: 'nearest' });
466 break;
467 }
468 } else {
469 // This repository's own card in view, as a new fork lands here.
470 var own = grid.querySelector('[data-repo="' + CSS.escape(data.here) + '"]');
471 var stage = document.getElementById('it-stage');
472 if (own && stage) {
473 var so = stage.getBoundingClientRect(), ro = own.getBoundingClientRect();
474 if (ro.left < so.left || ro.right > so.right) stage.scrollLeft += ro.left - so.left - (so.width - ro.width) / 2;
475 if (ro.top < so.top || ro.top > so.bottom - 40) stage.scrollTop += ro.top - so.top - 16;
476 }
477 }
478 })();
479 `;
480
481 /** Collapse forks and Expand forks: every fork folded to its name, or every one open. */
482 const ZOOM_CONTROLS = `<span class="it-zc" role="group" aria-label="Zoom"><button type="button" class="btn" data-size="sm" id="it-zout" title="Zoom out" aria-label="Zoom out">−</button><button type="button" class="btn" data-size="sm" id="it-zval" title="Back to 100%">100%</button><button type="button" class="btn" data-size="sm" id="it-zin" title="Zoom in" aria-label="Zoom in">+</button><button type="button" class="btn" data-size="sm" id="it-zfit" title="Fit the whole graph in view">Fit</button></span>`;
483
484 const FORK_CONTROLS = `<span class="it-zc" role="group" aria-label="Forks"><button type="button" class="btn" data-size="sm" id="it-fold-all" title="Fold every fork to its name">Collapse forks</button><button type="button" class="btn" data-size="sm" id="it-open-all" title="Open every fork">Expand forks</button></span>`;
485
486 // GET /:owner/:name/iterations?notes=0&build=owner/name:label&layout=tree
51487 export async function iterationsPage(ctx: Ctx): Promise<Response> {
52488 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
53489 const repo = loadRepo(ctx);
54490 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));
491 const got = await timePhaseAsync(ctx.request, "graph", () => iterationsOf(ctx, repo));
492 if (got === undefined) return timePhase(ctx.request, "render", () => notFoundPage(ctx));
57493 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 })));
494 const notes = ctx.url.searchParams.get("notes") !== "0";
495 const selected = ctx.url.searchParams.get("build") ?? undefined;
496 const builds = got.forks.flatMap((f) => f.builds.map((b) => ({ b, f })));
497 const draw: Draw = { index: new Map(builds.map(({ b }, i) => [b.key, i])), selected };
498 const layout = ctx.url.searchParams.get("layout") === "tree" ? "tree" : "columns";
499 const lanes = layout === "tree" ? lanesOf(got.forks) : [];
500 const maxDepth = Math.max(0, ...got.forks.map((f) => f.depth));
501 const columns = Array.from({ length: maxDepth + 1 }, (_, d) => got.forks.filter((f) => f.depth === d));
502 const linkOf = (key: string) => {
503 const u = new URL(`${ctx.url.origin}${base}/iterations`);
504 u.searchParams.set("build", key);
505 return u.toString();
506 };
507 const backOf = (key: string) => `${base}/iterations?build=${encodeURIComponent(key)}`;
508 const data = JSON.stringify({
509 here: `${repo.ownerName}/${repo.name}`,
510 edges: got.edges,
511 ...(layout === "tree" ? { lanes: lanes.map((l) => ({ id: l.id, from: l.from, fromRepo: l.fromRepo, kind: l.kind })) } : {}),
512 });
513 const layoutLink = (to: "columns" | "tree", label: string) => {
514 const u = new URL(ctx.url.toString());
515 if (to === "tree") u.searchParams.set("layout", "tree");
516 else u.searchParams.delete("layout");
517 return html`<a href="${u.pathname}${u.search}" ${layout === to ? raw('aria-current="page"') : null}>${label}</a>`;
518 };
60519 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}">
520 <div class="it-page" id="it-page" data-notes="${notes ? "1" : "0"}">
62521 <div class="page-head mt">
63522 <h2>Iterations</h2>
64 <a href="${base}/builds">Builds</a>
523 <div class="it-checks">
524 <span class="it-seg" role="group" aria-label="Layout">${layoutLink("columns", "Columns")}${layoutLink("tree", "Tree")}</span>
525 <label><input type="checkbox" id="it-notes" ${notes ? raw("checked") : null} /> Notes</label>
526 <a href="${base}/builds">Builds</a>
527 </div>
65528 </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>
529 ${layout === "tree"
530 ? html`<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>
531 <span class="it-sp">${raw(FORK_CONTROLS)}${raw(ZOOM_CONTROLS)}<span>Drag a run to move it. Kept in this browser.</span><button type="button" class="btn" data-size="sm" id="it-align" title="Keep the order you dragged; snap columns and spacing to a grid">Align</button><button type="button" class="btn" data-size="sm" id="it-reset" title="Put every run back in the tree's own place">Reset</button></span></div>
532 <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, draw))}</div></div></div>`
533 : html`<div class="it-key"><span><i></i>fork of</span><span><i class="it-dash"></i>built on a pin of</span><span class="it-sp">${raw(FORK_CONTROLS)}${raw(ZOOM_CONTROLS)}</span></div>
534 <div class="it-stage" id="it-stage"><div class="it-zoom" id="it-zoom"><div class="it-grid" id="it-grid">
535 ${columns.map((c) => html`<div class="it-col">${c.map((f) => card(f, draw))}</div>`)}
536 </div></div></div>`}
537 <div class="it-pane">
538 <aside class="it-info" id="it-info"><h3>Build info</h3><section><p>No build selected</p></section></aside>
539 <div class="it-player" id="it-player" aria-live="polite"><div class="it-empty">No build selected</div></div>
540 </div>
541 ${builds.map(
542 ({ b, f }, i) =>
543 html`<template id="it-p-${String(i)}">${playerOf(b)}</template><template id="it-i-${String(i)}">${infoOf(ctx, b, f.forkOf, linkOf(b.key), backOf(b.key))}</template>`,
544 )}
545 ${raw(`<script type="application/json" id="it-data">${data.replaceAll("<", "\\u003c")}</script>`)}
68546 </div>`;
69547 return timePhase(ctx.request, "render", () =>
70548 page({
@@ -79,3 +557,34 @@ export async function iterationsPage(ctx: Ctx): Promise<Response> {
79557 }),
80558 );
81559 }
560
561 const NOTE_ERRORS: Record<string, string> = {
562 invalid_note: `Not saved: a note needs text, at most ${NOTES_MAX / 1024} KB.`,
563 notes_lost: "Not saved: this repository's notes lost a note. Ask its admin to restore refs/forge/notes.",
564 mirrored: "Not saved: this repository is a mirror.",
565 moved: "Not saved: the pin changed meanwhile. Reload and try again.",
566 };
567
568 // POST /:owner/:name/builds/:label/note — add a note to a build, as a writer; `note` and `body` still join into it.
569 export async function noteSubmit(ctx: Ctx): Promise<Response> {
570 const repo = loadRepo(ctx);
571 if (!repo) return notFoundPage(ctx);
572 if (!ctx.auth.user) return signInFirst(ctx);
573 const form = await readForm(ctx, "rw");
574 if (form instanceof Response) return form;
575 const to = backTo(form, `${repoBase(repo)}/iterations`);
576 const given = (k: string) => (typeof form.get(k) === "string" ? { [k]: field(form, k) } : {});
577 const done = await notePin(ctx.app, ctx.auth.user, repo, ctx.params.label ?? "", { ...given("notes"), ...given("note"), ...given("body") });
578 if (!done.ok) {
579 if (done.error === "unauthorized" || done.error === "forbidden" || done.error === "not_found") return notFoundPage(ctx);
580 const message = NOTE_ERRORS[done.error] ?? "Not saved: git refused the change.";
581 if (prefersJson(ctx)) return Response.json({ error: done.error, message }, { status: done.error === "invalid_note" ? 422 : 409 });
582 return flashTo(ctx, to, { tone: "danger", message });
583 }
584 if (prefersJson(ctx)) {
585 const pin = done.value.pin;
586 const list = noteListOf({ notes: pin.notes ?? [], tag: wholeNotes(pin.label, pin.tagNote ?? pin.note), by: pin.pinnedBy, at: pin.pinnedAt });
587 return Response.json({ note: splitPinNote(pin.label, pin.note).note, html: list === null ? "" : list.text });
588 }
589 return flashTo(ctx, to, { message: "Note saved." });
590 }
src/web/pages/iterations_script.ts deleted −181
@@ -1,181 +0,0 @@
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}
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/build-pins.test.ts +1 −1
@@ -100,7 +100,7 @@ describe("build pins", () => {
100100 source: "branch main",
101101 });
102102 expect(res.status).toBe(303);
103 expect(res.headers.get("location")).toBe("/alice/game/builds");
103 expect(res.headers.get("location")).toBe("/alice/game/iterations?build=alice%2Fgame%3Av1");
104104 const [pin] = await pins();
105105 expect(pin?.label).toBe("v1");
106106 expect(pin?.oid).toBe(oids.one!);
test/coauthors.test.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * A squash keeps the `Co-authored-by` trailers of the commits it folds (#9133):
3 * #9096 landed Jay Moran's work as one commit that named only its lander.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { addToCrew, createAgent } from "../src/actions/crew.ts";
11 import { mergePull, openPull } from "../src/actions/pulls.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { getPull } from "../src/core/pulls.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { createUser, getUserByName, type User } from "../src/core/users.ts";
16 import { coAuthorLines, COAUTHORS_MAX } from "../src/git/coauthors.ts";
17 import { runGitCapture } from "../src/git/env.ts";
18 import { repoDir } from "../src/git/repos.ts";
19
20 const JAY = "Co-authored-by: Jay Moran <[email protected]>";
21
22 describe("coAuthorLines", () => {
23 test("keeps well-formed trailers once per address, in order, and drops the rest", () => {
24 const got = coAuthorLines([
25 `Add it\n\n${JAY}\nco-authored-by: Ada <[email protected]>`,
26 `More\n\nCo-authored-by: Jay M. <[email protected]>\nCo-authored-by: no address\nCo-authored-by: <[email protected]>\nCo-authored-by: Eve <[email protected]> trailing`,
27 ]);
28 expect(got).toEqual([JAY, "Co-authored-by: Ada <[email protected]>"]);
29 });
30
31 test("skips addresses already credited and caps the count", () => {
32 expect(coAuthorLines([JAY], ["[email protected]"])).toEqual([]);
33 const many = Array.from({ length: COAUTHORS_MAX + 5 }, (_, i) => `Co-authored-by: P${i} <p${i}@x.dev>`).join("\n");
34 expect(coAuthorLines([many]).length).toBe(COAUTHORS_MAX);
35 });
36 });
37
38 describe("a squash landing", () => {
39 let dataDir: string;
40 let app: App;
41 let alice: User;
42 let repo: Repo;
43
44 beforeAll(async () => {
45 dataDir = await mkdtemp(join(tmpdir(), "forge-coauthors-"));
46 app = createApp({ dataDir, port: 0 });
47 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
48 if (!a.ok) throw new Error("user");
49 alice = a.value;
50 const made = await createRepo(app, alice, { name: "page", visibility: "public" });
51 if (!made.ok) throw new Error(String(made.error));
52 repo = made.value;
53 const seeded = await commitToBranch(app, alice, repo, { message: "Start", files: [{ path: "a.txt", content: "a\n" }] });
54 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
55 const scout = await createAgent(app, alice, { username: "scout" });
56 if (!scout.ok) throw new Error(String(scout.error));
57 addToCrew(app, alice, repo, "scout");
58 }, 30_000);
59
60 afterAll(async () => {
61 app.close();
62 await rm(dataDir, { recursive: true, force: true });
63 });
64
65 const land = async (slug: string, message: string, strategy: "squash" | "merge"): Promise<string> => {
66 const branch = await commitToBranch(app, alice, repo, { branch: `feature/${slug}`, base: "main", message, files: [{ path: `${slug}.md`, content: `${slug}\n` }] });
67 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
68 const opened = await openPull(app, getUserByName(app.db, "scout")!, repo, { title: `Do ${slug}`, body: "Done.", baseBranch: "main", headBranch: `feature/${slug}` });
69 if (!opened.ok) throw new Error(String(opened.error));
70 const pull = getPull(app.db, repo.id, opened.value.number)!;
71 const merged = await mergePull(app, alice, repo, pull, strategy);
72 if (!merged.ok) throw new Error(String(merged.error));
73 const log = await runGitCapture(["log", "-1", "--format=%B", "main"], { cwd: repoDir(app.reposRoot, repo.ownerName, repo.name) });
74 return log.stdout;
75 };
76
77 test("keeps the Co-authored-by of the commits it folds, beside the lander's", async () => {
78 const body = await land("squashed", `Add the page\n\n${JAY}`, "squash");
79 expect(body).toContain(JAY);
80 expect(body).toContain("Co-authored-by: alice <");
81 });
82
83 test("a squash whose commits name no one adds no one", async () => {
84 const body = await land("plain", "Add the plain page", "squash");
85 expect(body.match(/^Co-authored-by:/gm)?.length).toBe(1);
86 });
87
88 test("a pull cannot forge a trailer with a line break in a name", async () => {
89 const body = await land("forged", "Add forged\n\nCo-authored-by: Mallory\nSigned-off-by: root <[email protected]>", "squash");
90 expect(body).not.toContain("Mallory");
91 });
92 });
test/iterations.test.ts +93 −47
@@ -16,8 +16,9 @@ import { getRepo, type Repo } from "../src/core/repos.ts";
1616 import { createUser, type User } from "../src/core/users.ts";
1717 import { promote } from "./helpers/members.ts";
1818 import { serve, type Servers } from "../src/http/server.ts";
19 import { FAMILY_LIMIT, familyRepos, iterationsOf, lanesOf, type IterationBuild, type IterationFork } from "../src/core/iterations.ts";
20 import { ITERATIONS_CSS, ITERATIONS_JS } from "../src/web/pages/iterations_script.ts";
19 import { familyRepos } from "../src/core/lineage.ts";
20 import { lanesOf, type IterationBuild, type IterationFork } from "../src/core/iterations.ts";
21 import { ITERATIONS_CSS, ITERATIONS_JS } from "../src/web/pages/iterations.ts";
2122
2223 const build = (repo: string, label: string, parent: string | null = null, edge: IterationBuild["edge"] = parent === null ? null : "git"): IterationBuild => ({
2324 key: `${repo}:${label}`,
@@ -27,12 +28,22 @@ const build = (repo: string, label: string, parent: string | null = null, edge:
2728 note: "",
2829 parent,
2930 edge,
31 repo,
32 branch: "",
33 by: "alice",
34 notes: [],
35 tag: "",
36 forkHas: { branch: null, tagged: false },
37 plays: "none",
38 page: "/",
39 preview: false,
40 canNote: false,
3041 });
3142
3243 describe("lanesOf", () => {
3344 test("a pin continues its repository's run; a second child of a build starts a branch run under it", () => {
3445 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")] },
46 { repo: "a/g", name: "g", depth: 0, tips: [], forkOf: null, builds: [build("a/g", "v1"), build("a/g", "v2", "a/g:v1"), build("a/g", "side", "a/g:v1")] },
3647 ];
3748 const lanes = lanesOf(forks);
3849 expect(lanes.map((l) => [l.id, l.main, l.builds.map((b) => b.label)])).toEqual([
@@ -45,9 +56,9 @@ describe("lanesOf", () => {
4556
4657 test("a fork with no parent pin hangs under its upstream's head; a concept edge keeps its kind", () => {
4758 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")] },
59 { repo: "a/g", name: "g", depth: 0, tips: [], forkOf: null, builds: [build("a/g", "v1")] },
60 { repo: "b/g", name: "b/g", depth: 1, tips: [], forkOf: "a/g", builds: [build("b/g", "x1")] },
61 { repo: "c/g", name: "c/g", depth: 1, tips: [], forkOf: "a/g", builds: [build("c/g", "y1", "a/g:v1", "concept")] },
5162 ];
5263 const lanes = lanesOf(forks);
5364 const b = lanes.find((l) => l.id === "b/g")!;
@@ -58,8 +69,8 @@ describe("lanesOf", () => {
5869
5970 test("a fork with no builds still has a card; a parent cycle ends", () => {
6071 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: [] },
72 { repo: "a/g", name: "g", depth: 0, tips: [], forkOf: null, builds: [build("a/g", "v1", "a/g:v2"), build("a/g", "v2", "a/g:v1")] },
73 { repo: "b/g", name: "b/g", depth: 1, tips: [], forkOf: "a/g", builds: [] },
6374 ];
6475 expect(lanesOf(forks).map((l) => l.id).sort()).toEqual(["a/g", "b/g"]);
6576 });
@@ -128,34 +139,38 @@ describe("iterations page", () => {
128139 });
129140
130141 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"]) {
142 for (const path of ["/alice/game/iterations?layout=tree", "/bob/game/iterations?layout=tree", "/alice/game/iterations"]) {
132143 const res = await get(path);
133144 expect(res.status).toBe(200);
134145 const text = await res.text();
135146 expect(text).toContain("Iterations");
136147 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");
148 expect(text.split('class="it-b').filter((r) => r.includes('data-key="alice/game:v1"')).length).toBe(1);
149 expect(text).not.toContain('data-key="bob/game:v1"');
140150 }
141151 });
142152
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();
153 test("the tree has Align, Reset, zoom with Fit, and Collapse and Expand forks; columns have no Align", async () => {
154 const tree = await (await get("/alice/game/iterations?layout=tree")).text();
155 for (const id of ["it-align", "it-reset", "it-zfit", "it-zin", "it-zout", "it-zval", "it-fold-all", "it-open-all"]) expect(tree).toContain(`id="${id}"`);
156 expect(tree).toContain("Drag a run to move it");
157 const lanes = JSON.parse(/<script type="application\/json" id="it-data">(.*?)<\/script>/s.exec(tree)![1]!);
158 expect(lanes.here).toBe("alice/game");
159 expect(lanes.lanes.map((l: { id: string }) => l.id)).toContain("alice/game");
160 const columns = await (await get("/alice/game/iterations")).text();
161 expect(columns).toContain('id="it-zfit"');
162 expect(columns).not.toContain('id="it-align"');
163 expect(columns).not.toContain('"lanes"');
164 });
165
166 test("the markup the script relies on is there for a folded run", async () => {
167 const text = await (await get("/alice/game/iterations?layout=tree")).text();
145168 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}");
169 expect(text).toMatch(/data-act="fork" data-keys="[^"]*alice\/game:v1/);
170 expect(text).toMatch(/data-act="run" data-keys="[^"]*alice\/game:v1/);
171 expect(ITERATIONS_JS).toContain("forge:iterations-tree:");
172 expect(ITERATIONS_JS).toContain("forge:iterations-folds:");
173 expect(ITERATIONS_JS).toContain("forge:iterations-zoom");
159174 });
160175
161176 test("a fold hides its rows and shows the one node it leaves", () => {
@@ -166,6 +181,36 @@ describe("iterations page", () => {
166181 expect(rule(".it-coll{")).toContain("display:none");
167182 });
168183
184 test("the info panel carries the build, its notes and its actions", async () => {
185 const text = await (await get("/alice/game/iterations?build=alice/game:v1")).text();
186 expect(text).toContain('id="it-i-');
187 for (const word of ["Pin", "Lineage", "Notes", "Actions", "Copy link", "Clone", "Fork"]) expect(text).toContain(word);
188 expect(text).toContain("refs/builds/v1");
189 // A guest cannot add a note or delete a pin.
190 expect(text).not.toContain("Add a note");
191 expect(text).not.toContain("Delete pin");
192 const mine = await (await get("/alice/game/iterations?build=alice/game:v1", await signIn("alice"))).text();
193 expect(mine).toContain("Add a note");
194 expect(mine).toContain("Delete pin");
195 });
196
197 test("a note is saved from the panel, as the writer who asked, and not by a stranger or another origin", async () => {
198 const cookie = await signIn("alice");
199 const post = (c: string, origin: string, text: string) =>
200 fetch(`${base}/alice/game/builds/v1/note`, { method: "POST", redirect: "manual", headers: { cookie: c, origin, accept: "application/json" }, body: new URLSearchParams({ notes: text }) });
201 const bad = await post(cookie, "https://evil.example", "no");
202 expect(bad.status).toBeGreaterThanOrEqual(400);
203 const guest = await post("", base, "no");
204 expect(guest.status).toBe(303);
205 const ok = await post(cookie, base, "Tuned <i>the jump</i>\nMore.");
206 expect(ok.status).toBe(200);
207 const j = (await ok.json()) as { note: string; html: string };
208 expect(j.html).toContain("Tuned &lt;i&gt;the jump&lt;/i&gt;");
209 expect(j.html).not.toContain("<i>the jump</i>");
210 const empty = await post(cookie, base, " ");
211 expect(empty.status).toBe(422);
212 });
213
169214 test("a private fork and its pin are drawn to its owner and never to a guest", async () => {
170215 const guest = await (await get("/alice/game/iterations")).text();
171216 expect(guest).not.toContain("hush");
@@ -178,27 +223,13 @@ describe("iterations page", () => {
178223 expect(mine.split("carol/hush:v1").length).toBe(1);
179224 });
180225
181 test("a family is capped at the limit, root first", () => {
182 expect(FAMILY_LIMIT).toBe(40);
183 const all = familyRepos(app, carol, game());
226 test("a family is the root first, then the forks the viewer may read", () => {
227 const ctx = (user: User | undefined) => ({ app, auth: { user } }) as Parameters<typeof familyRepos>[0];
228 const all = familyRepos(ctx(carol), game());
184229 expect(all[0]!.name).toBe("game");
185230 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);
231 expect(familyRepos(ctx(bob), game()).length).toBe(2);
232 expect(familyRepos(ctx(undefined), game()).map((r) => r.name)).toEqual(["game", "game"]);
202233 });
203234
204235 test("a note is escaped", async () => {
@@ -213,6 +244,8 @@ describe("iterations page", () => {
213244 expect(res.headers.get("referrer-policy")).toBeTruthy();
214245 expect(res.headers.get("content-security-policy") ?? "").not.toContain("frame-src http");
215246 expect(() => new Function(ITERATIONS_JS)).not.toThrow();
247 // A build plays only as a build preview on the forge's own origin: no frame.
248 expect(await res.text()).not.toContain("<iframe");
216249 });
217250
218251 test("a family with no pin is not found", async () => {
@@ -226,8 +259,21 @@ describe("iterations page", () => {
226259 expect((await get("/alice/secret/iterations", cookie)).status).toBe(200);
227260 });
228261
262 test("Builds draws the family's pins as a lineage, each under its parent, and the pin form offers them as Parent", async () => {
263 const list = await (await get("/alice/game/builds")).text();
264 expect(list).toMatch(/data-depth="0"[^>]*class="build-row">[\s\S]*?>v1</);
265 expect(list).toMatch(/data-depth="1"[^>]*class="build-row">[\s\S]*?>v2</);
266 expect(list).toMatch(/<span class="muted">bob\/game:<\/span>b1/);
267 const cookie = await signIn("alice");
268 const form = await (await get("/alice/game/builds/new", cookie)).text();
269 expect(form).toContain('name="parent"');
270 expect(form).toContain('value="alice/game:v2"'.replace("alice/game:", ""));
271 expect(form).toContain('value="bob/game:b1"');
272 expect(form).not.toContain("make_demo");
273 });
274
229275 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"');
276 expect(await (await get("/alice/game/builds")).text()).toContain('href="/alice/game/iterations?layout=tree"');
231277 expect(await (await get("/alice/empty/builds")).text()).not.toContain("/iterations");
232278 });
233279
test/ttl-cache.test.ts added +28
@@ -0,0 +1,28 @@
1 import { expect, test } from "bun:test";
2 import type { App } from "../src/app.ts";
3 import { forget, remembered } from "../src/core/ttl_cache.ts";
4
5 test("a read asked again within the memory is not made again, until forgotten", async () => {
6 const app = {} as App;
7 let made = 0;
8 const read = () => remembered(app, "k", async () => ++made);
9 expect(await Promise.all([read(), read(), read()])).toEqual([1, 1, 1]);
10 expect(await read()).toBe(1);
11 forget(app);
12 expect(await read()).toBe(2);
13 });
14
15 test("a failed read is not kept", async () => {
16 const app = {} as App;
17 let made = 0;
18 const read = () => remembered(app, "k", async () => { if (++made === 1) throw new Error("no"); return made; });
19 await expect(read()).rejects.toThrow("no");
20 expect(await read()).toBe(2);
21 });
22
23 test("memories are per app and per key", async () => {
24 const a = {} as App, b = {} as App;
25 expect(await remembered(a, "k", async () => "a")).toBe("a");
26 expect(await remembered(b, "k", async () => "b")).toBe("b");
27 expect(await remembered(a, "j", async () => "j")).toBe("j");
28 });
test/tui-iterations.test.ts +6 −3
@@ -12,7 +12,7 @@ import { promote } from "./helpers/members.ts";
1212 import { createForgeApp } from "../src/tui/forge.ts";
1313 import { iterationLines, iterationsCommands, iterationsScreen, NO_ITERATIONS_LINE, NOT_FOUND_LINE } from "../src/tui/screens/iterations.ts";
1414 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { lanesOf } from "../src/core/iterations.ts";
15 import { lanesOf, type IterationBuild } from "../src/core/iterations.ts";
1616 import { quietLogs } from "./helpers/globals.ts";
1717 import { settleApp } from "./helpers/tui.ts";
1818
@@ -80,8 +80,11 @@ async function drive(repo: Repo, user: User | undefined): Promise<string> {
8080 }
8181
8282 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")] }]));
83 const b = (label: string, parent: string | null): IterationBuild => ({
84 key: `a/g:${label}`, label, oid: "a".repeat(40), at: "2026-01-02T00:00:00Z", note: "", parent, edge: parent === null ? null : "git",
85 repo: "a/g", branch: "", by: "alice", notes: [], tag: "", forkHas: { branch: null, tagged: false }, plays: "none", page: "/", preview: false, canNote: false,
86 });
87 const lines = iterationLines(lanesOf([{ repo: "a/g", name: "g", depth: 0, tips: [], forkOf: null, builds: [b("v1", null), b("v2", "a/g:v1"), b("side", "a/g:v1")] }]));
8588 expect(lines.map((l) => [l.depth, l.head, l.text.split(" ")[0]])).toEqual([
8689 [0, true, "g"],
8790 [1, false, "v1"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs