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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8774-the-repository-head-shows-less-at-rest-a → main 4 commits fork 3 threads · 3 unresolved
7 files
changed +364
−278
docs/design/areas/repo-head.md +9
| @@ -3,3 +3,12 @@ | ||
| 3 | 3 | 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`. |
| 4 | 4 | |
| 5 | 5 | - `.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 @@ | ||
| 8 | 8 | * `ui.ts` may not. That is the line between the two files. |
| 9 | 9 | */ |
| 10 | 10 | |
| 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"; | |
| 15 | 11 | import { audienceFacts, audienceOf } from "../core/audience.ts"; |
| 16 | 12 | import { activeEdgeToken, edgeLoginUrl, edgeStrangerLogin } from "../http/edge.ts"; |
| 17 | 13 | import { |
| 18 | canAdmin, | |
| 19 | 14 | canRead, |
| 20 | 15 | forkParentFor, |
| 21 | 16 | getRepo, |
| 22 | visibleForks, | |
| 23 | 17 | } from "../core/repos.ts"; |
| 24 | import { canEnterApp, type Repo } from "../core/repos.ts"; | |
| 18 | import { type Repo } from "../core/repos.ts"; | |
| 25 | 19 | import { parentUrl, shownTrail, sourceLabel } from "../core/provenance.ts"; |
| 26 | 20 | import type { Database } from "bun:sqlite"; |
| 27 | 21 | import type { Ctx } from "../http/context.ts"; |
| 28 | 22 | import { apiError, prefersJson } from "../http/respond.ts"; |
| 29 | 23 | import { html, raw, when, type Html } from "./html.ts"; |
| 30 | 24 | import { icon } from "./icons.ts"; |
| 31 | import { page, redirect, type PaletteItem } from "./layout.ts"; | |
| 25 | import { page, redirect } from "./layout.ts"; | |
| 32 | 26 | import { |
| 33 | 27 | browseActions, |
| 34 | 28 | hostOf, |
| 35 | tellRow, | |
| 29 | repoPalette, | |
| 36 | 30 | localScope, |
| 37 | 31 | studioOriginNote, |
| 38 | 32 | upstreamHref, |
| 39 | 33 | whereRow, |
| 40 | 34 | type RepoScope, |
| 41 | 35 | } 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"; | |
| 46 | 38 | |
| 47 | 39 | // ---- loading ---- |
| 48 | 40 | |
| @@ -177,21 +169,8 @@ export function offersSignup(ctx: Ctx): boolean { | ||
| 177 | 169 | |
| 178 | 170 | // ---- repo header ---- |
| 179 | 171 | |
| 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 }; | |
| 195 | 174 | |
| 196 | 175 | export interface RepoHeaderOptions { |
| 197 | 176 | /** Rendered at the right of the title line (fork, clone, watch). */ |
| @@ -227,6 +206,25 @@ export function repoHeader( | ||
| 227 | 206 | return scopeHeader(ctx, localScope(ctx, repo), active, options); |
| 228 | 207 | } |
| 229 | 208 | |
| 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 | ||
| 230 | 228 | /** |
| 231 | 229 | * The one repo header, for either side of the world. Identity, the where |
| 232 | 230 | * strip, and the tab strip with live counts. Settings appears only for an |
| @@ -242,162 +240,28 @@ export function scopeHeader( | ||
| 242 | 240 | const base = scope.base; |
| 243 | 241 | const repo = scope.repo; |
| 244 | 242 | 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 | }); | |
| 246 | 259 | // "forked from …" names another repository, so it says nothing to somebody |
| 247 | 260 | // who cannot read that one — same rule as the About rail's Fork-of row. |
| 248 | 261 | const parent = |
| 249 | 262 | repo === undefined |
| 250 | 263 | ? undefined |
| 251 | 264 | : 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 | ||
| 401 | 265 | const owner = facts.owner; |
| 402 | 266 | const actions = options.actions ?? browseActions(scope); |
| 403 | 267 | const title = html`${icon( |
| @@ -443,7 +307,7 @@ export function scopeHeader( | ||
| 443 | 307 | </p>`, |
| 444 | 308 | )} |
| 445 | 309 | ${studio ? studioOriginNote(repo) : options.quiet ? null : whereRow(ctx, scope)} |
| 446 | ${tabs(items, "Repository")} | |
| 310 | ${repoNav(items)} | |
| 447 | 311 | </header>`; |
| 448 | 312 | } |
| 449 | 313 | |
| @@ -487,7 +351,7 @@ export function lineageLine( | ||
| 487 | 351 | |
| 488 | 352 | /** |
| 489 | 353 | * 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 | |
| 491 | 355 | * visibility; a Split repository (public code, private app) is the case it |
| 492 | 356 | * cannot say, so only then does a badge lead the sentence. The sentence is the |
| 493 | 357 | * link to the settings for whoever can open them. |
| @@ -502,50 +366,6 @@ function audienceLine(ctx: Ctx, repo: Repo, admin: boolean, base: string): Html | ||
| 502 | 366 | |
| 503 | 367 | export { hostOf }; |
| 504 | 368 | |
| 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 | ||
| 549 | 369 | // ---- generic furniture ---- |
| 550 | 370 | |
| 551 | 371 | /** 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 @@ | ||
| 23 | 23 | */ |
| 24 | 24 | |
| 25 | 25 | import { parseFrom } from "../actions/tell_from.ts"; |
| 26 | import { getAppByRepo } from "../core/apps.ts"; | |
| 27 | 26 | import { mayDirectCrew } from "../crew/direct.ts"; |
| 28 | 27 | import { isPinned } from "../core/pins.ts"; |
| 29 | 28 | import { |
| @@ -41,6 +40,7 @@ import type { | ||
| 41 | 40 | } from "../view/source.ts"; |
| 42 | 41 | import { localSource } from "../view/local.ts"; |
| 43 | 42 | import { html, when, type Html } from "./html.ts"; |
| 43 | import { repoSteps } from "./repo_steps.ts"; | |
| 44 | 44 | import { icon, type IconName } from "./icons.ts"; |
| 45 | 45 | import type { PaletteItem } from "./layout.ts"; |
| 46 | 46 | import { timeAgo } from "./ui.ts"; |
| @@ -172,10 +172,7 @@ export function localScope(ctx: Ctx, repo: Repo): RepoScope { | ||
| 172 | 172 | { label: repo.ownerName, href: `/${repo.ownerName}` }, |
| 173 | 173 | { label: repo.name, href: base }, |
| 174 | 174 | ], |
| 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), | |
| 179 | 176 | }; |
| 180 | 177 | } |
| 181 | 178 | |
| @@ -265,7 +262,7 @@ export async function originScope( | ||
| 265 | 262 | }, |
| 266 | 263 | { label: fullName, href: base }, |
| 267 | 264 | ], |
| 268 | palette: repoPalette(base, fullName, false), | |
| 265 | palette: originPalette(base, fullName, facts.htmlUrl ?? (where.kind === "origin" ? where.htmlUrl : ""), hostOf(origin.webUrl)), | |
| 269 | 266 | }; |
| 270 | 267 | } |
| 271 | 268 | |
| @@ -287,26 +284,38 @@ export function tellRow(ctx: Ctx, repo: Repo, group: string): PaletteItem[] { | ||
| 287 | 284 | return [{ t: `${base.slice(1)} — Tell the crew`, h, c: group }]; |
| 288 | 285 | } |
| 289 | 286 | |
| 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 | */ | |
| 293 | export function repoPalette(ctx: Ctx, repo: Repo): PaletteItem[] { | |
| 294 | const base = `/${repo.ownerName}/${repo.name}`; | |
| 295 | const name = `${repo.ownerName}/${repo.name}`; | |
| 295 | 296 | 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 [ | |
| 297 | 312 | { t: `${name} — Code`, h: base, c: here }, |
| 298 | 313 | { t: `${name} — Issues`, h: `${base}/issues`, c: here }, |
| 299 | 314 | { t: `${name} — Pull requests`, h: `${base}/pulls`, c: here }, |
| 300 | 315 | { 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 }]), | |
| 301 | 318 | ]; |
| 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; | |
| 310 | 319 | } |
| 311 | 320 | |
| 312 | 321 | // ---- the where strip ---- |
| @@ -572,50 +581,54 @@ export function forkButton(scope: RepoScope): Html { | ||
| 572 | 581 | if (isOwnRepo(origin.login, scope.fullName)) return html``; |
| 573 | 582 | return html`<form method="post" action="${scope.base}/fork"> |
| 574 | 583 | <button |
| 575 | class="btn" | |
| 576 | data-size="sm" | |
| 577 | data-variant="outline" | |
| 584 | class="menu-item" | |
| 578 | 585 | type="submit" |
| 579 | 586 | title="Fork ${scope.fullName} into your account at ${hostOf( |
| 580 | 587 | origin.webUrl, |
| 581 | 588 | )}" |
| 582 | 589 | > |
| 583 | ${icon("fork", 16)}Fork | |
| 590 | ${icon("fork", 16)}<span class="grow">Fork</span> | |
| 584 | 591 | </button> |
| 585 | 592 | </form>`; |
| 586 | 593 | } |
| 587 | 594 | |
| 588 | 595 | /** |
| 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. | |
| 592 | 599 | */ |
| 593 | 600 | export function browseActions(scope: RepoScope): Html { |
| 594 | 601 | if (scope.kind !== "origin" || scope.origin === undefined) return html``; |
| 595 | 602 | const host = hostOf(scope.origin.webUrl); |
| 596 | 603 | 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>`; | |
| 621 | 634 | } |
src/web/style/content.ts +4 −1
| @@ -22,7 +22,10 @@ export const content = /* css */ ` | ||
| 22 | 22 | .repo-head p.muted.small a { display: block; padding: 12px 0; min-height: 44px; box-sizing: border-box; } |
| 23 | 23 | } |
| 24 | 24 | .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; } | |
| 26 | 29 | |
| 27 | 30 | /* Provenance and pinning: one line under the title, so a <form> never has to |
| 28 | 31 | 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", () => { | ||
| 529 | 529 | // The repo header's tab strip (every studio page carries it): the |
| 530 | 530 | // tab to the changes list names its own noun, never the five-word |
| 531 | 531 | // "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. | |
| 533 | 534 | word: "app", |
| 534 | 535 | path: repoPath, |
| 535 | 536 | title: "vocab", |
| 536 | shows: [`#i-issue"/></svg>Issues`, `#i-pull"/></svg>Pulls`], | |
| 537 | shows: [">Issues", ">Pulls"], | |
| 537 | 538 | hides: [`#i-eye"/></svg>Look`, `>Look<`, 'class="where"'], |
| 538 | 539 | // #630: the app page is the home in every mode; its code, with dev's |
| 539 | 540 | // where strip, is the Files step one tab over. |
new above
offline
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.