Public: anyone can read this; the crew can change it.
Iterations page: /:owner/:name/iterations with pins, tree layout, zoom and folds (Jay Moran) #9096
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9052-iterations-page-owner-name-iterations-wi → main 4 commits fork 1 thread · 1 unresolved
11 files
changed +947
−0
docs/design/areas/iterations.md added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | # Iterations | |
| 2 | ||
| 3 | A fork family's build pins (`/:owner/:name/iterations`) as a downward tree of runs, reached from the Builds page. `iterationsPage` in `src/web/pages/iterations.ts` draws it; `iterations_family.ts` holds the data (`iterationsOf`, `lanesOf`) and `iterations_script.ts` the rules (`ITERATIONS_CSS`) and the script. Without the script the runs stand side by side; with it each run hangs under the build it grew from. | |
| 4 | ||
| 5 | - `.it-page` — the page below the repo header; carries `data-here` and `data-lanes` (the runs, as JSON) for the script. | |
| 6 | - `.it-key` — the one-line key: solid line for fork of, thin for a branch (`.it-thin`), dashed (`.it-dash`) for built on a pin of; `.it-sp` the Collapse/Expand forks and zoom buttons (`.it-zc`), shown only once the script adds `.it-on` to `.it-page`. | |
| 7 | - `.it-stage` — the graph pane: at most 60% of the viewport high, scrolling both ways; `.it-zoom` its sized box, `.it-tree` the scaled grid (`.it-placed` once the script has placed the runs). | |
| 8 | - `.it-edges` — the SVG of lines from a build to the head of the run that grew from it. | |
| 9 | - `.it-lane` — one run: `.it-lh` its head with `.it-nm` the fork's name (the script makes it a button that folds the fork) and `.it-ct` (a `btn` the script adds, folds the run); `.it-branch` a run that splits off a build; `.it-shut` and `.it-runshut` its folds, `.it-hid` a branch whose fork is folded. | |
| 10 | - `.it-coll` — the one node a fold leaves (`.it-fcoll` a fork, `.it-rcoll` a run): a full-width, borderless row that is part of the card, not a `btn`; hidden until its fold is shut. | |
| 11 | - `.it-rows` — a run's rows; `.it-sub` the title of a branch run. | |
| 12 | - `.it-b` — a build row: `.it-l` label (monospace), `.it-d` date, `.it-n` the note's first line. |
docs/features/iterations.md added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | # Iterations | |
| 2 | ||
| 3 | A fork family's build pins, drawn as a tree of runs. Read-only: pinning, notes and deletes stay on Builds. | |
| 4 | ||
| 5 | ## On the web | |
| 6 | ||
| 7 | Open `/:owner/:name/iterations`, or the **Iterations** button on a repository's Builds page (shown once it holds a pin). The page is not found when neither the repository nor any fork you may read holds a pin, and it is a 404 for a private repository you may not read. | |
| 8 | ||
| 9 | - A **run** is a card: the fork's name, then one row per pin (label, date, first line of its note). A pin continues its repository's run, oldest first, unless it names a parent (`--parent`), in which case it hangs under that pin; a parent in another fork (`owner/name:label`) draws a dashed concept line. | |
| 10 | - A fork carries its parent's pins; each is drawn once, in the parent. | |
| 11 | - The family is the root you may read, and every fork below it you may read, at most 40 repositories. A private fork is simply absent for anyone who may not read it. | |
| 12 | - With JavaScript: runs are placed under the build they grew from and joined by lines; **Collapse forks** / **Expand forks** fold every fork to its name; the fork name folds one fork, **fold run** folds a run to one node; **−**, **100%**, **+** and **Fit** zoom (Ctrl/⌘ + wheel too). Folds and zoom are remembered in this browser only. Without JavaScript the runs stand side by side and no button is shown. | |
| 13 | - Pins are read from git and kept for 5 seconds per repository, so a new pin may take that long to appear. | |
| 14 | ||
| 15 | ## In the terminal | |
| 16 | ||
| 17 | Open a repository, then the palette (`ctrl+k`) and **Iterations**. The family's runs are an indented list, a run under the build it grew from, its builds beneath; `j`/`k` move, `esc` goes back. It shows exactly what the page shows for the same viewer. |
src/http/server.ts +2
| @@ -183,6 +183,7 @@ import { | ||
| 183 | 183 | branchDelete, |
| 184 | 184 | branchesPage, |
| 185 | 185 | } from "../web/pages/branches.ts"; |
| 186 | import { iterationsPage } from "../web/pages/iterations.ts"; | |
| 186 | 187 | import { buildsPage, deletePinSubmit, newBuildPage, pinBuildSubmit, previewBuildSubmit } from "../web/pages/builds.ts"; |
| 187 | 188 | import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts"; |
| 188 | 189 | import { |
| @@ -1259,6 +1260,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1259 | 1260 | // Build pins (`git/builds.ts`): the list, the form, and the writes behind them. |
| 1260 | 1261 | router.add("GET", "/:owner/:name/builds", buildsPage); |
| 1261 | 1262 | router.add("GET", "/:owner/:name/builds/new", newBuildPage); |
| 1263 | router.add("GET", "/:owner/:name/iterations", iterationsPage); | |
| 1262 | 1264 | router.add("POST", "/:owner/:name/builds", pinBuildSubmit); |
| 1263 | 1265 | router.add("POST", "/:owner/:name/builds/preview", previewBuildSubmit); |
| 1264 | 1266 | router.add("POST", "/:owner/:name/builds/:label/delete", deletePinSubmit); |
src/tui/screens/iterations.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * The Iterations command, a repository's: its fork family's build pins as the | |
| 3 | * web page draws them (`iterationsOf`, `lanesOf`), as an indented list — a run | |
| 4 | * under the build it grew from, its builds beneath. Read-only. Offered to, and | |
| 5 | * read for, only someone who may read the repository; the family is what that | |
| 6 | * viewer may read of it (`canRead`, `visibleForks`), so a guest sees nothing | |
| 7 | * private. | |
| 8 | */ | |
| 9 | ||
| 10 | import { canRead, type Repo } from "../../core/repos.ts"; | |
| 11 | import { iterationsOf, lanesOf, type IterationLane } from "../../web/pages/iterations_family.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import type { Command } from "../app/palette.ts"; | |
| 15 | import type { Session } from "../session.ts"; | |
| 16 | import { style } from "../term/style.ts"; | |
| 17 | import { truncate } from "../term/width.ts"; | |
| 18 | import { ListState } from "./common.ts"; | |
| 19 | ||
| 20 | export const NOT_FOUND_LINE = "No such repository"; | |
| 21 | export const NO_ITERATIONS_LINE = "No builds pinned in this family yet."; | |
| 22 | ||
| 23 | export interface IterationLine { | |
| 24 | readonly depth: number; | |
| 25 | readonly text: string; | |
| 26 | /** A run's head, as against one of its builds. */ | |
| 27 | readonly head: boolean; | |
| 28 | } | |
| 29 | ||
| 30 | /** The family's runs flattened to lines: a head per run, then a line per build. */ | |
| 31 | export function iterationLines(lanes: readonly IterationLane[]): IterationLine[] { | |
| 32 | const laneOf = new Map<string, IterationLane>(); | |
| 33 | for (const l of lanes) for (const b of l.builds) laneOf.set(b.key, l); | |
| 34 | const mainOf = new Map(lanes.filter((l) => l.main).map((l) => [l.repo, l])); | |
| 35 | const depthOf = new Map<IterationLane, number>(); | |
| 36 | const depth = (l: IterationLane, guard = 0): number => { | |
| 37 | const got = depthOf.get(l); | |
| 38 | if (got !== undefined) return got; | |
| 39 | const up = l.from !== null ? laneOf.get(l.from) : l.fromRepo !== null ? mainOf.get(l.fromRepo) : undefined; | |
| 40 | const d = up === undefined || up === l || guard > lanes.length ? 0 : depth(up, guard + 1) + 1; | |
| 41 | depthOf.set(l, d); | |
| 42 | return d; | |
| 43 | }; | |
| 44 | const out: IterationLine[] = []; | |
| 45 | for (const l of lanes) { | |
| 46 | const d = depth(l); | |
| 47 | const n = l.builds.length; | |
| 48 | out.push({ depth: d, head: true, text: `${l.title} ${n === 0 ? "no build pinned" : `${String(n)} ${n === 1 ? "build" : "builds"}`}` }); | |
| 49 | for (const b of l.builds) out.push({ depth: d + 1, head: false, text: `${b.label} ${b.at.slice(0, 10)}${b.note === "" ? "" : ` ${b.note}`}` }); | |
| 50 | } | |
| 51 | return out; | |
| 52 | } | |
| 53 | ||
| 54 | export function iterationsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 55 | let lines: IterationLine[] | undefined; | |
| 56 | let hidden = false; | |
| 57 | const state = new ListState(); | |
| 58 | const screen: Screen = { | |
| 59 | title: "Iterations", | |
| 60 | keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")], | |
| 61 | async load() { | |
| 62 | // Gated here as well as on the command: the screen is exported. | |
| 63 | hidden = !canRead(f.db, repo, f.user); | |
| 64 | const forks = hidden ? undefined : await iterationsOf(f.app, f.user, repo); | |
| 65 | lines = forks === undefined ? [] : iterationLines(lanesOf(forks)); | |
| 66 | state.clamp(lines.length); | |
| 67 | ctx.invalidate(); | |
| 68 | }, | |
| 69 | render(area, s) { | |
| 70 | const c = ctx.theme.c; | |
| 71 | const right = area.x + area.w; | |
| 72 | const line = (y: number, x: number, text: string, fg = c.fg) => s.text(area.x + x, area.y + y, truncate(text, Math.max(1, area.w - x)), style(fg, c.bg), right); | |
| 73 | if (hidden) return void line(0, 1, NOT_FOUND_LINE); | |
| 74 | if (lines === undefined) return void line(0, 1, "Reading…"); | |
| 75 | if (lines.length === 0) return void line(0, 1, NO_ITERATIONS_LINE); | |
| 76 | const from = state.window(area.h, lines.length); | |
| 77 | for (let i = 0; i < area.h; i++) { | |
| 78 | const l = lines[from + i]; | |
| 79 | if (l === undefined) break; | |
| 80 | const picked = from + i === state.index; | |
| 81 | s.text(area.x, area.y + i, picked ? ">" : " ", style(c.accent, c.bg), right); | |
| 82 | line(i, 1 + l.depth * 2, l.text, picked || l.head ? c.fg : c.muted); | |
| 83 | } | |
| 84 | }, | |
| 85 | key(e) { | |
| 86 | const n = lines?.length ?? 0; | |
| 87 | if (e.key === "j" || e.key === "down") state.move(1, n); | |
| 88 | else if (e.key === "k" || e.key === "up") state.move(-1, n); | |
| 89 | else if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 90 | else return false; | |
| 91 | ctx.invalidate(); | |
| 92 | return true; | |
| 93 | }, | |
| 94 | }; | |
| 95 | return screen; | |
| 96 | } | |
| 97 | ||
| 98 | /** The palette's door on a repository screen, for whoever may read it. */ | |
| 99 | export function iterationsCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { | |
| 100 | if (repo === undefined || !canRead(f.db, repo, f.user)) return []; | |
| 101 | return [ | |
| 102 | { | |
| 103 | id: "iterations", | |
| 104 | title: `${repo.ownerName}/${repo.name} — Iterations`, | |
| 105 | hint: "its fork family's build pins as a tree of runs", | |
| 106 | run: () => ctx.push(iterationsScreen(f, ctx, repo)), | |
| 107 | }, | |
| 108 | ]; | |
| 109 | } |
src/tui/screens/workspace.ts +2
| @@ -134,6 +134,7 @@ import { deleteRepoCommands } from "./deleterepo.ts"; | ||
| 134 | 134 | import { grantsCommands } from "./grants.ts"; |
| 135 | 135 | import { visionCommands } from "./vision.ts"; |
| 136 | 136 | import { buildsCommands } from "./builds.ts"; |
| 137 | import { iterationsCommands } from "./iterations.ts"; | |
| 137 | 138 | import { labelsCommands } from "./labels.ts"; |
| 138 | 139 | import { appEnvCommands } from "./appenv.ts"; |
| 139 | 140 | import { autonomyCommands } from "./autonomy.ts"; |
| @@ -1452,6 +1453,7 @@ export function workspaceScreen( | ||
| 1452 | 1453 | ...visionCommands(f, ctx, repo), |
| 1453 | 1454 | ...labelsCommands(f, ctx, repo), |
| 1454 | 1455 | ...buildsCommands(f, ctx, repo), |
| 1456 | ...iterationsCommands(f, ctx, repo), | |
| 1455 | 1457 | ...appEnvCommands(f, ctx, repo), |
| 1456 | 1458 | ...autonomyCommands(f, ctx, repo), |
| 1457 | 1459 | ], |
src/web/pages/builds.ts +1
| @@ -90,6 +90,7 @@ export async function buildsPage(ctx: Ctx): Promise<Response> { | ||
| 90 | 90 | { |
| 91 | 91 | head: html`${icon("pin")} |
| 92 | 92 | <h2>Builds</h2> |
| 93 | ${when(pins.length > 0, () => btn("Iterations", { href: `${base}/iterations`, size: "sm", variant: "ghost" }))} | |
| 93 | 94 | ${when(writer, () => btn("Pin a build", { href: `${base}/builds/new`, size: "sm", variant: "primary" }))}`, |
| 94 | 95 | }, |
| 95 | 96 | )} |
src/web/pages/iterations.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | /** | |
| 2 | * Iterations (`/:owner/:name/iterations`): a fork family's build pins as | |
| 3 | * cards in a downward tree, a card per run, with fork and concept lines, | |
| 4 | * zoom, and folds. Reached from Builds; it is not found where the family | |
| 5 | * (what the viewer may read of it) holds no pin. Read-only: pinning stays on | |
| 6 | * Builds. The data is `iterations_family.ts`, the browser half | |
| 7 | * `iterations_script.ts`. | |
| 8 | */ | |
| 9 | ||
| 10 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; | |
| 11 | import type { Ctx } from "../../http/context.ts"; | |
| 12 | import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette } from "../chrome.ts"; | |
| 13 | import { html, when, type Html } from "../html.ts"; | |
| 14 | import { page } from "../layout.ts"; | |
| 15 | import { iterationsOf, lanesOf, type IterationBuild, type IterationLane } from "./iterations_family.ts"; | |
| 16 | import { btn } from "../ui.ts"; | |
| 17 | import { ITERATIONS_CSS, ITERATIONS_JS } from "./iterations_script.ts"; | |
| 18 | ||
| 19 | const row = (b: IterationBuild): Html => | |
| 20 | html`<div class="it-b" data-key="${b.key}"> | |
| 21 | <span class="it-l mono">${b.label}</span><span class="it-d">${b.at.slice(0, 10)}</span> | |
| 22 | ${when(b.note !== "", () => html`<span class="it-n">${b.note}</span>`)} | |
| 23 | </div>`; | |
| 24 | ||
| 25 | /** One run: its head, its rows, and the one node each fold leaves. */ | |
| 26 | function lane(l: IterationLane, branches: number): Html { | |
| 27 | const keys = l.builds.map((b) => b.key).join(" "); | |
| 28 | const n = l.builds.length; | |
| 29 | const first = l.builds[0]; | |
| 30 | const last = l.builds.at(-1); | |
| 31 | const count = `${String(n)} ${n === 1 ? "build" : "builds"}`; | |
| 32 | // The fold buttons are made by the script; without it a head is a name. | |
| 33 | const head = l.main | |
| 34 | ? html`<span class="it-nm" data-act="fork"><i aria-hidden="true"></i><b class="mono">${l.title}</b></span>` | |
| 35 | : html`<span class="it-sub mono">${l.title}</span>`; | |
| 36 | return html`<section class="it-lane${l.main ? "" : " it-branch"}" data-lane="${l.id}" data-repo="${l.repo}"> | |
| 37 | <header class="it-lh">${head}</header> | |
| 38 | <div class="it-rows">${l.builds.map(row)}${when(n === 0, () => html`<div class="it-b"><span class="it-n">No build pinned here</span></div>`)}</div> | |
| 39 | ${when(l.main, () => html`<button type="button" class="it-coll it-fcoll" data-act="fork" data-keys="${keys}"><b>${count}</b><span>${branches > 0 ? `${String(branches)} ${branches === 1 ? "branch" : "branches"} · ` : ""}folded</span></button>`)} | |
| 40 | ${when(first !== undefined && last !== undefined && n > 1, () => html`<button type="button" class="it-coll it-rcoll" data-act="run" data-keys="${keys}"><b class="mono">${first!.label} → ${last!.label}</b><span>${count} in a run</span></button>`)} | |
| 41 | </section>`; | |
| 42 | } | |
| 43 | ||
| 44 | const ctl = (id: string, text: string, title: string, label?: string): Html => | |
| 45 | btn(text, { id, type: "button", size: "sm", title, ...(label === undefined ? {} : { label }) }); | |
| 46 | ||
| 47 | /** Shown by the script only (`#it-page.it-on`): each one does nothing without it. */ | |
| 48 | const CONTROLS = html`<span class="it-zc" role="group" aria-label="Forks">${ctl("it-fold-all", "Collapse forks", "Fold every fork to its name")}${ctl("it-open-all", "Expand forks", "Open every fork")}</span><span class="it-zc" role="group" aria-label="Zoom">${ctl("it-zout", "−", "Zoom out", "Zoom out")}${ctl("it-zval", "100%", "Back to 100%")}${ctl("it-zin", "+", "Zoom in", "Zoom in")}${ctl("it-zfit", "Fit", "Fit the whole graph in view")}</span>`; | |
| 49 | ||
| 50 | // GET /:owner/:name/iterations | |
| 51 | export async function iterationsPage(ctx: Ctx): Promise<Response> { | |
| 52 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 53 | const repo = loadRepo(ctx); | |
| 54 | if (!repo) return timePhase(ctx.request, "render", () => notFoundPage(ctx)); | |
| 55 | const forks = await timePhaseAsync(ctx.request, "graph", () => iterationsOf(ctx.app, ctx.auth.user, repo)); | |
| 56 | if (forks === undefined) return timePhase(ctx.request, "render", () => notFoundPage(ctx)); | |
| 57 | const base = repoBase(repo); | |
| 58 | const lanes = lanesOf(forks); | |
| 59 | const data = JSON.stringify(lanes.map((l) => ({ id: l.id, from: l.from, fromRepo: l.fromRepo, kind: l.kind, main: l.main }))); | |
| 60 | const body = html`${repoHeader(ctx, repo, "branches", { compact: true })} | |
| 61 | <div class="it-page" id="it-page" data-here="${repo.ownerName}/${repo.name}" data-lanes="${data}"> | |
| 62 | <div class="page-head mt"> | |
| 63 | <h2>Iterations</h2> | |
| 64 | <a href="${base}/builds">Builds</a> | |
| 65 | </div> | |
| 66 | <div class="it-key"><span><i></i>fork of</span><span><i class="it-thin"></i>branch</span><span><i class="it-dash"></i>built on a pin of</span><span class="it-sp">${CONTROLS}</span></div> | |
| 67 | <div class="it-stage" id="it-stage"><div class="it-zoom" id="it-zoom"><div class="it-tree" id="it-grid">${lanes.map((l) => lane(l, l.main ? lanes.filter((x) => !x.main && x.repo === l.repo).length : 0))}</div></div></div> | |
| 68 | </div>`; | |
| 69 | return timePhase(ctx.request, "render", () => | |
| 70 | page({ | |
| 71 | ctx, | |
| 72 | title: `Iterations · ${repo.name}`, | |
| 73 | crumbs: crumbsFor(repo), | |
| 74 | palette: repoPalette(ctx, repo), | |
| 75 | body, | |
| 76 | styleCss: ITERATIONS_CSS, | |
| 77 | scriptJs: ITERATIONS_JS, | |
| 78 | wide: true, | |
| 79 | }), | |
| 80 | ); | |
| 81 | } |
src/web/pages/iterations_family.ts added +193
| @@ -0,0 +1,193 @@ | ||
| 1 | /** | |
| 2 | * Iterations, the data: a fork family's build pins as plain data, and the | |
| 3 | * downward tree's runs drawn from them. No HTML here; `iterations.ts` draws. | |
| 4 | * | |
| 5 | * A pin hangs under the parent its person named (`BuildPin.parent`: a label | |
| 6 | * here, or `owner/name:label` for a concept edge to another pin of the | |
| 7 | * family), else it continues its repository's run, oldest first. A fork | |
| 8 | * carries its parent's pins; the family draws each once, in the parent. | |
| 9 | */ | |
| 10 | ||
| 11 | import { canRead, getRepoById, visibleForks, type Repo } from "../../core/repos.ts"; | |
| 12 | import { listBuildPins, type BuildPin } from "../../git/builds.ts"; | |
| 13 | import { repoDir } from "../../git/repos.ts"; | |
| 14 | import type { App } from "../../app.ts"; | |
| 15 | import type { User } from "../../core/users.ts"; | |
| 16 | ||
| 17 | /** How many repositories a family draws. */ | |
| 18 | export const FAMILY_LIMIT = 40; | |
| 19 | ||
| 20 | export interface IterationBuild { | |
| 21 | /** `owner/name:label`. */ | |
| 22 | readonly key: string; | |
| 23 | readonly label: string; | |
| 24 | readonly oid: string; | |
| 25 | /** ISO time it was pinned. */ | |
| 26 | readonly at: string; | |
| 27 | /** The first line of its note. */ | |
| 28 | readonly note: string; | |
| 29 | /** The key of the build it hangs under, when that is in the family. */ | |
| 30 | readonly parent: string | null; | |
| 31 | readonly edge: "git" | "concept" | null; | |
| 32 | } | |
| 33 | ||
| 34 | export interface IterationFork { | |
| 35 | /** `owner/name`. */ | |
| 36 | readonly repo: string; | |
| 37 | readonly name: string; | |
| 38 | /** The repository it was forked from, when that is in the family. */ | |
| 39 | readonly forkOf: string | null; | |
| 40 | /** Oldest first. */ | |
| 41 | readonly builds: readonly IterationBuild[]; | |
| 42 | } | |
| 43 | ||
| 44 | export interface IterationLane { | |
| 45 | /** The repository for a fork's main run, else the key of the run's first build. */ | |
| 46 | readonly id: string; | |
| 47 | readonly repo: string; | |
| 48 | readonly title: string; | |
| 49 | readonly main: boolean; | |
| 50 | /** The build it grew from; null for a root. */ | |
| 51 | readonly from: string | null; | |
| 52 | /** For a main run with no build to hang from, the repository it was forked from. */ | |
| 53 | readonly fromRepo: string | null; | |
| 54 | readonly kind: "fork" | "branch" | "concept"; | |
| 55 | readonly builds: readonly IterationBuild[]; | |
| 56 | } | |
| 57 | ||
| 58 | /** The repositories the viewer may read: up to the root, then every fork below, root first. */ | |
| 59 | export function familyRepos(app: App, viewer: User | undefined, repo: Repo, limit = FAMILY_LIMIT): Repo[] { | |
| 60 | const db = app.db; | |
| 61 | let root = repo; | |
| 62 | for (let i = 0; i < limit && root.forkParentId !== null; i++) { | |
| 63 | const up = getRepoById(db, root.forkParentId); | |
| 64 | if (up === undefined || !canRead(db, up, viewer)) break; | |
| 65 | root = up; | |
| 66 | } | |
| 67 | const repos: Repo[] = [root]; | |
| 68 | for (let i = 0; i < repos.length && repos.length < limit; i++) | |
| 69 | repos.push(...visibleForks(db, repos[i]!.id, viewer).slice(0, limit - repos.length)); | |
| 70 | return repos; | |
| 71 | } | |
| 72 | ||
| 73 | /** A pin the fork inherited: the same label on the same commit in its parent. */ | |
| 74 | const copied = (pin: BuildPin, up: readonly BuildPin[]): boolean => up.some((p) => p.label === pin.label && p.oid === pin.oid); | |
| 75 | ||
| 76 | /** | |
| 77 | * A repository's pins are read from git, one process each, up to | |
| 78 | * FAMILY_LIMIT at a time; a read is kept this long per repository so a reload | |
| 79 | * or a second viewer costs no process. A new pin shows within this time. | |
| 80 | */ | |
| 81 | export const PINS_TTL_MS = 5000; | |
| 82 | const kept = new WeakMap<App, Map<string, { at: number; pins: Promise<BuildPin[]> }>>(); | |
| 83 | ||
| 84 | function pinsOf(app: App, repo: Repo, now: number): Promise<BuildPin[]> { | |
| 85 | let mine = kept.get(app); | |
| 86 | if (mine === undefined) kept.set(app, (mine = new Map())); | |
| 87 | const hit = mine.get(repo.id); | |
| 88 | if (hit !== undefined && now - hit.at < PINS_TTL_MS) return hit.pins; | |
| 89 | const pins = listBuildPins(repoDir(app.reposRoot, repo.ownerName, repo.name)); | |
| 90 | mine.set(repo.id, { at: now, pins }); | |
| 91 | if (mine.size > 4 * FAMILY_LIMIT) for (const [id, v] of mine) if (now - v.at >= PINS_TTL_MS) mine.delete(id); | |
| 92 | pins.catch(() => mine.delete(repo.id)); | |
| 93 | return pins; | |
| 94 | } | |
| 95 | ||
| 96 | /** The family's pins as fork cards; undefined when it holds none. */ | |
| 97 | export async function iterationsOf(app: App, viewer: User | undefined, repo: Repo, limit = FAMILY_LIMIT): Promise<IterationFork[] | undefined> { | |
| 98 | const family = familyRepos(app, viewer, repo, limit); | |
| 99 | const now = Date.now(); | |
| 100 | const names = new Map(family.map((r) => [r.id, `${r.ownerName}/${r.name}`])); | |
| 101 | const pins = new Map<string, BuildPin[]>(); | |
| 102 | await Promise.all( | |
| 103 | family.map(async (r) => pins.set(r.id, [...(await pinsOf(app, r, now))])), | |
| 104 | ); | |
| 105 | for (const r of family) { | |
| 106 | const up = r.forkParentId === null ? undefined : pins.get(r.forkParentId); | |
| 107 | if (up !== undefined) pins.set(r.id, pins.get(r.id)!.filter((p) => !copied(p, up))); | |
| 108 | } | |
| 109 | if ([...pins.values()].every((p) => p.length === 0)) return undefined; | |
| 110 | const known = new Set(family.flatMap((r) => pins.get(r.id)!.map((p) => `${names.get(r.id)}:${p.label}`))); | |
| 111 | const forks: IterationFork[] = []; | |
| 112 | for (const r of family) { | |
| 113 | const name = names.get(r.id)!; | |
| 114 | const builds: IterationBuild[] = [...pins.get(r.id)!] | |
| 115 | .sort((a, b) => (a.pinnedAt < b.pinnedAt ? -1 : a.pinnedAt > b.pinnedAt ? 1 : a.label < b.label ? -1 : 1)) | |
| 116 | .map((p) => { | |
| 117 | const key = `${name}:${p.label}`; | |
| 118 | const named = p.parent === undefined ? undefined : p.parent.includes(":") ? p.parent : `${name}:${p.parent}`; | |
| 119 | const ok = named !== undefined && named !== key && known.has(named); | |
| 120 | return { | |
| 121 | key, | |
| 122 | label: p.label, | |
| 123 | oid: p.oid, | |
| 124 | at: p.pinnedAt, | |
| 125 | note: (p.note.split("\n", 1)[0] ?? "").slice(0, 200), | |
| 126 | parent: ok ? named : null, | |
| 127 | edge: ok ? (p.parent!.includes(":") ? "concept" : "git") : null, | |
| 128 | }; | |
| 129 | }); | |
| 130 | forks.push({ repo: name, name: r.name, forkOf: r.forkParentId === null ? null : (names.get(r.forkParentId) ?? null), builds }); | |
| 131 | } | |
| 132 | // A fork is named by its repository name; its owner joins only when another fork shares that name. | |
| 133 | const clash = (n: string) => forks.filter((f) => f.name === n).length > 1; | |
| 134 | return forks.map((f) => (clash(f.name) ? { ...f, name: f.repo } : f)); | |
| 135 | } | |
| 136 | ||
| 137 | /** | |
| 138 | * The family as the tree's runs, in drawing order: depth first, and under a | |
| 139 | * run the one that splits off nearest its foot first, so a line never | |
| 140 | * crosses a run. A build continues its parent's run when the parent is that | |
| 141 | * run's last build in the same repository; otherwise it starts a run. | |
| 142 | */ | |
| 143 | export function lanesOf(forks: readonly IterationFork[]): IterationLane[] { | |
| 144 | type Lane = { -readonly [K in keyof IterationLane]: IterationLane[K] } & { builds: IterationBuild[] }; | |
| 145 | const lanes: Lane[] = []; | |
| 146 | const laneOf = new Map<string, Lane>(); | |
| 147 | const known = new Set(forks.flatMap((f) => f.builds.map((b) => b.key))); | |
| 148 | for (const f of forks) { | |
| 149 | let main: Lane | undefined; | |
| 150 | const add = (l: Lane) => (lanes.push(l), l); | |
| 151 | for (const b of f.builds) { | |
| 152 | const from = b.parent !== null && known.has(b.parent) ? b.parent : null; | |
| 153 | const at = from === null ? undefined : laneOf.get(from); | |
| 154 | let lane: Lane; | |
| 155 | if (at !== undefined && at.repo === f.repo && at.builds.at(-1)?.key === from) lane = at; | |
| 156 | else if (from === null && main !== undefined) lane = main; | |
| 157 | else if (main === undefined && (at === undefined || at.repo !== f.repo)) | |
| 158 | lane = main = add({ | |
| 159 | id: f.repo, repo: f.repo, title: f.name, main: true, from, | |
| 160 | fromRepo: from === null ? f.forkOf : null, | |
| 161 | kind: from !== null && b.edge === "concept" ? "concept" : "fork", | |
| 162 | builds: [], | |
| 163 | }); | |
| 164 | else | |
| 165 | lane = add({ | |
| 166 | id: b.key, repo: f.repo, title: `${f.name} · branch`, main: false, from, fromRepo: null, | |
| 167 | kind: at !== undefined && at.repo === f.repo ? "branch" : b.edge === "concept" ? "concept" : "fork", | |
| 168 | builds: [], | |
| 169 | }); | |
| 170 | lane.builds.push(b); | |
| 171 | laneOf.set(b.key, lane); | |
| 172 | } | |
| 173 | if (main === undefined) add({ id: f.repo, repo: f.repo, title: f.name, main: true, from: null, fromRepo: f.forkOf, kind: "fork", builds: [] }); | |
| 174 | } | |
| 175 | const childrenOf = (l: Lane) => | |
| 176 | lanes | |
| 177 | .filter((c) => c !== l && ((c.from !== null && laneOf.get(c.from) === l) || (c.from === null && c.fromRepo === l.repo && l.main))) | |
| 178 | .sort((a, b) => { | |
| 179 | const at = (c: Lane) => (c.from === null ? -1 : l.builds.findIndex((x) => x.key === c.from)); | |
| 180 | return at(b) - at(a); | |
| 181 | }); | |
| 182 | const out: Lane[] = []; | |
| 183 | const seen = new Set<Lane>(); | |
| 184 | const visit = (l: Lane) => { | |
| 185 | if (seen.has(l)) return; | |
| 186 | seen.add(l); | |
| 187 | out.push(l); | |
| 188 | for (const c of childrenOf(l)) visit(c); | |
| 189 | }; | |
| 190 | for (const l of lanes) if ((l.from === null || laneOf.get(l.from) === undefined) && (l.fromRepo === null || !lanes.some((x) => x.main && x.repo === l.fromRepo))) visit(l); | |
| 191 | for (const l of lanes) visit(l); | |
| 192 | return out; | |
| 193 | } |
src/web/pages/iterations_script.ts added +181
| @@ -0,0 +1,181 @@ | ||
| 1 | /** | |
| 2 | * Iterations, the browser's half: the rules and the script. The server draws | |
| 3 | * every card and row; the script places each run under the build it grew | |
| 4 | * from, draws the lines, folds a fork or a run, and zooms. Folds and zoom are | |
| 5 | * kept in this browser only. Without the script the runs stand side by side and no button is shown: the controls and the fold buttons are made or shown by it. | |
| 6 | */ | |
| 7 | ||
| 8 | export const ITERATIONS_CSS = /* css */ ` | |
| 9 | .it-key{display:flex;gap:16px;color:var(--muted-fg);font-size:var(--fs-min);margin:4px 0 8px;flex-wrap:wrap;align-items:center} | |
| 10 | .it-key i{display:inline-block;width:24px;height:0;border-top:2px solid var(--muted-fg);vertical-align:middle;margin-right:6px} | |
| 11 | .it-key i.it-thin{border-top-width:1px} | |
| 12 | .it-key i.it-dash{border-top-style:dashed} | |
| 13 | .it-sp{margin-left:auto;display:none;gap:8px;align-items:center;flex-wrap:wrap} | |
| 14 | .it-on .it-sp{display:flex} | |
| 15 | .it-zc{display:inline-flex;gap:2px} | |
| 16 | #it-zval{min-width:56px;font-variant-numeric:tabular-nums} | |
| 17 | .it-stage{position:relative;padding:16px;border:1px solid var(--border);border-radius:var(--radius);overflow:auto;max-height:60vh} | |
| 18 | #it-grid{--it-card:calc(50ch + 26px);font-family:var(--mono);font-size:var(--fs-min);transform-origin:0 0} | |
| 19 | .it-tree{position:relative;display:flex;gap:32px;align-items:flex-start;width:max-content;min-width:100%} | |
| 20 | .it-tree.it-placed{display:block} | |
| 21 | .it-placed .it-lane{position:absolute} | |
| 22 | .it-lane{width:var(--it-card);background:var(--card);border:1px solid var(--border);border-radius:var(--radius);z-index:1} | |
| 23 | .it-lh{display:flex;align-items:center;gap:6px;padding:8px;border-bottom:1px solid var(--border);min-height:32px} | |
| 24 | .it-nm{display:flex;gap:8px;align-items:baseline;min-width:0;padding:0;background:none;border:0;color:inherit;font:inherit;text-align:left;cursor:pointer} | |
| 25 | .it-nm i{font-style:normal;color:var(--muted-fg);width:10px} | |
| 26 | .it-nm i::before{content:"\\25BE"} | |
| 27 | .it-lane.it-shut .it-nm i::before{content:"\\25B8"} | |
| 28 | .it-nm b{overflow-wrap:anywhere} | |
| 29 | .it-sub{color:var(--muted-fg);font-size:var(--fs-min)} | |
| 30 | .it-ct{margin-left:auto;white-space:nowrap} | |
| 31 | .it-ct::before{content:"fold run"} | |
| 32 | .it-lane.it-runshut .it-ct::before{content:"open run"} | |
| 33 | .it-rows{display:flex;flex-direction:column} | |
| 34 | .it-b{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:8px;padding:8px 12px;border-top:1px solid var(--border)} | |
| 35 | .it-rows > .it-b:first-child{border-top:0} | |
| 36 | .it-l{font-weight:700;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:inherit} | |
| 37 | .it-d{color:var(--muted-fg);font-size:var(--fs-min);text-align:right;white-space:nowrap} | |
| 38 | .it-n{grid-column:1/-1;color:var(--muted-fg);font-size:var(--fs-min);overflow-wrap:anywhere} | |
| 39 | .it-coll{display:none;flex-direction:column;gap:1px;width:100%;background:none;border:0;color:inherit;font:inherit;text-align:left;padding:8px 12px;cursor:pointer} | |
| 40 | .it-coll span{color:var(--muted-fg);font-size:var(--fs-min)} | |
| 41 | .it-lane.it-shut .it-fcoll,.it-lane.it-runshut:not(.it-shut) .it-rcoll{display:flex} | |
| 42 | .it-lane.it-shut .it-rows,.it-lane.it-runshut .it-rows,.it-lane.it-shut .it-ct{display:none} | |
| 43 | .it-lane.it-shut .it-lh{border-bottom:0} | |
| 44 | .it-lane.it-hid{display:none} | |
| 45 | .it-edges{position:absolute;left:0;top:0;pointer-events:none;overflow:visible} | |
| 46 | .it-edges path{fill:none;stroke:var(--muted-fg);stroke-width:2} | |
| 47 | .it-edges path[data-kind="concept"]{stroke-dasharray:6 5} | |
| 48 | .it-edges path[data-kind="branch"]{stroke-width:1.25} | |
| 49 | `; | |
| 50 | ||
| 51 | export const ITERATIONS_JS = ` | |
| 52 | (function () { | |
| 53 | var root = document.getElementById('it-page'), stage = document.getElementById('it-stage'), box = document.getElementById('it-zoom'), grid = document.getElementById('it-grid'); | |
| 54 | if (!root || !stage || !box || !grid) return; | |
| 55 | root.classList.add('it-on'); | |
| 56 | // The fold buttons exist only here: a fork's name becomes one, a run of several builds gets one. | |
| 57 | grid.querySelectorAll('span.it-nm').forEach(function (s) { | |
| 58 | var b = document.createElement('button'); b.type = 'button'; b.className = 'it-nm'; b.setAttribute('data-act', 'fork'); b.title = 'Fold this fork'; b.innerHTML = s.innerHTML; s.replaceWith(b); | |
| 59 | }); | |
| 60 | grid.querySelectorAll('.it-lane').forEach(function (l) { | |
| 61 | var h = l.querySelector('.it-lh'); | |
| 62 | if (!l.querySelector('.it-rcoll') || !h) return; | |
| 63 | var b = document.createElement('button'); b.type = 'button'; b.className = 'btn it-ct'; b.setAttribute('data-size', 'sm'); b.setAttribute('data-variant', 'ghost'); b.setAttribute('data-act', 'run'); b.title = 'Fold this run'; h.appendChild(b); | |
| 64 | }); | |
| 65 | var lanes = []; try { lanes = JSON.parse(root.getAttribute('data-lanes') || '[]') || []; } catch (e) {} | |
| 66 | var NS = 'http://www.w3.org/2000/svg', W = 220, G = 36, PAD = 16, Z = 1, ZKEY = 'forge:iterations-zoom', FKEY = 'forge:iterations-folds:' + root.getAttribute('data-here'); | |
| 67 | var STEPS = [0.25, 0.33, 0.5, 0.67, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 2]; | |
| 68 | try { Z = Math.min(2, Math.max(0.2, Number(localStorage.getItem(ZKEY)) || 1)); } catch (e) {} | |
| 69 | var els = {}; grid.querySelectorAll('.it-lane').forEach(function (el) { els[el.getAttribute('data-lane')] = el; }); | |
| 70 | var byId = {}; lanes.forEach(function (l) { byId[l.id] = l; }); | |
| 71 | function seen(el) { return el && el.offsetParent !== null; } | |
| 72 | function anchor(key) { | |
| 73 | var rows = grid.querySelectorAll('.it-b[data-key]'); | |
| 74 | for (var i = 0; i < rows.length; i++) if (rows[i].getAttribute('data-key') === key && seen(rows[i])) return rows[i]; | |
| 75 | var holds = grid.querySelectorAll('[data-keys]'); | |
| 76 | for (var k = 0; k < holds.length; k++) if ((' ' + holds[k].getAttribute('data-keys') + ' ').indexOf(' ' + key + ' ') >= 0 && seen(holds[k])) return holds[k]; | |
| 77 | return null; | |
| 78 | } | |
| 79 | function parentOf(l) { | |
| 80 | if (l.from) { var a = anchor(l.from); return a ? { a: a, lane: a.closest('.it-lane') } : null; } | |
| 81 | if (l.fromRepo) { var m = byId[l.fromRepo] && els[l.fromRepo]; return m && seen(m) ? { a: m.querySelector('.it-lh'), lane: m } : null; } | |
| 82 | return null; | |
| 83 | } | |
| 84 | function visible() { return lanes.filter(function (l) { return seen(els[l.id]); }); } | |
| 85 | function save() { | |
| 86 | var s = { shut: [], run: [] }; | |
| 87 | Object.keys(els).forEach(function (id) { if (els[id].classList.contains('it-shut')) s.shut.push(id); if (els[id].classList.contains('it-runshut')) s.run.push(id); }); | |
| 88 | try { if (s.shut.length || s.run.length) localStorage.setItem(FKEY, JSON.stringify(s)); else localStorage.removeItem(FKEY); } catch (e) {} | |
| 89 | } | |
| 90 | function place() { | |
| 91 | var one = grid.querySelector('.it-lane'); if (one && one.offsetWidth) W = one.offsetWidth; | |
| 92 | var hid = {}; | |
| 93 | grid.querySelectorAll('.it-lane.it-shut').forEach(function (el) { hid[el.getAttribute('data-repo')] = true; }); | |
| 94 | grid.querySelectorAll('.it-lane.it-branch').forEach(function (el) { el.classList.toggle('it-hid', !!hid[el.getAttribute('data-repo')]); }); | |
| 95 | var pos = {}; | |
| 96 | visible().forEach(function (l, i) { | |
| 97 | var y = PAD, p = parentOf(l); | |
| 98 | if (p && p.lane && pos[p.lane.getAttribute('data-lane')]) y = pos[p.lane.getAttribute('data-lane')].y + p.a.offsetTop + p.a.offsetHeight + 10; | |
| 99 | pos[l.id] = { x: PAD + i * (W + G), y: y }; | |
| 100 | els[l.id].style.left = pos[l.id].x + 'px'; els[l.id].style.top = y + 'px'; | |
| 101 | }); | |
| 102 | var w = 0, h = 0; | |
| 103 | grid.querySelectorAll('.it-lane').forEach(function (el) { if (!seen(el)) return; w = Math.max(w, el.offsetLeft + el.offsetWidth); h = Math.max(h, el.offsetTop + el.offsetHeight); }); | |
| 104 | grid.style.width = (w + PAD) + 'px'; grid.style.height = (h + PAD) + 'px'; | |
| 105 | lines(); fit(); | |
| 106 | } | |
| 107 | function lines() { | |
| 108 | var old = grid.querySelector('svg.it-edges'); if (old) old.remove(); | |
| 109 | var svg = document.createElementNS(NS, 'svg'); svg.setAttribute('class', 'it-edges'); | |
| 110 | var o = grid.getBoundingClientRect(); | |
| 111 | function R(el) { var r = el.getBoundingClientRect(); return { l: (r.left - o.left) / Z, t: (r.top - o.top) / Z, r: (r.right - o.left) / Z, b: (r.bottom - o.top) / Z, cx: ((r.left + r.right) / 2 - o.left) / Z, cy: ((r.top + r.bottom) / 2 - o.top) / Z }; } | |
| 112 | visible().forEach(function (l) { | |
| 113 | var p = parentOf(l); if (!p) return; | |
| 114 | var a = R(p.a), h = R(els[l.id].querySelector('.it-lh')), sx, sy, ex = h.cx, ey = h.t, d; | |
| 115 | if (h.cx > a.r) { sx = a.r; sy = a.cy; } else if (h.cx < a.l) { sx = a.l; sy = a.cy; } else { sx = a.cx; sy = a.b; } | |
| 116 | if (sx === a.cx) { if (ey < sy) ey = h.b; var my = (sy + ey) / 2; d = 'M' + sx + ',' + sy + ' C' + sx + ',' + my + ' ' + ex + ',' + my + ' ' + ex + ',' + ey; } | |
| 117 | else if (ey > sy + 8) { var dir = ex > sx ? 1 : -1, r = Math.min(12, Math.abs(ex - sx) / 2, (ey - sy) / 2); d = 'M' + sx + ',' + sy + ' H' + (ex - dir * r) + ' Q' + ex + ',' + sy + ' ' + ex + ',' + (sy + r) + ' V' + ey; } | |
| 118 | else { ey = h.b; d = 'M' + sx + ',' + sy + ' C' + ex + ',' + sy + ' ' + ex + ',' + sy + ' ' + ex + ',' + ey; } | |
| 119 | var path = document.createElementNS(NS, 'path'); path.setAttribute('d', d); path.setAttribute('data-kind', l.kind || 'fork'); svg.appendChild(path); | |
| 120 | }); | |
| 121 | svg.setAttribute('width', grid.scrollWidth); svg.setAttribute('height', grid.scrollHeight); | |
| 122 | grid.insertBefore(svg, grid.firstChild); | |
| 123 | } | |
| 124 | // Zoom: the grid is scaled, its box takes the scaled size so the stage scrolls the right distance. | |
| 125 | function fit() { | |
| 126 | grid.style.transform = Z === 1 ? '' : 'scale(' + Z + ')'; | |
| 127 | box.style.width = Math.ceil(grid.offsetWidth * Z) + 'px'; box.style.height = Math.ceil(grid.offsetHeight * Z) + 'px'; | |
| 128 | var v = document.getElementById('it-zval'); if (v) v.textContent = Math.round(Z * 100) + '%'; | |
| 129 | } | |
| 130 | function setZoom(z, px, py) { | |
| 131 | z = Math.min(2, Math.max(0.2, Math.round(z * 100) / 100)); | |
| 132 | if (z === Z) return; | |
| 133 | if (px === undefined) { px = stage.clientWidth / 2; py = stage.clientHeight / 2; } | |
| 134 | var gx = (stage.scrollLeft + px) / Z, gy = (stage.scrollTop + py) / Z; | |
| 135 | Z = z; | |
| 136 | try { if (Z === 1) localStorage.removeItem(ZKEY); else localStorage.setItem(ZKEY, String(Z)); } catch (e) {} | |
| 137 | fit(); lines(); | |
| 138 | stage.scrollLeft = gx * Z - px; stage.scrollTop = gy * Z - py; | |
| 139 | } | |
| 140 | function step(dir) { | |
| 141 | var next = dir > 0 ? STEPS.find(function (s) { return s > Z + 0.001; }) : STEPS.slice().reverse().find(function (s) { return s < Z - 0.001; }); | |
| 142 | if (next !== undefined) setZoom(next); | |
| 143 | } | |
| 144 | function on(id, f) { var b = document.getElementById(id); if (b) b.addEventListener('click', f); } | |
| 145 | on('it-zin', function () { step(1); }); | |
| 146 | on('it-zout', function () { step(-1); }); | |
| 147 | on('it-zval', function () { setZoom(1); }); | |
| 148 | on('it-zfit', function () { | |
| 149 | var w = grid.offsetWidth, h = grid.offsetHeight; if (!w || !h) return; | |
| 150 | var cs = getComputedStyle(stage), padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight), padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom); | |
| 151 | var tall = parseFloat(cs.maxHeight); if (!(tall > 0)) tall = window.innerHeight * 0.6; | |
| 152 | setZoom(Math.floor(Math.min(1, (stage.clientWidth - padX - 2) / w, (tall - padY - 2) / h) * 100) / 100); | |
| 153 | stage.scrollLeft = 0; stage.scrollTop = 0; | |
| 154 | }); | |
| 155 | stage.addEventListener('wheel', function (e) { | |
| 156 | if (!e.ctrlKey && !e.metaKey) return; | |
| 157 | e.preventDefault(); | |
| 158 | var r = stage.getBoundingClientRect(); | |
| 159 | setZoom(Z * Math.exp(-e.deltaY / 300), e.clientX - r.left, e.clientY - r.top); | |
| 160 | }, { passive: false }); | |
| 161 | // Folds: a fork to its name, a run to one node. | |
| 162 | grid.addEventListener('click', function (ev) { | |
| 163 | var t = ev.target.closest && ev.target.closest('[data-act]'); if (!t) return; | |
| 164 | var el = t.closest('.it-lane'); if (!el) return; | |
| 165 | el.classList.toggle(t.getAttribute('data-act') === 'fork' ? 'it-shut' : 'it-runshut'); | |
| 166 | save(); place(); | |
| 167 | }); | |
| 168 | function all(shut) { | |
| 169 | Object.keys(els).forEach(function (id) { if (byId[id] && byId[id].main) els[id].classList.toggle('it-shut', shut); els[id].classList.remove('it-runshut'); }); | |
| 170 | save(); place(); | |
| 171 | } | |
| 172 | on('it-fold-all', function () { all(true); }); | |
| 173 | on('it-open-all', function () { all(false); }); | |
| 174 | try { | |
| 175 | var s = JSON.parse(localStorage.getItem(FKEY) || 'null'); | |
| 176 | if (s) { (s.shut || []).forEach(function (id) { if (els[id]) els[id].classList.add('it-shut'); }); (s.run || []).forEach(function (id) { if (els[id]) els[id].classList.add('it-runshut'); }); } | |
| 177 | } catch (e) {} | |
| 178 | grid.classList.add('it-placed'); place(); | |
| 179 | if (document.fonts && document.fonts.ready) document.fonts.ready.then(place); | |
| 180 | })(); | |
| 181 | `; |
test/iterations.test.ts added +233
| @@ -0,0 +1,233 @@ | ||
| 1 | /** | |
| 2 | * Iterations (`/:owner/:name/iterations`): a fork family's pins as a tree of | |
| 3 | * runs. A pin hangs under the parent it names, else continues its | |
| 4 | * repository's run; a fork's inherited pins are drawn once, in the parent; a | |
| 5 | * family with no pin, or a private one to a stranger, is not found; every | |
| 6 | * note is escaped; the page's script parses; Builds links to it. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { pinBuild } from "../src/actions/builds.ts"; | |
| 14 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 15 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { promote } from "./helpers/members.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | import { FAMILY_LIMIT, familyRepos, iterationsOf, lanesOf, type IterationBuild, type IterationFork } from "../src/web/pages/iterations_family.ts"; | |
| 20 | import { ITERATIONS_CSS, ITERATIONS_JS } from "../src/web/pages/iterations_script.ts"; | |
| 21 | ||
| 22 | const build = (repo: string, label: string, parent: string | null = null, edge: IterationBuild["edge"] = parent === null ? null : "git"): IterationBuild => ({ | |
| 23 | key: `${repo}:${label}`, | |
| 24 | label, | |
| 25 | oid: "a".repeat(40), | |
| 26 | at: "2026-01-01T00:00:00Z", | |
| 27 | note: "", | |
| 28 | parent, | |
| 29 | edge, | |
| 30 | }); | |
| 31 | ||
| 32 | describe("lanesOf", () => { | |
| 33 | test("a pin continues its repository's run; a second child of a build starts a branch run under it", () => { | |
| 34 | const forks: IterationFork[] = [ | |
| 35 | { repo: "a/g", name: "g", forkOf: null, builds: [build("a/g", "v1"), build("a/g", "v2", "a/g:v1"), build("a/g", "side", "a/g:v1")] }, | |
| 36 | ]; | |
| 37 | const lanes = lanesOf(forks); | |
| 38 | expect(lanes.map((l) => [l.id, l.main, l.builds.map((b) => b.label)])).toEqual([ | |
| 39 | ["a/g", true, ["v1", "v2"]], | |
| 40 | ["a/g:side", false, ["side"]], | |
| 41 | ]); | |
| 42 | expect(lanes[1]!.from).toBe("a/g:v1"); | |
| 43 | expect(lanes[1]!.kind).toBe("branch"); | |
| 44 | }); | |
| 45 | ||
| 46 | test("a fork with no parent pin hangs under its upstream's head; a concept edge keeps its kind", () => { | |
| 47 | const forks: IterationFork[] = [ | |
| 48 | { repo: "a/g", name: "g", forkOf: null, builds: [build("a/g", "v1")] }, | |
| 49 | { repo: "b/g", name: "b/g", forkOf: "a/g", builds: [build("b/g", "x1")] }, | |
| 50 | { repo: "c/g", name: "c/g", forkOf: "a/g", builds: [build("c/g", "y1", "a/g:v1", "concept")] }, | |
| 51 | ]; | |
| 52 | const lanes = lanesOf(forks); | |
| 53 | const b = lanes.find((l) => l.id === "b/g")!; | |
| 54 | expect([b.from, b.fromRepo, b.kind]).toEqual([null, "a/g", "fork"]); | |
| 55 | const c = lanes.find((l) => l.id === "c/g")!; | |
| 56 | expect([c.from, c.kind]).toEqual(["a/g:v1", "concept"]); | |
| 57 | }); | |
| 58 | ||
| 59 | test("a fork with no builds still has a card; a parent cycle ends", () => { | |
| 60 | const forks: IterationFork[] = [ | |
| 61 | { repo: "a/g", name: "g", forkOf: null, builds: [build("a/g", "v1", "a/g:v2"), build("a/g", "v2", "a/g:v1")] }, | |
| 62 | { repo: "b/g", name: "b/g", forkOf: "a/g", builds: [] }, | |
| 63 | ]; | |
| 64 | expect(lanesOf(forks).map((l) => l.id).sort()).toEqual(["a/g", "b/g"]); | |
| 65 | }); | |
| 66 | }); | |
| 67 | ||
| 68 | describe("iterations page", () => { | |
| 69 | let dataDir: string; | |
| 70 | let app: App; | |
| 71 | let servers: Servers; | |
| 72 | let base: string; | |
| 73 | let alice: User; | |
| 74 | let bob: User; | |
| 75 | let carol: User; | |
| 76 | const game = (): Repo => getRepo(app.db, "alice", "game")!; | |
| 77 | const get = (path: string, cookie = "") => fetch(`${base}${path}`, { headers: { cookie } }); | |
| 78 | const signIn = async (username: string) => | |
| 79 | ((await fetch(`${base}/signin`, { method: "POST", body: new URLSearchParams({ username, password: "a-good-passphrase" }), redirect: "manual" })).headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 80 | ||
| 81 | beforeAll(async () => { | |
| 82 | dataDir = await mkdtemp(join(tmpdir(), "forge-iterations-")); | |
| 83 | app = createApp({ dataDir, port: 0 }); | |
| 84 | servers = await serve(app); | |
| 85 | base = servers.url; | |
| 86 | for (const name of ["alice", "bob", "carol"]) { | |
| 87 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: true }); | |
| 88 | if (!made.ok) throw new Error(made.error); | |
| 89 | if (name === "alice") alice = made.value; | |
| 90 | if (name === "bob") bob = made.value; | |
| 91 | if (name === "carol") carol = made.value; | |
| 92 | } | |
| 93 | promote(app.db, bob); | |
| 94 | promote(app.db, carol); | |
| 95 | for (const name of ["game", "secret", "empty"]) { | |
| 96 | const made = await createRepo(app, alice, { name, visibility: name === "secret" ? "private" : "public" }); | |
| 97 | if (!made.ok) throw new Error(String(made.error)); | |
| 98 | } | |
| 99 | const commit = async (repo: Repo, who: User, message: string) => { | |
| 100 | const c = await commitToBranch(app, who, repo, { branch: "main", message, files: [{ path: `${message}.txt`, content: `${message}\n` }] }); | |
| 101 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 102 | return c.value.commitOid; | |
| 103 | }; | |
| 104 | const pin = async (repo: Repo, who: User, label: string, oid: string, extra: { parent?: string; note?: string } = {}) => { | |
| 105 | const p = await pinBuild(app, who, repo, { label, oid, ...extra }); | |
| 106 | if (!p.ok) throw new Error(JSON.stringify(p.error)); | |
| 107 | }; | |
| 108 | const one = await commit(game(), alice, "one"); | |
| 109 | const two = await commit(game(), alice, "two"); | |
| 110 | await pin(game(), alice, "v1", one, { note: "first <b>playable</b>" }); | |
| 111 | await pin(game(), alice, "v2", two, { parent: "v1" }); | |
| 112 | await pin(game(), alice, "side", two, { parent: "v1" }); | |
| 113 | const forked = await forkRepo(app, bob, game(), "game"); | |
| 114 | if (!forked.ok) throw new Error(String(forked.error)); | |
| 115 | const mine = getRepo(app.db, "bob", "game")!; | |
| 116 | await pin(mine, bob, "b1", await commit(mine, bob, "bob one"), { parent: "alice/game:v2" }); | |
| 117 | const hush = await forkRepo(app, carol, game(), "hush", { visibility: "private" }); | |
| 118 | if (!hush.ok) throw new Error(String(hush.error)); | |
| 119 | await pin(hush.value, carol, "hush-pin", await commit(hush.value, carol, "hush one"), { parent: "alice/game:v1" }); | |
| 120 | const secret = getRepo(app.db, "alice", "secret")!; | |
| 121 | await pin(secret, alice, "s1", await commit(secret, alice, "s")); | |
| 122 | }); | |
| 123 | ||
| 124 | afterAll(async () => { | |
| 125 | servers.stop(); | |
| 126 | app.close(); | |
| 127 | await rm(dataDir, { recursive: true, force: true }); | |
| 128 | }); | |
| 129 | ||
| 130 | test("the family is drawn from any member, each pin once, with the runs as data", async () => { | |
| 131 | for (const path of ["/alice/game/iterations", "/bob/game/iterations"]) { | |
| 132 | const res = await get(path); | |
| 133 | expect(res.status).toBe(200); | |
| 134 | const text = await res.text(); | |
| 135 | expect(text).toContain("Iterations"); | |
| 136 | for (const key of ["alice/game:v1", "alice/game:v2", "alice/game:side", "bob/game:b1"]) expect(text).toContain(`data-key="${key}"`); | |
| 137 | expect(text.split('data-key="alice/game:v1"').length).toBe(2); | |
| 138 | expect(text.split('data-key="bob/game:v1"').length).toBe(1); | |
| 139 | expect(text).toContain("alice/game:side"); | |
| 140 | } | |
| 141 | }); | |
| 142 | ||
| 143 | test("the markup the script relies on is there for a folded run, and no button waits for the script", async () => { | |
| 144 | const text = await (await get("/alice/game/iterations")).text(); | |
| 145 | expect(text).toContain('data-lane="alice/game" data-repo="alice/game"'); | |
| 146 | // Pins made in the same second sort by label. | |
| 147 | const keys = "alice/game:side alice/game:v1 alice/game:v2"; | |
| 148 | expect(text).toContain(`data-act="fork" data-keys="${keys}"`); | |
| 149 | expect(text).toContain(`data-act="run" data-keys="${keys}"`); | |
| 150 | expect(text).toContain('data-lanes="[{'); | |
| 151 | expect(text).toContain('data-here="alice/game"'); | |
| 152 | // Names and the controls are drawn inert; the script turns them on. | |
| 153 | expect(text).toContain('<span class="it-nm" data-act="fork">'); | |
| 154 | expect(text).not.toContain('<button type="button" class="it-nm"'); | |
| 155 | expect(text).toContain('id="it-zfit"'); | |
| 156 | expect(ITERATIONS_JS).toContain("classList.add('it-on')"); | |
| 157 | expect(ITERATIONS_CSS).toMatch(/\.it-sp\{[^}]*display:none/); | |
| 158 | expect(ITERATIONS_CSS).toContain(".it-on .it-sp{display:flex}"); | |
| 159 | }); | |
| 160 | ||
| 161 | test("a fold hides its rows and shows the one node it leaves", () => { | |
| 162 | const rule = (selector: string) => ITERATIONS_CSS.split("\n").find((l) => l.startsWith(selector) || l.includes(`,${selector}`)) ?? ""; | |
| 163 | for (const shut of [".it-lane.it-shut .it-rows", ".it-lane.it-runshut .it-rows", ".it-lane.it-shut .it-ct"]) expect(rule(shut)).toMatch(/\{display:none\}$/); | |
| 164 | expect(rule(".it-lane.it-shut .it-fcoll")).toMatch(/\{display:flex\}$/); | |
| 165 | expect(rule(".it-lane.it-runshut:not(.it-shut) .it-rcoll")).toMatch(/\{display:flex\}$/); | |
| 166 | expect(rule(".it-coll{")).toContain("display:none"); | |
| 167 | }); | |
| 168 | ||
| 169 | test("a private fork and its pin are drawn to its owner and never to a guest", async () => { | |
| 170 | const guest = await (await get("/alice/game/iterations")).text(); | |
| 171 | expect(guest).not.toContain("hush"); | |
| 172 | expect(guest).not.toContain("hush-pin"); | |
| 173 | expect((await get("/carol/hush/iterations")).status).toBe(404); | |
| 174 | const mine = await (await get("/carol/hush/iterations", await signIn("carol"))).text(); | |
| 175 | expect(mine).toContain('data-key="carol/hush:hush-pin"'); | |
| 176 | // What it carried from alice/game is drawn once, in alice/game. | |
| 177 | expect(mine.split('data-key="alice/game:v1"').length).toBe(2); | |
| 178 | expect(mine.split("carol/hush:v1").length).toBe(1); | |
| 179 | }); | |
| 180 | ||
| 181 | test("a family is capped at the limit, root first", () => { | |
| 182 | expect(FAMILY_LIMIT).toBe(40); | |
| 183 | const all = familyRepos(app, carol, game()); | |
| 184 | expect(all[0]!.name).toBe("game"); | |
| 185 | expect(all.length).toBe(3); | |
| 186 | expect(familyRepos(app, carol, game(), 2).length).toBe(2); | |
| 187 | expect(familyRepos(app, bob, game()).length).toBe(2); | |
| 188 | expect(familyRepos(app, undefined, game(), 5).map((r) => r.name)).toEqual(["game", "game"]); | |
| 189 | }); | |
| 190 | ||
| 191 | test("a read of a family's pins is kept for a few seconds", async () => { | |
| 192 | const made = await createRepo(app, alice, { name: "kept", visibility: "public" }); | |
| 193 | if (!made.ok) throw new Error(String(made.error)); | |
| 194 | const c = await commitToBranch(app, alice, made.value, { branch: "main", message: "k", files: [{ path: "k.txt", content: "k\n" }] }); | |
| 195 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 196 | const first = await pinBuild(app, alice, made.value, { label: "k1", oid: c.value.commitOid }); | |
| 197 | if (!first.ok) throw new Error(JSON.stringify(first.error)); | |
| 198 | expect((await iterationsOf(app, undefined, made.value))![0]!.builds.length).toBe(1); | |
| 199 | const second = await pinBuild(app, alice, made.value, { label: "k2", oid: c.value.commitOid }); | |
| 200 | if (!second.ok) throw new Error(JSON.stringify(second.error)); | |
| 201 | expect((await iterationsOf(app, undefined, made.value))![0]!.builds.length).toBe(1); | |
| 202 | }); | |
| 203 | ||
| 204 | test("a note is escaped", async () => { | |
| 205 | const text = await (await get("/alice/game/iterations")).text(); | |
| 206 | expect(text).toContain("first <b>playable</b>"); | |
| 207 | expect(text).not.toContain("<b>playable</b>"); | |
| 208 | }); | |
| 209 | ||
| 210 | test("the page keeps the security floor, loosens no frames, and ships a script that parses", async () => { | |
| 211 | const res = await get("/alice/game/iterations"); | |
| 212 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 213 | expect(res.headers.get("referrer-policy")).toBeTruthy(); | |
| 214 | expect(res.headers.get("content-security-policy") ?? "").not.toContain("frame-src http"); | |
| 215 | expect(() => new Function(ITERATIONS_JS)).not.toThrow(); | |
| 216 | }); | |
| 217 | ||
| 218 | test("a family with no pin is not found", async () => { | |
| 219 | expect((await get("/alice/empty/iterations")).status).toBe(404); | |
| 220 | expect((await get("/alice/nope/iterations")).status).toBe(404); | |
| 221 | }); | |
| 222 | ||
| 223 | test("a private family is not found to a stranger and drawn to its owner", async () => { | |
| 224 | expect((await get("/alice/secret/iterations")).status).toBe(404); | |
| 225 | const cookie = await signIn("alice"); | |
| 226 | expect((await get("/alice/secret/iterations", cookie)).status).toBe(200); | |
| 227 | }); | |
| 228 | ||
| 229 | test("Builds links to Iterations when it holds a pin", async () => { | |
| 230 | expect(await (await get("/alice/game/builds")).text()).toContain('href="/alice/game/iterations"'); | |
| 231 | expect(await (await get("/alice/empty/builds")).text()).not.toContain("/iterations"); | |
| 232 | }); | |
| 233 | }); |
test/tui-iterations.test.ts added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | /** The terminal's Iterations screen: a family's pins as an indented list, for whoever may read it; nothing private to a guest. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { pinBuild } from "../src/actions/builds.ts"; | |
| 7 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { promote } from "./helpers/members.ts"; | |
| 12 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 13 | import { iterationLines, iterationsCommands, iterationsScreen, NO_ITERATIONS_LINE, NOT_FOUND_LINE } from "../src/tui/screens/iterations.ts"; | |
| 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 15 | import { lanesOf } from "../src/web/pages/iterations_family.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | let dir: string; | |
| 22 | let app: App; | |
| 23 | let alice: User; | |
| 24 | let carol: User; | |
| 25 | let game: Repo; | |
| 26 | let empty: Repo; | |
| 27 | const guest = { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test" }; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dir = await mkdtemp(join(tmpdir(), "forge-tui-iterations-")); | |
| 31 | app = createApp({ dataDir: dir, port: 0 }); | |
| 32 | const mkUser = async (username: string) => { | |
| 33 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase", adminIfFirst: true }); | |
| 34 | if (!made.ok) throw new Error(made.error); | |
| 35 | return made.value; | |
| 36 | }; | |
| 37 | alice = await mkUser("alice"); | |
| 38 | const bob = await mkUser("bob"); | |
| 39 | carol = await mkUser("carol"); | |
| 40 | promote(app.db, bob); | |
| 41 | promote(app.db, carol); | |
| 42 | const commit = async (repo: Repo, who: User, message: string) => { | |
| 43 | const c = await commitToBranch(app, who, repo, { branch: repo.defaultBranch, message, files: [{ path: `${message}.txt`, content: `${message}\n` }] }); | |
| 44 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 45 | return c.value.commitOid; | |
| 46 | }; | |
| 47 | const pin = async (repo: Repo, who: User, label: string, oid: string, parent?: string) => { | |
| 48 | const p = await pinBuild(app, who, repo, { label, oid, ...(parent === undefined ? {} : { parent }) }); | |
| 49 | if (!p.ok) throw new Error(JSON.stringify(p.error)); | |
| 50 | }; | |
| 51 | const made = await createRepo(app, alice, { name: "game", visibility: "public" }); | |
| 52 | const quiet = await createRepo(app, alice, { name: "empty", visibility: "public" }); | |
| 53 | if (!made.ok || !quiet.ok) throw new Error("repos"); | |
| 54 | game = made.value; | |
| 55 | empty = quiet.value; | |
| 56 | await pin(game, alice, "v1", await commit(game, alice, "one")); | |
| 57 | await pin(game, alice, "v2", await commit(game, alice, "two"), "v1"); | |
| 58 | const hush = await forkRepo(app, carol, game, "hush", { visibility: "private" }); | |
| 59 | if (!hush.ok) throw new Error(String(hush.error)); | |
| 60 | await pin(hush.value, carol, "hidden-pin", await commit(hush.value, carol, "h"), "alice/game:v2"); | |
| 61 | const open = await forkRepo(app, bob, game, "open"); | |
| 62 | if (!open.ok) throw new Error(String(open.error)); | |
| 63 | await pin(open.value, bob, "shown-pin", await commit(open.value, bob, "o"), "alice/game:v1"); | |
| 64 | }, 60_000); | |
| 65 | ||
| 66 | afterAll(async () => { | |
| 67 | app.close(); | |
| 68 | await rm(dir, { recursive: true, force: true }); | |
| 69 | }); | |
| 70 | ||
| 71 | async function drive(repo: Repo, user: User | undefined): Promise<string> { | |
| 72 | const terminal = headlessTerminal(100, 30); | |
| 73 | const { app: tui, session } = createForgeApp(app, user ? { ...guest, user } : guest, { terminal, exitHooks: false }); | |
| 74 | tui.start(); | |
| 75 | tui.ctx.push(iterationsScreen(session, tui.ctx, repo)); | |
| 76 | await settleApp(tui); | |
| 77 | const text = tui.screen.toText(); | |
| 78 | tui.stop?.(); | |
| 79 | return text; | |
| 80 | } | |
| 81 | ||
| 82 | test("iterationLines indents a run under the one it grew from", () => { | |
| 83 | const b = (label: string, parent: string | null) => ({ key: `a/g:${label}`, label, oid: "a".repeat(40), at: "2026-01-02T00:00:00Z", note: "", parent, edge: parent === null ? null : ("git" as const) }); | |
| 84 | const lines = iterationLines(lanesOf([{ repo: "a/g", name: "g", forkOf: null, builds: [b("v1", null), b("v2", "a/g:v1"), b("side", "a/g:v1")] }])); | |
| 85 | expect(lines.map((l) => [l.depth, l.head, l.text.split(" ")[0]])).toEqual([ | |
| 86 | [0, true, "g"], | |
| 87 | [1, false, "v1"], | |
| 88 | [1, false, "v2"], | |
| 89 | [1, true, "g · branch"], | |
| 90 | [2, false, "side"], | |
| 91 | ]); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a reader sees the family's runs and builds; a guest sees what is public only", async () => { | |
| 95 | const mine = await drive(game, carol); | |
| 96 | for (const word of ["v1", "v2", "shown-pin", "hidden-pin"]) expect(mine).toContain(word); | |
| 97 | const seen = await drive(game, undefined); | |
| 98 | for (const word of ["v1", "v2", "shown-pin"]) expect(seen).toContain(word); | |
| 99 | expect(seen).not.toContain("hidden-pin"); | |
| 100 | expect(seen).not.toContain("hush"); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a family with no pin says so", async () => { | |
| 104 | expect(await drive(empty, undefined)).toContain(NO_ITERATIONS_LINE); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a private repository reads as not found to a guest, and is not offered", async () => { | |
| 108 | const hush = getRepo(app.db, "carol", "hush")!; | |
| 109 | const text = await drive(hush, undefined); | |
| 110 | expect(text).toContain(NOT_FOUND_LINE); | |
| 111 | expect(text).not.toContain("hidden-pin"); | |
| 112 | const terminal = headlessTerminal(100, 30); | |
| 113 | const { app: tui, session } = createForgeApp(app, guest, { terminal, exitHooks: false }); | |
| 114 | expect(iterationsCommands(session, tui.ctx, hush)).toEqual([]); | |
| 115 | expect(iterationsCommands(session, tui.ctx, game).map((c) => c.id)).toEqual(["iterations"]); | |
| 116 | }); |
new above
offline
This rule looks mangled. It was meant to hide a folded fork's or run's rows and the fork-level
.it-ct, but it setsmargin-left:auto;white-space:nowrapinstead. Nothing setsdisplay:noneon.it-rows, so after a fold both the rows and the.it-collsummary node show, and the fold only changes the toggle glyph. Fix:.it-lane.it-shut .it-rows,.it-lane.it-runshut .it-rows{display:none}, and drop.it-ctfrom the rule (the.it-ctrule on line 30 already hasmargin-left:auto;white-space:nowrap)..it-shut .it-ctshould stay visible only if you want run-folding inside a folded fork. Otherwise hide it too. Add a test that asserts this rule in ITERATIONS_CSS.