Public: anyone can read this; the crew can change it.

Features row shows provenance: repository, upstream if a fork, who last changed the declaration #9811

Merged opened this

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

Merged

scout/forge:scout/9770-features-row-shows-provenance-repository → main 5 commits fork 7 threads · 7 unresolved

  • Merged. scout/9770-features-row-shows-provenance-repository was merged into main as 1818422.

Review

Sign in to review this change.
9 files changed +221 −13
docs/design/areas/features.md +1 −1
@@ -3,4 +3,4 @@
33 `src/web/pages/features.ts` adds no component class. The list and a feature's page are a heading over rows, as the
44 home page is: a `<section class="settings-block">` with an `<h2>` (a `.bar` when a badge sits beside it) over a
55 `.setting-list` of `row()`s. No card around the list or a feature's form, and no icon in a row's lead. Classes used are
6 named in `docs/design/areas/settings.md`.
6 named in `docs/design/areas/settings.md`. Where an app comes from (fork of, last changed by) is the row's `facts` (`.row-facts`, in `docs/design/areas/lists.md`) on the list, and `.status-rows` `dl` rows on a feature's page.
docs/design/areas/lists.md +4
@@ -12,6 +12,10 @@ trails a palette row.
1212 end, ellipsised past 45% of the row. Written by `src/web/client.ts` (the file finder's rows and the command
1313 palette's `row()`), never in server markup. Styled in `src/web/style/components.ts` (`.palette-item .cat`) and
1414 `src/web/style/content.ts` (`.finder .palette-item .cat`).
15 - `.row-facts` — the `dl` of a §5 row's facts (`row({ facts })` in `src/web/ui.ts`): label and value pairs under the
16 meta, wrapping as a phone narrows, in the meta's size and colour. The row's main column is then a `<div>`, so the
17 `dl` is valid. First used by Settings › Features for where an app comes from (#9770). Styled in
18 `src/web/style/components.ts` (list rows).
1519 - `.flow-row` — a §5 `.row` link on the flow panel (`flow-panel.ts`: idle pulls and the "and K more" row) with a 44px
1620 minimum height, so it is a touch target on a 360px phone. Styled in `src/web/style/components.ts` (list rows).
1721
docs/features/absorb.md +9
@@ -107,3 +107,12 @@ their own "Features from apps" heading. The declared `summary` is the entry's `t
107107 Open the palette and choose "Menu entries to review" (site admins who are people only). It lists the
108108 absorbed apps whose declaration changed; `j`/`k` move, `a` accepts the selected row. The accept posts the
109109 name and page the screen showed, so one that changed since is refused ("review it again"), as on the page.
110
111 ## Where an app comes from
112
113 Beside each app on Settings › Features, on its own page, and in the terminal's "Menu entries to review", two facts may show (the repository is already in the title):
114
115 - **Fork of**: the repository the app's repository was forked from. It shows only to someone who may read that repository, so a fork of a private repository does not name it to anyone else.
116 - **Last changed by**: the author and date of the latest commit on the default branch that touched `forge.json`. The author is the name the commit carries, not a verified identity: anyone who can push can write any name.
117
118 Nothing shows when there is nothing to say. The last change is read from git once per tip and kept.
src/actions/features.ts +91 −7
@@ -34,6 +34,7 @@ import {
3434 import { canRead, getRepoById, type Repo } from "../core/repos.ts";
3535 import type { User } from "../core/users.ts";
3636 import { resolveOid } from "../git/merge.ts";
37 import { commitLog } from "../git/read.ts";
3738 import { repoDir } from "../git/repos.ts";
3839 import { err, ok, type Result } from "../kernel/result.ts";
3940
@@ -80,6 +81,75 @@ export interface FeatureView {
8081 /** Why the declaration is not usable, when it is not; `settings` is then empty. */
8182 readonly problem: string | null;
8283 readonly settings: readonly SettingView[];
84 /** Where the app comes from: the upstream only when the reader may read it. */
85 readonly provenance: Provenance;
86 }
87
88 /**
89 * Who stands behind an app's declaration: its repository, the repository it
90 * was forked from, and who last changed forge.json.
91 */
92 export interface Provenance {
93 readonly repo: string;
94 readonly upstream: string | null;
95 readonly changedBy: string | null;
96 readonly changedAt: string | null;
97 }
98
99 /** The last change by tip. Bounded on its own, apart from the declarations': cleared whole when full. */
100 const changed = new Map<string, { by: string; at: string } | null>();
101 const CHANGED_MAX = 500;
outdated

Use changed.has(tip) ? changed.get(tip) : fetch. Do not borrow DECLARED_MAX for a different cache; give it its own bound.

102
103 /**
104 * The facts a door shows for a provenance, label and value, in the order
105 * shown. The repository is not one: the row's title has it.
106 */
outdated

Principle 8: the value 'X (commit author) on 2026-11-01' is clumsy. Use plain 'X, 2026-11-01' and keep the commit-author caveat in docs/features/absorb.md.

107 export function provenanceFacts(p: Provenance): { label: string; value: string }[] {
108 const facts: { label: string; value: string }[] = [];
109 if (p.upstream !== null) facts.push({ label: "Fork of", value: p.upstream });
110 if (p.changedBy !== null) {
111 const on = p.changedAt === null ? "" : `, ${p.changedAt.slice(0, 10)}`;
112 facts.push({ label: "Last changed by", value: `${p.changedBy}${on}` });
outdated

High: parent is looked up by id and its owner/name is shown to whoever can read the fork, with no canRead(app.db, actor, parent) check. A private upstream's name leaks across users. Pass the actor into provenanceOf and set upstream to null unless the actor can read the parent. Add a test.

113 }
114 return facts;
115 }
116
117 /**
118 * The latest commit at or under `tip` that touched forge.json: its author and
119 * date. A commit never changes, so it is read from git once per tip and kept;
120 * `read` is git's log, or a stand-in that counts.
121 */
122 export async function lastChange(
123 dir: string,
124 tip: string,
125 read: typeof commitLog = commitLog,
126 ): Promise<{ by: string; at: string } | null> {
127 if (changed.has(tip)) return changed.get(tip) ?? null;
128 const log = await read(dir, tip, { limit: 1, path: "forge.json" });
129 const c = log.ok ? log.value[0] : undefined;
130 const last = c === undefined ? null : { by: c.author, at: c.date };
131 if (changed.size >= CHANGED_MAX) changed.clear();
132 changed.set(tip, last);
133 return last;
134 }
135
136 /**
137 * The repository's provenance as the actor may see it: the upstream is named
138 * only to one who may read it, so a fork does not leak a private parent's name.
139 * The last change is the default branch's latest commit touching forge.json, kept by tip.
140 */
141 async function provenanceOf(app: App, actor: User | undefined, repo: Repo): Promise<Provenance> {
142 const found = repo.forkParentId === null ? undefined : getRepoById(app.db, repo.forkParentId);
143 const parent = found !== undefined && canRead(app.db, found, actor) ? found : undefined;
144 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
145 const tip = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`);
146 const last = tip === undefined ? null : await lastChange(dir, tip);
147 return {
148 repo: `${repo.ownerName}/${repo.name}`,
149 upstream: parent === undefined ? null : `${parent.ownerName}/${parent.name}`,
150 changedBy: last?.by ?? null,
151 changedAt: last?.at ?? null,
152 };
83153 }
84154
85155 const siteAdmin = (actor: User | undefined): actor is User =>
@@ -132,7 +202,14 @@ export async function declaresFeature(app: App, actor: User | undefined, repo: R
132202 return read.ok && read.value !== undefined;
133203 }
134204
135 function viewOf(record: AppRecord, repo: Repo, app: App, spec: FeatureSpec | undefined, problem: string | null): FeatureView {
205 function viewOf(
206 record: AppRecord,
207 repo: Repo,
208 app: App,
209 spec: FeatureSpec | undefined,
210 problem: string | null,
211 provenance: Provenance,
212 ): FeatureView {
136213 const kept = featureSettings(app.db, record.id);
137214 const absorbed = getAbsorbed(app.db, record.id);
138215 const env = spec === undefined ? {} : featureEnv(spec, kept);
@@ -152,6 +229,7 @@ function viewOf(record: AppRecord, repo: Repo, app: App, spec: FeatureSpec | und
152229 absorbedBy: absorbed?.absorbedBy ?? null,
153230 absorbedAt: absorbed?.absorbedAt ?? null,
154231 problem,
232 provenance,
155233 settings: (spec?.settings ?? []).map((s) => {
156234 const name = settingEnvName(s.key);
157235 return {
@@ -193,7 +271,9 @@ export async function listFeatures(app: App, actor: User | undefined): Promise<R
193271 if (repo === undefined) continue;
194272 const declared = await declaredFeature(app, repo);
195273 if (declared.ok && declared.value === undefined && getAbsorbed(app.db, record.id) === undefined) continue;
196 out.push(viewOf(record, repo, app, declared.ok ? declared.value : undefined, declared.ok ? null : declared.error));
274 const spec = declared.ok ? declared.value : undefined;
275 const provenance = await provenanceOf(app, actor, repo);
276 out.push(viewOf(record, repo, app, spec, declared.ok ? null : declared.error, provenance));
197277 }
198278 return ok(out.sort((a, b) => Number(b.absorbed) - Number(a.absorbed) || a.name.localeCompare(b.name)));
199279 }
@@ -232,7 +312,9 @@ export async function getFeature(app: App, actor: User | undefined, slug: string
232312 const declared = await declaredFeature(app, found.repo);
233313 if (declared.ok && declared.value === undefined && getAbsorbed(app.db, found.record.id) === undefined)
234314 return err("not_a_feature");
235 return ok(viewOf(found.record, found.repo, app, declared.ok ? declared.value : undefined, declared.ok ? null : declared.error));
315 const spec = declared.ok ? declared.value : undefined;
316 const provenance = await provenanceOf(app, actor, found.repo);
317 return ok(viewOf(found.record, found.repo, app, spec, declared.ok ? null : declared.error, provenance));
236318 }
237319
238320 async function redeployAll(app: App, record: AppRecord, repo: Repo): Promise<void> {
@@ -250,7 +332,7 @@ export async function absorbApp(app: App, actor: User | undefined, slug: string)
250332 app.bus.append({ kind: "app.absorbed", repoId: found.repo.id, actorId: actor.id, payload: { slug, name: declared.value.name } });
251333 await redeployAll(app, found.record, found.repo);
252334 }
253 return ok(viewOf(found.record, found.repo, app, declared.value, null));
335 return ok(viewOf(found.record, found.repo, app, declared.value, null, await provenanceOf(app, actor, found.repo)));
254336 }
255337
256338 /**
@@ -283,7 +365,7 @@ export async function acceptDeclaration(
283365 payload: { slug, name, page, was: { name: before.name, page: before.page } },
284366 });
285367 }
286 return ok(viewOf(found.record, found.repo, app, declared.value, null));
368 return ok(viewOf(found.record, found.repo, app, declared.value, null, await provenanceOf(app, actor, found.repo)));
287369 }
288370
289371 /** Put an absorbed app back as an ordinary app. Its settings are kept for the next absorb. */
@@ -295,7 +377,9 @@ export async function unabsorbApp(app: App, actor: User | undefined, slug: strin
295377 app.bus.append({ kind: "app.unabsorbed", repoId: found.repo.id, actorId: actor.id, payload: { slug } });
296378 await redeployAll(app, found.record, found.repo);
297379 const declared = await declaredFeature(app, found.repo);
298 return ok(viewOf(found.record, found.repo, app, declared.ok ? declared.value : undefined, declared.ok ? null : declared.error));
380 const spec = declared.ok ? declared.value : undefined;
381 const provenance = await provenanceOf(app, actor, found.repo);
382 return ok(viewOf(found.record, found.repo, app, spec, declared.ok ? null : declared.error, provenance));
299383 }
300384
301385 /**
@@ -336,5 +420,5 @@ export async function changeFeatureSettings(
336420 });
337421 await redeployAll(app, found.record, found.repo);
338422 }
339 return ok(viewOf(found.record, found.repo, app, declared.value, null));
423 return ok(viewOf(found.record, found.repo, app, declared.value, null, await provenanceOf(app, actor, found.repo)));
340424 }
src/tui/screens/features.ts +2 −2
@@ -6,7 +6,7 @@
66 * refused. Offered to a site admin who is a person, as the page is.
77 */
88
9 import { acceptDeclaration, listFeatures, type FeatureView } from "../../actions/features.ts";
9 import { acceptDeclaration, listFeatures, provenanceFacts, type FeatureView } from "../../actions/features.ts";
1010 import type { Ctx, Screen } from "../app/app.ts";
1111 import { binding } from "../app/keys.ts";
1212 import { style } from "../term/style.ts";
@@ -50,7 +50,7 @@ export function featuresScreen(f: Session, ctx: Ctx): FeaturesScreen {
5050 s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
5151 const list: ListRow[] = rows.map((r) => ({
5252 title: `${r.name} (${r.page})`,
53 secondary: `${r.repo} declares ${r.pending!.name} (${r.pending!.page})`,
53 secondary: [`${r.repo} declares ${r.pending!.name} (${r.pending!.page})`, ...provenanceFacts(r.provenance).map((x) => `${x.label} ${x.value}`)].join(" · "),
5454 meta: "",
5555 }));
5656 drawList(s, body!, c, state, list);
src/web/pages/features.ts +10
@@ -15,6 +15,7 @@ import {
1515 unabsorbApp,
1616 type FeatureView,
1717 type SettingView,
18 provenanceFacts,
1819 } from "../../actions/features.ts";
1920 import { DAYS } from "../../apps/feature.ts";
2021 import type { Ctx } from "../../http/context.ts";
@@ -51,12 +52,20 @@ export function switchNote(f: Pick<FeatureView, "name" | "absorbed">): string {
5152 return f.absorbed ? `Un-absorbing takes ${f.name} off every menu; its settings are kept.` : `Absorbing restarts ${f.name} and adds it to the menu.`;
5253 }
5354
55 /** Where the app comes from, on a feature's own page: `dl` rows, the upstream when a fork and who last changed it. */
outdated

Principles 1, 7 and 8, and a new unregistered class. See points 1, 2 and 3 in the review body. The repo is already in the title, and a fact belongs in a dl row.

56 export function provenanceRows(p: FeatureView["provenance"]) {
57 const facts = provenanceFacts(p);
58 if (facts.length === 0) return null;
59 return html`<dl class="status-rows">${facts.map((f) => html`<div class="status-row"><dt>${f.label}</dt><dd>${f.value}</dd></div>`)}</dl>`;
60 }
61
5462 function featureRow(f: FeatureView) {
5563 const base = `/settings/features/${encodeURIComponent(f.slug)}`;
5664 return row({
5765 title: html`${f.name} <span class="muted small mono">${f.repo}</span>
5866 ${f.absorbed ? badge("absorbed", { tone: "success" }) : null}
5967 ${f.pending !== null ? badge("declaration changed: review", { tone: "attn" }) : null}`,
68 facts: provenanceFacts(f.provenance),
outdated

A block-level dl is inside the row's span.row-meta, which is invalid HTML and an ad-hoc placement (principle 7: a fact is a dl row, in a place the row component provides). Add a slot to row() or render the facts outside the span.

6069 meta: html`${f.pending !== null
6170 ? html`Menu: <strong>${f.name}</strong> at <span class="mono">${f.page}</span> → app: <strong>${f.pending.name}</strong> at
6271 <span class="mono">${f.pending.page}</span>${f.pending.summary !== null
@@ -183,6 +192,7 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused): Response {
183192 <h2>${f.name}</h2>
184193 ${f.absorbed ? badge("absorbed", { tone: "success" }) : badge("not absorbed", { variant: "outline" })}
185194 </div>
195 ${provenanceRows(f.provenance)}
186196 ${f.settings.length === 0
187197 ? html`<p class="muted small">${f.problem ?? "This feature declares no settings."}</p>`
188198 : html`<form method="post" action="${base}">
src/web/style/components.ts +4
@@ -466,6 +466,10 @@ a.row:hover .row-title { color: var(--accent); }
466466 font-variant-numeric: tabular-nums;
467467 }
468468 .row-meta .byline { display: inline; }
469 .row-facts { display: flex; flex-wrap: wrap; gap: 0 16px; margin: 0; font-size: 12.5px; color: var(--muted-fg); line-height: 1.5; }
470 .row-facts > div { display: flex; gap: 6px; min-width: 0; }
471 .row-facts dt { font-weight: 550; }
472 .row-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
469473 .row-meta .avatar { width: 16px; height: 16px; font-size: var(--fs-min); vertical-align: -0.28em; }
470474 .row-meta .byline-name { margin-left: 4px; }
471475 .row-lead { color: var(--faint); display: inline-flex; flex: none; }
src/web/ui.ts +14 −2
@@ -946,6 +946,9 @@ export interface RowOptions {
946946 href?: string;
947947 title: Html | string;
948948 meta?: Html;
949 /** Facts about the row, one `dt`/`dd` pair each, in a `dl.row-facts` under the meta. With facts the row's
950 * main column is a `<div>`, so the `dl` is valid HTML: not for a `fold` row, whose `<summary>` takes phrasing only. */
951 facts?: readonly { label: string; value: Html | string }[];
949952 /** Draw `.row-meta` even when `meta` is empty, for a script to write into. */
950953 metaSlot?: boolean;
951954 lead?: Html;
@@ -975,17 +978,26 @@ export interface RowOptions {
975978 * controls, `<summary>` when it folds — the hover affordance follows
976979 * automatically. */
977980 export function row(o: RowOptions): Html {
981 const facts = o.facts !== undefined && o.facts.length > 0 && o.fold === undefined;
978982 const inner = html`${o.lead
979983 ? html`<span class="row-lead">${o.lead}</span>`
980984 : null}
981 <span class="row-main">
985 ${facts ? html`<div class="row-main">
982986 <span
983987 class="row-title"
984988 ${o.titleAttr ? html`title="${o.titleAttr}"` : null}
985989 >${o.title}</span
986990 >

Non-blocking: this branch copies the title and meta markup from the else branch. Share the content and vary only the wrapper element.

987991 ${o.meta || o.metaSlot ? html`<span class="row-meta">${o.meta}</span>` : null}
988 </span>
992 <dl class="row-facts">${o.facts!.map((f) => html`<div><dt>${f.label}</dt><dd>${f.value}</dd></div>`)}</dl>
993 </div>` : html`<span class="row-main">
994 <span
995 class="row-title"
996 ${o.titleAttr ? html`title="${o.titleAttr}"` : null}
997 >${o.title}</span
998 >
999 ${o.meta || o.metaSlot ? html`<span class="row-meta">${o.meta}</span>` : null}
1000 </span>`}
9891001 ${o.trail && o.trail.text.trim() !== ""
9901002 ? html`<span class="row-trail">${o.trail}</span>`
9911003 : null}`;
test/features.test.ts +86 −1
@@ -15,8 +15,12 @@ import { join } from "node:path";
1515 import { statements } from "./helpers/statements.ts";
1616 import { expectWork } from "./helpers/work.ts";
1717 import { createApp, type App } from "../src/app.ts";
18 import { absorbApp, acceptDeclaration, declaresFeature, absorbedForNav, declaredFeature, changeFeatureSettings, getFeature, unabsorbApp } from "../src/actions/features.ts";
18 import { absorbApp, acceptDeclaration, declaresFeature, absorbedForNav, declaredFeature, changeFeatureSettings, getFeature, lastChange, provenanceFacts, unabsorbApp } from "../src/actions/features.ts";
1919 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
20 import { forkRepo } from "../src/core/repos.ts";
21 import { resolveOid } from "../src/git/merge.ts";
22 import { commitLog } from "../src/git/read.ts";
23 import { repoDir } from "../src/git/repos.ts";
2024 import { checkSetting, featureEnv, parseFeature, settingEnvName } from "../src/apps/feature.ts";
2125 import { featureVarsAt } from "../src/apps/feature_env.ts";
2226 import { appEnv } from "../src/apps/process.ts";
@@ -27,6 +31,7 @@ import type { Repo } from "../src/core/repos.ts";
2731 import { createToken } from "../src/core/tokens.ts";
2832 import { createUser, type User } from "../src/core/users.ts";
2933 import { eventVerb } from "../src/design/activity.ts";
34 import { provenanceRows } from "../src/web/pages/features.ts";
3035 import { serve, type Servers } from "../src/http/server.ts";
3136 import { quietLogs } from "./helpers/globals.ts";
3237 import { mcpTool } from "./helpers/mcp.ts";
@@ -324,6 +329,85 @@ describe("absorbing an app", () => {
324329 expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar");
325330 });
326331
332 test("a row says who last changed the declaration, on the list and on the feature's page", async () => {
333 const view = await getFeature(app, ada, "radar");
334 expect(view.ok && view.value.provenance).toMatchObject({ upstream: null, changedBy: "cy" });
335 const detail = await (await web(adaCookie, "/settings/features/radar")).text();
336 expect(detail).toContain("<dt>Last changed by</dt>");
337 const list = await (await web(adaCookie, "/settings/features")).text();
338 expect(list).toMatch(/<dl class="row-facts"><div><dt>Last changed by<\/dt><dd>cy, \d{4}-\d{2}-\d{2}<\/dd><\/div><\/dl>/);
outdated

These two negative assertions guard markup that does not exist in this version of the change; they would pass without the feature. Remove them.

339 });
340
341 test("the facts are plain and escaped", () => {
342 const facts = provenanceFacts({ repo: "me/r", upstream: "them/r", changedBy: "<b>x</b>", changedAt: "2026-11-01T10:00:00Z" });
343 expect(facts).toEqual([
344 { label: "Fork of", value: "them/r" },
345 { label: "Last changed by", value: "<b>x</b>, 2026-11-01" },
346 ]);
347 const rows = provenanceRows({ repo: "me/r", upstream: null, changedBy: "<b>x</b>", changedAt: null })!.toString();
348 expect(rows).toContain("&lt;b&gt;x&lt;/b&gt;");
349 expect(provenanceRows({ repo: "me/r", upstream: null, changedBy: null, changedAt: null })).toBeNull();
350 });
351
352 test("the last change is read from git once per tip", async () => {
353 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
354 const tip = (await resolveOid(dir, `refs/heads/${repo.defaultBranch}`))!;
355 let reads = 0;
356 const counting: typeof commitLog = (...args) => {
357 reads++;
358 return commitLog(...args);
359 };
360 const first = await lastChange(dir, tip, counting);
361 const again = await lastChange(dir, tip, counting);
362 expect(again).toEqual(first);
363 expect(reads).toBeLessThanOrEqual(1);
364 // A tip never seen: read once, then kept, even when it found nothing.
365 const before = reads;
366 const fresh = `${"0".repeat(39)}1`;
367 expect(await lastChange(dir, fresh, counting)).toBeNull();
368 expect(await lastChange(dir, fresh, counting)).toBeNull();
369 expect(reads - before).toBe(1);
370 });
371
372 test("a fork names its upstream only to whoever may read the upstream, on the page and in the terminal", async () => {
373 // bob forks cy's private radar: he may read his fork, not its parent.
374 const forked = await forkRepo(app.db, app.reposRoot, repo, bob.id, "radar-fork");
375 if (!forked.ok) throw new Error(String(forked.error));
376 const declare = (name: string) =>
377 commitToBranch(app, bob, forked.value, {
378 message: name,
379 files: [{ path: "forge.json", content: JSON.stringify({ app: { run: "x" }, feature: { ...FEATURE, name } }) }],
380 });
381 await declare("Radar Fork");
382 const row = insertApp(app.db, { repoId: forked.value.id, slug: "radar-fork", access: "private" });
383 if (!row.ok) throw new Error(row.error);
384 try {
385 const upstream = `${repo.ownerName}/${repo.name}`;
386 const seen = await getFeature(app, ada, "radar-fork");
387 expect(seen.ok && seen.value.provenance.upstream).toBe(upstream);
388 const hidden = await getFeature(app, bob, "radar-fork");
389 expect(hidden.ok && hidden.value.provenance.upstream).toBeNull();
390 expect(JSON.stringify(hidden)).not.toContain(upstream);
391 const list = await (await web(adaCookie, "/settings/features")).text();
392 expect(list).toContain(`<dt>Fork of</dt><dd>${upstream}</dd>`);
393
394 expect((await absorbApp(app, ada, "radar-fork")).ok).toBe(true);
395 await declare("Radar Fork Later");
396 const { featuresScreen } = await import("../src/tui/screens/features.ts");
397 const { Screen: Surface } = await import("../src/tui/term/screen.ts");
398 const { theme } = await import("../src/tui/ui/theme.ts");
399 const ctx = { theme: theme("dark"), invalidate() {}, push() {}, pop() {}, copy() {}, toast() {} } as never;
400 const screen = featuresScreen({ app, db: app.db, user: ada } as never, ctx);
401 await screen.busy();
402 const shown = new Surface(200, 20);
403 screen.render({ x: 0, y: 0, w: 200, h: 20 }, shown);
404 expect(shown.toText()).toContain(`Fork of ${upstream}`);
405 } finally {
406 await unabsorbApp(app, ada, "radar-fork");
407 deleteApp(app.db, row.value.id);
408 }
409 });
410
327411 test("a changed summary alone is pending, and accepting stores it for the menu title", async () => {
328412 const before = absorbedForNav(app, ada)[0]!;
329413 expect(before.summary).toBe(FEATURE.summary);
@@ -378,6 +462,7 @@ describe("absorbing an app", () => {
378462 const shown = new Surface(110, 20);
379463 (await open(ada)).render({ x: 0, y: 0, w: 110, h: 20 }, shown);
380464 expect(shown.toText()).toContain("Radar TUI");
465 expect(shown.toText()).toContain("Last changed by");
381466 const denied = await open(cy);
382467 expect(denied.key!({ key: "a" } as never)).toBe(false);
383468 expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs