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

The repository head shows less at rest, and every control it had stays one step away #8802

Merged opened this

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

Merged

mason/forge:mason/8774-the-repository-head-shows-less-at-rest-a → main 4 commits fork 3 threads · 3 unresolved

  • Merged. mason/8774-the-repository-head-shows-less-at-rest-a was merged into main as 01c7769.

Review

Sign in to review this change.
7 files changed +364 −278
docs/design/areas/repo-head.md +9
@@ -3,3 +3,12 @@
33 The head of a repository page names the repository and what you can do with it. `scopeHeader()` in `src/web/chrome.ts` draws it; the rules live in `src/web/style/content.ts`.
44
55 - `.repo-title` — the head's `owner / name` line: the owner muted, the slash faint, the name in full weight. It is an `h1` on a repository's own pages and a `div` where the page has its own `h1`.
6 - `.repo-nav` — the head's navigation at rest (#8774): Code (or Studio and Files), Issues, Pulls and Settings as
7 words-only tabs, and every other step (Map, Commits, Branches, Forks, Runs, Events, API, Terminal) in one "More"
8 menu beside them; a rare step the page is on stays a tab. `src/web/repo_steps.ts` is the one list, with the
9 conditions for each; the command palette draws its rows from it, so a link the head shows is a palette row.
10 - Quiet at rest: no tab icons, no "Public" badge (the audience line says it in words; only "Private" wears a badge),
11 and on a live browse one primary action ("Mirror into your workbench" / "Open the mirror") with Fork and "Open on
12 <host>" in a "More" menu beside it.
13
14 The head is for knowing where you are and taking the next step; everything else waits one tap away.
src/web/chrome.ts +44 −224
@@ -8,41 +8,33 @@
88 * `ui.ts` may not. That is the line between the two files.
99 */
1010
11 import { sshCommand } from "../ssh/command.ts";
12 import { mayReadRuns } from "../actions/runs.ts";
13 import { runningCount } from "../core/runs.ts";
14 import { getAppByRepo } from "../core/apps.ts";
1511 import { audienceFacts, audienceOf } from "../core/audience.ts";
1612 import { activeEdgeToken, edgeLoginUrl, edgeStrangerLogin } from "../http/edge.ts";
1713 import {
18 canAdmin,
1914 canRead,
2015 forkParentFor,
2116 getRepo,
22 visibleForks,
2317 } from "../core/repos.ts";
24 import { canEnterApp, type Repo } from "../core/repos.ts";
18 import { type Repo } from "../core/repos.ts";
2519 import { parentUrl, shownTrail, sourceLabel } from "../core/provenance.ts";
2620 import type { Database } from "bun:sqlite";
2721 import type { Ctx } from "../http/context.ts";
2822 import { apiError, prefersJson } from "../http/respond.ts";
2923 import { html, raw, when, type Html } from "./html.ts";
3024 import { icon } from "./icons.ts";
31 import { page, redirect, type PaletteItem } from "./layout.ts";
25 import { page, redirect } from "./layout.ts";
3226 import {
3327 browseActions,
3428 hostOf,
35 tellRow,
29 repoPalette,
3630 localScope,
3731 studioOriginNote,
3832 upstreamHref,
3933 whereRow,
4034 type RepoScope,
4135 } from "./scope.ts";
42 import { changesHref, tellsHref } from "./studio/nouns.ts";
43 import { knownSpec, warmSpec } from "../apps/api_spec.ts";
44 import { appsRuntime } from "../apps/runtime.ts";
45 import { badge, btn, empty, tabs, type TabItem } from "./ui.ts";
36 import { repoSteps, TAB_STEPS, type RepoTab } from "./repo_steps.ts";
37 import { badge, btn, empty, pill, tabs, type TabItem } from "./ui.ts";
4638
4739 // ---- loading ----
4840
@@ -177,21 +169,8 @@ export function offersSignup(ctx: Ctx): boolean {
177169
178170 // ---- repo header ----
179171
180 export type RepoTab =
181 | "code"
182 | "issues"
183 | "pulls"
184 | "forks"
185 | "map"
186 | "commits"
187 | "branches"
188 | "app"
189 | "runs"
190 | "events"
191 | "api"
192 | "settings"
193 /** A page that belongs to no tab (waves): nothing is marked current. */
194 | "none";
172 export { repoPalette };
173 export type { RepoTab };
195174
196175 export interface RepoHeaderOptions {
197176 /** Rendered at the right of the title line (fork, clone, watch). */
@@ -227,6 +206,25 @@ export function repoHeader(
227206 return scopeHeader(ctx, localScope(ctx, repo), active, options);
228207 }
229208
209 /**
210 * The head's navigation at rest: the few steps most visits want as tabs, the
211 * rest (and any tab the page is on) folded into one "More" menu. Links keep
212 * their `g` shortcuts; each is also a palette row (`repoPalette`).
213 */
214 function repoNav(items: TabItem[]): Html {
215 const shown = items.filter((t) => TAB_STEPS.has(t.label) || t.current === true);
216 const rest = items.filter((t) => !shown.includes(t));
217 if (rest.length === 0) return tabs(shown, "Repository");
218 const more = html`<details class="menu" data-align="start">
219 <summary class="btn" data-variant="ghost" data-size="sm">More</summary>
220 <div class="menu-panel">${rest.map(
221 (t) =>
222 html`<a class="menu-item" href="${t.href}"${t.shortcut ? html` data-shortcut="${t.shortcut}"` : null}>${t.label}${t.count !== undefined && t.count > 0 ? pill(t.count) : null}</a>`,
223 )}</div>
224 </details>`;
225 return html`<div class="repo-nav">${tabs(shown, "Repository")}${more}</div>`;
226 }
227
230228 /**
231229 * The one repo header, for either side of the world. Identity, the where
232230 * strip, and the tab strip with live counts. Settings appears only for an
@@ -242,162 +240,28 @@ export function scopeHeader(
242240 const base = scope.base;
243241 const repo = scope.repo;
244242 const facts = scope.facts;
245 const admin = repo !== undefined && canAdmin(ctx.app.db, repo, ctx.auth.user);
243 const { steps, admin, studio } = repoSteps(ctx, base, repo);
244 const counts: Partial<Record<RepoTab, number | null>> = {
245 issues: facts.openIssues,
246 pulls: facts.openPulls,
247 branches: options.branchCount ?? null,
248 };
249 const items: TabItem[] = steps.map((st) => {
250 const count = st.count ?? counts[st.key] ?? undefined;
251 return {
252 label: st.label,
253 href: st.href,
254 current: st.key === active && st.key !== "none",
255 ...(st.shortcut === undefined ? {} : { shortcut: st.shortcut }),
256 ...(count === undefined ? {} : { count }),
257 };
258 });
246259 // "forked from …" names another repository, so it says nothing to somebody
247260 // who cannot read that one — same rule as the About rail's Fork-of row.
248261 const parent =
249262 repo === undefined
250263 ? undefined
251264 : forkParentFor(ctx.app.db, repo, ctx.auth.user);
252
253 const record =
254 repo === undefined ? undefined : getAppByRepo(ctx.app.db, repo.id);
255 const isApp = record !== undefined;
256 // An app speaks the studio's words (told, changes), a plain repository
257 // git's (issues, pulls, commits, branches) — for every viewer (#603).
258 const studio = isApp;
259 // An app's home is the app, for every viewer, and its code is the Files
260 // step one tab over (`repoFiles`); any other repository's home is its code.
261 const items: TabItem[] = [
262 ...(isApp
263 ? [
264 {
265 label: studio ? "Studio" : "App",
266 href: base,
267 icon: "rocket" as const,
268 current: active === "app",
269 },
270 {
271 label: "Files",
272 href: `${base}/files`,
273 icon: "repo" as const,
274 current: active === "code",
275 shortcut: "g c",
276 },
277 ]
278 : [
279 {
280 label: "Code",
281 href: base,
282 icon: "repo" as const,
283 current: active === "code",
284 shortcut: "g c",
285 },
286 ]),
287 {
288 // Issues and pull requests are called that in every mode (#3241).
289 label: "Issues",
290 href: tellsHref(base),
291 icon: "issue",
292 ...(facts.openIssues === null ? {} : { count: facts.openIssues }),
293 current: active === "issues",
294 shortcut: "g i",
295 },
296 {
297 label: "Pulls",
298 href: changesHref(base),
299 icon: "pull",
300 ...(facts.openPulls === null ? {} : { count: facts.openPulls }),
301 current: active === "pulls",
302 shortcut: "g p",
303 },
304 {
305 label: "Map",
306 href: `${base}/map`,
307 icon: "map",
308 current: active === "map",
309 shortcut: "g a",
310 },
311 ...(studio
312 ? []
313 : [
314 {
315 label: "Commits",
316 href: `${base}/commits`,
317 icon: "commit" as const,
318 current: active === "commits",
319 shortcut: "g m",
320 },
321 {
322 label: "Branches",
323 href: `${base}/branches`,
324 icon: "branch" as const,
325 ...(options.branchCount === undefined
326 ? {}
327 : { count: options.branchCount }),
328 current: active === "branches",
329 shortcut: "g b",
330 },
331 ]),
332 ];
333 // The family: a repository with forks, or one that is a fork, gets a tab
334 // to the tree — where each fork stands against what it follows. One with
335 // neither has no family to show, and the tab stays away.
336 if (repo !== undefined) {
337 // Only the forks this viewer may read: a private fork is not counted.
338 const forks = visibleForks(ctx.app.db, repo.id, ctx.auth.user).length;
339 if (forks > 0 || repo.forkParentId !== null)
340 items.splice(3, 0, {
341 label: "Forks",
342 href: `${base}/forks`,
343 icon: "fork",
344 ...(forks > 0 ? { count: forks } : {}),
345 current: active === "forks",
346 shortcut: "g f",
347 });
348 }
349 // The depth row's last two steps (#603): the runs, for who may read them
350 // (`mayReadRuns`: a private app's are for those who may enter it), and
351 // how to open the terminal (the TUI over SSH) for whoever has keys. A
352 // repository only browsed from elsewhere has no runs here.
353 if (repo !== undefined && mayReadRuns(ctx.app, ctx.auth.user, repo)) {
354 const running = runningCount(ctx.app.db, repo.id);
355 items.push({
356 label: "Runs",
357 href: `${base}/runs`,
358 icon: "activity",
359 ...(running > 0 ? { count: running } : {}),
360 current: active === "runs",
361 shortcut: "g r",
362 });
363 }
364 // What happened on the repository, as the reader may see it: the events
365 // page, for any repository kept here.
366 if (repo !== undefined)
367 items.push({
368 label: "Events",
369 href: `${base}/events`,
370 icon: "activity",
371 current: active === "events",
372 });
373 // An app that serves /openapi.json gets its API reference one tab over.
374 // The spec is read in the background and remembered, so a header never waits.
375 if (repo !== undefined && record !== undefined && canEnterApp(ctx.app.db, repo, record, ctx.auth.user)) {
376 const port = appsRuntime(ctx.app)?.envStatus(record, "production")?.port;
377 warmSpec(record, port);
378 if (knownSpec(record.id) != null)
379 items.push({
380 label: "API",
381 href: `${base}/api`,
382 icon: "command",
383 current: active === "api",
384 });
385 }
386 if (ctx.auth.user !== undefined && sshCommand(ctx.app) !== undefined)
387 items.push({
388 label: "Terminal",
389 href: "/settings/keys#terminal",
390 icon: "command",
391 });
392 if (admin)
393 items.push({
394 label: "Settings",
395 href: `${base}/settings`,
396 icon: "settings",
397 current: active === "settings",
398 shortcut: "g s",
399 });
400
401265 const owner = facts.owner;
402266 const actions = options.actions ?? browseActions(scope);
403267 const title = html`${icon(
@@ -443,7 +307,7 @@ export function scopeHeader(
443307 </p>`,
444308 )}
445309 ${studio ? studioOriginNote(repo) : options.quiet ? null : whereRow(ctx, scope)}
446 ${tabs(items, "Repository")}
310 ${repoNav(items)}
447311 </header>`;
448312 }
449313
@@ -487,7 +351,7 @@ export function lineageLine(
487351
488352 /**
489353 * Who can see this repository, in the one sentence `audienceOf` gives, on a
490 * line under the title. The Public/Private badge in the title says the code's
354 * line under the title. The Private badge in the title says the code's
491355 * visibility; a Split repository (public code, private app) is the case it
492356 * cannot say, so only then does a badge lead the sentence. The sentence is the
493357 * link to the settings for whoever can open them.
@@ -502,50 +366,6 @@ function audienceLine(ctx: Ctx, repo: Repo, admin: boolean, base: string): Html
502366
503367 export { hostOf };
504368
505 /** This repo's own rows for the command palette — the studio's nouns for a
506 * studio-mode viewer of an app, so the palette never sends someone to a
507 * list under a name the address bar would not use. Commits is dev's own
508 * page (no studio tab reaches it, `scopeHeader` above), so it drops out of
509 * a studio app's rows rather than sit there under a dev word; New issue
510 * gives way to the studio's own first verb, telling the crew, which posts
511 * from the app page itself (`${base}#tell`, `studio/app.ts`). */
512 export function repoPalette(ctx: Ctx, repo: Repo): PaletteItem[] {
513 const base = repoBase(repo);
514 const name = `${repo.ownerName}/${repo.name}`;
515 const isApp = getAppByRepo(ctx.app.db, repo.id) !== undefined;
516 const studio = isApp;
517 if (studio) {
518 return [
519 { t: `${name} — Studio`, h: base, c: "This repo" },
520 { t: `${name} — Files`, h: `${base}/files`, c: "This repo" },
521 { t: `${name} — Issues`, h: tellsHref(base), c: "This repo" },
522 { t: `${name} — Pull requests`, h: changesHref(base), c: "This repo" },
523 // Telling the crew is for who may direct it (`mayDirectCrew`: a
524 // writer, or anyone signed in where the owner let outsiders in), so
525 // nobody else is offered a door that leads nowhere.
526 ...tellRow(ctx, repo, "This repo"),
527 ...(mayReadRuns(ctx.app, ctx.auth.user, repo)
528 ? [{ t: `${name} — Runs`, h: `${base}/runs`, c: "This repo" }]
529 : []),
530 { t: `${name} — Events`, h: `${base}/events`, c: "This repo" },
531 { t: `${name} — Settings`, h: `${base}/settings`, c: "This repo" },
532 ];
533 }
534 return [
535 ...(isApp ? [{ t: `${name} — App`, h: base, c: "This repo" }] : []),
536 { t: `${name} — Code`, h: isApp ? `${base}/files` : base, c: "This repo" },
537 { t: `${name} — Issues`, h: `${base}/issues`, c: "This repo" },
538 { t: `${name} — Pull requests`, h: `${base}/pulls`, c: "This repo" },
539 { t: `${name} — Commits`, h: `${base}/commits`, c: "This repo" },
540 { t: `${name} — New issue`, h: `${base}/issues/new`, c: "This repo" },
541 ...(mayReadRuns(ctx.app, ctx.auth.user, repo)
542 ? [{ t: `${name} — Runs`, h: `${base}/runs`, c: "This repo" }]
543 : []),
544 { t: `${name} — Events`, h: `${base}/events`, c: "This repo" },
545 { t: `${name} — Settings`, h: `${base}/settings`, c: "This repo" },
546 ];
547 }
548
549369 // ---- generic furniture ----
550370
551371 /** Title line with optional right-aligned actions and a subtitle. */
src/web/repo_steps.ts added +129
@@ -0,0 +1,129 @@
1 /**
2 * The steps a repository offers: where its head can send somebody, and on
3 * what conditions. One list for the head's tabs and "More" menu and for the
4 * command palette, so a link the head shows is a palette row and a row the
5 * head hides is not offered. Worked out once per request: the head and the
6 * palette both ask, and the answer is the same.
7 */
8
9 import { mayReadRuns } from "../actions/runs.ts";
10 import { appsRuntime } from "../apps/runtime.ts";
11 import { knownSpec, warmSpec } from "../apps/api_spec.ts";
12 import { runningCount } from "../core/runs.ts";
13 import { getAppByRepo } from "../core/apps.ts";
14 import { canAdmin, canEnterApp, visibleForks, type Repo } from "../core/repos.ts";
15 import type { Ctx } from "../http/context.ts";
16 import { sshCommand } from "../ssh/command.ts";
17 import { changesHref, tellsHref } from "./studio/nouns.ts";
18
19 export type RepoTab =
20 | "code"
21 | "issues"
22 | "pulls"
23 | "forks"
24 | "map"
25 | "commits"
26 | "branches"
27 | "app"
28 | "runs"
29 | "events"
30 | "api"
31 | "settings"
32 /** A page that belongs to no tab (waves): nothing is marked current. */
33 | "none";
34
35 export interface Step {
36 key: RepoTab;
37 label: string;
38 href: string;
39 shortcut?: string;
40 /** How many forks, or runs going now, where the step counts them. */
41 count?: number;
42 }
43
44 export interface Steps {
45 steps: Step[];
46 /** May this viewer open the repository's settings. */
47 admin: boolean;
48 /** An app speaks the studio's words; a plain repository git's. */
49 studio: boolean;
50 }
51
52 /** The steps that stay a tab; every other one waits in "More". */
53 export const TAB_STEPS: ReadonlySet<string> = new Set(["Studio", "App", "Files", "Code", "Issues", "Pulls", "Settings"]);
54
55 const cache = new WeakMap<Ctx, Map<string, Steps>>();
56
57 /** `repo` is absent for a repository only browsed from elsewhere: no runs, events or settings here. */
58 export function repoSteps(ctx: Ctx, base: string, repo: Repo | undefined): Steps {
59 const key = `${base}|${repo?.id ?? ""}`;
60 let seen = cache.get(ctx);
61 if (seen === undefined) cache.set(ctx, (seen = new Map()));
62 let made = seen.get(key);
63 if (made === undefined) seen.set(key, (made = work(ctx, base, repo)));
64 return made;
65 }
66
67 function work(ctx: Ctx, base: string, repo: Repo | undefined): Steps {
68 // Settings appears only for an admin of a repository we hold — a tab that
69 // 404s is a lie about what you can do.
70 const admin = repo !== undefined && canAdmin(ctx.app.db, repo, ctx.auth.user);
71 const record = repo === undefined ? undefined : getAppByRepo(ctx.app.db, repo.id);
72 const studio = record !== undefined;
73 // An app's home is the app, for every viewer, and its code is the Files
74 // step one tab over; any other repository's home is its code.
75 const steps: Step[] = [
76 ...(studio
77 ? [
78 { key: "app" as const, label: "Studio", href: base },
79 { key: "code" as const, label: "Files", href: `${base}/files`, shortcut: "g c" },
80 ]
81 : [{ key: "code" as const, label: "Code", href: base, shortcut: "g c" }]),
82 // Issues and pull requests are called that in every mode (#3241).
83 { key: "issues", label: "Issues", href: tellsHref(base), shortcut: "g i" },
84 { key: "pulls", label: "Pulls", href: changesHref(base), shortcut: "g p" },
85 { key: "map", label: "Map", href: `${base}/map`, shortcut: "g a" },
86 ...(studio
87 ? []
88 : [
89 { key: "commits" as const, label: "Commits", href: `${base}/commits`, shortcut: "g m" },
90 { key: "branches" as const, label: "Branches", href: `${base}/branches`, shortcut: "g b" },
91 ]),
92 ];
93 if (repo !== undefined) {
94 // The family: a fork, or a repository with forks this viewer may read
95 // (a private fork is not counted), gets the tree.
96 const forks = visibleForks(ctx.app.db, repo.id, ctx.auth.user).length;
97 if (forks > 0 || repo.forkParentId !== null)
98 steps.splice(3, 0, {
99 key: "forks",
100 label: "Forks",
101 href: `${base}/forks`,
102 shortcut: "g f",
103 ...(forks > 0 ? { count: forks } : {}),
104 });
105 // The runs, for who may read them (a private app's are for those who may enter it).
106 if (mayReadRuns(ctx.app, ctx.auth.user, repo)) {
107 const running = runningCount(ctx.app.db, repo.id);
108 steps.push({
109 key: "runs",
110 label: "Runs",
111 href: `${base}/runs`,
112 shortcut: "g r",
113 ...(running > 0 ? { count: running } : {}),
114 });
115 }
116 steps.push({ key: "events", label: "Events", href: `${base}/events` });
117 // An app that serves /openapi.json gets its API reference. The spec is
118 // read in the background and remembered, so a header never waits.
119 if (record !== undefined && canEnterApp(ctx.app.db, repo, record, ctx.auth.user)) {
120 warmSpec(record, appsRuntime(ctx.app)?.envStatus(record, "production")?.port);
121 if (knownSpec(record.id) != null) steps.push({ key: "api", label: "API", href: `${base}/api` });
122 }
123 }
124 // How to open the terminal (the TUI over SSH), for whoever has keys.
125 if (ctx.auth.user !== undefined && sshCommand(ctx.app) !== undefined)
126 steps.push({ key: "none", label: "Terminal", href: "/settings/keys#terminal" });
127 if (admin) steps.push({ key: "settings", label: "Settings", href: `${base}/settings`, shortcut: "g s" });
128 return { steps, admin, studio };
129 }
src/web/scope.ts +64 −51
@@ -23,7 +23,6 @@
2323 */
2424
2525 import { parseFrom } from "../actions/tell_from.ts";
26 import { getAppByRepo } from "../core/apps.ts";
2726 import { mayDirectCrew } from "../crew/direct.ts";
2827 import { isPinned } from "../core/pins.ts";
2928 import {
@@ -41,6 +40,7 @@ import type {
4140 } from "../view/source.ts";
4241 import { localSource } from "../view/local.ts";
4342 import { html, when, type Html } from "./html.ts";
43 import { repoSteps } from "./repo_steps.ts";
4444 import { icon, type IconName } from "./icons.ts";
4545 import type { PaletteItem } from "./layout.ts";
4646 import { timeAgo } from "./ui.ts";
@@ -172,10 +172,7 @@ export function localScope(ctx: Ctx, repo: Repo): RepoScope {
172172 { label: repo.ownerName, href: `/${repo.ownerName}` },
173173 { label: repo.name, href: base },
174174 ],
175 palette: [
176 ...repoPalette(base, `${repo.ownerName}/${repo.name}`, true),
177 ...(getAppByRepo(ctx.app.db, repo.id) ? tellRow(ctx, repo, "This repository") : []),
178 ],
175 palette: repoPalette(ctx, repo),
179176 };
180177 }
181178
@@ -265,7 +262,7 @@ export async function originScope(
265262 },
266263 { label: fullName, href: base },
267264 ],
268 palette: repoPalette(base, fullName, false),
265 palette: originPalette(base, fullName, facts.htmlUrl ?? (where.kind === "origin" ? where.htmlUrl : ""), hostOf(origin.webUrl)),
269266 };
270267 }
271268
@@ -287,26 +284,38 @@ export function tellRow(ctx: Ctx, repo: Repo, group: string): PaletteItem[] {
287284 return [{ t: `${base.slice(1)} — Tell the crew`, h, c: group }];
288285 }
289286
290 function repoPalette(
291 base: string,
292 name: string,
293 local: boolean,
294 ): PaletteItem[] {
287 /**
288 * This repo's rows for the command palette: every step the head offers, on
289 * the head's own conditions (`repoSteps`), in the studio's nouns for an app —
290 * plus the one first verb a step list has no word for: telling the crew on an
291 * app (it posts from the app page itself), a new issue anywhere else.
292 */
outdated

Runs/Forks rows use different conditions from the head's links (read rule for Runs; Forks only if this repo is a fork), so the palette and head disagree. Use the same conditions.

293 export function repoPalette(ctx: Ctx, repo: Repo): PaletteItem[] {
294 const base = `/${repo.ownerName}/${repo.name}`;
295 const name = `${repo.ownerName}/${repo.name}`;
295296 const here = "This repository";
296 const items: PaletteItem[] = [
297 const { steps, studio } = repoSteps(ctx, base, repo);
298 const rows: PaletteItem[] = steps.map((st) => ({
299 t: `${name} — ${st.label === "Pulls" ? "Pull requests" : st.label}`,
300 h: st.href,
301 c: here,
302 }));
303 return studio
304 ? [...rows, ...tellRow(ctx, repo, here)]
305 : [...rows, { t: `${name} — New issue`, h: `${base}/issues/new`, c: here }];
306 }
307
308 /** A browsed repository's rows: its code, issues, pull requests, commits and the way out to its origin. */
309 function originPalette(base: string, name: string, away: string, host: string): PaletteItem[] {
310 const here = "This repository";
311 return [
297312 { t: `${name} — Code`, h: base, c: here },
298313 { t: `${name} — Issues`, h: `${base}/issues`, c: here },
299314 { t: `${name} — Pull requests`, h: `${base}/pulls`, c: here },
300315 { t: `${name} — Commits`, h: `${base}/commits`, c: here },
316 // The head's "More" holds the way out; so does the palette.
317 ...(away === "" ? [] : [{ t: `${name} — Open on ${host}`, h: away, c: here }]),
301318 ];
302 if (local) {
303 items.push(
304 { t: `${name} — New issue`, h: `${base}/issues/new`, c: here },
305 { t: `${name} — Events`, h: `${base}/events`, c: here },
306 { t: `${name} — Settings`, h: `${base}/settings`, c: here },
307 );
308 }
309 return items;
310319 }
311320
312321 // ---- the where strip ----
@@ -572,50 +581,54 @@ export function forkButton(scope: RepoScope): Html {
572581 if (isOwnRepo(origin.login, scope.fullName)) return html``;
573582 return html`<form method="post" action="${scope.base}/fork">
574583 <button
575 class="btn"
576 data-size="sm"
577 data-variant="outline"
584 class="menu-item"
578585 type="submit"
579586 title="Fork ${scope.fullName} into your account at ${hostOf(
580587 origin.webUrl,
581588 )}"
582589 >
583 ${icon("fork", 16)}Fork
590 ${icon("fork", 16)}<span class="grow">Fork</span>
584591 </button>
585592 </form>`;
586593 }
587594
588595 /**
589 * What a live browse offers instead of the write affordances: fork it, one
590 * primary verb, and the way out to the forge that actually owns this
591 * repository.
596 * What a live browse offers instead of the write affordances: one primary
597 * verb in a fixed place, and the rest (fork it, the way out to the forge that
598 * owns this repository) in an overflow beside it.
592599 */
593600 export function browseActions(scope: RepoScope): Html {
594601 if (scope.kind !== "origin" || scope.origin === undefined) return html``;
595602 const host = hostOf(scope.origin.webUrl);
596603 const mirror = scope.mirror;
597 return html`${forkButton(scope)}${mirror === undefined
598 ? html`<form method="post" action="${scope.base}/mirror">
599 <button class="btn" data-variant="primary" data-size="sm" type="submit">
600 ${icon("download", 16)}Mirror into your workbench
601 </button>
602 </form>`
603 : html`<a
604 class="btn"
605 data-variant="primary"
606 data-size="sm"
607 href="/${mirror.ownerName}/${mirror.name}"
608 >${icon("repo", 16)}Open the mirror</a
609 >`}
610 ${scope.originUrl === ""
611 ? null
612 : html`<a
613 class="btn"
614 data-size="sm"
615 data-variant="outline"
616 href="${scope.originUrl}"
617 target="_blank"
618 rel="noopener noreferrer"
619 >${icon("external", 16)}Open on ${host}</a
620 >`}`;
604 const primary =
605 mirror === undefined
606 ? html`<form method="post" action="${scope.base}/mirror">
607 <button class="btn" data-variant="primary" data-size="sm" type="submit">
608 ${icon("download", 16)}Mirror into your workbench
609 </button>
610 </form>`
611 : html`<a
612 class="btn"
613 data-variant="primary"
614 data-size="sm"
615 href="/${mirror.ownerName}/${mirror.name}"
616 >${icon("repo", 16)}Open the mirror</a
617 >`;
618 const fork = forkButton(scope);
619 const away =
620 scope.originUrl === ""
621 ? null
622 : html`<a
623 class="menu-item"
624 href="${scope.originUrl}"
625 target="_blank"
626 rel="noopener noreferrer"
627 >${icon("external", 16)}<span class="grow">Open on ${host}</span></a
628 >`;
629 if (fork.text.trim() === "" && away === null) return primary;
630 return html`${primary}<details class="menu" data-align="end">
631 <summary class="btn" data-variant="ghost" data-size="sm" aria-label="More actions">More</summary>
632 <div class="menu-panel">${fork}${away}</div>
633 </details>`;
621634 }
src/web/style/content.ts +4 −1
@@ -22,7 +22,10 @@ export const content = /* css */ `
2222 .repo-head p.muted.small a { display: block; padding: 12px 0; min-height: 44px; box-sizing: border-box; }
2323 }
2424 .repo-head p.muted.small { margin-top: 4px; text-wrap: pretty; }
25 .repo-head .tabs { margin-top: 12px; }
25 .repo-head .tabs, .repo-head .repo-nav { margin-top: 12px; }
26 .repo-nav { display: flex; align-items: flex-start; gap: 4px; border-bottom: 1px solid var(--border); }
27 .repo-nav .tabs { margin-top: 0; border-bottom: 0; flex: 1 1 auto; }
28 .repo-nav .menu { margin-top: 4px; }
2629
2730 /* Provenance and pinning: one line under the title, so a <form> never has to
2831 live inside the <h1>. */
test/repo-head-quiet.test.ts added +111
@@ -0,0 +1,111 @@
1 /** The repository head at rest (#8774): few tabs, the rest in "More", nothing lost, and every step in the palette. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { forkRepo } from "../src/actions/repos.ts";
4 import { rememberSpec } from "../src/apps/api_spec.ts";
5 import { createApp } from "../src/core/apps.ts";
6 import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts";
7
8 let f: SnappyFixture;
9 beforeAll(async () => {
10 f = await snappyFixture();
11 }, 120_000);
12 afterAll(async () => {
13 await f.close();
14 });
15
16 // Bytes on the snappy fixture as the owner: [before the head folded its rare steps away, measured now].
17 // The budget is the measurement plus a few bytes for port digits, not room.
18 const BYTES: Record<string, { before: number; budget: number }> = {
19 "": { before: 25_929, budget: 24_840 },
20 "/issues": { before: 51_705, budget: 50_590 },
21 "/pulls": { before: 25_151, budget: 24_030 },
22 };
23
24 const get = async (path: string): Promise<string> =>
25 (await fetch(`${f.base}${path}`, { headers: { cookie: f.ownerCookie } })).text();
26
27 const head = (page: string): string => /<header class="repo-head">[\s\S]*?<\/header>/.exec(page)?.[0] ?? "";
28
29 test("the repository, issues and pulls pages are lighter and keep a short tab strip", async () => {
30 const repo = f.repos[0]!;
31 for (const [path, { before, budget }] of Object.entries(BYTES)) {
32 const text = await get(`/${repo.ownerName}/${repo.name}${path}`);
33 const size = new TextEncoder().encode(text).length;
34 expect(size).toBeLessThan(before);
35 expect(size).toBeLessThan(budget);
36 const strip = /<nav class="tabs"[\s\S]*?<\/nav>/.exec(head(text))?.[0] ?? "";
37 expect((strip.match(/<a\s/g) ?? []).length).toBeLessThanOrEqual(5);
38 expect(head(text)).toContain('class="repo-nav"');
outdated

Add Forks, API and Terminal, and a non-owner viewer; as written a lost link there is not caught.

39 }
40 });
41
42 const paletteRows = (page: string): { t: string; h: string; c: string }[] => {
43 const raw = /<script type="application\/json" id="palette-index">([\s\S]*?)<\/script>/.exec(page)?.[1] ?? "{}";
44 return JSON.parse(raw).items;
45 };
46
47 /** Every link the head draws, tabs and "More" alike. */
48 const headLinks = (page: string): string[] => {
49 const nav = /<div class="repo-nav">[\s\S]*?<\/details><\/div>|<nav class="tabs"[\s\S]*?<\/nav>/.exec(head(page))?.[0] ?? "";
50 return [...nav.matchAll(/<a\s[^>]*?href="([^"]+)"/g)].map((m) => m[1]!).sort();
51 };
52
53 /** The palette's rows for the steps (not its two first verbs: a new issue, telling the crew). */
54 const stepRows = (page: string): string[] =>
55 paletteRows(page)
56 .filter((r) => r.c === "This repository" && !r.h.endsWith("/issues/new") && !r.h.includes("#tell") && !/\/tell\?/.test(r.h))
57 .map((r) => r.h)
58 .sort();
59
60 const asViewer = async (who: "owner" | "peer" | "guest", path: string): Promise<string> =>
61 (
62 await fetch(`${f.base}${path}`, {
63 headers: who === "guest" ? {} : { cookie: who === "owner" ? f.ownerCookie : f.peerCookie },
64 })
65 ).text();
66
67 test("the palette holds the head's links and no others, for the owner, a reader and a guest, on a repository with forks, an app with an API, and the terminal on", async () => {
68 const plain = f.repos[6]!; // public
69 const appRepo = f.repos[3]!; // public, made an app below
70 const forked = await forkRepo(f.app, f.peer, plain);
71 expect(forked.ok).toBe(true);
72 const made = createApp(f.app.db, { repoId: appRepo.id, slug: "three", access: "public" });
73 if (!made.ok) throw new Error(made.error);
74 rememberSpec(made.value.id, { openapi: "3.0.0", paths: {} });
75 (f.app.config as { appSsh?: string }).appSsh = "ssh -p 2222 three@localhost";
76
77 const plainBase = `/${plain.ownerName}/${plain.name}`;
78 const appBase = `/${appRepo.ownerName}/${appRepo.name}`;
79 const seen = new Map<string, string[]>();
80 for (const base of [plainBase, appBase])
81 for (const who of ["owner", "peer", "guest"] as const)
82 for (const path of ["", "/issues", "/pulls", "/map", "/events"]) {
83 const page = await asViewer(who, `${base}${path}`);
84 const links = headLinks(page);
85 expect(links.length).toBeGreaterThan(3);
86 // Nothing the head offers is missing from the palette, nothing the head hides leaks into it.
87 expect([`${who} ${base}${path}`, ...stepRows(page)]).toEqual([`${who} ${base}${path}`, ...links]);
88 if (path === "") seen.set(`${who} ${base}`, links);
89 }
90
91 const owner = seen.get(`owner ${plainBase}`)!;
92 for (const step of ["issues", "pulls", "map", "commits", "branches", "forks", "runs", "events", "settings"])
93 expect(owner).toContain(`${plainBase}/${step}`);
94 expect(owner).toContain("/settings/keys#terminal");
95 expect(seen.get(`owner ${appBase}`)).toContain(`${appBase}/api`);
96 expect(seen.get(`owner ${appBase}`)).toContain(`${appBase}/files`);
97 expect(seen.get(`guest ${appBase}`)).toContain(`${appBase}/api`);
98 // A guest has no settings and no terminal; a reader no settings.
99 for (const who of ["peer", "guest"] as const) expect(seen.get(`${who} ${plainBase}`)).not.toContain(`${plainBase}/settings`);
100 expect(seen.get(`guest ${plainBase}`)).not.toContain("/settings/keys#terminal");
101 expect(seen.get(`peer ${plainBase}`)).toContain("/settings/keys#terminal");
102 expect(seen.get(`guest ${plainBase}`)).toContain(`${plainBase}/forks`);
103 });
104
105 test("the page you are on stays a tab even when it is a rare one", async () => {
106 const repo = f.repos[0]!;
107 const base = `/${repo.ownerName}/${repo.name}`;
108 const strip = /<nav class="tabs"[\s\S]*?<\/nav>/.exec(head(await get(`${base}/map`)))?.[0] ?? "";
109 expect(strip).toContain(`href="${base}/map"`);
110 expect(strip).toContain('aria-current="page"');
111 });
test/studio-words.test.ts +3 −2
@@ -529,11 +529,12 @@ describe("dev's vocabulary never reaches a studio page", () => {
529529 // The repo header's tab strip (every studio page carries it): the
530530 // tab to the changes list names its own noun, never the five-word
531531 // "look" a green pull earns, and never that word's own eye icon —
532 // both stay the picture card's and the word badge's alone (#172).
532 // both stay the picture card's and the word badge's alone (#172). The
533 // tabs are words only since #8774, so the eye is simply absent.
533534 word: "app",
534535 path: repoPath,
535536 title: "vocab",
536 shows: [`#i-issue"/></svg>Issues`, `#i-pull"/></svg>Pulls`],
537 shows: [">Issues", ">Pulls"],
537538 hides: [`#i-eye"/></svg>Look`, `>Look<`, 'class="where"'],
538539 // #630: the app page is the home in every mode; its code, with dev's
539540 // where strip, is the Files step one tab over.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs