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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9133-iterations-is-jay-s-actual-page-with-his → main 7 commits fork 3 threads · 3 unresolved
22 files
changed +1849
−520
docs/design/areas/iterations.md +16 −9
| @@ -1,12 +1,19 @@ | ||
| 1 | 1 | # Iterations |
| 2 | 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; `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. | |
| 4 | 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. | |
| 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 @@ | ||
| 1 | 1 | # Iterations |
| 2 | 2 | |
| 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. | |
| 4 | 4 | |
| 5 | 5 | ## On the web |
| 6 | 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. | |
| 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. | |
| 8 | 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. | |
| 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. | |
| 14 | 17 | |
| 15 | 18 | ## In the terminal |
| 16 | 19 | |
| 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. | |
| 18 | 25 | |
| 19 | 26 | ## Over the API |
| 20 | 27 |
src/actions/builds.ts +4
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * is what the feed and webhooks read. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { forget } from "../core/ttl_cache.ts"; | |
| 8 | 9 | import type { App } from "../app.ts"; |
| 9 | 10 | import { commitIdentity } from "../core/commit_identity.ts"; |
| 10 | 11 | import { copyNoteToIntent } from "../core/intent_notes.ts"; |
| @@ -65,6 +66,7 @@ export async function pinBuild( | ||
| 65 | 66 | tagger: who, |
| 66 | 67 | }); |
| 67 | 68 | if (!pinned.ok) return pinned; |
| 69 | forget(app); | |
| 68 | 70 | app.bus.append({ |
| 69 | 71 | kind: "build.pinned", |
| 70 | 72 | repoId: repo.id, |
| @@ -95,6 +97,7 @@ export async function deletePin( | ||
| 95 | 97 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); |
| 96 | 98 | const gone = await gitDeletePin(dir, label); |
| 97 | 99 | if (!gone.ok) return gone; |
| 100 | forget(app); | |
| 98 | 101 | // Its notes stay: they are append-only (`git/builds.ts`). |
| 99 | 102 | app.bus.append({ |
| 100 | 103 | kind: "build.deleted", |
| @@ -126,6 +129,7 @@ export async function notePin( | ||
| 126 | 129 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); |
| 127 | 130 | const noted = await notePinBuild(dir, label, text, who, commitIdentity(app.config, writer.value)); |
| 128 | 131 | if (!noted.ok) return noted; |
| 132 | forget(app); | |
| 129 | 133 | const { pin, note, commit } = noted.value; |
| 130 | 134 | app.bus.append({ |
| 131 | 135 | kind: "build.noted", |
src/actions/pulls.ts +17 −2
| @@ -64,6 +64,7 @@ import { approveGuest } from "../origins/guest.ts"; | ||
| 64 | 64 | import { grantRun } from "../core/run_grant.ts"; |
| 65 | 65 | import { commitIdentity } from "../core/commit_identity.ts"; |
| 66 | 66 | import { getUserById, getUserByName, type User } from "../core/users.ts"; |
| 67 | import { coAuthorLines, messagesBetween } from "../git/coauthors.ts"; | |
| 67 | 68 | import { carriedSummary, resolveOid, type MergeStrategy } from "../git/merge.ts"; |
| 68 | 69 | import { landingKind, thanksNote } from "../core/landing_kind.ts"; |
| 69 | 70 | import { repoDir } from "../git/repos.ts"; |
| @@ -477,15 +478,29 @@ export async function mergePull( | ||
| 477 | 478 | const requester = |
| 478 | 479 | asked === null ? undefined : getUserByName(app.db, asked); |
| 479 | 480 | 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 | : []; | |
| 480 | 496 | const trailers = [ |
| 481 | 497 | landingTrailers( |
| 482 | 498 | requester ? commitIdentity(app.config, requester) : null, |
| 483 | 499 | lander, |
| 484 | 500 | ), |
| 501 | ...kept, | |
| 485 | 502 | ...(from === undefined ? [] : [sourceTrailers(from, ownHost(app.config))]), |
| 486 | 503 | ].join("\n"); |
| 487 | const baseDir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 488 | const at = await pullDir(app.db, app.reposRoot, repo, pull); | |
| 489 | 504 | const carriedBefore = |
| 490 | 505 | strategy === "merge" && pull.crossRepo |
| 491 | 506 | ? await carriedSummary( |
src/core/iterations.ts +210 −111
| @@ -1,151 +1,175 @@ | ||
| 1 | 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. | |
| 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. | |
| 9 | 7 | */ |
| 10 | 8 | |
| 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"; | |
| 13 | 15 | 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"; | |
| 16 | 17 | |
| 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"; | |
| 19 | 20 | |
| 21 | /** One build, as its row, player and info panel draw it. */ | |
| 20 | 22 | export interface IterationBuild { |
| 21 | /** `owner/name:label`. */ | |
| 22 | 23 | readonly key: string; |
| 24 | readonly repo: string; | |
| 23 | 25 | readonly label: string; |
| 24 | 26 | 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; | |
| 26 | 29 | 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. */ | |
| 28 | 32 | 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`. */ | |
| 30 | 40 | readonly parent: string | null; |
| 31 | 41 | 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; | |
| 32 | 48 | } |
| 33 | 49 | |
| 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; | |
| 35 | 54 | /** `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 { | |
| 36 | 117 | readonly repo: string; |
| 37 | 118 | readonly name: string; |
| 119 | /** Its column: fork depth, a concept edge counting as one more step. */ | |
| 120 | readonly depth: number; | |
| 38 | 121 | /** The repository it was forked from, when that is in the family. */ |
| 39 | 122 | readonly forkOf: string | null; |
| 40 | /** Oldest first. */ | |
| 123 | /** Its builds, oldest first. */ | |
| 41 | 124 | readonly builds: readonly IterationBuild[]; |
| 125 | /** Branches with no pin at their tip. */ | |
| 126 | readonly tips: readonly string[]; | |
| 42 | 127 | } |
| 43 | 128 | |
| 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 | */ | |
| 44 | 145 | 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. */ | |
| 46 | 147 | readonly id: string; |
| 47 | 148 | readonly repo: string; |
| 48 | 149 | readonly title: string; |
| 49 | 150 | 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. */ | |
| 51 | 152 | readonly from: string | null; |
| 52 | 153 | /** For a main run with no build to hang from, the repository it was forked from. */ |
| 53 | 154 | readonly fromRepo: string | null; |
| 54 | readonly kind: "fork" | "branch" | "concept"; | |
| 155 | readonly kind: "fork" | "branch" | "concept" | null; | |
| 55 | 156 | 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[]; | |
| 135 | 158 | } |
| 136 | 159 | |
| 137 | 160 | /** |
| 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. | |
| 142 | 166 | */ |
| 143 | 167 | 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[] }; | |
| 145 | 169 | const lanes: Lane[] = []; |
| 146 | 170 | const laneOf = new Map<string, Lane>(); |
| 147 | 171 | 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)) { | |
| 149 | 173 | let main: Lane | undefined; |
| 150 | 174 | const add = (l: Lane) => (lanes.push(l), l); |
| 151 | 175 | for (const b of f.builds) { |
| @@ -156,22 +180,35 @@ export function lanesOf(forks: readonly IterationFork[]): IterationLane[] { | ||
| 156 | 180 | else if (from === null && main !== undefined) lane = main; |
| 157 | 181 | else if (main === undefined && (at === undefined || at.repo !== f.repo)) |
| 158 | 182 | 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, | |
| 160 | 188 | 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", | |
| 162 | 190 | builds: [], |
| 191 | tips: f.tips, | |
| 163 | 192 | }); |
| 164 | 193 | else |
| 165 | 194 | 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, | |
| 167 | 201 | kind: at !== undefined && at.repo === f.repo ? "branch" : b.edge === "concept" ? "concept" : "fork", |
| 168 | 202 | builds: [], |
| 203 | tips: [], | |
| 169 | 204 | }); |
| 170 | 205 | lane.builds.push(b); |
| 171 | 206 | laneOf.set(b.key, lane); |
| 172 | 207 | } |
| 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 }); | |
| 174 | 210 | } |
| 211 | // Depth first from the roots; under a run, the child that starts lowest comes first. | |
| 175 | 212 | const childrenOf = (l: Lane) => |
| 176 | 213 | lanes |
| 177 | 214 | .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[] { | ||
| 191 | 228 | for (const l of lanes) visit(l); |
| 192 | 229 | return out; |
| 193 | 230 | } |
| 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[]> { | ||
| 470 | 470 | } |
| 471 | 471 | return pins; |
| 472 | 472 | } |
| 473 | ||
| 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 { | ||
| 16 | 16 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 17 | 17 | const repo = loadRepo(ctx); |
| 18 | 18 | if (!repo) return notFound(); |
| 19 | const forks = (await iterationsOf(ctx.app, ctx.auth.user, repo)) ?? []; | |
| 19 | const forks = (await iterationsOf(ctx, repo))?.forks ?? []; | |
| 20 | 20 | const res = json({ forks, lanes: lanesOf(forks) }); |
| 21 | 21 | res.headers.set("cache-control", "private, no-cache"); |
| 22 | 22 | res.headers.set("vary", "authorization, cookie"); |
src/http/mcp/tools/iterations.ts +1 −1
| @@ -14,7 +14,7 @@ export const tool: Tool = { | ||
| 14 | 14 | async run(ctx, user, args) { |
| 15 | 15 | const repo = repoArg(ctx, user, { repo: `${str(args["owner"])}/${str(args["name"])}` }); |
| 16 | 16 | 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 ?? []; | |
| 18 | 18 | return good({ forks, lanes: lanesOf(forks) }); |
| 19 | 19 | }, |
| 20 | 20 | }; |
src/http/server.ts +2 −1
| @@ -183,7 +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 | import { iterationsPage, noteSubmit } from "../web/pages/iterations.ts"; | |
| 187 | 187 | import { buildsPage, deletePinSubmit, newBuildPage, pinBuildSubmit, previewBuildSubmit } from "../web/pages/builds.ts"; |
| 188 | 188 | import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts"; |
| 189 | 189 | import { |
| @@ -1264,6 +1264,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1264 | 1264 | router.add("POST", "/:owner/:name/builds", pinBuildSubmit); |
| 1265 | 1265 | router.add("POST", "/:owner/:name/builds/preview", previewBuildSubmit); |
| 1266 | 1266 | router.add("POST", "/:owner/:name/builds/:label/delete", deletePinSubmit); |
| 1267 | router.add("POST", "/:owner/:name/builds/:label/note", noteSubmit); | |
| 1267 | 1268 | router.add("GET", "/:owner/:name/waves.atom", wavesAtom); |
| 1268 | 1269 | router.add("GET", "/:owner/:name/waves", wavesPage); |
| 1269 | 1270 | 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 { | ||
| 61 | 61 | async load() { |
| 62 | 62 | // Gated here as well as on the command: the screen is exported. |
| 63 | 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)); | |
| 64 | const forks = hidden ? undefined : await iterationsOf({ app: f.app, auth: { user: f.user } }, repo); | |
| 65 | lines = forks === undefined ? [] : iterationLines(lanesOf(forks.forks)); | |
| 66 | 66 | state.clamp(lines.length); |
| 67 | 67 | ctx.invalidate(); |
| 68 | 68 | }, |
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
|
||
| 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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 5 | 7 | */ |
| 6 | 8 | |
| 7 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 8 | import { previewBuild } from "../../actions/build_preview.ts"; | |
| 9 | import { openEnvUrl } from "../../actions/apps.ts"; | |
| 10 | 9 | 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"; | |
| 11 | 14 | import { canWrite } from "../../core/repos.ts"; |
| 15 | import { | |
| 16 | isPinLabel, | |
| 17 | listBuildPins, | |
| 18 | type BuildPin, | |
| 19 | } from "../../git/builds.ts"; | |
| 12 | 20 | import { exactRefOid } from "../../git/branches.ts"; |
| 13 | import { listBuildPins, type BuildPin } from "../../git/builds.ts"; | |
| 14 | 21 | import { isSafeOid } from "../../git/guard.ts"; |
| 15 | 22 | import { repoDir } from "../../git/repos.ts"; |
| 23 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 16 | 24 | import type { Ctx } from "../../http/context.ts"; |
| 17 | 25 | 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"; | |
| 20 | 29 | 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 | } | |
| 22 | 44 | |
| 23 | 45 | /** The newest label stepped on by one. */ |
| 24 | 46 | export function nextLabel(pins: readonly BuildPin[]): string { |
| @@ -31,75 +53,113 @@ export function nextLabel(pins: readonly BuildPin[]): string { | ||
| 31 | 53 | return `${last}-2`; |
| 32 | 54 | } |
| 33 | 55 | |
| 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"; | |
| 42 | 58 | |
| 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. | |
| 44 | 60 | export async function buildsPage(ctx: Ctx): Promise<Response> { |
| 61 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 45 | 62 | const repo = loadRepo(ctx); |
| 46 | 63 | if (!repo) return notFoundPage(ctx); |
| 47 | 64 | const base = repoBase(repo); |
| 48 | const pins = await listBuildPins(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name)); | |
| 65 | const nodes = await buildLineage(ctx, repo); | |
| 49 | 66 | const writer = ctx.auth.user !== undefined && canWrite(ctx.app.db, repo, ctx.auth.user); |
| 67 | const isApp = getAppByRepo(ctx.app.db, repo.id) !== undefined; | |
| 50 | 68 | 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 | ); | |
| 99 | 73 | } |
| 100 | 74 | |
| 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 | (max-width:640px){.build-meta{display:none}} | |
| 158 | `; | |
| 159 | ||
| 101 | 160 | // GET /:owner/:name/builds/new?sha=&source= — the pin form. |
| 102 | 161 | export async function newBuildPage(ctx: Ctx): Promise<Response> { |
| 162 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 103 | 163 | const repo = loadRepo(ctx); |
| 104 | 164 | if (!repo) return notFoundPage(ctx); |
| 105 | 165 | if (!ctx.auth.user) return signInFirst(ctx); |
| @@ -107,26 +167,60 @@ export async function newBuildPage(ctx: Ctx): Promise<Response> { | ||
| 107 | 167 | const base = repoBase(repo); |
| 108 | 168 | const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); |
| 109 | 169 | 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. | |
| 110 | 172 | 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; | |
| 113 | 181 | const body = html`${repoHeader(ctx, repo, "branches", { compact: true })} |
| 114 | 182 | <div class="mt"> |
| 115 | 183 | ${card( |
| 116 | 184 | html`<form method="post" action="${base}/builds" class="stack"> |
| 117 | 185 | ${field({ label: "Commit", name: "sha", value: isSafeOid(want) ? want : "", required: true, mono: true, hint: "Full commit SHA." })} |
| 118 | 186 | <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." })} | |
| 120 | 189 | ${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 | })} | |
| 121 | 204 | <div class="bar"> |
| 122 | 205 | ${btn("Pin build", { variant: "primary", icon: "pin" })} |
| 123 | ${btn("Cancel", { href: `${base}/builds`, variant: "ghost" })} | |
| 206 | ${btn("Cancel", { href: back ?? `${base}/branches`, variant: "ghost" })} | |
| 124 | 207 | </div> |
| 125 | 208 | </form>`, |
| 126 | { head: html`${icon("pin")}<h2>Pin a build</h2>` }, | |
| 209 | { | |
| 210 | head: html`${icon("pin")} | |
| 211 | <h2>Pin a build</h2>`, | |
| 212 | }, | |
| 127 | 213 | )} |
| 128 | 214 | </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 | ); | |
| 130 | 224 | } |
| 131 | 225 | |
| 132 | 226 | const PIN_ERRORS: Record<string, string> = { |
| @@ -138,8 +232,6 @@ const PIN_ERRORS: Record<string, string> = { | ||
| 138 | 232 | git_failed: "Pin failed: git refused the ref.", |
| 139 | 233 | }; |
| 140 | 234 | |
| 141 | const hidden = (error: string): boolean => error === "unauthorized" || error === "not_found" || error === "forbidden"; | |
| 142 | ||
| 143 | 235 | // POST /:owner/:name/builds — pin it. |
| 144 | 236 | export async function pinBuildSubmit(ctx: Ctx): Promise<Response> { |
| 145 | 237 | const repo = loadRepo(ctx); |
| @@ -156,31 +248,17 @@ export async function pinBuildSubmit(ctx: Ctx): Promise<Response> { | ||
| 156 | 248 | source: formValue(form, "source") || "commit", |
| 157 | 249 | }); |
| 158 | 250 | 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); | |
| 160 | 253 | return flashTo(ctx, `${base}/builds/new?sha=${encodeURIComponent(formValue(form, "sha"))}`, { |
| 161 | 254 | tone: "danger", |
| 162 | 255 | message: PIN_ERRORS[pinned.error] ?? "Pin failed.", |
| 163 | 256 | }); |
| 164 | 257 | } |
| 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 }); | |
| 184 | 262 | } |
| 185 | 263 | |
| 186 | 264 | const PREVIEW_ERRORS: Record<string, string> = { |
| @@ -190,36 +268,44 @@ const PREVIEW_ERRORS: Record<string, string> = { | ||
| 190 | 268 | no_runtime: "Apps are disabled on this forge.", |
| 191 | 269 | }; |
| 192 | 270 | |
| 193 | // POST /:owner/:name/builds/preview — serve one commit for a while, then open it. | |
| 194 | 271 | export async function previewBuildSubmit(ctx: Ctx): Promise<Response> { |
| 195 | 272 | const repo = loadRepo(ctx); |
| 196 | 273 | if (!repo) return notFoundPage(ctx); |
| 197 | 274 | if (!ctx.auth.user) return signInFirst(ctx); |
| 198 | 275 | const form = await readForm(ctx, "rw"); |
| 199 | 276 | 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")); | |
| 202 | 278 | 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`, { | |
| 205 | 283 | tone: "danger", |
| 206 | 284 | message: PREVIEW_ERRORS[preview.error] ?? "Preview failed.", |
| 207 | 285 | }); |
| 208 | 286 | } |
| 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; | |
| 212 | 296 | // 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); | |
| 215 | 301 | return page({ |
| 216 | 302 | ctx, |
| 217 | title: `Opening ${oid.slice(0, 7)} · ${repo.name}`, | |
| 303 | title: `Opening ${short} · ${repo.name}`, | |
| 218 | 304 | crumbs: crumbsFor(repo), |
| 219 | 305 | scriptJs: `location.replace(${JSON.stringify(url).replace(/</g, "\\u003c")});`, |
| 220 | 306 | body: html`<div class="mt"> |
| 221 | 307 | ${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> | |
| 223 | 309 | <p><a class="btn" data-variant="primary" href="${url}">Open ${url}</a></p>`, |
| 224 | 310 | { head: html`${icon("eye")}<h2>Opening preview</h2>` }, |
| 225 | 311 | )} |
src/web/pages/iterations.ts +554 −45
| @@ -1,70 +1,548 @@ | ||
| 1 | 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 `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. | |
| 8 | 14 | */ |
| 9 | 15 | |
| 16 | import { notePin } from "../../actions/builds.ts"; | |
| 17 | import { iterationsOf, lanesOf, type IterationBuild, type IterationFork, type IterationLane } from "../../core/iterations.ts"; | |
| 10 | 18 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 19 | import { NOTES_MAX, splitPinNote, wholeNotes } from "../../git/builds.ts"; | |
| 11 | 20 | 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"; | |
| 18 | 26 | |
| 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> | |
| 22 | 42 | ${when(b.note !== "", () => html`<span class="it-n">${b.note}</span>`)} |
| 23 | </div>`; | |
| 43 | </button>`; | |
| 44 | } | |
| 24 | 45 | |
| 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> | |
| 41 | 57 | </section>`; |
| 42 | 58 | } |
| 43 | 59 | |
| 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 | } | |
| 46 | 78 | |
| 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 | `; | |
| 49 | 144 | |
| 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 | |
| 51 | 487 | export async function iterationsPage(ctx: Ctx): Promise<Response> { |
| 52 | 488 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 53 | 489 | const repo = loadRepo(ctx); |
| 54 | 490 | 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)); | |
| 57 | 493 | 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 | }; | |
| 60 | 519 | 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"}"> | |
| 62 | 521 | <div class="page-head mt"> |
| 63 | 522 | <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> | |
| 65 | 528 | </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>`)} | |
| 68 | 546 | </div>`; |
| 69 | 547 | return timePhase(ctx.request, "render", () => |
| 70 | 548 | page({ |
| @@ -79,3 +557,34 @@ export async function iterationsPage(ctx: Ctx): Promise<Response> { | ||
| 79 | 557 | }), |
| 80 | 558 | ); |
| 81 | 559 | } |
| 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", () => { | ||
| 100 | 100 | source: "branch main", |
| 101 | 101 | }); |
| 102 | 102 | 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"); | |
| 104 | 104 | const [pin] = await pins(); |
| 105 | 105 | expect(pin?.label).toBe("v1"); |
| 106 | 106 | 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"; | ||
| 16 | 16 | import { createUser, type User } from "../src/core/users.ts"; |
| 17 | 17 | import { promote } from "./helpers/members.ts"; |
| 18 | 18 | 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"; | |
| 21 | 22 | |
| 22 | 23 | const build = (repo: string, label: string, parent: string | null = null, edge: IterationBuild["edge"] = parent === null ? null : "git"): IterationBuild => ({ |
| 23 | 24 | key: `${repo}:${label}`, |
| @@ -27,12 +28,22 @@ const build = (repo: string, label: string, parent: string | null = null, edge: | ||
| 27 | 28 | note: "", |
| 28 | 29 | parent, |
| 29 | 30 | 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, | |
| 30 | 41 | }); |
| 31 | 42 | |
| 32 | 43 | describe("lanesOf", () => { |
| 33 | 44 | test("a pin continues its repository's run; a second child of a build starts a branch run under it", () => { |
| 34 | 45 | 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")] }, | |
| 36 | 47 | ]; |
| 37 | 48 | const lanes = lanesOf(forks); |
| 38 | 49 | expect(lanes.map((l) => [l.id, l.main, l.builds.map((b) => b.label)])).toEqual([ |
| @@ -45,9 +56,9 @@ describe("lanesOf", () => { | ||
| 45 | 56 | |
| 46 | 57 | test("a fork with no parent pin hangs under its upstream's head; a concept edge keeps its kind", () => { |
| 47 | 58 | 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")] }, | |
| 51 | 62 | ]; |
| 52 | 63 | const lanes = lanesOf(forks); |
| 53 | 64 | const b = lanes.find((l) => l.id === "b/g")!; |
| @@ -58,8 +69,8 @@ describe("lanesOf", () => { | ||
| 58 | 69 | |
| 59 | 70 | test("a fork with no builds still has a card; a parent cycle ends", () => { |
| 60 | 71 | 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: [] }, | |
| 63 | 74 | ]; |
| 64 | 75 | expect(lanesOf(forks).map((l) => l.id).sort()).toEqual(["a/g", "b/g"]); |
| 65 | 76 | }); |
| @@ -128,34 +139,38 @@ describe("iterations page", () => { | ||
| 128 | 139 | }); |
| 129 | 140 | |
| 130 | 141 | 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"]) { | |
| 132 | 143 | const res = await get(path); |
| 133 | 144 | expect(res.status).toBe(200); |
| 134 | 145 | const text = await res.text(); |
| 135 | 146 | expect(text).toContain("Iterations"); |
| 136 | 147 | 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"'); | |
| 140 | 150 | } |
| 141 | 151 | }); |
| 142 | 152 | |
| 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(); | |
| 145 | 168 | 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"); | |
| 159 | 174 | }); |
| 160 | 175 | |
| 161 | 176 | test("a fold hides its rows and shows the one node it leaves", () => { |
| @@ -166,6 +181,36 @@ describe("iterations page", () => { | ||
| 166 | 181 | expect(rule(".it-coll{")).toContain("display:none"); |
| 167 | 182 | }); |
| 168 | 183 | |
| 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 <i>the jump</i>"); | |
| 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 | ||
| 169 | 214 | test("a private fork and its pin are drawn to its owner and never to a guest", async () => { |
| 170 | 215 | const guest = await (await get("/alice/game/iterations")).text(); |
| 171 | 216 | expect(guest).not.toContain("hush"); |
| @@ -178,27 +223,13 @@ describe("iterations page", () => { | ||
| 178 | 223 | expect(mine.split("carol/hush:v1").length).toBe(1); |
| 179 | 224 | }); |
| 180 | 225 | |
| 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()); | |
| 184 | 229 | expect(all[0]!.name).toBe("game"); |
| 185 | 230 | 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"]); | |
| 202 | 233 | }); |
| 203 | 234 | |
| 204 | 235 | test("a note is escaped", async () => { |
| @@ -213,6 +244,8 @@ describe("iterations page", () => { | ||
| 213 | 244 | expect(res.headers.get("referrer-policy")).toBeTruthy(); |
| 214 | 245 | expect(res.headers.get("content-security-policy") ?? "").not.toContain("frame-src http"); |
| 215 | 246 | 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"); | |
| 216 | 249 | }); |
| 217 | 250 | |
| 218 | 251 | test("a family with no pin is not found", async () => { |
| @@ -226,8 +259,21 @@ describe("iterations page", () => { | ||
| 226 | 259 | expect((await get("/alice/secret/iterations", cookie)).status).toBe(200); |
| 227 | 260 | }); |
| 228 | 261 | |
| 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 | ||
| 229 | 275 | 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"'); | |
| 231 | 277 | expect(await (await get("/alice/empty/builds")).text()).not.toContain("/iterations"); |
| 232 | 278 | }); |
| 233 | 279 |
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"; | ||
| 12 | 12 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 13 | 13 | import { iterationLines, iterationsCommands, iterationsScreen, NO_ITERATIONS_LINE, NOT_FOUND_LINE } from "../src/tui/screens/iterations.ts"; |
| 14 | 14 | 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"; | |
| 16 | 16 | import { quietLogs } from "./helpers/globals.ts"; |
| 17 | 17 | import { settleApp } from "./helpers/tui.ts"; |
| 18 | 18 | |
| @@ -80,8 +80,11 @@ async function drive(repo: Repo, user: User | undefined): Promise<string> { | ||
| 80 | 80 | } |
| 81 | 81 | |
| 82 | 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")] }])); | |
| 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")] }])); | |
| 85 | 88 | expect(lines.map((l) => [l.depth, l.head, l.text.split(" ")[0]])).toEqual([ |
| 86 | 89 | [0, true, "g"], |
| 87 | 90 | [1, false, "v1"], |
new above
offline
Add a blank line before this function's doc comment. Remove the stray blank line at the file's end.