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

Merged opened this

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

Merged

torque/forge:torque/9768-a-pull-s-looks-open-with-one-line-counti → main 4 commits fork 3 threads · 3 unresolved

  • Merged. torque/9768-a-pull-s-looks-open-with-one-line-counti was merged into main as abc5ef1.

Review

Sign in to review this change.
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:
1313 - **MCP** — the `pull` tool carries `looks`, the same items (no new tool).
1414 - **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.
1515
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
1621 Who sees what is `mayFetchLook`, the rule the pictures themselves use: a
1722 reader who may not read the repository gets 404; a writer sees every look
1823 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 @@
77 import type { App } from "../app.ts";
88 import { previewEnv } from "../apps/data.ts";
99 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";
1111 import type { Pull } from "../core/pulls.ts";
1212 import { canEnterApp, canSeeOwnerFacts, type Repo } from "../core/repos.ts";
1313 import { sealedPreview } from "../core/sealed.ts";
1414 import type { User } from "../core/users.ts";
15 import { pairCounts, pairsLine } from "./pairs.ts";
outdated

Move this import up beside the other ../core imports.

1516 import { keepLook } from "./service.ts";
1617
1718 /**
@@ -77,3 +78,20 @@ export function pullLooks(
7778 at: l.createdAt,
7879 }));
7980 }
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 @@
66
77 import type { Pull } from "../../core/pulls.ts";
88 import type { Repo } from "../../core/repos.ts";
9 import { pullLooks } from "../../looks/readable.ts";
9 import { pullLooks, pullPairsLine } from "../../looks/readable.ts";
1010 import type { Ctx, Screen } from "../app/app.ts";
1111 import { binding } from "../app/keys.ts";
1212 import type { Session } from "../session.ts";
13 import { style } from "../term/style.ts";
1314 import { emptyState } from "../ui/widgets.ts";
1415 import { ListState, ago, drawList, type ListRow } from "./common.ts";
1516
@@ -19,6 +20,7 @@ const plain = (text: string): string =>
1920 export function pullLooksScreen(f: Session, ctx: Ctx, repo: Repo, pull: Pull): Screen {
2021 const state = new ListState();
2122 const looks = pullLooks(f.app, repo, pull, f.user);
23 const pairs = pullPairsLine(f.app, repo, pull, f.user);
2224 const rows: ListRow[] = looks.map((l) => ({
2325 title: plain(`${l.width} · ${l.says ?? "taken by the forge"}`),
2426 meta: ago(l.at),
@@ -37,7 +39,10 @@ export function pullLooksScreen(f: Session, ctx: Ctx, repo: Repo, pull: Pull): S
3739 });
3840 return;
3941 }
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);
4146 },
4247 key(e) {
4348 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";
2929 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
3030 import type { Ctx } from "../../http/context.ts";
3131 import { notFound } from "../../http/respond.ts";
32 import { pairCounts, pairsLine } from "../../looks/pairs.ts";
3233 import { mayFetchLook as mayFetchLookFor } from "../../looks/readable.ts";
3334 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
3435 import { loadRepo, repoBase } from "../chrome.ts";
@@ -294,9 +295,14 @@ export function lookCard(
294295 </figcaption>
295296 </figure>`;
296297 };
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>`;
297303 return card(
298304 html`<div class="stack">
299 ${pages.map(group)}${when(attached.length > 0, () =>
305 ${summary}${pages.map(group)}${when(attached.length > 0, () =>
300306 html`<div class="looks">${attached.map(person)}</div>`,
301307 )}
302308 </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
102102 expect(text).toContain(`/${HEAD}/desktop.png`);
103103 expect(text).not.toContain(MAIN);
104104 });
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 () => {
233233 return tui.screen.toText();
234234 };
235235 expect(await shown("alice", "hush")).toContain("phone · private");
236 expect(await shown("alice", "hush")).toContain("main has no pictures yet.");
236237 expect(await shown("bob", "hush")).toContain("No looks yet");
237238 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs