Public: anyone can read this; the crew can change it.
A preview's toolbar page says what its pull changed and whether its check is green, each link a 44 px tap on touch screens #4221
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4205-a-preview-s-toolbar-page-says-what-its-p → main 7 commits fork 2 threads · 2 unresolved
6 files
changed +241
−8
README.md +1 −1
| @@ -982,7 +982,7 @@ Under the box, four starter sentences are plain links (no script): | ||
| 982 | 982 | (`"toolbar": false` in `forge.json` turns it off; to write it in, the proxy asks the app for the page uncompressed, so a page that carries the pill goes out without gzip) links to a forge page, |
| 983 | 983 | `/:owner/:name/toolbar?env=production|pull-N`, opened in a tab of its own: the |
| 984 | 984 | pill posts nothing and reads nothing of the app's page, and every action is a |
| 985 | same-origin form on the forge. Signed out, the page is where you are and Home. | |
| 985 | same-origin form on the forge. On a preview, everyone who may see the pull sees what it changed (files, +added, −removed, linked to its Files tab) and where its latest check stands (linked to the run); the totals are left out when git cannot give them. Signed out, the page is where you are and Home. | |
| 986 | 986 | Signed in it shows the live agent run on this app or preview, linked to its |
| 987 | 987 | run page (read once when the page opens; runs on work you may not see, such as |
| 988 | 988 | a sealed report, are left out); *Report this page* / *Comment on this pull*, |
docs/design/areas/toolbar.md added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | # Toolbar page | |
| 2 | ||
| 3 | - `.change-line` — a preview's toolbar page: the pull's diff totals and its check badge on one wrapping line, each link 40px tall (the dense-row step, set by padding) and 44px under `@media (pointer: coarse)`. It shares `.diff-summary`'s `.add`/`.del` colours. A stack of `.diff-summary` and the badge cannot carry the sizing: `.diff-summary` is a padded block, not a row of separately tappable links, and the badge is not itself a link target. |
src/web/diff-view.ts +12
| @@ -545,6 +545,18 @@ function fileHasReview(file: FileDiff, opts: DiffViewOptions): boolean { | ||
| 545 | 545 | return false; |
| 546 | 546 | } |
| 547 | 547 | |
| 548 | /** The diff's own totals as the summary line words them: "3 files", "+12", "−4". */ | |
| 549 | export function diffTotals(diff: { | |
| 550 | files: { length: number }; | |
| 551 | additions: number; | |
| 552 | deletions: number; | |
| 553 | }): Html { | |
| 554 | const n = diff.files.length; | |
| 555 | return html`<strong>${n}</strong> ${n === 1 ? "file" : "files"} | |
| 556 | <span class="add">+${diff.additions}</span> | |
| 557 | <span class="del">−${diff.deletions}</span>`; | |
| 558 | } | |
| 559 | ||
| 548 | 560 | /** |
| 549 | 561 | * The whole diff: a summary line, then one collapsible view per file. Options |
| 550 | 562 | * add syntax color and inline review comments; omit them and the diff still |
src/web/studio/toolbar.ts +64 −5
| @@ -19,21 +19,24 @@ import { addCommentThrough, openIssueThrough } from "../../actions/issues.ts"; | ||
| 19 | 19 | import { reportTitle } from "../../actions/page-report.ts"; |
| 20 | 20 | import { getAppByRepo } from "../../core/apps.ts"; |
| 21 | 21 | import { getIssue, MAX_BODY } from "../../core/issues.ts"; |
| 22 | import { diffBetween, getPull, pullDir, type Pull } from "../../core/pulls.ts"; | |
| 22 | 23 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| 23 | import { listRuns } from "../../core/runs.ts"; | |
| 24 | import { checkSummary, listRuns } from "../../core/runs.ts"; | |
| 24 | 25 | import { mayView, mayViewId } from "../../core/sealed.ts"; |
| 25 | 26 | import { mayDirectCrew } from "../../crew/direct.ts"; |
| 26 | 27 | import { isSafeOid } from "../../git/guard.ts"; |
| 28 | import { resolveOid } from "../../git/merge.ts"; | |
| 27 | 29 | import { commitLog } from "../../git/read.ts"; |
| 28 | 30 | import { repoDir } from "../../git/repos.ts"; |
| 29 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 31 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; | |
| 30 | 32 | import type { Ctx } from "../../http/context.ts"; |
| 31 | 33 | import { notFoundPage, pageHead, loadRepo, repoBase } from "../chrome.ts"; |
| 34 | import { diffTotals } from "../diff-view.ts"; | |
| 32 | 35 | import { field, readForm } from "../form.ts"; |
| 33 | 36 | import { html, when, type Html } from "../html.ts"; |
| 34 | 37 | import { page } from "../layout.ts"; |
| 35 | 38 | import { originError } from "../pages/thread.ts"; |
| 36 | import { btn, card, envFacts } from "../ui.ts"; | |
| 39 | import { btn, card, envFacts, runStateBadge, badge } from "../ui.ts"; | |
| 37 | 40 | |
| 38 | 41 | /** The longest page address the bar may carry. */ |
| 39 | 42 | const PAGE_MAX = 512; |
| @@ -58,6 +61,57 @@ function parseEnv(raw: string): { env: string; pull?: number } | undefined { | ||
| 58 | 61 | return n === undefined ? undefined : { env: raw, pull: Number(n) }; |
| 59 | 62 | } |
| 60 | 63 | |
| 64 | interface Stat { | |
| 65 | readonly files: number; | |
| 66 | readonly additions: number; | |
| 67 | readonly deletions: number; | |
| 68 | } | |
| 69 | ||
| 70 | /** The diff stat per pull, head oid and base oid: git is asked once per pair. */ | |
| 71 | const stats = new Map<string, Stat>(); | |
| 72 | const STATS_MAX = 500; | |
| 73 | ||
| 74 | /** How many times git was asked for a stat: a test reads it to prove the cache. */ | |
| 75 | export const statReads = { count: 0 }; | |
| 76 | ||
| 77 | /** The totals, or undefined when git could not say: a guess would read as "changed nothing". */ | |
| 78 | async function statOf(ctx: Ctx, repo: Repo, pull: Pull): Promise<Stat | undefined> { | |
| 79 | try { | |
| 80 | const dir = await pullDir(ctx.app.db, ctx.app.reposRoot, repo, pull); | |
| 81 | const baseOid = (await resolveOid(dir, `refs/heads/${pull.baseBranch}`)) ?? pull.baseOid; | |
| 82 | const key = `${pull.id}:${pull.headOid}:${baseOid}`; | |
| 83 | const kept = stats.get(key); | |
| 84 | if (kept) return kept; | |
| 85 | statReads.count++; | |
| 86 | const { diff, failed } = await diffBetween(dir, baseOid, pull.headOid); | |
| 87 | if (failed) return undefined; | |
| 88 | const found = { files: diff.files.length, additions: diff.additions, deletions: diff.deletions }; | |
| 89 | if (stats.size >= STATS_MAX) stats.delete(stats.keys().next().value!); | |
| 90 | stats.set(key, found); | |
| 91 | return found; | |
| 92 | } catch { | |
| 93 | return undefined; | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | /** What the pull changed and where its latest check stands, each a link. */ | |
| 98 | async function changeLine(ctx: Ctx, repo: Repo, pull: Pull, base: string) { | |
| 99 | const stat = await statOf(ctx, repo, pull); | |
| 100 | const check = checkSummary(ctx.app.db, pull.id, pull.headOid); | |
| 101 | return html`<p class="change-line"> | |
| 102 | ${stat | |
| 103 | ? html`<a href="${base}/pulls/${pull.number}/files">${diffTotals({ | |
| 104 | files: { length: stat.files }, | |
| 105 | additions: stat.additions, | |
| 106 | deletions: stat.deletions, | |
| 107 | })}</a>` | |
| 108 | : html``} | |
| 109 | ${check.run | |
| 110 | ? html`<a href="${base}/runs/${check.run.id}">${runStateBadge(check.run.state, check.run.kind)}</a>` | |
| 111 | : badge("No check", { icon: "clock", variant: "outline" })} | |
| 112 | </p>`; | |
| 113 | } | |
| 114 | ||
| 61 | 115 | /** The toolbar page's own path, for forms to come back to. */ |
| 62 | 116 | const toolbarPath = (base: string, env: string, address: string): string => |
| 63 | 117 | `${base}/toolbar?env=${env}${address === "" ? "" : `&page=${encodeURIComponent(address)}`}`; |
| @@ -113,13 +167,18 @@ export async function toolbarPage(ctx: Ctx): Promise<Response> { | ||
| 113 | 167 | const here = html`${repo.name}, ${pull ? html`preview of pull #${pull.number}` : "production"}`; |
| 114 | 168 | const liveNote = pull ? html`` : await liveLine(ctx, repo, base); |
| 115 | 169 | const home = btn("Home", { href: "/", variant: "ghost" }); |
| 170 | // The issue row carries no head oid; a pull row may be absent (an import in progress). | |
| 171 | const joined = pull ? getPull(db, repo.id, pull.number) : undefined; | |
| 172 | const change = joined | |
| 173 | ? await timePhaseAsync(request, "change", () => changeLine(ctx, repo, joined, base)) | |
| 174 | : null; | |
| 116 | 175 | const rendering = performance.now(); |
| 117 | 176 | if (!user) { |
| 118 | 177 | const next = encodeURIComponent(toolbarPath(base, where.env, address)); |
| 119 | 178 | const response = page({ |
| 120 | 179 | ctx, |
| 121 | 180 | title: `${repo.name} · forge`, |
| 122 | body: html`<div class="narrow-wide">${pageHead(here)}${liveNote}<div class="form-actions">${btn("Sign in to comment", { href: `/signin?next=${next}`, variant: "primary" })}${home}</div></div>`, | |
| 181 | body: html`<div class="narrow-wide">${pageHead(here)}${change}${liveNote}<div class="form-actions">${btn("Sign in to comment", { href: `/signin?next=${next}`, variant: "primary" })}${home}</div></div>`, | |
| 123 | 182 | }); |
| 124 | 183 | notePhase(request, "render", performance.now() - rendering); |
| 125 | 184 | return response; |
| @@ -139,7 +198,7 @@ export async function toolbarPage(ctx: Ctx): Promise<Response> { | ||
| 139 | 198 | const comment = pull ? "Comment on this pull" : "Report this page"; |
| 140 | 199 | const body = html`<div class="narrow-wide"> |
| 141 | 200 | ${pageHead(here)} |
| 142 | ${liveNote} | |
| 201 | ${change}${liveNote} | |
| 143 | 202 | ${when( |
| 144 | 203 | live !== undefined, |
| 145 | 204 | () => html`<p class="mono" data-working> |
src/web/style/content.ts +5 −2
| @@ -611,9 +611,12 @@ form.inline { display: inline; margin-left: auto; } | ||
| 611 | 611 | |
| 612 | 612 | /* ---- diff viewer ---- */ |
| 613 | 613 | .diff { display: flex; flex-direction: column; gap: 12px; } |
| 614 | .change-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; margin: 0 0 12px; font-size: 13px; } | |
| 615 | .change-line a { display: inline-flex; align-items: center; gap: 6px; padding: 12px 0; line-height: 16px; color: inherit; } | |
color: inherit hides that these are links; add an underline or the standard link colour. |
||
| 616 | @media (pointer: coarse) { .change-line a { padding: 14px 0; } } | |
outdated
Duplicates .diff-summary .add/.del; extend that selector list instead of copying the declarations. |
||
| 614 | 617 | .diff-summary { padding: 10px 14px; font-size: 13px; } |
| 615 | .diff-summary .add { color: var(--add-fg); font-weight: 600; font-variant-numeric: tabular-nums; } | |
| 616 | .diff-summary .del { color: var(--del-fg); font-weight: 600; font-variant-numeric: tabular-nums; } | |
| 618 | .diff-summary .add, .change-line .add { color: var(--add-fg); font-weight: 600; font-variant-numeric: tabular-nums; } | |
| 619 | .diff-summary .del, .change-line .del { color: var(--del-fg); font-weight: 600; font-variant-numeric: tabular-nums; } | |
| 617 | 620 | |
| 618 | 621 | .diff-file { |
| 619 | 622 | /* Pure-CSS windowing: off-screen files cost nothing until scrolled to. */ |
test/toolbar-change-line.test.ts added +156
| @@ -0,0 +1,156 @@ | ||
| 1 | /** A preview's toolbar page says what its pull changed and where its check stands (#4221). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { newApp } from "../src/actions/apps.ts"; | |
| 8 | import { openPull } from "../src/actions/pulls.ts"; | |
| 9 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 10 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 11 | import { createSession } from "../src/core/sessions.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { getPull } from "../src/core/pulls.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { statReads } from "../src/web/studio/toolbar.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let cookie = ""; | |
| 24 | let repoId = ""; | |
| 25 | let aliceId = ""; | |
| 26 | let aliceUser: Parameters<typeof newApp>[1]; | |
| 27 | let repoRow: Parameters<typeof commitToBranch>[2]; | |
| 28 | const numbers: number[] = []; | |
| 29 | const cells = process.env["FORGE_CELLS"]; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | process.env["FORGE_CELLS"] = "off"; | |
| 33 | dataDir = await mkdtemp(join(tmpdir(), "forge-toolbar-change-")); | |
| 34 | app = createApp({ dataDir, port: 0 }); | |
| 35 | servers = await serve(app); | |
| 36 | const made = await createUser(app.db, { | |
| 37 | origin: "seed", | |
| 38 | trusted: true, | |
| 39 | username: "alice", | |
| 40 | email: "[email protected]", | |
| 41 | password: "a-good-passphrase", | |
| 42 | adminIfFirst: true, | |
| 43 | }); | |
| 44 | if (!made.ok) throw new Error(String(made.error)); | |
| 45 | const alice = made.value; | |
| 46 | aliceId = alice.id; | |
| 47 | aliceUser = alice; | |
| 48 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 49 | const repo = await newApp(app, alice, { name: "notes", description: "x", visibility: "public" }); | |
| 50 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 51 | repoId = repo.value.repo.id; | |
| 52 | repoRow = repo.value.repo; | |
| 53 | const base = await commitToBranch(app, alice, repo.value.repo, { | |
| 54 | message: "base", | |
| 55 | files: [{ path: "forge.json", content: "{}" }], | |
| 56 | }); | |
| 57 | if (!base.ok) throw new Error(JSON.stringify(base.error)); | |
| 58 | for (const name of ["green", "red", "none"]) { | |
| 59 | const done = await commitToBranch(app, alice, repo.value.repo, { | |
| 60 | branch: name, | |
| 61 | message: name, | |
| 62 | files: [ | |
| 63 | { path: `${name}.txt`, content: "a\nb\nc\n" }, | |
| 64 | { path: `${name}2.txt`, content: "x\n" }, | |
| 65 | ], | |
| 66 | }); | |
| 67 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 68 | const pull = await openPull(app, alice, repo.value.repo, { | |
| 69 | title: name, | |
| 70 | body: "", | |
| 71 | baseBranch: "main", | |
| 72 | headBranch: name, | |
| 73 | }); | |
| 74 | if (!pull.ok) throw new Error(JSON.stringify(pull.error)); | |
| 75 | numbers.push(pull.value.number); | |
| 76 | } | |
| 77 | }, 60_000); | |
| 78 | ||
| 79 | afterAll(async () => { | |
| 80 | await servers.stop(); | |
| 81 | app.close(); | |
| 82 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 83 | else process.env["FORGE_CELLS"] = cells; | |
| 84 | await rm(dataDir, { recursive: true, force: true }); | |
| 85 | }); | |
| 86 | ||
| 87 | const read = (env: string, c = cookie) => | |
| 88 | fetch(`${servers.url}/alice/notes/toolbar?env=${env}`, { headers: c ? { cookie: c } : {} }); | |
| 89 | ||
| 90 | function check(number: number, state: "ok" | "failed"): string { | |
| 91 | const pull = getPull(app.db, repoId, number)!; | |
| 92 | const run = createRun(app.db, { repoId, kind: "check", issueId: pull.id, commitOid: pull.headOid, actorId: aliceId }); | |
| 93 | claimRun(app.db, run.id); | |
| 94 | finishRun(app.db, run.id, { state, summary: "" }); | |
| 95 | return run.id; | |
| 96 | } | |
| 97 | ||
| 98 | test("a green check: totals link to Files, the badge to the log", async () => { | |
| 99 | const run = check(numbers[0]!, "ok"); | |
| 100 | const res = await read(`pull-${numbers[0]}`); | |
| 101 | expect(res.status).toBe(200); | |
| 102 | const text = await res.text(); | |
| 103 | expect(text).toContain('class="change-line"'); | |
| 104 | expect(text).toContain(`href="/alice/notes/pulls/${numbers[0]}/files"`); | |
| 105 | expect(text).toContain("<strong>2</strong>"); | |
| 106 | expect(text).toContain('<span class="add">+4</span>'); | |
| 107 | expect(text).toContain('<span class="del">−0</span>'); | |
| 108 | expect(text).toContain(`href="/alice/notes/runs/${run}"`); | |
| 109 | expect(text).toContain("Passed"); | |
| 110 | }); | |
| 111 | ||
| 112 | test("a red check", async () => { | |
| 113 | const run = check(numbers[1]!, "failed"); | |
| 114 | const text = await (await read(`pull-${numbers[1]}`)).text(); | |
| 115 | expect(text).toContain(`href="/alice/notes/runs/${run}"`); | |
| 116 | expect(text).toContain("Failed"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("no check, and git is asked once per head and base", async () => { | |
| 120 | const text = await (await read(`pull-${numbers[2]}`)).text(); | |
| 121 | expect(text).toContain("No check"); | |
| 122 | const before = statReads.count; | |
| 123 | await (await read(`pull-${numbers[2]}`)).text(); | |
| 124 | expect(statReads.count).toBe(before); | |
| 125 | // The base moves: the totals are read again, not served stale. | |
| 126 | const moved = await commitToBranch(app, aliceUser, repoRow, { | |
| 127 | message: "base moves", | |
| 128 | files: [{ path: "later.txt", content: "z\n" }], | |
| 129 | }); | |
| 130 | if (!moved.ok) throw new Error(JSON.stringify(moved.error)); | |
| 131 | await (await read(`pull-${numbers[2]}`)).text(); | |
| 132 | expect(statReads.count).toBe(before + 1); | |
| 133 | }); | |
| 134 | ||
| 135 | test("when git cannot give the totals, none are shown", async () => { | |
| 136 | const pull = getPull(app.db, repoId, numbers[2]!)!; | |
| 137 | app.db.query("UPDATE pull SET head_oid = ?1 WHERE issue_id = ?2").run("0".repeat(40), pull.id); | |
| 138 | try { | |
| 139 | const text = await (await read(`pull-${numbers[2]}`)).text(); | |
| 140 | expect(text).toContain('class="change-line"'); | |
| 141 | expect(text).not.toContain("0</strong> files"); | |
| 142 | expect(text).not.toContain('class="add"'); | |
| 143 | } finally { | |
| 144 | app.db.query("UPDATE pull SET head_oid = ?1 WHERE issue_id = ?2").run(pull.headOid, pull.id); | |
| 145 | } | |
| 146 | }); | |
| 147 | ||
| 148 | test("signed out sees it too; production is unchanged", async () => { | |
| 149 | expect(await (await read(`pull-${numbers[0]}`, "")).text()).toContain("change-line"); | |
| 150 | const prod = await (await read("production")).text(); | |
| 151 | expect(prod).not.toContain('class="change-line"'); | |
| 152 | }); | |
| 153 | ||
| 154 | test("a pull the viewer may not see is a 404", async () => { | |
| 155 | expect((await read("pull-9999")).status).toBe(404); | |
| 156 | }); |
new above
offline
min-height: 36px is off the 40px dense-row step in DESIGN (4px grid, dense rows 40px). Use 40px, set by padding so the text stays centered.