Public: anyone can read this; the crew can change it.
A pull's looks open with one line counting the pages shown beside main and those main has no picture of #9773
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/9768-a-pull-s-looks-open-with-one-line-counti → main 4 commits fork 3 threads · 3 unresolved
8 files
changed +151
−4
docs/features/pull-looks.md +5
| @@ -13,6 +13,11 @@ One list, one rule (`pullLooks` in `src/looks/readable.ts`), three doors: | ||
| 13 | 13 | - **MCP** — the `pull` tool carries `looks`, the same items (no new tool). |
| 14 | 14 | - **Terminal** — `I` on a pull opens its looks; the pull screen's conversation also lists them under a `Looks` heading (none, no heading), each with its width, who, the line it says and the absolute URL on its own line. It is the same `pullLooks` read as the API and MCP, so a reader sees only what they may fetch. |
| 15 | 15 | |
| 16 | Above the pictures on the pull page, and at the top of the terminal screen, | |
| 17 | one line counts the pages shown beside main and those main has no picture of | |
| 18 | (`4 pages beside main · 1 new to this pull`); when main has no pictures at all | |
| 19 | it says so once. Both doors share the count in `src/looks/pairs.ts`. | |
| 20 | ||
| 16 | 21 | Who sees what is `mayFetchLook`, the rule the pictures themselves use: a |
| 17 | 22 | reader who may not read the repository gets 404; a writer sees every look |
| 18 | 23 | that has a picture; anyone else sees only those taken as nobody, of an app |
src/looks/pairs.ts added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | /** | |
| 2 | * How many of a pull's pages sit beside a picture of main, and how many are | |
| 3 | * new to the pull: one count, read by the pull page and the terminal screen. | |
| 4 | * Plain data in, plain data out; it reads nothing. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Look, LookKind } from "../core/looks.ts"; | |
| 8 | ||
| 9 | export interface PairCounts { | |
| 10 | /** Pages the head pictures. */ | |
| 11 | readonly pages: number; | |
| 12 | /** Of those, pages main has a light picture of at a width the head shows. */ | |
| 13 | readonly beside: number; | |
| 14 | /** Pages main has no picture of. */ | |
| 15 | readonly fresh: number; | |
| 16 | /** Whether main has no picture at all. */ | |
| 17 | readonly mainEmpty: boolean; | |
| 18 | } | |
| 19 | ||
| 20 | const WIDTHS: readonly LookKind[] = ["phone", "desktop"]; | |
| 21 | ||
| 22 | /** `taken`: the head's light, forge-taken looks; `mainLooks`: main's the reader may fetch. */ | |
| 23 | export function pairCounts( | |
| 24 | taken: readonly Look[], | |
| 25 | mainLooks: readonly Look[], | |
| 26 | ): PairCounts { | |
| 27 | const ok = (l: Look, page: string, kind: LookKind): boolean => | |
| 28 | l.page === page && l.kind === kind && l.error === null; | |
| 29 | const pages = [...new Set(taken.map((l) => l.page))].filter((page) => | |
| 30 | WIDTHS.some((k) => taken.some((l) => ok(l, page, k))), | |
| 31 | ); | |
| 32 | const beside = pages.filter((page) => | |
| 33 | WIDTHS.some( | |
| 34 | (k) => | |
| 35 | taken.some((l) => ok(l, page, k)) && | |
| 36 | mainLooks.some((l) => l.theme === "light" && ok(l, page, k)), | |
| 37 | ), | |
| 38 | ).length; | |
| 39 | return { | |
| 40 | pages: pages.length, | |
| 41 | beside, | |
| 42 | fresh: pages.length - beside, | |
| 43 | mainEmpty: mainLooks.length === 0, | |
| 44 | }; | |
| 45 | } | |
| 46 | ||
| 47 | /** The one line over a pull's pictures; none when the head has no picture. */ | |
| 48 | export function pairsLine(c: PairCounts): string | undefined { | |
| 49 | if (c.pages === 0) return undefined; | |
| 50 | if (c.beside === 0 && c.mainEmpty) return "main has no pictures yet."; | |
| 51 | const n = `${c.beside} page${c.beside === 1 ? "" : "s"}`; | |
| 52 | return `${n} beside main · ${c.fresh} new to this pull`; | |
| 53 | } |
src/looks/readable.ts +19 −1
| @@ -7,11 +7,12 @@ | ||
| 7 | 7 | import type { App } from "../app.ts"; |
| 8 | 8 | import { previewEnv } from "../apps/data.ts"; |
| 9 | 9 | import { getAppByRepo, type AppRecord } from "../core/apps.ts"; |
| 10 | import { looksForEnv, lookUrl, type Look } from "../core/looks.ts"; | |
| 10 | import { isAttached, looksForEnv, lookUrl, type Look } from "../core/looks.ts"; | |
| 11 | 11 | import type { Pull } from "../core/pulls.ts"; |
| 12 | 12 | import { canEnterApp, canSeeOwnerFacts, type Repo } from "../core/repos.ts"; |
| 13 | 13 | import { sealedPreview } from "../core/sealed.ts"; |
| 14 | 14 | import type { User } from "../core/users.ts"; |
| 15 | import { pairCounts, pairsLine } from "./pairs.ts"; | |
| 15 | 16 | import { keepLook } from "./service.ts"; |
| 16 | 17 | |
| 17 | 18 | /** |
| @@ -77,3 +78,20 @@ export function pullLooks( | ||
| 77 | 78 | at: l.createdAt, |
| 78 | 79 | })); |
| 79 | 80 | } |
| 81 | ||
| 82 | /** The pairs line over a pull's looks for this reader (`pairs.ts`), or none. */ | |
| 83 | export function pullPairsLine( | |
| 84 | app: App, | |
| 85 | repo: Repo, | |
| 86 | pull: Pull, | |
| 87 | user: User | undefined, | |
| 88 | ): string | undefined { | |
| 89 | const record = getAppByRepo(app.db, repo.id); | |
| 90 | if (!record) return undefined; | |
| 91 | const mine = (env: string) => | |
| 92 | looksForEnv(app.db, record.id, env, "any").filter( | |
| 93 | (l) => !isAttached(l) && mayFetchLook(app, repo, record, l, user), | |
| 94 | ); | |
| 95 | const head = mine(previewEnv(pull.number)).filter((l) => l.theme === "light"); | |
| 96 | return pairsLine(pairCounts(head, mine("production"))); | |
| 97 | } |
src/tui/screens/pulllooks.ts +7 −2
| @@ -6,10 +6,11 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Pull } from "../../core/pulls.ts"; |
| 8 | 8 | import type { Repo } from "../../core/repos.ts"; |
| 9 | import { pullLooks } from "../../looks/readable.ts"; | |
| 9 | import { pullLooks, pullPairsLine } from "../../looks/readable.ts"; | |
| 10 | 10 | import type { Ctx, Screen } from "../app/app.ts"; |
| 11 | 11 | import { binding } from "../app/keys.ts"; |
| 12 | 12 | import type { Session } from "../session.ts"; |
| 13 | import { style } from "../term/style.ts"; | |
| 13 | 14 | import { emptyState } from "../ui/widgets.ts"; |
| 14 | 15 | import { ListState, ago, drawList, type ListRow } from "./common.ts"; |
| 15 | 16 | |
| @@ -19,6 +20,7 @@ const plain = (text: string): string => | ||
| 19 | 20 | export function pullLooksScreen(f: Session, ctx: Ctx, repo: Repo, pull: Pull): Screen { |
| 20 | 21 | const state = new ListState(); |
| 21 | 22 | const looks = pullLooks(f.app, repo, pull, f.user); |
| 23 | const pairs = pullPairsLine(f.app, repo, pull, f.user); | |
| 22 | 24 | const rows: ListRow[] = looks.map((l) => ({ |
| 23 | 25 | title: plain(`${l.width} · ${l.says ?? "taken by the forge"}`), |
| 24 | 26 | meta: ago(l.at), |
| @@ -37,7 +39,10 @@ export function pullLooksScreen(f: Session, ctx: Ctx, repo: Repo, pull: Pull): S | ||
| 37 | 39 | }); |
| 38 | 40 | return; |
| 39 | 41 | } |
| 40 | drawList(s, area, ctx.theme.c, state, rows); | |
| 42 | const shown = pairs !== undefined && area.h > 1; | |
| 43 | if (shown) | |
| 44 | s.text(area.x, area.y, plain(pairs).slice(0, area.w), style(ctx.theme.c.mutedFg, ctx.theme.c.bg), area.x + area.w); | |
| 45 | drawList(s, shown ? { ...area, y: area.y + 1, h: area.h - 1 } : area, ctx.theme.c, state, rows); | |
| 41 | 46 | }, |
| 42 | 47 | key(e) { |
| 43 | 48 | if (e.key === "esc" || e.key === "q") ctx.pop(); |
src/web/pages/looks.ts +7 −1
| @@ -29,6 +29,7 @@ import type { Pull } from "../../core/pulls.ts"; | ||
| 29 | 29 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 30 | 30 | import type { Ctx } from "../../http/context.ts"; |
| 31 | 31 | import { notFound } from "../../http/respond.ts"; |
| 32 | import { pairCounts, pairsLine } from "../../looks/pairs.ts"; | |
| 32 | 33 | import { mayFetchLook as mayFetchLookFor } from "../../looks/readable.ts"; |
| 33 | 34 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 34 | 35 | import { loadRepo, repoBase } from "../chrome.ts"; |
| @@ -294,9 +295,14 @@ export function lookCard( | ||
| 294 | 295 | </figcaption> |
| 295 | 296 | </figure>`; |
| 296 | 297 | }; |
| 298 | // One line over the pictures, counted from the data `compare` uses. | |
| 299 | const line = pairsLine(pairCounts(taken, mainLooks)); | |
outdated
Both doors: this count lives only in the page. The terminal's pull looks screen needs the same line. Move headPages/besideMain/fresh into a plain function over (taken, mainLooks) and call it from both src/web/pages/looks.ts and src/tui/screens/pulllooks.ts. |
||
| 300 | const summary = line === undefined | |
| 301 | ? html`` | |
| 302 | : html`<p class="muted small" data-pairs>${line}</p>`; | |
| 297 | 303 | return card( |
| 298 | 304 | html`<div class="stack"> |
| 299 | ${pages.map(group)}${when(attached.length > 0, () => | |
| 305 | ${summary}${pages.map(group)}${when(attached.length > 0, () => | |
| 300 | 306 | html`<div class="looks">${attached.map(person)}</div>`, |
| 301 | 307 | )} |
| 302 | 308 | </div>`, |
test/looks-pairs.test.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | /** The pairs count shared by the pull page and the terminal (#9768). */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import type { Look } from "../src/core/looks.ts"; | |
| 4 | import { pairCounts, pairsLine } from "../src/looks/pairs.ts"; | |
| 5 | ||
| 6 | const look = (page: string, kind: Look["kind"], theme: Look["theme"] = "light"): Look => | |
| 7 | ({ page, kind, theme, error: null }) as Look; | |
| 8 | ||
| 9 | test("paired pages count beside main, an unpaired one is new", () => { | |
| 10 | const head = [look("/", "phone"), look("/", "desktop"), look("/about", "phone")]; | |
| 11 | const main = [look("/", "phone")]; | |
| 12 | const c = pairCounts(head, main); | |
| 13 | expect(c).toEqual({ pages: 2, beside: 1, fresh: 1, mainEmpty: false }); | |
| 14 | expect(pairsLine(c)).toBe("1 page beside main · 1 new to this pull"); | |
| 15 | expect(pairsLine(pairCounts(head, [look("/", "phone"), look("/about", "phone")]))).toBe( | |
| 16 | "2 pages beside main · 0 new to this pull", | |
| 17 | ); | |
| 18 | }); | |
| 19 | ||
| 20 | test("no main pictures says so once; no head pictures says nothing; dark does not pair", () => { | |
| 21 | expect(pairsLine(pairCounts([look("/", "phone")], []))).toBe("main has no pictures yet."); | |
| 22 | expect(pairsLine(pairCounts([], [look("/", "phone")]))).toBeUndefined(); | |
| 23 | expect(pairCounts([look("/", "phone")], [look("/", "phone", "dark")]).beside).toBe(0); | |
| 24 | }); |
test/pull-looks-pair.test.ts +35
| @@ -102,3 +102,38 @@ test("with no picture on main, the head stands alone under one line, no empty fr | ||
| 102 | 102 | expect(text).toContain(`/${HEAD}/desktop.png`); |
| 103 | 103 | expect(text).not.toContain(MAIN); |
| 104 | 104 | }); |
| 105 | ||
| 106 | /** Runs `body` against an empty look table, then puts every saved row back. */ | |
| 107 | async function withLooksSwapped(body: () => Promise<void>): Promise<void> { | |
| 108 | type Row = Record<string, unknown>; | |
| 109 | const saved = app.db.query("SELECT * FROM look").all() as Row[]; | |
| 110 | app.db.run("DELETE FROM look"); | |
| 111 | try { | |
| 112 | await body(); | |
| 113 | } finally { | |
| 114 | app.db.run("DELETE FROM look"); | |
| 115 | for (const r of saved) { | |
| 116 | const cols = Object.keys(r); | |
| 117 | app.db | |
| 118 | .query(`INSERT INTO look (${cols.join(",")}) VALUES (${cols.map(() => "?").join(",")})`) | |
| 119 | .run(...(Object.values(r) as never[])); | |
| 120 | } | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | test("one line over the pictures counts pages beside main and pages new to this pull", async () => { | |
| 125 | const a = app.db.query("SELECT id FROM app LIMIT 1").get() as { id: string }; | |
| 126 | await withLooksSwapped(async () => { | |
| 127 | const take = (env: string, commitOid: string, p: string) => | |
| 128 | recordLook(app.db, { appId: a.id, env, commitOid, path: "looks/x.png", takenAs: "nobody", kind: "phone", page: p }); | |
| 129 | take("production", MAIN, "/"); | |
| 130 | take(previewEnv(pullNumber), HEAD, "/"); | |
| 131 | take(previewEnv(pullNumber), HEAD, "/about"); | |
| 132 | const text = await (await fetch(`${servers.url}/alice/garden/pulls/${pullNumber}`)).text(); | |
| 133 | expect(text).toContain("1 page beside main · 1 new to this pull"); | |
| 134 | app.db.run("DELETE FROM look WHERE env = 'production'"); | |
| 135 | const none = await (await fetch(`${servers.url}/alice/garden/pulls/${pullNumber}`)).text(); | |
| 136 | expect(none).toContain("main has no pictures yet."); | |
| 137 | expect(none).toContain("data-no-main"); | |
| 138 | }); | |
| 139 | }); |
test/pull-looks.test.ts +1
| @@ -233,5 +233,6 @@ test("the terminal screen lists the same looks, gated the same", async () => { | ||
| 233 | 233 | return tui.screen.toText(); |
| 234 | 234 | }; |
| 235 | 235 | expect(await shown("alice", "hush")).toContain("phone · private"); |
| 236 | expect(await shown("alice", "hush")).toContain("main has no pictures yet."); | |
| 236 | 237 | expect(await shown("bob", "hush")).toContain("No looks yet"); |
| 237 | 238 | }); |
Move this import up beside the other ../core imports.