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

A repository's forks page lists the forks with the most work to offer first: commits ahead, then fewest behind #5970

Merged opened this

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

Merged

scout/forge:scout/5960-a-repository-s-forks-page-lists-the-fork → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/5960-a-repository-s-forks-page-lists-the-fork was merged into main as bbd8a45.

Review

Sign in to review this change.
8 files changed +480 −6
README.md +14
@@ -1917,6 +1917,20 @@ in the web settings first" (the address is set on the settings page). A check fe
19171917 the fetched head, the check fails (`squatted`), says so on the row, opens
19181918 no pull and leaves the branch as it was.
19191919
1920 ## Ranked forks
1921
1922 `/:owner/:name/forks` and the terminal's forks screen (`f` in a repository)
1923 list a repository's direct forks the same way, so the best of a fork tree
1924 rises. Order: commits ahead of the parent's default branch (most first),
1925 then fewest behind, then the newest tip commit. Each row reads `N ahead · M
1926 behind` and when the fork last moved; a fork that is ahead has a **Compare**
1927 button (on the page) to its `/pulls/new`, which proposes it to the parent.
1928 Forks with nothing ahead (or whose standing git cannot read) follow under
1929 `N forks with nothing new`. At most 60 forks are ranked per view; past that
1930 the page says `Ranking the first 60 of N forks`. A fork's standing is cached
1931 by the pair of tips (`actions/forks.ts`), so a view of forks that have not
1932 moved costs one `git log` per fork.
1933
19201934 ## Shape
19211935
19221936 ```
src/actions/forks.ts +120
@@ -22,6 +22,8 @@ import {
2222 import { commitIdentity } from "../core/commit_identity.ts";
2323 import type { User } from "../core/users.ts";
2424 import { divergence } from "../git/branches.ts";
25 import { runGit } from "../git/env.ts";
26 import { isSafeRef } from "../git/guard.ts";
2527 import { resolveOid } from "../git/merge.ts";
2628 import { repoDir } from "../git/repos.ts";
2729 import { err, type Result } from "../kernel/result.ts";
@@ -104,3 +106,121 @@ export async function forkDrift(
104106 return { ahead: null, behind: null };
105107 return divergence(forkDir, base, head);
106108 }
109
110 // ---- ranking ----
111
112 export interface RankedFork {
113 readonly fork: Repo;
114 readonly drift: ForkDrift;
115 /** When the fork's default branch last moved (ISO), or null. */
116 readonly pushedAt: string | null;
117 }
118
119 /** Forks ranked in one page are bounded; each costs a git call. */

Truncation at 60 is silent; surface it on the page.

120 export const MAX_RANKED_FORKS = 60;
121
122 /**
123 * Most work to offer first: commits ahead, then fewest behind, then the
124 * newest push. A fork whose drift is unknown has nothing to offer and sorts
125 * with the ones that are level.
126 */
127 export function rankForks(forks: readonly RankedFork[]): {
128 offering: RankedFork[];
129 nothingNew: RankedFork[];
130 } {
131 const newest = (f: RankedFork) => Date.parse(f.pushedAt ?? "") || 0;
132 const order = (a: RankedFork, b: RankedFork) =>
133 (b.drift.ahead ?? 0) - (a.drift.ahead ?? 0) ||
134 (a.drift.behind ?? Infinity) - (b.drift.behind ?? Infinity) ||
135 newest(b) - newest(a) ||
136 a.fork.id.localeCompare(b.fork.id);
137 const sorted = [...forks].sort(order);
138 return {
139 offering: sorted.filter((f) => (f.drift.ahead ?? 0) > 0),
140 nothingNew: sorted.filter((f) => (f.drift.ahead ?? 0) === 0),
141 };
142 }
143
144 /** What a fork's standing is worth saying: the words every surface uses. */
145 export const driftWords = (d: ForkDrift): string =>
146 d.ahead === null || d.behind === null
147 ? "standing unknown"
148 : `${d.ahead} ahead · ${d.behind} behind`;
149
150 export const nothingNewHeading = (n: number): string =>
151 `${n} fork${n === 1 ? "" : "s"} with nothing new`;
152
153 /**
154 * A fork's standing is a pure function of the two tips, so it is kept by
155 * them: a view whose forks have not moved costs one `git log` each and no
156 * `rev-list`. Bounded; a full cache is emptied rather than tracked.
157 */
158 const standing = new Map<string, ForkDrift>();
159 const STANDING_MAX = 2_000;
160
161 async function tipAndTime(
162 dir: string,
163 branch: string,
164 ): Promise<{ oid: string; pushedAt: string | null } | undefined> {
165 if (!isSafeRef(branch)) return undefined;
166 const out = await runGit(
167 ["log", "-1", "--format=%H %cI", `refs/heads/${branch}`],
168 { cwd: dir },
169 );
170 if (!out.ok) return undefined;
171 const [oid, at] = out.value.trim().split(" ");
172 return oid === undefined || oid === ""
173 ? undefined
174 : { oid, pushedAt: at ?? null };
175 }
176
177 /** Each fork's drift from `parent` and its last push, read from git. */
178 export async function driftOfForks(
179 app: App,
180 parent: Repo,
181 forks: readonly Repo[],
182 ): Promise<RankedFork[]> {
183 const base = await resolveOid(
184 repoDir(app.reposRoot, parent.ownerName, parent.name),
185 `refs/heads/${parent.defaultBranch}`,
186 );
187 return Promise.all(
188 forks.slice(0, MAX_RANKED_FORKS).map(async (fork) => {
189 const dir = repoDir(app.reposRoot, fork.ownerName, fork.name);
190 const tip = await tipAndTime(dir, fork.defaultBranch);
191 if (tip === undefined || base === undefined)
192 return { fork, drift: { ahead: null, behind: null }, pushedAt: null };
193 const key = `${base}:${tip.oid}`;
194 let drift = standing.get(key);
195 if (drift === undefined) {
196 drift = await divergence(dir, base, tip.oid);
197 if (standing.size >= STANDING_MAX) standing.clear();
198 standing.set(key, drift);
199 }
200 return { fork, drift, pushedAt: tip.pushedAt };
201 }),
202 );
203 }
204
205 export interface ForkRanking {
206 /** Every fork the viewer may read, ranked or not. */
207 readonly total: number;
208 readonly offering: RankedFork[];
209 readonly nothingNew: RankedFork[];
210 /** True when forks past `MAX_RANKED_FORKS` were left out of the ranking. */
211 readonly truncated: boolean;
212 }
213
214 /** The ranked forks of `repo` among those `mayRead`: one answer for every surface. */
215 export async function rankedForksOf(
216 app: App,
217 repo: Repo,
218 forks: readonly Repo[],
219 ): Promise<ForkRanking> {
220 const ranked = rankForks(await driftOfForks(app, repo, forks));
221 return {
222 total: forks.length,
223 ...ranked,
224 truncated: forks.length > MAX_RANKED_FORKS,
225 };
226 }
src/tui/screens/forks.ts added +101
@@ -0,0 +1,101 @@
1 /**
2 * A repository's forks, ranked: the same order and words as the forks page
3 * (`actions/forks.ts` decides both), so the best of the tree rises here too.
4 * Forks that are ahead come first, with the one that has the most to offer on
5 * top; those with nothing new follow under their count.
6 */
7
8 import {
9 driftWords,
10 nothingNewHeading,
11 rankedForksOf,
12 MAX_RANKED_FORKS,
13 type ForkRanking,
14 } from "../../actions/forks.ts";
15 import { canRead, listForks, type Repo } from "../../core/repos.ts";
16 import type { Ctx, Screen } from "../app/app.ts";
17 import { binding } from "../app/keys.ts";
18 import { style } from "../term/style.ts";
19 import { truncate } from "../term/width.ts";
20 import { fill, len, split } from "../ui/layout.ts";
21 import { emptyState } from "../ui/widgets.ts";
22 import type { Session } from "../session.ts";
23 import { local } from "./where.ts";
24 import { repoScreen } from "./workspace.ts";
25 import { ListState, drawList, plural, type ListRow } from "./common.ts";
26
27 /** The rows the screen draws, in order: pure so a test reads them without a terminal. */
28 export function forkRows(ranked: ForkRanking): { repo: Repo; name: string; words: string; heading: string }[] {
29 const row = (f: ForkRanking["offering"][number], heading: string) => ({
30 repo: f.fork,
31 name: `${f.fork.ownerName}/${f.fork.name}`,
32 words: driftWords(f.drift),
33 heading,
34 });
35 return [
36 ...ranked.offering.map((f) => row(f, "")),
37 ...ranked.nothingNew.map((f, i) => row(f, i === 0 ? nothingNewHeading(ranked.nothingNew.length) : "")),
38 ];
39 }
40
41 export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
42 let ranked: ForkRanking | undefined;
43 const state = new ListState();
44 const rows = () => (ranked === undefined ? [] : forkRows(ranked));
45 return {
46 title: "Forks",
47 keys: [binding(["j", "k"], "move"), binding(["enter"], "open this fork"), binding(["esc"], "close")],
48 async load() {
49 const forks = listForks(f.db, repo.id).filter((r) => canRead(f.db, r, f.user));
50 ranked = await rankedForksOf(f.app, repo, forks);
outdated

binding(enter, open this fork) has no handler in key(); implement it or remove it.

51 ctx.invalidate();
52 },
53 render(area, s) {
54 const c = ctx.theme.c;
55 const [head, body] = split(area, "v", [len(1), fill(1)]);
56 const list = rows();
57 s.text(
58 head!.x,
59 head!.y,
60 truncate(
61 `${repo.ownerName}/${repo.name} · ${plural(ranked?.total ?? 0, "fork")}${ranked?.truncated ? ` · ranking the first ${MAX_RANKED_FORKS}` : ""}`,
62 head!.w,
63 ),
64 style(c.mutedFg, c.bg),
65 head!.x + head!.w,
66 );
67 if (list.length === 0) {
68 emptyState(s, body!, c, {
69 title: ranked === undefined ? "Reading…" : "No forks yet",
70 body: "",
71 });
72 return;
73 }
74 drawList(
75 s,
76 body!,
77 c,
78 state,
79 list.map((r): ListRow => ({
80 title: r.name,
81 secondary: r.heading,
82 meta: r.words,
83 })),
84 );
85 },
86 key(e) {
87 const list = rows();
88 if (state.key(e, list.length)) return true;
89 if (e.key === "enter") {
90 const row = list[state.index];
91 if (row !== undefined) ctx.push(repoScreen(f, ctx, local(f, row.repo)));
92 return true;
93 }
94 if (e.key === "esc" || e.key === "q") {
95 ctx.pop();
96 return true;
97 }
98 return false;
99 },
100 };
101 }
src/tui/screens/workspace.ts +6
@@ -125,6 +125,7 @@ import { issuesScreen } from "./issues.ts";
125125 import { liveBindings, liveKey } from "./mirror.ts";
126126 import { forkScreen } from "./newrepo.ts";
127127 import { pullScreen } from "./pull.ts";
128 import { forksScreen } from "./forks.ts";
128129 import { branchesScreen } from "./refs.ts";
129130 import { wavesScreen } from "./waves.ts";
130131 import {
@@ -1297,6 +1298,7 @@ export function workspaceScreen(
12971298 disabled: !editable(),
12981299 }),
12991300 binding(["b", "B"], "switch ref / new branch"),
1301 ...(repo === undefined ? [] : [binding(["f"], "forks, most work first")]),
13001302 binding(["t", "ctrl+p"], "find file"),
13011303 binding(["y"], "copy path"),
13021304 binding(["i", "p", "C"], "issues / pulls / commits"),
@@ -1594,6 +1596,10 @@ export function workspaceScreen(
15941596 );
15951597 return true;
15961598 }
1599 if (plain && e.key === "f" && repo !== undefined) {
1600 ctx.push(forksScreen(f, ctx, repo));
1601 return true;
1602 }
15971603 if (plain && e.key === "B") {
15981604 newBranch();
15991605 return true;
src/web/pages/repo.ts +56 −2
@@ -13,7 +13,17 @@ import { NOT_A_MEMBER } from "../../core/roles.ts";
1313 import { storageLimitMessage } from "../../core/storage.ts";
1414 import { canRead, listForks, type Repo } from "../../core/repos.ts";
1515 import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "../../actions/repos.ts";
16 import { forkDrift, takeUpdates, type ForkDrift } from "../../actions/forks.ts";
16 import {
17 type RankedFork,
18 forkDrift,
19 driftWords,
20 nothingNewHeading,
21 rankedForksOf,
22 MAX_RANKED_FORKS,
23 type ForkRanking,
24 takeUpdates,
25 type ForkDrift,
26 } from "../../actions/forks.ts";
1727 import { lineageOf } from "../../core/following.ts";
1828 import {
1929 followingCard,
@@ -124,6 +134,9 @@ export async function forksPage(ctx: Ctx): Promise<Response> {
124134 );
125135 notePhase(ctx.request, "lineage", performance.now() - t);
126136 t = performance.now();

drift phase runs up to 120 git calls per view; cache or bound it.

137 const ranked = await rankedForksOf(ctx.app, repo, forks);
138 notePhase(ctx.request, "drift", performance.now() - t);
139 t = performance.now();
127140 const body = html`${repoHeader(ctx, repo, "forks")}
128141 <div class="page-head mt">
129142 <h2>${forks.length} fork${forks.length === 1 ? "" : "s"}</h2>
@@ -134,7 +147,9 @@ export async function forksPage(ctx: Ctx): Promise<Response> {
134147 title: "No forks yet",
135148 note: "A fork is a copy under another name that can propose changes back here, and follows this one until it has work of its own.",
136149 })
137 : lineageCard(lineage, repo.id)}`;
150 : html`${forkRanking(ranked)}
151 <h3 class="mt">The family, by who follows whom</h3>
152 ${lineageCard(lineage, repo.id)}`}`;
138153 const res = page({
139154 ctx,
140155 title: `Forks · ${repo.ownerName}/${repo.name}`,
@@ -144,6 +159,45 @@ export async function forksPage(ctx: Ctx): Promise<Response> {
144159 return res;
145160 }
146161
162 /**
163 * The forks with work to offer, best first, each linking to the compare
164 * view that proposes it upstream; then those with nothing new, counted.
165 */
166 function forkRanking(ranked: ForkRanking): Html {
167 const line = (f: RankedFork, offer: boolean): Html =>
168 row({
169 lead: icon("fork"),
170 title: html`<a href="/${f.fork.ownerName}/${f.fork.name}"
171 >${f.fork.ownerName}/${f.fork.name}</a
172 >`,
173 meta: html`${driftWords(f.drift)}${f.pushedAt === null
174 ? html``
175 : html` · pushed ${time(f.pushedAt)}`}`,
176 ...(offer
177 ? {
178 trail: btn("Compare", {
179 href: `/${f.fork.ownerName}/${f.fork.name}/pulls/new`,
180 size: "sm",
181 icon: "merge",
182 }),
183 }
184 : {}),
185 });
186 const { offering, nothingNew } = ranked;
187 return html`${ranked.truncated
188 ? html`<p class="muted small">
189 Ranking the first ${MAX_RANKED_FORKS} of ${ranked.total} forks.
190 </p>`
191 : html``}
192 ${offering.length === 0
193 ? html``
194 : listCard(offering.map((f) => line(f, true)))}
195 ${nothingNew.length === 0
196 ? html``
197 : html`<h3 class="mt">${nothingNewHeading(nothingNew.length)}</h3>
198 ${listCard(nothingNew.map((f) => line(f, false)))}`}`;
199 }
200
147201 /** The "forked from …" lineage line and a Fork button, for the repo home. */
148202 function forkBar(ctx: Ctx, repo: Repo): Html {
149203 const canFork =
test/following.test.ts +3 −2
@@ -733,9 +733,10 @@ describe("a fork that follows its parent", () => {
733733 ),
734734 );
735735 expect(page).not.toMatch(/class="[^"]*\blineage(-children|-here)?\b/);
736 const bobAt = page.indexOf('href="/bob/game"');
736 const family = page.indexOf("The family");
737 const bobAt = page.indexOf('href="/bob/game"', family);
737738 const nested = page.indexOf("row-children", bobAt);
738 const fayAt = page.indexOf('href="/fay/game-fay"');
739 const fayAt = page.indexOf('href="/fay/game-fay"', family);
739740 expect(bobAt).toBeGreaterThan(-1);
740741 expect(nested).toBeGreaterThan(bobAt);
741742 expect(fayAt).toBeGreaterThan(nested);
test/forks-ranking.test.ts added +178
@@ -0,0 +1,178 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
6 import {
7 MAX_RANKED_FORKS,
8 rankedForksOf,
9 rankForks,
10 type RankedFork,
11 } from "../src/actions/forks.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { getRepo, listForks } from "../src/core/repos.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import { forksScreen } from "../src/tui/screens/forks.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { settleApp } from "./helpers/tui.ts";
18 import { createApp, type App } from "../src/app.ts";
19 import type { Repo } from "../src/core/repos.ts";
20 import { createUser, type User } from "../src/core/users.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { promote } from "./helpers/members.ts";
23
24 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
25 if (!r.ok) throw new Error(JSON.stringify(r.error));
26 return r.value;
27 };
28
29 describe("rankForks", () => {
30 const f = (id: string, ahead: number | null, behind: number | null, pushedAt: string | null) =>
31 ({ fork: { id } as Repo, drift: { ahead, behind }, pushedAt }) as RankedFork;
32 test("ahead desc, then behind asc, then newest push; level forks apart", () => {
33 const { offering, nothingNew } = rankForks([
34 f("d", 0, 0, "2026-01-05T00:00:00Z"),
35 f("b", 2, 3, "2026-01-01T00:00:00Z"),
36 f("c", 2, 1, "2026-01-01T00:00:00Z"),
37 f("a", 3, 9, "2026-01-01T00:00:00Z"),
38 f("e", 2, 1, "2026-02-01T00:00:00Z"),
39 f("n", null, null, null),
40 ]);
41 expect(offering.map((x) => x.fork.id)).toEqual(["a", "e", "c", "b"]);
42 expect(nothingNew.map((x) => x.fork.id)).toEqual(["d", "n"]);
43 });
44 });
45
46 describe("the forks page ranks", () => {
47 let dataDir: string;
48 let app: App;
49 let servers: Servers;
50 let upstream: Repo;
51 const users: Record<string, User> = {};
52
53 beforeAll(async () => {
54 dataDir = await mkdtemp(join(tmpdir(), "forge-fork-rank-"));
55 app = createApp({ dataDir, port: 0 });
56 servers = await serve(app);
57 for (const name of ["alice", "bob", "carol", "dan", "erin"]) {
58 users[name] = must(
59 await createUser(app.db, {
60 origin: "seed",
61 trusted: true,
62 username: name,
63 email: `${name}@f.dev`,
64 password: "a-good-passphrase",
65 adminIfFirst: name === "alice",
66 }),
67 );
68 promote(app.db, users[name]!);
69 }
70 upstream = must(await createRepo(app, users["alice"]!, { name: "app", visibility: "public" }));
71 const commit = async (who: string, repo: Repo, n: number) => {
72 for (let i = 0; i < n; i++)
73 must(
74 await commitToBranch(app, users[who]!, repo, {
75 message: `${who} ${i} ${repo.name}`,
76 files: [{ path: `${who}-${repo.name}-${i}.txt`, content: "x\n" }],
77 }),
78 );
79 };
80 await commit("alice", upstream, 1);
81 const bob = must(await forkRepo(app, users["bob"]!, upstream));
82 const carol = must(await forkRepo(app, users["carol"]!, upstream));
83 const dan = must(await forkRepo(app, users["dan"]!, upstream));
84 must(await forkRepo(app, users["erin"]!, upstream));
85 await commit("bob", bob, 1);
86 await commit("carol", carol, 2);
87 await commit("dan", dan, 3);
88 await commit("alice", upstream, 1); // everyone now 1 behind
89 }, 60_000);
90
91 afterAll(async () => {
92 servers.stop();
93 app.close();
94 await rm(dataDir, { recursive: true, force: true });
95 });
96
97 test("most ahead first, links to compare, level forks last under their count", async () => {
98 const res = await fetch(`${servers.url}/alice/app/forks`, {
99 headers: { cookie: "forge_mode=dev" },
100 });
101 const text = await res.text();
102 const at = (s: string) => text.indexOf(s);
103 expect(text).toContain("3 ahead · 1 behind");
104 expect(text).toContain("2 ahead · 1 behind");
105 expect(text).toContain("1 ahead · 1 behind");
106 expect(text).toContain("0 ahead · 0 behind");
107 expect(text).toContain("1 fork with nothing new");
108 expect(at("/carol/app")).toBeGreaterThan(-1);
109 // dan is furthest ahead; carol next; bob last of those with work.
110 expect(at('href="/dan/app"')).toBeLessThan(at('href="/carol/app"'));
111 expect(at('href="/carol/app"')).toBeLessThan(at('href="/bob/app"'));
112 expect(at('href="/bob/app"')).toBeLessThan(at("1 fork with nothing new"));
113 expect(at("1 fork with nothing new")).toBeLessThan(at('href="/erin/app"'));
114 expect(text).toContain('href="/bob/app/pulls/new"');
115 expect(text).not.toContain('href="/erin/app/pulls/new"');
116 });
117
118 test("Compare opens the fork's own proposal page, aimed at the parent and not at the fork", async () => {
119 const cookie = `forge_session=${createSession(app.db, users["carol"]!.id).token}; forge_mode=dev`;
120 const res = await fetch(`${servers.url}/carol/app/pulls/new`, { headers: { cookie } });
121 expect(res.status).toBe(200);
122 const text = await res.text();
123 expect(text).toContain('name="baseRepo" value="parent"');
124 expect(text).toContain("alice/app");
125 expect(text).toContain("Proposing");
126 });
127
128 test("past the bound the ranking says so, and the count stays whole", async () => {
129 const bob = getRepo(app.db, "bob", "app")!;
130 const forks = Array.from({ length: MAX_RANKED_FORKS + 1 }, () => bob);
131 const ranked = await rankedForksOf(app, upstream, forks);
132 expect(ranked.total).toBe(MAX_RANKED_FORKS + 1);
133 expect(ranked.truncated).toBe(true);
134 expect(ranked.offering.length).toBe(MAX_RANKED_FORKS);
135 const again = await rankedForksOf(app, upstream, forks);
136 expect(again.offering.map((f) => f.drift)).toEqual(ranked.offering.map((f) => f.drift));
137 });
138
139 test("the terminal's forks screen shows the same order and words", async () => {
140 const terminal = headlessTerminal(120, 30);
141 const { app: tui, session } = createForgeApp(
142 app,
143 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: users["alice"]! },
144 { terminal, exitHooks: false },
145 );
146 tui.start();
147 tui.ctx.push(forksScreen(session, tui.ctx, getRepo(app.db, "alice", "app")!));
148 await settleApp(tui);
149 const text = tui.screen.toText();
150 tui.stop?.();
151 expect(listForks(app.db, upstream.id).length).toBe(4);
152 const at = (s: string) => text.indexOf(s);
153 expect(at("dan/app")).toBeGreaterThan(-1);
154 expect(at("dan/app")).toBeLessThan(at("carol/app"));
155 expect(at("carol/app")).toBeLessThan(at("bob/app"));
156 expect(at("bob/app")).toBeLessThan(at("erin/app"));
157 expect(text).toContain("3 ahead · 1 behind");
158 expect(text).toContain("1 fork with nothing new");
159 });
160
161 test("enter on a fork opens that fork's workspace", async () => {
162 const terminal = headlessTerminal(120, 30);
163 const { app: tui, session } = createForgeApp(
164 app,
165 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: users["alice"]! },
166 { terminal, exitHooks: false },
167 );
168 tui.start();
169 tui.ctx.push(forksScreen(session, tui.ctx, getRepo(app.db, "alice", "app")!));
170 await settleApp(tui);
171 terminal.feed("\r");
172 await settleApp(tui);
173 const text = tui.screen.toText();
174 tui.stop?.();
175 expect(text).toContain("dan/app");
176 expect(text).not.toContain("nothing new");
177 });
178 });
test/forks-timing.test.ts +2 −2
@@ -36,11 +36,11 @@ afterAll(async () => {
3636 const names = (res: Response): string[] =>
3737 (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!).filter((n) => n !== "app");
3838
39 test("the forks page names session, repository, forks, lineage and render, in order", async () => {
39 test("the forks page names session, repository, forks, lineage, drift and render, in order", async () => {
4040 const res = await fetch(`${servers.url}/alice/notes/forks`, { headers: { authorization: `Bearer ${bearer}` } });
4141 expect(res.status).toBe(200);
4242 await res.text();
43 expect(names(res)).toEqual(["session", "repository", "forks", "lineage", "render"]);
43 expect(names(res)).toEqual(["session", "repository", "forks", "lineage", "drift", "render"]);
4444 });
4545
4646 test("a missing repository still names session and repository", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs