Public: anyone can read this; the crew can change it.
The fork tree page links each node to its fork's page and marks the one you are in #11439
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11321-the-fork-tree-page-links-each-node-to-it → main 3 commits fork 2 threads · 2 unresolved
4 files
changed +118
−7
src/tui/screens/fork_tree.ts +8 −4
| @@ -13,12 +13,16 @@ import { style } from "../term/style.ts"; | ||
| 13 | 13 | import { truncate } from "../term/width.ts"; |
| 14 | 14 | import { Scroll } from "./common.ts"; |
| 15 | 15 | |
| 16 | /** The tree as text lines, `asked` marked with `*`; two spaces per generation. */ | |
| 17 | export function treeLines(tree: ForkTree | undefined, asked: string, width = 80): string[] { | |
| 16 | /** | |
| 17 | * The tree as text lines, `asked` marked with `*`, the viewer's own forks | |
| 18 | * with `(you)`; two spaces per generation. | |
| 19 | */ | |
| 20 | export function treeLines(tree: ForkTree | undefined, asked: string, width = 80, viewer?: string): string[] { | |
| 18 | 21 | if (tree === undefined) return ["Nothing to show."]; |
| 19 | 22 | const lines = tree.nodes.map((n) => { |
| 20 | 23 | const follow = n.follow === "autonomous" ? "" : ` ${n.follow}`; |
| 21 | return truncate(`${" ".repeat(n.depth)}${n.repo === asked ? "* " : ""}${n.repo}${follow}`, width); | |
| 24 | const you = n.repo !== asked && viewer !== undefined && n.repo.startsWith(`${viewer}/`) ? " (you)" : ""; | |
| 25 | return truncate(`${" ".repeat(n.depth)}${n.repo === asked ? "* " : ""}${n.repo}${you}${follow}`, width); | |
| 22 | 26 | }); |
| 23 | 27 | if (tree.truncated) lines.push("", "More forks exist than are shown."); |
| 24 | 28 | return lines; |
| @@ -32,7 +36,7 @@ export function forkTreeScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 32 | 36 | keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")], |
| 33 | 37 | render(area, s) { |
| 34 | 38 | const c = ctx.theme.c; |
| 35 | lines = treeLines(forkTree(f.db, repo, f.user), `${repo.ownerName}/${repo.name}`, area.w - 4); | |
| 39 | lines = treeLines(forkTree(f.db, repo, f.user), `${repo.ownerName}/${repo.name}`, area.w - 4, f.user?.username); | |
| 36 | 40 | const from = scroll.window(area.h, lines.length); |
| 37 | 41 | for (let i = 0; i < area.h; i++) { |
| 38 | 42 | const line = lines[from + i]; |
src/web/pages/following.ts +5 −2
| @@ -54,13 +54,16 @@ import { disconnectedOn } from "./frozen.ts"; | ||
| 54 | 54 | * The family as a tree: nested lists, so the nesting a sighted reader sees |
| 55 | 55 | * as indentation is the nesting a screen reader announces, and a phone |
| 56 | 56 | * never has to scroll sideways to follow it. The repository being looked at |
| 57 | * is marked, and every fork carries its standing against its own parent. | |
| 57 | * is marked "you are here", the viewer's other forks say "you", and every | |
| 58 | * fork carries its standing against its own parent. | |
| 58 | 59 | */ |
| 59 | export function lineageCard(lineage: Lineage, hereId: string): Html { | |
| 60 | export function lineageCard(lineage: Lineage, hereId: string, viewerId?: string): Html { | |
| 60 | 61 | const node = (n: LineageNode, depth: number): Html => { |
| 61 | 62 | const here = n.repo.id === hereId; |
| 62 | 63 | const trail = [ |
| 63 | 64 | here ? badge("you are here", { variant: "outline" }) : null, |
| 65 | // The viewer's own forks say "you" in words. aria-current stays on the one row being looked at. | |
outdated
The 'you' badge has no aria-current or other non-visual hook beyond the word; box 2 asks for aria-current. Also mirror this in src/tui/screens/fork_tree.ts (both doors). |
||
| 66 | !here && viewerId !== undefined && n.repo.ownerId === viewerId ? badge("you", { variant: "outline" }) : null, | |
| 64 | 67 | n.relation === undefined ? null : relationBadge(n.relation), |
| 65 | 68 | ].filter((t): t is Html => t !== null); |
| 66 | 69 | return row({ |
src/web/pages/repo.ts +1 −1
| @@ -196,7 +196,7 @@ export async function forksPage(ctx: Ctx): Promise<Response> { | ||
| 196 | 196 | }) |
| 197 | 197 | : html`${forkRanking(ranked, new Map(tree.map((n) => [n.repo.id, n])))} |
| 198 | 198 | <h3 class="mt">The family, by who follows whom</h3> |
| 199 | ${lineageCard(lineage, repo.id)}`}`; | |
| 199 | ${lineageCard(lineage, repo.id, ctx.auth.user?.id)}`}`; | |
| 200 | 200 | const res = page({ |
| 201 | 201 | ctx, |
| 202 | 202 | title: `Forks · ${repo.ownerName}/${repo.name}`, |
test/fork-tree-links.test.ts added +104
| @@ -0,0 +1,104 @@ | ||
| 1 | /** | |
| 2 | * #11321: the fork tree is for browsing. Every node is a link, the viewer's | |
| 3 | * own forge says "you" in words, and a private fork's name is not on the page. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 10 | import { setRole } from "../src/actions/roles.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createUser, type User } from "../src/core/users.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | ||
| 22 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 23 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 24 | return r.value; | |
| 25 | }; | |
| 26 | const mk = async (username: string, first: boolean): Promise<User> => | |
| 27 | must( | |
| 28 | await createUser(app.db, { | |
| 29 | origin: "seed", | |
| 30 | trusted: true, | |
| 31 | username, | |
| 32 | email: `${username}@forge.local`, | |
| 33 | password: "a-good-passphrase", | |
| 34 | adminIfFirst: first, | |
| 35 | }), | |
| 36 | ); | |
| 37 | const signIn = async (username: string): Promise<string> => | |
| 38 | ( | |
| 39 | ( | |
| 40 | await fetch(`${servers.url}/signin`, { | |
| 41 | method: "POST", | |
| 42 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 43 | redirect: "manual", | |
| 44 | }) | |
| 45 | ).headers.get("set-cookie") ?? "" | |
| 46 | ).split(";")[0]!; | |
| 47 | ||
| 48 | beforeAll(async () => { | |
| 49 | dataDir = await mkdtemp(join(tmpdir(), "forge-fork-tree-links-")); | |
| 50 | app = createApp({ dataDir, port: 0 }); | |
| 51 | servers = await serve(app); | |
| 52 | const ann = await mk("ann", true); | |
| 53 | const bea = await mk("bea", false); | |
| 54 | const cal = await mk("cal", false); | |
| 55 | const dee = await mk("dee", false); | |
| 56 | for (const n of ["bea", "cal", "dee"]) must(setRole(app, ann, n, "member")); | |
| 57 | const root = must(await createRepo(app, ann, { name: "yard", visibility: "public" })); | |
| 58 | must(await commitToBranch(app, ann, root, { message: "first", files: [{ path: "a.txt", content: "a\n" }] })); | |
| 59 | const middle = must(await forkRepo(app, bea, root)); | |
| 60 | must(await forkRepo(app, cal, middle)); | |
| 61 | must(await forkRepo(app, dee, middle, undefined, { visibility: "private" })); | |
| 62 | }, 60_000); | |
| 63 | ||
| 64 | afterAll(async () => { | |
| 65 | servers?.stop(); | |
| 66 | app?.close(); | |
| 67 | await rm(dataDir, { recursive: true, force: true }); | |
| 68 | }); | |
| 69 | ||
| 70 | test("every readable node links, the viewer's own says you, a private fork is absent", async () => { | |
| 71 | const cookie = await signIn("bea"); | |
| 72 | const body = await (await fetch(`${servers.url}/ann/yard/forks`, { headers: { cookie } })).text(); | |
| 73 | const from = body.indexOf('aria-label="Forks, by who follows whom"'); | |
| 74 | const tree = body.slice(from, body.indexOf("</section>", from)); | |
| 75 | for (const h of ["/ann/yard", "/bea/yard", "/cal/yard"]) expect(tree).toContain(`href="${h}"`); | |
| 76 | // One <li> per node, split at the row's own opening tag; only bea's carries "you". | |
| 77 | const rows = new Map<string, string>(); | |
| 78 | for (const m of tree.matchAll(/<a href="(\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/g)) rows.set(m[1]!, m[2]!); | |
| 79 | expect([...rows.keys()].sort()).toEqual(["/ann/yard", "/bea/yard", "/cal/yard"]); | |
| 80 | const marked = (h: string): boolean => /<[^>]*>you<\//.test(rows.get(h)!); | |
| 81 | expect(marked("/bea/yard")).toBe(true); | |
| 82 | expect(marked("/ann/yard")).toBe(false); | |
| 83 | expect(marked("/cal/yard")).toBe(false); | |
| 84 | // dee's private fork is neither linked nor named. | |
| 85 | expect(body).not.toContain("dee/yard"); | |
| 86 | ||
| 87 | // On her own page the row is the current one, in words and in aria. | |
| 88 | const own = await (await fetch(`${servers.url}/bea/yard/forks`, { headers: { cookie } })).text(); | |
| 89 | expect(own).toContain("you are here"); | |
| 90 | expect(own).toContain('aria-current="true"'); | |
| 91 | }); | |
| 92 | ||
| 93 | test("the terminal tree marks the viewer's own forks, not the asked one, as the page does", async () => { | |
| 94 | const { treeLines } = await import("../src/tui/screens/fork_tree.ts"); | |
| 95 | const node = (repo: string, parent: string | null, depth: number) => ({ repo, parent, depth, follow: "autonomous" as const }); | |
| 96 | const tree = { | |
| 97 | root: "ann/yard", | |
| 98 | truncated: false, | |
| 99 | nodes: [node("ann/yard", null, 0), node("bea/yard", "ann/yard", 1), node("cal/yard", "bea/yard", 2)], | |
| 100 | }; | |
| 101 | expect(treeLines(tree, "cal/yard", 80, "bea")).toEqual(["ann/yard", " bea/yard (you)", " * cal/yard"]); | |
| 102 | expect(treeLines(tree, "bea/yard", 80, "bea")).toEqual(["ann/yard", " * bea/yard", " cal/yard"]); | |
| 103 | expect(treeLines(tree, "ann/yard")).toEqual(["* ann/yard", " bea/yard", " cal/yard"]); | |
| 104 | }); |
Line runs long and reads twice ('you are here' vs 'you'). Rewrite in plain words (principle 8).