Public: anyone can read this; the crew can change it.
Contraction: remove the TUI's origins group #418
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/399-contraction-remove-the-tui-s-origins-gro → main 1 commit fork
7 files
changed +8
−2288
docs/DESIGN.md +3 −8
| @@ -496,12 +496,7 @@ JavaScript at all it is a `<textarea>` in a `<form>`, which is what it was | ||
| 496 | 496 | before any of this. The Preview tab is offered on Markdown only, and renders |
| 497 | 497 | through the same `/preview` fragment the server will use for the file. |
| 498 | 498 | |
| 499 | **Terminal keys and glyphs for origins.** Account → `o` opens Origins; there | |
| 500 | `c` connects, `s` syncs now, `r` browses the origin's repositories and `D` | |
| 501 | disconnects. In the picker `enter` **browses** the row — live, read-only, | |
| 502 | nothing downloaded — and `m` mirrors it into the workbench; a second `m` on a | |
| 503 | running mirror joins the job rather than starting a second one. On a repo's | |
| 504 | home `y` syncs the mirror and `P` pins it — `p` has meant _pulls_ on that | |
| 499 | **Origins are web-only.** Connecting, syncing and browsing an origin live on the web pages (`/origins`) and in migrate; the terminal has no origins screen. On a mirrored repo's home `y` syncs the mirror and `P` pins it — `p` has meant _pulls_ on that | |
| 505 | 500 | screen since before pins existed, so the toggle takes the shifted key there |
| 506 | 501 | and the plain `p` everywhere a repo is a _row_ (the dashboard, explore). |
| 507 | 502 | |
| @@ -599,8 +594,8 @@ Four rules hold the gestures together: | ||
| 599 | 594 | Clickable today: every list row, the dashboard's four panes, the workspace's |
| 600 | 595 | explorer rows and tab chips (including the active tab's `✗`), the file pane |
| 601 | 596 | (focus, and the wheel), the where-strip's three places, the palette's results, |
| 602 | the appearance picker's rows, the owner, refs, settings, account and origins | |
| 603 | lists, the origin picker's rows, the pull's diff-and-threads pane, and **every | |
| 597 | the appearance picker's rows, the owner, refs, settings, account | |
| 598 | lists, the pull's diff-and-threads pane, and **every | |
| 604 | 599 | hint in the help bar** — clicking a hint presses the key it names, taken from |
| 605 | 600 | the binding itself so a label can never drift from the action. A list with no |
| 606 | 601 | `hitList` is a bug, not a design decision: "everything you can see is |
src/tui/forge.ts −7
| @@ -38,7 +38,6 @@ import { exploreScreen } from "./screens/explore.ts"; | ||
| 38 | 38 | import { issuesScreen, newIssueScreen } from "./screens/issues.ts"; |
| 39 | 39 | import { newPullScreen } from "./screens/newpull.ts"; |
| 40 | 40 | import { newRepoScreen } from "./screens/newrepo.ts"; |
| 41 | import { originsScreen } from "./screens/origins.ts"; | |
| 42 | 41 | import { ownerScreen } from "./screens/owner.ts"; |
| 43 | 42 | import { repoScreen } from "./screens/repo.ts"; |
| 44 | 43 | import { settingsScreen } from "./screens/settings.ts"; |
| @@ -177,12 +176,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 177 | 176 | title: "Account settings", |
| 178 | 177 | run: () => ctx.push(accountScreen(session, ctx)), |
| 179 | 178 | }, |
| 180 | { | |
| 181 | id: "origins", | |
| 182 | title: "Origins", | |
| 183 | hint: "connected forges", | |
| 184 | run: () => ctx.push(originsScreen(session, ctx)), | |
| 185 | }, | |
| 186 | 179 | ); |
| 187 | 180 | if (session.user?.isAdmin === true) |
| 188 | 181 | commands.push({ |
src/tui/screens/account.ts −10
| @@ -39,7 +39,6 @@ import { | ||
| 39 | 39 | runAsync, |
| 40 | 40 | } from "./common.ts"; |
| 41 | 41 | import { crewScreen } from "./crew.ts"; |
| 42 | import { originsScreen } from "./origins.ts"; | |
| 43 | 42 | |
| 44 | 43 | const TABS = ["Profile", "Tokens", "SSH keys"] as const; |
| 45 | 44 | |
| @@ -68,7 +67,6 @@ export function accountScreen(f: Session, ctx: Ctx): Screen { | ||
| 68 | 67 | binding(["d"], "display name"), |
| 69 | 68 | binding(["e"], "email"), |
| 70 | 69 | binding(["p"], "password"), |
| 71 | binding(["o"], "origins"), | |
| 72 | 70 | ...crew, |
| 73 | 71 | ]; |
| 74 | 72 | if (tab === 1) |
| @@ -76,14 +74,12 @@ export function accountScreen(f: Session, ctx: Ctx): Screen { | ||
| 76 | 74 | binding(["tab"], "next tab"), |
| 77 | 75 | binding(["n"], "new token"), |
| 78 | 76 | binding(["D"], "revoke"), |
| 79 | binding(["o"], "origins"), | |
| 80 | 77 | ...crew, |
| 81 | 78 | ]; |
| 82 | 79 | return [ |
| 83 | 80 | binding(["tab"], "next tab"), |
| 84 | 81 | binding(["n"], "add key"), |
| 85 | 82 | binding(["D"], "delete key"), |
| 86 | binding(["o"], "origins"), | |
| 87 | 83 | ...crew, |
| 88 | 84 | ]; |
| 89 | 85 | }, |
| @@ -148,12 +144,6 @@ export function accountScreen(f: Session, ctx: Ctx): Screen { | ||
| 148 | 144 | state.index = 0; |
| 149 | 145 | return true; |
| 150 | 146 | } |
| 151 | // Origins are an account-level thing, like tokens and keys, so they | |
| 152 | // hang off this screen from every tab rather than becoming a fourth. | |
| 153 | if (e.key === "o") { | |
| 154 | ctx.push(originsScreen(f, ctx)); | |
| 155 | return true; | |
| 156 | } | |
| 157 | 147 | if (e.key === "c" && f.user?.isAdmin === true) { |
| 158 | 148 | ctx.push(crewScreen(f, ctx)); |
| 159 | 149 | return true; |
src/tui/screens/origins.ts deleted −1030
| @@ -1,1030 +0,0 @@ | ||
| 1 | /** | |
| 2 | * Origins in the terminal: the connected accounts, the three ways to connect | |
| 3 | * one, and the picker that turns a remote repository into a local mirror. | |
| 4 | * | |
| 5 | * The device grant is why this screen exists in the shape it does. It is the | |
| 6 | * only connect flow that works with no browser on this machine — the forge | |
| 7 | * shows a short code, a phone types it in somewhere else, and the forge does | |
| 8 | * the polling. Everything here is a client of `actions/origins.ts`: no token | |
| 9 | * ever reaches a cell buffer, and what a session may see is decided by the | |
| 10 | * same `getOriginFor` every other surface goes through. | |
| 11 | */ | |
| 12 | ||
| 13 | import { | |
| 14 | connectOrigin, | |
| 15 | disconnectOrigin, | |
| 16 | listRemoteRepos, | |
| 17 | listUserOrigins, | |
| 18 | pollOAuthConnect, | |
| 19 | registerOriginApp, | |
| 20 | startOAuthConnect, | |
| 21 | syncOriginNow, | |
| 22 | } from "../../actions/origins.ts"; | |
| 23 | import type { OriginRepoRow } from "../../core/origins.ts"; | |
| 24 | import { pinnedRepoIds } from "../../core/pins.ts"; | |
| 25 | import { getRepoById, getReposByIds, type Repo } from "../../core/repos.ts"; | |
| 26 | import { sourceFor } from "../../view/source.ts"; | |
| 27 | import type { Origin } from "../../origins/types.ts"; | |
| 28 | import { forgeBaseUrl, oauthCallbackUri } from "../../http/api.ts"; | |
| 29 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 30 | import { binding, type Binding } from "../app/keys.ts"; | |
| 31 | import { BOLD, UNDERLINE, style, type Style } from "../term/style.ts"; | |
| 32 | import { G, type Rect, type Screen as TermScreen } from "../term/screen.ts"; | |
| 33 | import { truncate } from "../term/width.ts"; | |
| 34 | import { fill, len, split } from "../ui/layout.ts"; | |
| 35 | import { card, emptyState, spinner } from "../ui/widgets.ts"; | |
| 36 | import { drawQr, qrSize, terminalQr } from "../ui/qr.ts"; | |
| 37 | import type { Session } from "../session.ts"; | |
| 38 | import { | |
| 39 | Filter, | |
| 40 | ListState, | |
| 41 | PIN_MARK, | |
| 42 | ago, | |
| 43 | confirmScreen, | |
| 44 | drawList, | |
| 45 | errorText, | |
| 46 | later, | |
| 47 | openLink, | |
| 48 | promptScreen, | |
| 49 | runAsync, | |
| 50 | type ListRow, | |
| 51 | hitList, | |
| 52 | } from "./common.ts"; | |
| 53 | import { startMirror } from "./mirror.ts"; | |
| 54 | import { forkAtOrigin } from "./contribute.ts"; | |
| 55 | import { repoScreen } from "./repo.ts"; | |
| 56 | import { local } from "./where.ts"; | |
| 57 | ||
| 58 | /** | |
| 59 | * Push a screen *after* the prompt that asked for it has closed itself. | |
| 60 | * | |
| 61 | * `promptScreen` pops on submit, so pushing straight from `onSubmit` would | |
| 62 | * hand the pop the screen we just pushed. Returning a promise defers the | |
| 63 | * push by a microtask, which is exactly the ordering wanted: prompt closes, | |
| 64 | * next screen opens. | |
| 65 | */ | |
| 66 | /** `codeberg.org` from `https://codeberg.org` — what a person calls a forge. */ | |
| 67 | export function hostLabel(url: string): string { | |
| 68 | return URL.parse(url)?.host ?? url; | |
| 69 | } | |
| 70 | ||
| 71 | /** Healthy, spent, or merely erroring — three words, never a colour alone. */ | |
| 72 | export function originState(origin: Origin): { | |
| 73 | text: string; | |
| 74 | tone: "success" | "danger" | "attn"; | |
| 75 | } { | |
| 76 | if (origin.needsReauth) return { text: "needs re-auth", tone: "danger" }; | |
| 77 | if (origin.lastError !== "") return { text: "error", tone: "attn" }; | |
| 78 | return { text: "healthy", tone: "success" }; | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Where an OAuth application must send the browser back to. | |
| 83 | * | |
| 84 | * The same rule the web surface uses, asked from this side: `FORGE_URL`, or a | |
| 85 | * host the operator vouched for, or loopback. A terminal session has no | |
| 86 | * request to read a Host header from — which is the point, since that header | |
| 87 | * was never trustworthy for this — so it asks about the forge's own address. | |
| 88 | */ | |
| 89 | function callbackUri(f: Session): string { | |
| 90 | const url = selfUrl(f); | |
| 91 | return url === null ? "" : oauthCallbackUri(url, f.app.config.host); | |
| 92 | } | |
| 93 | ||
| 94 | /** The forge's own base URL, by the same rule — "" when it knows of none. */ | |
| 95 | function baseUri(f: Session): string { | |
| 96 | const url = selfUrl(f); | |
| 97 | return url === null ? "" : forgeBaseUrl(url, f.app.config.host); | |
| 98 | } | |
| 99 | ||
| 100 | function selfUrl(f: Session): URL | null { | |
| 101 | const host = f.app.config.host ?? "localhost"; | |
| 102 | return URL.parse(`http://${host}:${f.app.config.port ?? 3000}/`); | |
| 103 | } | |
| 104 | ||
| 105 | /** `<forge>/go/<code>` — what a phone types, or a camera reads. */ | |
| 106 | function shortLink(f: Session, code: string): string { | |
| 107 | const base = baseUri(f); | |
| 108 | return base === "" || code === "" ? "" : `${base}/go/${code}`; | |
| 109 | } | |
| 110 | ||
| 111 | // ---- the list ---- | |
| 112 | ||
| 113 | export function originsScreen(f: Session, ctx: Ctx): Screen { | |
| 114 | let origins: Origin[] = []; | |
| 115 | const state = new ListState(); | |
| 116 | ||
| 117 | const reload = (): void => { | |
| 118 | origins = f.user ? listUserOrigins(f.app, f.user) : []; | |
| 119 | state.clamp(origins.length); | |
| 120 | ctx.invalidate(); | |
| 121 | }; | |
| 122 | ||
| 123 | const current = (): Origin | undefined => origins[state.index]; | |
| 124 | ||
| 125 | const screen: Screen = { | |
| 126 | title: "Origins", | |
| 127 | keys: [ | |
| 128 | binding(["j", "k"], "move"), | |
| 129 | binding(["c"], "connect — a token needs no setup"), | |
| 130 | binding(["s"], "sync now"), | |
| 131 | binding(["r"], "browse its repositories"), | |
| 132 | binding(["D"], "disconnect"), | |
| 133 | ], | |
| 134 | load() { | |
| 135 | reload(); | |
| 136 | return Promise.resolve(); | |
| 137 | }, | |
| 138 | render(area, s) { | |
| 139 | const c = ctx.theme.c; | |
| 140 | if (origins.length === 0) { | |
| 141 | emptyState(s, area, c, { | |
| 142 | title: "No origins connected", | |
| 143 | body: "Paste a personal access token — or set up one-click GitHub or Forgejo.", | |
| 144 | action: "c connect an origin", | |
| 145 | }); | |
| 146 | return; | |
| 147 | } | |
| 148 | drawList( | |
| 149 | s, | |
| 150 | area, | |
| 151 | c, | |
| 152 | state, | |
| 153 | origins.map((origin): ListRow => { | |
| 154 | const health = originState(origin); | |
| 155 | const kind = origin.authKind === "oauth" ? "oauth" : "token"; | |
| 156 | return { | |
| 157 | title: `${origin.login} @ ${hostLabel(origin.webUrl)}`, | |
| 158 | secondary: `${kind} · ${health.text}${origin.lastError === "" ? "" : `: ${origin.lastError}`}`, | |
| 159 | meta: | |
| 160 | origin.lastSyncAt === null | |
| 161 | ? "never synced" | |
| 162 | : `synced ${ago(origin.lastSyncAt)}`, | |
| 163 | }; | |
| 164 | }), | |
| 165 | ); | |
| 166 | // Opening an origin is browsing its repositories — the same thing `r` | |
| 167 | // does, so the pointer cannot reach somewhere the keyboard could not. | |
| 168 | hitList(ctx, area, state, origins.length, { | |
| 169 | open: (index) => { | |
| 170 | const origin = origins[index]; | |
| 171 | if (origin !== undefined) ctx.push(originReposScreen(f, ctx, origin)); | |
| 172 | }, | |
| 173 | }); | |
| 174 | }, | |
| 175 | key(e) { | |
| 176 | if (state.key(e, origins.length)) return true; | |
| 177 | if (e.key === "c") { | |
| 178 | ctx.push(connectScreen(f, ctx, screen, reload)); | |
| 179 | return true; | |
| 180 | } | |
| 181 | if (e.key === "s") { | |
| 182 | const origin = current(); | |
| 183 | if (!origin) return true; | |
| 184 | runAsync(ctx, async () => { | |
| 185 | const report = await syncOriginNow(f.app, f.user, origin.id); | |
| 186 | if (!report.ok) ctx.toast(errorText(report.error), "danger"); | |
| 187 | else if (report.value.error !== "") | |
| 188 | ctx.toast(report.value.error, "danger"); | |
| 189 | else | |
| 190 | ctx.toast(`synced ${report.value.repos} repositories`, "success"); | |
| 191 | reload(); | |
| 192 | }); | |
| 193 | return true; | |
| 194 | } | |
| 195 | if (e.key === "r") { | |
| 196 | const origin = current(); | |
| 197 | if (origin) ctx.push(originReposScreen(f, ctx, origin)); | |
| 198 | return true; | |
| 199 | } | |
| 200 | if (e.key === "D") { | |
| 201 | const origin = current(); | |
| 202 | if (!origin) return true; | |
| 203 | ctx.push( | |
| 204 | confirmScreen(ctx, screen, { | |
| 205 | title: "Disconnect origin", | |
| 206 | body: [ | |
| 207 | `${origin.login} @ ${hostLabel(origin.webUrl)}`, | |
| 208 | "", | |
| 209 | "Mirrors keep everything they have and stop syncing.", | |
| 210 | "The grant is revoked upstream only where the forge allows it.", | |
| 211 | ], | |
| 212 | actions: [ | |
| 213 | { label: "Cancel", variant: "ghost" }, | |
| 214 | { label: "Disconnect", variant: "danger" }, | |
| 215 | ], | |
| 216 | onChoose: (choice) => { | |
| 217 | if (choice !== 1) return; | |
| 218 | runAsync(ctx, async () => { | |
| 219 | const gone = await disconnectOrigin(f.app, f.user, origin.id); | |
| 220 | if (!gone.ok) ctx.toast(errorText(gone.error), "danger"); | |
| 221 | else | |
| 222 | ctx.toast( | |
| 223 | gone.value.revokedUpstream | |
| 224 | ? "disconnected, grant revoked upstream" | |
| 225 | : "disconnected — revoke the token on the origin too", | |
| 226 | "success", | |
| 227 | ); | |
| 228 | reload(); | |
| 229 | }); | |
| 230 | }, | |
| 231 | }), | |
| 232 | ); | |
| 233 | return true; | |
| 234 | } | |
| 235 | return false; | |
| 236 | }, | |
| 237 | }; | |
| 238 | return screen; | |
| 239 | } | |
| 240 | ||
| 241 | // ---- connecting ---- | |
| 242 | ||
| 243 | /** | |
| 244 | * The chooser. Which flow works is a property of the forge, not a | |
| 245 | * preference, so the rows say what each one *is* rather than offering them | |
| 246 | * as equals — and they are in the order that gets a person connected. | |
| 247 | * | |
| 248 | * The token is first because it is the only route with no setup at all: you | |
| 249 | * paste a string and you are connected, on any forge, from any terminal. The | |
| 250 | * two one-click flows are genuinely nicer *once they exist*, and each needs | |
| 251 | * somebody to register an application first — so each says so on its own | |
| 252 | * line rather than failing later with an error about an environment | |
| 253 | * variable. | |
| 254 | */ | |
| 255 | function connectScreen( | |
| 256 | f: Session, | |
| 257 | ctx: Ctx, | |
| 258 | parent: Screen, | |
| 259 | done: () => void, | |
| 260 | ): Screen { | |
| 261 | const ways = [ | |
| 262 | { | |
| 263 | id: "token", | |
| 264 | label: "Personal access token — recommended", | |
| 265 | note: "No setup at all. Works with any forge, any terminal.", | |
| 266 | note2: "Paste one from the forge and you are connected.", | |
| 267 | }, | |
| 268 | { | |
| 269 | id: "github", | |
| 270 | label: "GitHub, one click", | |
| 271 | note: "Needs a one-time OAuth app setup.", | |
| 272 | note2: "Then a short code you type on your phone.", | |
| 273 | }, | |
| 274 | { | |
| 275 | id: "forgejo", | |
| 276 | label: "Forgejo / Gitea, one click", | |
| 277 | note: "Needs an app registration on the instance.", | |
| 278 | note2: "Then a browser to authorize it.", | |
| 279 | }, | |
| 280 | ] as const; | |
| 281 | let at = 0; | |
| 282 | ||
| 283 | return { | |
| 284 | title: "Connect an origin", | |
| 285 | keys: [ | |
| 286 | binding(["j", "k"], "move"), | |
| 287 | binding(["enter"], "choose"), | |
| 288 | binding(["esc"], "cancel"), | |
| 289 | ], | |
| 290 | render(area, s) { | |
| 291 | parent.render(area, s); | |
| 292 | const c = ctx.theme.c; | |
| 293 | const w = Math.min(62, Math.max(24, area.w - 4)); | |
| 294 | const h = Math.min(area.h, ways.length * 4 + 1); | |
| 295 | const inner = card( | |
| 296 | s, | |
| 297 | { | |
| 298 | x: area.x + ((area.w - w) >> 1), | |
| 299 | y: area.y + ((area.h - h) >> 1), | |
| 300 | w, | |
| 301 | h, | |
| 302 | }, | |
| 303 | c, | |
| 304 | { title: "Connect an origin", focused: true }, | |
| 305 | ); | |
| 306 | const right = inner.x + inner.w; | |
| 307 | ways.forEach((way, i) => { | |
| 308 | const y = inner.y + i * 4; | |
| 309 | if (y + 2 >= inner.y + inner.h) return; | |
| 310 | const chosen = i === at; | |
| 311 | s.text( | |
| 312 | inner.x, | |
| 313 | y, | |
| 314 | chosen ? G.caretRight : " ", | |
| 315 | style(c.accent, c.card), | |
| 316 | right, | |
| 317 | ); | |
| 318 | s.text( | |
| 319 | inner.x + 2, | |
| 320 | y, | |
| 321 | truncate(way.label, Math.max(0, inner.w - 2)), | |
| 322 | style(chosen ? c.fg : c.mutedFg, c.card, chosen ? BOLD : 0), | |
| 323 | right, | |
| 324 | ); | |
| 325 | for (const [n, line] of [way.note, way.note2].entries()) | |
| 326 | s.text( | |
| 327 | inner.x + 2, | |
| 328 | y + 1 + n, | |
| 329 | truncate(line, Math.max(0, inner.w - 2)), | |
| 330 | style(c.faint, c.card), | |
| 331 | right, | |
| 332 | ); | |
| 333 | }); | |
| 334 | }, | |
| 335 | key(e) { | |
| 336 | if (e.key === "j" || e.key === "down") at = (at + 1) % ways.length; | |
| 337 | else if (e.key === "k" || e.key === "up") | |
| 338 | at = (at - 1 + ways.length) % ways.length; | |
| 339 | else if (e.key === "enter") { | |
| 340 | const choice = ways[at]?.id; | |
| 341 | ctx.pop(); | |
| 342 | if (choice === "github") askGithubHost(f, ctx, parent, done); | |
| 343 | else if (choice === "forgejo") startCodeFlow(f, ctx, parent, done); | |
| 344 | else if (choice === "token") startToken(f, ctx, parent, done); | |
| 345 | } else if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 346 | return true; | |
| 347 | }, | |
| 348 | }; | |
| 349 | } | |
| 350 | ||
| 351 | function startToken( | |
| 352 | f: Session, | |
| 353 | ctx: Ctx, | |
| 354 | parent: Screen, | |
| 355 | done: () => void, | |
| 356 | ): void { | |
| 357 | ctx.push( | |
| 358 | promptScreen(ctx, parent, { | |
| 359 | title: "Connect with a token", | |
| 360 | label: "instance", | |
| 361 | placeholder: "github.com", | |
| 362 | onSubmit: (host) => { | |
| 363 | if (host === "") return "an instance is required"; | |
| 364 | return later(ctx, () => | |
| 365 | promptScreen(ctx, parent, { | |
| 366 | title: `Token for ${host}`, | |
| 367 | label: "personal access token", | |
| 368 | // Masked: a terminal is the one surface where somebody is | |
| 369 | // routinely looking over your shoulder. | |
| 370 | mask: true, | |
| 371 | hint: "Needs repository and issue access. Never displayed again.", | |
| 372 | onSubmit: (token) => { | |
| 373 | if (token === "") return "a token is required"; | |
| 374 | return (async () => { | |
| 375 | const connected = await connectOrigin(f.app, f.user, { | |
| 376 | host, | |
| 377 | token, | |
| 378 | }); | |
| 379 | if (!connected.ok) { | |
| 380 | ctx.toast(errorText(connected.error), "danger"); | |
| 381 | return; | |
| 382 | } | |
| 383 | ctx.toast( | |
| 384 | `connected ${connected.value.login} @ ${hostLabel(connected.value.webUrl)}`, | |
| 385 | "success", | |
| 386 | ); | |
| 387 | done(); | |
| 388 | })(); | |
| 389 | }, | |
| 390 | }), | |
| 391 | ); | |
| 392 | }, | |
| 393 | }), | |
| 394 | ); | |
| 395 | } | |
| 396 | ||
| 397 | /** | |
| 398 | * Which GitHub. Empty means github.com, which is what almost everyone wants | |
| 399 | * and one keypress away; a host is how GitHub Enterprise gets named, since | |
| 400 | * it publishes nothing discovery could recognise. | |
| 401 | */ | |
| 402 | function askGithubHost( | |
| 403 | f: Session, | |
| 404 | ctx: Ctx, | |
| 405 | parent: Screen, | |
| 406 | done: () => void, | |
| 407 | ): void { | |
| 408 | ctx.push( | |
| 409 | promptScreen(ctx, parent, { | |
| 410 | title: "Connect GitHub", | |
| 411 | label: "host", | |
| 412 | placeholder: "github.com — or a GitHub Enterprise host", | |
| 413 | // No `later` here: the device start awaits the network before it | |
| 414 | // pushes anything, so the prompt has already closed by then. | |
| 415 | onSubmit: (host) => { | |
| 416 | startDevice(f, ctx, parent, host === "" ? "github" : host, done); | |
| 417 | return undefined; | |
| 418 | }, | |
| 419 | }), | |
| 420 | ); | |
| 421 | } | |
| 422 | ||
| 423 | function startDevice( | |
| 424 | f: Session, | |
| 425 | ctx: Ctx, | |
| 426 | parent: Screen, | |
| 427 | host: string, | |
| 428 | done: () => void, | |
| 429 | ): void { | |
| 430 | runAsync(ctx, async () => { | |
| 431 | const started = await startOAuthConnect(f.app, f.user, { | |
| 432 | host, | |
| 433 | kind: "github", | |
| 434 | redirectUri: callbackUri(f), | |
| 435 | }); | |
| 436 | if (!started.ok) { | |
| 437 | // "Not configured" is a task, not a status: the one error a person can | |
| 438 | // actually fix from here gets the instructions instead of a toast that | |
| 439 | // names an environment variable and leaves them to it. | |
| 440 | if (started.error.error === "no_app") | |
| 441 | ctx.push(githubSetup(f, ctx, parent)); | |
| 442 | else ctx.toast(started.error.message, "danger"); | |
| 443 | return; | |
| 444 | } | |
| 445 | ctx.push(deviceScreen(f, ctx, parent, started.value, done)); | |
| 446 | }); | |
| 447 | } | |
| 448 | ||
| 449 | // ---- handing a connect to a device that has a browser ---- | |
| 450 | ||
| 451 | /** | |
| 452 | * Everything a person needs to finish a connect somewhere else, and every way | |
| 453 | * of getting it to them. | |
| 454 | * | |
| 455 | * This screen exists because of one situation: the forge is on a server, you | |
| 456 | * are in a terminal over SSH, and the URL on the screen is a string you can | |
| 457 | * neither click nor reliably copy. So all three routes off this screen are | |
| 458 | * offered at once — a QR code the phone in your hand reads directly, a short | |
| 459 | * link that is ten characters of path and can be typed by hand, and OSC 52, | |
| 460 | * which asks *your* terminal (not the server) to put the link on *your* | |
| 461 | * clipboard. Opening a browser is the one thing that genuinely cannot be done | |
| 462 | * from the far side of an SSH connection, and `o` says so rather than | |
| 463 | * appearing to work. | |
| 464 | */ | |
| 465 | interface Handoff { | |
| 466 | /** The device grant's user code. Empty for the code flow, which has none. */ | |
| 467 | readonly userCode: string; | |
| 468 | /** The full URL the person has to reach. */ | |
| 469 | readonly url: string; | |
| 470 | /** `<forge>/go/<code>`, or "" when the forge does not know its address. */ | |
| 471 | readonly link: string; | |
| 472 | /** The lead line: what to do with all this. */ | |
| 473 | readonly lead: string; | |
| 474 | /** What the spinner says. A failed poll rewrites it. */ | |
| 475 | detail: string; | |
| 476 | } | |
| 477 | ||
| 478 | function handoffKeys(h: Handoff): Binding[] { | |
| 479 | return [ | |
| 480 | binding(["c"], "copy link"), | |
| 481 | ...(h.userCode === "" ? [] : [binding(["C"], "copy code")]), | |
| 482 | binding(["o"], "open"), | |
| 483 | binding(["esc"], "cancel"), | |
| 484 | ]; | |
| 485 | } | |
| 486 | ||
| 487 | const COPY_NOTE = | |
| 488 | "copied — if your terminal allows it (iTerm2, Ghostty, kitty, WezTerm do; Apple Terminal does not)"; | |
| 489 | ||
| 490 | function handoffKey(ctx: Ctx, h: Handoff, key: string): boolean { | |
| 491 | if (key === "c") { | |
| 492 | ctx.copy(h.link === "" ? h.url : h.link); | |
| 493 | ctx.toast(COPY_NOTE, "success"); | |
| 494 | return true; | |
| 495 | } | |
| 496 | if (key === "C" && h.userCode !== "") { | |
| 497 | ctx.copy(h.userCode); | |
| 498 | ctx.toast(COPY_NOTE, "success"); | |
| 499 | return true; | |
| 500 | } | |
| 501 | if (key === "o") { | |
| 502 | // Over SSH there is no browser here to reach, so the URL goes to the | |
| 503 | // reader's *own* clipboard and the toast names the gesture that opens | |
| 504 | // the link on screen. Three routes, no dead end. | |
| 505 | openLink(ctx, h.url); | |
| 506 | return true; | |
| 507 | } | |
| 508 | return false; | |
| 509 | } | |
| 510 | ||
| 511 | /** | |
| 512 | * Draw the card. The QR is the first thing dropped when the terminal is | |
| 513 | * short: a code that does not fit is not a smaller code, and the short link | |
| 514 | * below it says the same thing in characters. | |
| 515 | */ | |
| 516 | function drawHandoff( | |
| 517 | ctx: Ctx, | |
| 518 | surface: TermScreen, | |
| 519 | area: Rect, | |
| 520 | parent: Screen, | |
| 521 | h: Handoff, | |
| 522 | title: string, | |
| 523 | ): void { | |
| 524 | const c = ctx.theme.c; | |
| 525 | parent.render(area, surface); | |
| 526 | const w = Math.min(64, Math.max(24, area.w - 6)); | |
| 527 | const urlLines = wrap(h.url, w).slice(0, 2); | |
| 528 | const linkRows = h.link === "" ? 0 : 1; | |
| 529 | const codeRows = h.userCode === "" ? 0 : 2; | |
| 530 | const fixed = 1 + codeRows + 1 + linkRows + urlLines.length + 1 + 1; | |
| 531 | ||
| 532 | const qr = terminalQr(h.link === "" ? h.url : h.link); | |
| 533 | const dim = qr === undefined ? undefined : qrSize(qr); | |
| 534 | const withQr = | |
| 535 | qr !== undefined && | |
| 536 | dim !== undefined && | |
| 537 | dim.cols <= w && | |
| 538 | fixed + dim.rows + 1 + 2 <= area.h; | |
| 539 | const rows = fixed + (withQr && dim ? dim.rows + 1 : 0); | |
| 540 | const height = Math.min(area.h, rows + 2); | |
| 541 | ||
| 542 | const inner = card( | |
| 543 | surface, | |
| 544 | { | |
| 545 | x: area.x + ((area.w - w) >> 1), | |
| 546 | y: area.y + ((area.h - height) >> 1), | |
| 547 | w, | |
| 548 | h: height, | |
| 549 | }, | |
| 550 | c, | |
| 551 | { title, focused: true }, | |
| 552 | ); | |
| 553 | const right = inner.x + inner.w; | |
| 554 | let y = inner.y; | |
| 555 | const line = (text: string, st: Style, centred = false): void => { | |
| 556 | const x = centred | |
| 557 | ? inner.x + Math.max(0, (inner.w - text.length) >> 1) | |
| 558 | : inner.x; | |
| 559 | surface.text(x, y++, truncate(text, inner.w), st, right); | |
| 560 | }; | |
| 561 | ||
| 562 | line(h.lead, style(c.mutedFg, c.card)); | |
| 563 | if (h.userCode !== "") { | |
| 564 | y++; | |
| 565 | // The code is the whole point of the screen, so it gets the one | |
| 566 | // deliberately loud line in the terminal UI: spaced out, centred. | |
| 567 | line([...h.userCode].join(" "), style(c.fg, c.card, BOLD), true); | |
| 568 | } | |
| 569 | y++; | |
| 570 | if (withQr && qr) { | |
| 571 | y = drawQr(surface, { x: inner.x, y, w: inner.w, h: inner.h }, qr); | |
| 572 | y++; | |
| 573 | } | |
| 574 | // Both URLs are real hyperlinks (OSC 8), so a terminal that supports them | |
| 575 | // turns the one thing this screen exists to hand over into something the | |
| 576 | // reader can click instead of retype. The QR and the plain text stay for | |
| 577 | // everybody else — three routes to one URL, because none of them is | |
| 578 | // reliable on its own. | |
| 579 | if (h.link !== "") { | |
| 580 | surface.linkText( | |
| 581 | inner.x, | |
| 582 | y++, | |
| 583 | truncate(h.link, inner.w), | |
| 584 | style(c.accent, c.card, UNDERLINE), | |
| 585 | h.link, | |
| 586 | right, | |
| 587 | ); | |
| 588 | } | |
| 589 | for (const part of urlLines) { | |
| 590 | surface.linkText( | |
| 591 | inner.x, | |
| 592 | y++, | |
| 593 | truncate(part, inner.w), | |
| 594 | style(c.faint, c.card), | |
| 595 | h.url, | |
| 596 | right, | |
| 597 | ); | |
| 598 | } | |
| 599 | y++; | |
| 600 | const x = spinner(surface, inner.x, y, c, ctx.tick); | |
| 601 | surface.text( | |
| 602 | x + 1, | |
| 603 | y, | |
| 604 | truncate(h.detail, Math.max(0, right - x - 1)), | |
| 605 | style(c.mutedFg, c.card), | |
| 606 | right, | |
| 607 | ); | |
| 608 | } | |
| 609 | ||
| 610 | /** | |
| 611 | * How to make the GitHub button work, in the three steps that do it. Shown | |
| 612 | * where the failure happens, because a person who just pressed "GitHub" is | |
| 613 | * exactly the person who wants to know. | |
| 614 | */ | |
| 615 | function githubSetup(f: Session, ctx: Ctx, parent: Screen): Screen { | |
| 616 | const back = callbackUri(f); | |
| 617 | return confirmScreen(ctx, parent, { | |
| 618 | title: "Set up GitHub", | |
| 619 | body: [ | |
| 620 | "No GitHub OAuth application is configured yet.", | |
| 621 | "", | |
| 622 | "1. Create an OAuth App at", | |
| 623 | " github.com/settings/developers. Homepage: this", | |
| 624 | " forge. Authorization callback URL:", | |
| 625 | ` ${back === "" ? "<your forge>/settings/origins/callback" : back}`, | |
| 626 | " Tick 'Enable Device Flow'.", | |
| 627 | "2. Start the forge with", | |
| 628 | " FORGE_GITHUB_CLIENT_ID=<client id> (and, only", | |
| 629 | " for revoke-on-disconnect, the client secret).", | |
| 630 | "3. Come back here — GitHub connects.", | |
| 631 | "", | |
| 632 | "Until then, a pasted token needs no setup at all.", | |
| 633 | ], | |
| 634 | actions: [{ label: "OK", variant: "default" }], | |
| 635 | onChoose: () => undefined, | |
| 636 | }); | |
| 637 | } | |
| 638 | ||
| 639 | /** | |
| 640 | * The device-code screen: the code, the ways to reach the verification page, | |
| 641 | * and a spinner while the forge polls on the user's behalf. Esc cancels — the | |
| 642 | * flow row expires on its own, so abandoning it costs nothing. | |
| 643 | */ | |
| 644 | function deviceScreen( | |
| 645 | f: Session, | |
| 646 | ctx: Ctx, | |
| 647 | parent: Screen, | |
| 648 | flow: { | |
| 649 | flowId: string; | |
| 650 | userCode: string; | |
| 651 | url: string; | |
| 652 | shortCode: string; | |
| 653 | intervalSecs: number; | |
| 654 | }, | |
| 655 | done: () => void, | |
| 656 | ): Screen { | |
| 657 | let finished = false; | |
| 658 | const handoff: Handoff = { | |
| 659 | userCode: flow.userCode, | |
| 660 | url: flow.url, | |
| 661 | link: shortLink(f, flow.shortCode), | |
| 662 | lead: `Type this code at ${hostLabel(flow.url)}:`, | |
| 663 | detail: "waiting for you to authorize…", | |
| 664 | }; | |
| 665 | ||
| 666 | return { | |
| 667 | title: "Connect GitHub", | |
| 668 | keys: handoffKeys(handoff), | |
| 669 | load(signal) { | |
| 670 | const every = Math.max(1, flow.intervalSecs) * 1000; | |
| 671 | const timer = setInterval(() => { | |
| 672 | if (finished) return; | |
| 673 | void (async () => { | |
| 674 | const polled = await pollOAuthConnect(f.app, f.user, flow.flowId); | |
| 675 | if (!polled.ok || finished) return; | |
| 676 | if (polled.value.status === "pending") return; | |
| 677 | finished = true; | |
| 678 | clearInterval(timer); | |
| 679 | if (polled.value.status === "done") { | |
| 680 | ctx.toast("origin connected", "success"); | |
| 681 | done(); | |
| 682 | ctx.pop(); | |
| 683 | return; | |
| 684 | } | |
| 685 | handoff.detail = polled.value.detail || "that connect failed"; | |
| 686 | ctx.toast(handoff.detail, "danger"); | |
| 687 | ctx.invalidate(); | |
| 688 | })(); | |
| 689 | }, every); | |
| 690 | signal.addEventListener("abort", () => clearInterval(timer)); | |
| 691 | return Promise.resolve(); | |
| 692 | }, | |
| 693 | render(area, s) { | |
| 694 | drawHandoff(ctx, s, area, parent, handoff, "Connect GitHub"); | |
| 695 | }, | |
| 696 | key(e) { | |
| 697 | if (handoffKey(ctx, handoff, e.key)) return true; | |
| 698 | if (e.key === "esc" || e.key === "q") { | |
| 699 | finished = true; | |
| 700 | ctx.pop(); | |
| 701 | } | |
| 702 | return true; | |
| 703 | }, | |
| 704 | }; | |
| 705 | } | |
| 706 | ||
| 707 | /** | |
| 708 | * Forgejo and Gitea: register an application on the instance, then authorize | |
| 709 | * in a browser. There is no device grant to fall back on — their token | |
| 710 | * endpoints accept only `authorization_code` and `refresh_token` — so the | |
| 711 | * terminal prints the URL and waits for the origin list to grow. | |
| 712 | */ | |
| 713 | function startCodeFlow( | |
| 714 | f: Session, | |
| 715 | ctx: Ctx, | |
| 716 | parent: Screen, | |
| 717 | done: () => void, | |
| 718 | ): void { | |
| 719 | const redirect = callbackUri(f); | |
| 720 | ctx.push( | |
| 721 | promptScreen(ctx, parent, { | |
| 722 | title: "Connect Forgejo or Gitea", | |
| 723 | label: "instance", | |
| 724 | placeholder: "codeberg.org", | |
| 725 | onSubmit: (host) => { | |
| 726 | if (host === "") return "an instance is required"; | |
| 727 | if (redirect === "") | |
| 728 | return "this forge does not know its own public URL — set FORGE_URL"; | |
| 729 | return later(ctx, () => | |
| 730 | promptScreen(ctx, parent, { | |
| 731 | title: `Register an app on ${host}`, | |
| 732 | label: "client id", | |
| 733 | hint: `1. register an app at ${host}/user/settings/applications with redirect ${redirect} 2. paste client id`, | |
| 734 | onSubmit: (clientId) => { | |
| 735 | if (clientId === "") return "a client id is required"; | |
| 736 | return (async () => { | |
| 737 | const saved = await registerOriginApp(f.app, f.user, { | |
| 738 | host, | |
| 739 | clientId, | |
| 740 | }); | |
| 741 | if (!saved.ok) { | |
| 742 | ctx.toast(errorText(saved.error), "danger"); | |
| 743 | return; | |
| 744 | } | |
| 745 | const started = await startOAuthConnect(f.app, f.user, { | |
| 746 | host, | |
| 747 | redirectUri: redirect, | |
| 748 | }); | |
| 749 | if (!started.ok) { | |
| 750 | ctx.toast(started.error.message, "danger"); | |
| 751 | return; | |
| 752 | } | |
| 753 | ctx.push( | |
| 754 | authorizeScreen( | |
| 755 | f, | |
| 756 | ctx, | |
| 757 | parent, | |
| 758 | { | |
| 759 | url: started.value.url, | |
| 760 | shortCode: started.value.shortCode, | |
| 761 | }, | |
| 762 | done, | |
| 763 | ), | |
| 764 | ); | |
| 765 | })(); | |
| 766 | }, | |
| 767 | }), | |
| 768 | ); | |
| 769 | }, | |
| 770 | }), | |
| 771 | ); | |
| 772 | } | |
| 773 | ||
| 774 | /** | |
| 775 | * Print the authorize URL and wait. The callback lands on the *web* surface, | |
| 776 | * so this screen has nothing to poll but the origin list itself — which is | |
| 777 | * exactly the question being asked: did an origin appear? | |
| 778 | * | |
| 779 | * A Forgejo authorize URL is long — client id, state, challenge, scopes — | |
| 780 | * which is precisely the URL nobody retypes off a terminal. The short link | |
| 781 | * and the QR carry it instead. | |
| 782 | */ | |
| 783 | function authorizeScreen( | |
| 784 | f: Session, | |
| 785 | ctx: Ctx, | |
| 786 | parent: Screen, | |
| 787 | flow: { url: string; shortCode: string }, | |
| 788 | done: () => void, | |
| 789 | ): Screen { | |
| 790 | const before = f.user ? listUserOrigins(f.app, f.user).length : 0; | |
| 791 | let finished = false; | |
| 792 | const handoff: Handoff = { | |
| 793 | userCode: "", | |
| 794 | url: flow.url, | |
| 795 | link: shortLink(f, flow.shortCode), | |
| 796 | lead: `Open this and approve the application on ${hostLabel(flow.url)}:`, | |
| 797 | detail: "waiting for you to authorize…", | |
| 798 | }; | |
| 799 | return { | |
| 800 | title: "Authorize", | |
| 801 | keys: handoffKeys(handoff), | |
| 802 | load(signal) { | |
| 803 | const timer = setInterval(() => { | |
| 804 | if (finished || !f.user) return; | |
| 805 | if (listUserOrigins(f.app, f.user).length <= before) return; | |
| 806 | finished = true; | |
| 807 | clearInterval(timer); | |
| 808 | ctx.toast("origin connected", "success"); | |
| 809 | done(); | |
| 810 | ctx.pop(); | |
| 811 | }, 1000); | |
| 812 | signal.addEventListener("abort", () => clearInterval(timer)); | |
| 813 | return Promise.resolve(); | |
| 814 | }, | |
| 815 | render(area, s) { | |
| 816 | drawHandoff(ctx, s, area, parent, handoff, "Authorize on the instance"); | |
| 817 | }, | |
| 818 | key(e) { | |
| 819 | if (handoffKey(ctx, handoff, e.key)) return true; | |
| 820 | if (e.key === "esc" || e.key === "q") { | |
| 821 | finished = true; | |
| 822 | ctx.pop(); | |
| 823 | } | |
| 824 | return true; | |
| 825 | }, | |
| 826 | }; | |
| 827 | } | |
| 828 | ||
| 829 | /** Hard-wrap a URL: it has no spaces, so nothing else will break it. */ | |
| 830 | function wrap(text: string, width: number): string[] { | |
| 831 | const out: string[] = []; | |
| 832 | const chars = [...text]; | |
| 833 | const size = Math.max(1, width); | |
| 834 | for (let at = 0; at < chars.length; at += size) | |
| 835 | out.push(chars.slice(at, at + size).join("")); | |
| 836 | return out; | |
| 837 | } | |
| 838 | ||
| 839 | // ---- the picker ---- | |
| 840 | ||
| 841 | /** | |
| 842 | * One origin's repositories. | |
| 843 | * | |
| 844 | * `enter` *opens* the repository — live, read-only, through the origin's own | |
| 845 | * API — and nothing is downloaded. This is the whole point of the picker: | |
| 846 | * pressing enter used to mean "clone this, import every issue, and take | |
| 847 | * several seconds doing it with nothing on screen", which taught people to | |
| 848 | * press it twice. Taking a copy is now a separate, deliberate key with a | |
| 849 | * progress card behind it, and a row that has already been taken says so. | |
| 850 | */ | |
| 851 | export function originReposScreen( | |
| 852 | f: Session, | |
| 853 | ctx: Ctx, | |
| 854 | origin: Origin, | |
| 855 | ): Screen { | |
| 856 | let repos: OriginRepoRow[] = []; | |
| 857 | let failed = false; | |
| 858 | const state = new ListState(); | |
| 859 | const filter = new Filter(); | |
| 860 | ||
| 861 | const load = async (refresh = false): Promise<void> => { | |
| 862 | const listed = await listRemoteRepos(f.app, f.user, origin.id, refresh); | |
| 863 | failed = !listed.ok; | |
| 864 | repos = listed.ok ? listed.value : []; | |
| 865 | state.clamp(repos.length); | |
| 866 | ctx.invalidate(); | |
| 867 | }; | |
| 868 | ||
| 869 | const shown = (): OriginRepoRow[] => | |
| 870 | repos.filter((r) => filter.matches(`${r.fullName} ${r.description}`)); | |
| 871 | ||
| 872 | /** | |
| 873 | * The local repositories these rows were mirrored into, and which of them | |
| 874 | * are pinned — both resolved for the whole page in one query each, because | |
| 875 | * `getRepoById` and `isPinned` inside `rows.map` was two queries per row on | |
| 876 | * a list that routinely runs to a few hundred. | |
| 877 | */ | |
| 878 | const mirrors = (): { | |
| 879 | repoOf: (row: OriginRepoRow) => Repo | undefined; | |
| 880 | pinned: ReadonlySet<string>; | |
| 881 | } => { | |
| 882 | const held = getReposByIds( | |
| 883 | f.db, | |
| 884 | repos.flatMap((r) => (r.mirroredAs === null ? [] : [r.mirroredAs])), | |
| 885 | ); | |
| 886 | return { | |
| 887 | repoOf: (row) => | |
| 888 | row.mirroredAs === null ? undefined : held.get(row.mirroredAs), | |
| 889 | pinned: f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>(), | |
| 890 | }; | |
| 891 | }; | |
| 892 | ||
| 893 | /** The local repository a row was mirrored into, if it still exists. */ | |
| 894 | const mirrorOf = (remote: OriginRepoRow): Repo | undefined => | |
| 895 | remote.mirroredAs === null | |
| 896 | ? undefined | |
| 897 | : getRepoById(f.db, remote.mirroredAs); | |
| 898 | ||
| 899 | const browse = (remote: OriginRepoRow): void => { | |
| 900 | const src = sourceFor(f.app, f.user, { | |
| 901 | originId: origin.id, | |
| 902 | fullName: remote.fullName, | |
| 903 | }); | |
| 904 | if (!src.ok) { | |
| 905 | ctx.toast(errorText(src.error), "danger"); | |
| 906 | return; | |
| 907 | } | |
| 908 | // Pushed with nothing loaded: the screen paints its own frame with a | |
| 909 | // spinner and fills in as the origin answers. | |
| 910 | ctx.push(repoScreen(f, ctx, src.value)); | |
| 911 | }; | |
| 912 | ||
| 913 | const screen: Screen = { | |
| 914 | title: `${origin.login} @ ${hostLabel(origin.webUrl)}`, | |
| 915 | keys: [ | |
| 916 | binding(["j", "k"], "move"), | |
| 917 | binding(["enter"], "browse — nothing is downloaded"), | |
| 918 | binding(["m"], "mirror into your workbench"), | |
| 919 | binding(["F"], `fork to ${origin.login}@${hostLabel(origin.webUrl)}`), | |
| 920 | binding(["/"], "filter"), | |
| 921 | binding(["r"], "refresh from origin"), | |
| 922 | ], | |
| 923 | load: () => load(), | |
| 924 | render(area, s) { | |
| 925 | const c = ctx.theme.c; | |
| 926 | const [box, body] = split(area, "v", [len(1), fill(1)]); | |
| 927 | ctx.setCursor(filter.render(s, box!, c, "filter repositories")); | |
| 928 | const rows = shown(); | |
| 929 | if (rows.length === 0) { | |
| 930 | emptyState(s, body!, c, { | |
| 931 | title: failed ? "The origin could not be reached" : "No repositories", | |
| 932 | body: failed | |
| 933 | ? "Nothing was changed. Press r to try again." | |
| 934 | : "Nothing on this origin matches.", | |
| 935 | }); | |
| 936 | return; | |
| 937 | } | |
| 938 | const page = mirrors(); | |
| 939 | drawList( | |
| 940 | s, | |
| 941 | body!, | |
| 942 | c, | |
| 943 | state, | |
| 944 | rows.map((remote): ListRow => { | |
| 945 | const held = page.repoOf(remote); | |
| 946 | const pinned = held !== undefined && page.pinned.has(held.id); | |
| 947 | const marks = [ | |
| 948 | remote.private ? "private" : "public", | |
| 949 | remote.forkOf === null | |
| 950 | ? remote.fork | |
| 951 | ? "fork" | |
| 952 | : "" | |
| 953 | : `fork of ${remote.forkOf}`, | |
| 954 | held === undefined | |
| 955 | ? "" | |
| 956 | : `${G.dot} mirrored as ${held.ownerName}/${held.name}`, | |
| 957 | pinned ? `${PIN_MARK} on your machine` : "", | |
| 958 | ].filter((m) => m !== ""); | |
| 959 | return { | |
| 960 | title: remote.fullName, | |
| 961 | secondary: marks.join(" · "), | |
| 962 | meta: | |
| 963 | remote.pushedAt === null | |
| 964 | ? "never pushed" | |
| 965 | : `pushed ${ago(remote.pushedAt)}`, | |
| 966 | }; | |
| 967 | }), | |
| 968 | ); | |
| 969 | hitList(ctx, body!, state, rows.length, { | |
| 970 | open: (index) => { | |
| 971 | const remote = rows[index]; | |
| 972 | if (remote !== undefined) browse(remote); | |
| 973 | }, | |
| 974 | }); | |
| 975 | }, | |
| 976 | key(e) { | |
| 977 | if (filter.key(e)) { | |
| 978 | state.clamp(shown().length); | |
| 979 | return true; | |
| 980 | } | |
| 981 | const rows = shown(); | |
| 982 | if (state.key(e, rows.length)) return true; | |
| 983 | if (e.key === "enter") { | |
| 984 | const remote = rows[state.index]; | |
| 985 | if (remote) browse(remote); | |
| 986 | return true; | |
| 987 | } | |
| 988 | if (e.key === "m") { | |
| 989 | const remote = rows[state.index]; | |
| 990 | if (remote === undefined) return true; | |
| 991 | if (remote.mirroredAs !== null) { | |
| 992 | ctx.toast("already mirrored — enter opens it", "attn"); | |
| 993 | return true; | |
| 994 | } | |
| 995 | startMirror(f, ctx, { | |
| 996 | parent: screen, | |
| 997 | originId: origin.id, | |
| 998 | fullName: remote.fullName, | |
| 999 | onLanded: () => { | |
| 1000 | runAsync(ctx, () => load()); | |
| 1001 | }, | |
| 1002 | }); | |
| 1003 | return true; | |
| 1004 | } | |
| 1005 | if (e.key === "F") { | |
| 1006 | const remote = rows[state.index]; | |
| 1007 | if (remote === undefined) return true; | |
| 1008 | forkAtOrigin(f, ctx, { | |
| 1009 | parent: screen, | |
| 1010 | target: { | |
| 1011 | originId: origin.id, | |
| 1012 | fullName: remote.fullName, | |
| 1013 | host: hostLabel(origin.webUrl), | |
| 1014 | }, | |
| 1015 | onForked: (fork) => { | |
| 1016 | runAsync(ctx, () => load(true)); | |
| 1017 | ctx.push(repoScreen(f, ctx, local(f, fork))); | |
| 1018 | }, | |
| 1019 | }); | |
| 1020 | return true; | |
| 1021 | } | |
| 1022 | if (e.key === "r") { | |
| 1023 | runAsync(ctx, () => load(true)); | |
| 1024 | return true; | |
| 1025 | } | |
| 1026 | return false; | |
| 1027 | }, | |
| 1028 | }; | |
| 1029 | return screen; | |
| 1030 | } |
test/tui-code.test.ts +1 −1
| @@ -435,7 +435,7 @@ describe("the terminal, around the workspace", () => { | ||
| 435 | 435 | await h.settle(); |
| 436 | 436 | await openRepo(h, "lighthouse"); |
| 437 | 437 | // A native repo has no origin page, and the screen says so rather than |
| 438 | // pretending. (The mirrored case is covered in tui-origins.) | |
| 438 | // pretending. (The mirrored case is covered on the web.) | |
| 439 | 439 | await h.press("O"); |
| 440 | 440 | expect(h.text()).toContain("no origin page"); |
| 441 | 441 | } finally { |
test/tui-origins.test.ts deleted −863
| @@ -1,863 +0,0 @@ | ||
| 1 | /** | |
| 2 | * Origins in the terminal, driven headlessly against a real forge, a fake | |
| 3 | * Forgejo API, and a second real forge standing in for the git host — so the | |
| 4 | * mirror is a genuine clone and the write-through genuinely leaves the | |
| 5 | * machine. | |
| 6 | * | |
| 7 | * Every assertion is made against the cell grid the renderer produced. That | |
| 8 | * is the point of a headless terminal: "the code is on the screen" is a claim | |
| 9 | * about what a person would see, not about a variable. | |
| 10 | */ | |
| 11 | ||
| 12 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 13 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 14 | import { tmpdir } from "node:os"; | |
| 15 | import { join } from "node:path"; | |
| 16 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { resetConnectLimit } from "../src/actions/origins.ts"; | |
| 19 | import { signUp } from "../src/actions/users.ts"; | |
| 20 | import { listOrigins } from "../src/core/origins.ts"; | |
| 21 | import { listPins } from "../src/core/pins.ts"; | |
| 22 | import { getRepo } from "../src/core/repos.ts"; | |
| 23 | import type { User } from "../src/core/users.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | import { forgetProviders } from "../src/origins/providers/index.ts"; | |
| 26 | import type { App } from "../src/tui/app/app.ts"; | |
| 27 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 28 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 29 | import { settleApp } from "./helpers/tui.ts"; | |
| 30 | import { | |
| 31 | headlessTerminal, | |
| 32 | type HeadlessTerminal, | |
| 33 | } from "../src/tui/term/terminal.ts"; | |
| 34 | import { | |
| 35 | newFakeOAuth, | |
| 36 | newFakeState, | |
| 37 | serveFakeForge, | |
| 38 | type FakeForge, | |
| 39 | type FakeOAuth, | |
| 40 | } from "./fake-origin.ts"; | |
| 41 | ||
| 42 | quietLogs(); | |
| 43 | ||
| 44 | const ORIGIN_LOGIN = "octo"; | |
| 45 | const ORIGIN_TOKEN = "octo-origin-secret-9f2a"; | |
| 46 | const CLIENT_ID = "Iv1.forge-tui-client"; | |
| 47 | /** What an operator sets so the forge knows its own address. */ | |
| 48 | const FORGE_URL = "https://forge.example.test"; | |
| 49 | ||
| 50 | const basic = (user: string, secret: string): string => | |
| 51 | `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`; | |
| 52 | ||
| 53 | interface Harness { | |
| 54 | app: App; | |
| 55 | terminal: HeadlessTerminal; | |
| 56 | text(): string; | |
| 57 | press(...keys: string[]): Promise<void>; | |
| 58 | /** Keys with no settle between them — for "what does a double press do?". */ | |
| 59 | burst(...keys: string[]): void; | |
| 60 | typeText(text: string): Promise<void>; | |
| 61 | settle(): Promise<void>; | |
| 62 | /** Pump frames until the screen says something, or give up loudly. */ | |
| 63 | until(what: string, ms?: number): Promise<void>; | |
| 64 | stop(): void; | |
| 65 | } | |
| 66 | ||
| 67 | /** The crumb trail the loop paints: one entry per screen on the stack. */ | |
| 68 | const crumbLine = (h: Harness): string => h.text().split("\n")[1] ?? ""; | |
| 69 | ||
| 70 | const occurrences = (haystack: string, needle: string): number => | |
| 71 | haystack.split(needle).length - 1; | |
| 72 | ||
| 73 | function boot( | |
| 74 | forge: Forge, | |
| 75 | identity: TuiIdentity, | |
| 76 | cols = 140, | |
| 77 | rows = 40, | |
| 78 | ): Harness { | |
| 79 | const terminal = headlessTerminal(cols, rows); | |
| 80 | const { app } = createForgeApp(forge, identity, { | |
| 81 | terminal, | |
| 82 | exitHooks: false, | |
| 83 | }); | |
| 84 | app.start(); | |
| 85 | const settle = (): Promise<void> => settleApp(app); | |
| 86 | const one = async (key: string): Promise<void> => { | |
| 87 | app.dispatch({ | |
| 88 | type: "key", | |
| 89 | key: key.startsWith("ctrl+") ? key.slice(5) : key, | |
| 90 | ctrl: key.startsWith("ctrl+"), | |
| 91 | alt: false, | |
| 92 | shift: false, | |
| 93 | }); | |
| 94 | await settle(); | |
| 95 | }; | |
| 96 | return { | |
| 97 | app, | |
| 98 | terminal, | |
| 99 | text: () => app.screen.toText(), | |
| 100 | async press(...keys) { | |
| 101 | for (const key of keys) await one(key); | |
| 102 | }, | |
| 103 | burst(...keys) { | |
| 104 | for (const key of keys) | |
| 105 | app.dispatch({ | |
| 106 | type: "key", | |
| 107 | key, | |
| 108 | ctrl: false, | |
| 109 | alt: false, | |
| 110 | shift: false, | |
| 111 | }); | |
| 112 | app.frame(); | |
| 113 | }, | |
| 114 | async typeText(text) { | |
| 115 | for (const ch of text) | |
| 116 | app.dispatch({ | |
| 117 | type: "key", | |
| 118 | key: ch, | |
| 119 | ctrl: false, | |
| 120 | alt: false, | |
| 121 | shift: false, | |
| 122 | }); | |
| 123 | await settle(); | |
| 124 | }, | |
| 125 | settle, | |
| 126 | async until(what, ms = 20_000) { | |
| 127 | const deadline = Date.now() + ms; | |
| 128 | for (;;) { | |
| 129 | await settle(); | |
| 130 | if (app.screen.toText().includes(what)) return; | |
| 131 | if (Date.now() > deadline) | |
| 132 | throw new Error( | |
| 133 | `never saw ${JSON.stringify(what)} on screen:\n${app.screen.toText()}`, | |
| 134 | ); | |
| 135 | await new Promise((r) => setTimeout(r, 20)); | |
| 136 | } | |
| 137 | }, | |
| 138 | stop: () => app.stop(), | |
| 139 | }; | |
| 140 | } | |
| 141 | ||
| 142 | describe("origins in the terminal", () => { | |
| 143 | let dataDir: string; | |
| 144 | let originDataDir: string; | |
| 145 | let forge: Forge; | |
| 146 | let originApp: Forge; | |
| 147 | let originServers: Servers; | |
| 148 | let fake: FakeForge; | |
| 149 | let github: FakeForge; | |
| 150 | let ghOauth: FakeOAuth; | |
| 151 | let alice: User; | |
| 152 | ||
| 153 | const asAlice = (): TuiIdentity => ({ | |
| 154 | sshUsername: "alice", | |
| 155 | remoteAddress: "127.0.0.1", | |
| 156 | fingerprint: "SHA256:test", | |
| 157 | user: alice, | |
| 158 | }); | |
| 159 | ||
| 160 | /** Dashboard → account → origins → connect → GitHub → the device screen. */ | |
| 161 | const openDeviceScreen = async (h: Harness): Promise<void> => { | |
| 162 | await h.press("A"); | |
| 163 | await h.until("Account"); | |
| 164 | await h.press("o"); | |
| 165 | await h.until("Origins"); | |
| 166 | await h.press("c"); | |
| 167 | await h.until("Connect an origin"); | |
| 168 | // The token is the first row now, so GitHub is one down. | |
| 169 | await h.press("j", "enter"); | |
| 170 | await h.until("Connect GitHub"); | |
| 171 | await h.typeText(github.url); | |
| 172 | await h.press("enter"); | |
| 173 | await h.until("W X Y Z - 1 2 3 4"); | |
| 174 | }; | |
| 175 | ||
| 176 | beforeAll(async () => { | |
| 177 | dataDir = await mkdtemp(join(tmpdir(), "forge-tuiorigins-")); | |
| 178 | originDataDir = await mkdtemp(join(tmpdir(), "forge-tuiupstream-")); | |
| 179 | ||
| 180 | originApp = createApp({ dataDir: originDataDir, port: 0 }); | |
| 181 | originServers = await serve(originApp); | |
| 182 | await fetch(`${originServers.url}/api/v1/users`, { | |
| 183 | method: "POST", | |
| 184 | headers: { "content-type": "application/json" }, | |
| 185 | body: JSON.stringify({ | |
| 186 | username: ORIGIN_LOGIN, | |
| 187 | email: "[email protected]", | |
| 188 | password: ORIGIN_TOKEN, | |
| 189 | }), | |
| 190 | }); | |
| 191 | await fetch(`${originServers.url}/api/v1/repos`, { | |
| 192 | method: "POST", | |
| 193 | headers: { | |
| 194 | "content-type": "application/json", | |
| 195 | authorization: basic(ORIGIN_LOGIN, ORIGIN_TOKEN), | |
| 196 | }, | |
| 197 | body: JSON.stringify({ name: "widgets", visibility: "public" }), | |
| 198 | }); | |
| 199 | await fetch(`${originServers.url}/api/v1/repos/octo/widgets/contents`, { | |
| 200 | method: "POST", | |
| 201 | headers: { | |
| 202 | "content-type": "application/json", | |
| 203 | authorization: basic(ORIGIN_LOGIN, ORIGIN_TOKEN), | |
| 204 | }, | |
| 205 | body: JSON.stringify({ | |
| 206 | branch: "main", | |
| 207 | message: "first", | |
| 208 | files: [{ path: "README.md", content: "# widgets\nhello\n" }], | |
| 209 | }), | |
| 210 | }); | |
| 211 | ||
| 212 | await fetch(`${originServers.url}/api/v1/repos`, { | |
| 213 | method: "POST", | |
| 214 | headers: { | |
| 215 | "content-type": "application/json", | |
| 216 | authorization: basic(ORIGIN_LOGIN, ORIGIN_TOKEN), | |
| 217 | }, | |
| 218 | body: JSON.stringify({ name: "gadgets", visibility: "public" }), | |
| 219 | }); | |
| 220 | await fetch(`${originServers.url}/api/v1/repos/octo/gadgets/contents`, { | |
| 221 | method: "POST", | |
| 222 | headers: { | |
| 223 | "content-type": "application/json", | |
| 224 | authorization: basic(ORIGIN_LOGIN, ORIGIN_TOKEN), | |
| 225 | }, | |
| 226 | body: JSON.stringify({ | |
| 227 | branch: "main", | |
| 228 | message: "first", | |
| 229 | files: [{ path: "README.md", content: "# gadgets\nhello\n" }], | |
| 230 | }), | |
| 231 | }); | |
| 232 | ||
| 233 | const state = newFakeState("forgejo", ORIGIN_LOGIN, ORIGIN_TOKEN); | |
| 234 | state.repos.push( | |
| 235 | { | |
| 236 | id: 41, | |
| 237 | fullName: "octo/widgets", | |
| 238 | description: "gears and things", | |
| 239 | cloneUrl: `${originServers.url}/octo/widgets.git`, | |
| 240 | htmlUrl: `${originServers.url}/octo/widgets`, | |
| 241 | defaultBranch: "main", | |
| 242 | }, | |
| 243 | // A second remote, so there is still something un-mirrored to press | |
| 244 | // `m` on after the first one has been taken. | |
| 245 | { | |
| 246 | id: 42, | |
| 247 | fullName: "octo/gadgets", | |
| 248 | description: "bits and bobs", | |
| 249 | cloneUrl: `${originServers.url}/octo/gadgets.git`, | |
| 250 | htmlUrl: `${originServers.url}/octo/gadgets`, | |
| 251 | defaultBranch: "main", | |
| 252 | }, | |
| 253 | ); | |
| 254 | state.files.set( | |
| 255 | "octo/gadgets", | |
| 256 | new Map([["README.md", "# gadgets\n\nbits and bobs, read live\n"]]), | |
| 257 | ); | |
| 258 | state.branches.set("octo/gadgets", [ | |
| 259 | { name: "main", sha: "2".repeat(40), protected: false }, | |
| 260 | ]); | |
| 261 | // The git side of the same repository, as the API would describe it — | |
| 262 | // this is what a *live* browse reads, with nothing cloned anywhere. | |
| 263 | state.files.set( | |
| 264 | "octo/widgets", | |
| 265 | new Map([ | |
| 266 | ["README.md", "# widgets\n\ngears and things, read live\n"], | |
| 267 | ["src/index.ts", "export const one = 1;\n"], | |
| 268 | ]), | |
| 269 | ); | |
| 270 | state.branches.set("octo/widgets", [ | |
| 271 | { name: "main", sha: "1".repeat(40), protected: false }, | |
| 272 | ]); | |
| 273 | state.commits.set("octo/widgets", [ | |
| 274 | { | |
| 275 | sha: "1".repeat(40), | |
| 276 | message: "first", | |
| 277 | authorName: "Mona Lisa", | |
| 278 | login: "mona", | |
| 279 | date: "2026-01-01T00:00:00.000Z", | |
| 280 | }, | |
| 281 | ]); | |
| 282 | state.labels.set("octo/widgets", [{ id: 1, name: "bug", color: "d73a4a" }]); | |
| 283 | state.issues.set("octo/widgets", [ | |
| 284 | { | |
| 285 | id: 900, | |
| 286 | number: 7, | |
| 287 | title: "The gear slips", | |
| 288 | body: "under load", | |
| 289 | user: "mona", | |
| 290 | labels: ["bug"], | |
| 291 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 292 | updatedAt: "2026-01-02T00:00:00.000Z", | |
| 293 | }, | |
| 294 | ]); | |
| 295 | fake = serveFakeForge(state); | |
| 296 | ||
| 297 | const ghState = newFakeState("github", ORIGIN_LOGIN, "unused-pat"); | |
| 298 | ghOauth = newFakeOAuth(CLIENT_ID); | |
| 299 | ghState.oauth = ghOauth; | |
| 300 | github = serveFakeForge(ghState); | |
| 301 | ||
| 302 | forge = createApp({ dataDir, port: 0 }); | |
| 303 | const created = await signUp(forge, { | |
| 304 | username: "alice", | |
| 305 | email: "[email protected]", | |
| 306 | password: "a-long-passphrase", | |
| 307 | }); | |
| 308 | if (!created.ok) throw new Error("signup failed"); | |
| 309 | alice = created.value; | |
| 310 | resetConnectLimit(); | |
| 311 | forgetProviders(dataDir); | |
| 312 | delete process.env.FORGE_GITHUB_CLIENT_ID; | |
| 313 | }); | |
| 314 | ||
| 315 | afterAll(async () => { | |
| 316 | delete process.env.FORGE_GITHUB_CLIENT_ID; | |
| 317 | fake.stop(); | |
| 318 | github.stop(); | |
| 319 | originServers.stop(); | |
| 320 | forge.close(); | |
| 321 | originApp.close(); | |
| 322 | forgetProviders(dataDir); | |
| 323 | await rm(dataDir, { recursive: true, force: true }); | |
| 324 | await rm(originDataDir, { recursive: true, force: true }); | |
| 325 | }, 60_000); | |
| 326 | ||
| 327 | test("the origins screen is empty, then a pasted token fills it", async () => { | |
| 328 | const h = boot(forge, asAlice()); | |
| 329 | try { | |
| 330 | // Dashboard → account → origins. | |
| 331 | await h.press("A"); | |
| 332 | await h.until("Account"); | |
| 333 | await h.press("o"); | |
| 334 | await h.until("No origins connected"); | |
| 335 | expect(h.text()).toContain("connect an origin"); | |
| 336 | ||
| 337 | // c → the chooser. A token needs no setup anywhere, so it is the first | |
| 338 | // row and the one `enter` lands on; the two one-click flows come after | |
| 339 | // it, each saying what it needs first. | |
| 340 | await h.press("c"); | |
| 341 | await h.until("Connect an origin"); | |
| 342 | const chooser = h.text(); | |
| 343 | expect(chooser).toContain("Personal access token — recommended"); | |
| 344 | expect(chooser).toContain("GitHub, one click"); | |
| 345 | expect(chooser).toContain("Forgejo / Gitea, one click"); | |
| 346 | expect(chooser).toContain("Needs a one-time OAuth app setup"); | |
| 347 | expect(chooser).toContain("Needs an app registration on the instance"); | |
| 348 | expect(chooser).toContain("No setup at all"); | |
| 349 | expect(chooser.indexOf("Personal access token")).toBeLessThan( | |
| 350 | chooser.indexOf("GitHub, one click"), | |
| 351 | ); | |
| 352 | await h.press("enter"); | |
| 353 | await h.until("Connect with a token"); | |
| 354 | ||
| 355 | await h.typeText(fake.url); | |
| 356 | await h.press("enter"); | |
| 357 | await h.until("personal access token"); | |
| 358 | await h.typeText(ORIGIN_TOKEN); | |
| 359 | // A secret typed into a terminal is never left as plain cells. | |
| 360 | expect(h.text()).not.toContain(ORIGIN_TOKEN); | |
| 361 | expect(h.text()).toContain("•"); | |
| 362 | await h.press("enter"); | |
| 363 | ||
| 364 | await h.until(`${ORIGIN_LOGIN} @`); | |
| 365 | expect(h.text()).toContain("healthy"); | |
| 366 | expect(h.text()).toContain("token"); | |
| 367 | expect(listOrigins(forge.db, alice.id)).toHaveLength(1); | |
| 368 | } finally { | |
| 369 | h.stop(); | |
| 370 | } | |
| 371 | }, 60_000); | |
| 372 | ||
| 373 | test("the device-code screen shows the code and where to type it", async () => { | |
| 374 | process.env.FORGE_GITHUB_CLIENT_ID = CLIENT_ID; | |
| 375 | resetConnectLimit(); | |
| 376 | const h = boot(forge, asAlice()); | |
| 377 | try { | |
| 378 | await h.press("A"); | |
| 379 | await h.until("Account"); | |
| 380 | await h.press("o"); | |
| 381 | await h.until(`${ORIGIN_LOGIN} @`); | |
| 382 | await h.press("c"); | |
| 383 | await h.until("Connect an origin"); | |
| 384 | await h.press("j", "enter"); | |
| 385 | await h.until("Connect GitHub"); | |
| 386 | await h.typeText(github.url); | |
| 387 | await h.press("enter"); | |
| 388 | ||
| 389 | await h.until("WXYZ-1234".split("").join(" ")); | |
| 390 | expect(h.text()).toContain(ghOauth.verificationUri); | |
| 391 | expect(h.text()).toContain("waiting for you to authorize"); | |
| 392 | // The device code is the forge's half and never reaches the screen. | |
| 393 | expect(h.text()).not.toContain(ghOauth.deviceCode); | |
| 394 | await h.press("esc"); | |
| 395 | } finally { | |
| 396 | delete process.env.FORGE_GITHUB_CLIENT_ID; | |
| 397 | h.stop(); | |
| 398 | } | |
| 399 | }, 60_000); | |
| 400 | ||
| 401 | /** | |
| 402 | * The reason this screen has the shape it does: over SSH the URL on screen | |
| 403 | * is a string you cannot click and often cannot copy. So the test asserts | |
| 404 | * the three ways off it — a QR made of real cells, a short link short | |
| 405 | * enough to type, and an OSC 52 sequence that reaches the *user's* | |
| 406 | * terminal rather than the server's. | |
| 407 | */ | |
| 408 | test("the device screen hands the connect to a phone, and c reaches the user's clipboard", async () => { | |
| 409 | process.env.FORGE_GITHUB_CLIENT_ID = CLIENT_ID; | |
| 410 | process.env.FORGE_URL = FORGE_URL; | |
| 411 | resetConnectLimit(); | |
| 412 | const h = boot(forge, asAlice(), 120, 40); | |
| 413 | try { | |
| 414 | await openDeviceScreen(h); | |
| 415 | ||
| 416 | const text = h.text(); | |
| 417 | const found = /https:\/\/forge\.example\.test\/go\/[0-9a-z]{10}/.exec( | |
| 418 | text, | |
| 419 | ); | |
| 420 | expect(found).not.toBeNull(); | |
| 421 | const short = found![0]; | |
| 422 | // The code in it belongs to a live flow row, and to nothing else. | |
| 423 | const row = forge.db | |
| 424 | .query< | |
| 425 | { short_code: string }, | |
| 426 | [] | |
| 427 | >("SELECT short_code FROM oauth_flow WHERE status = 'pending' ORDER BY id DESC LIMIT 1") | |
| 428 | .get(); | |
| 429 | expect(short.endsWith(row!.short_code)).toBe(true); | |
| 430 | ||
| 431 | // A QR, drawn as half blocks. The signature is the top of a finder | |
| 432 | // pattern: seven dark modules over a dark-light-dark row render as one | |
| 433 | // full block, five upper halves and one full block. | |
| 434 | expect(text).toContain("\u2588\u2580\u2580\u2580\u2580\u2580\u2588"); | |
| 435 | // And the full URL is still there for anyone who wants to read it. | |
| 436 | expect(text).toContain(ghOauth.verificationUri); | |
| 437 | ||
| 438 | // `c` copies the short link through the terminal itself. | |
| 439 | h.terminal.takeOutput(); | |
| 440 | await h.press("c"); | |
| 441 | const copied = h.terminal.takeOutput(); | |
| 442 | expect(copied).toContain( | |
| 443 | `\u001b]52;c;${Buffer.from(short, "utf8").toString("base64")}\u0007`, | |
| 444 | ); | |
| 445 | expect(h.text()).toContain("copied"); | |
| 446 | expect(h.text()).toContain("Apple Terminal does not"); | |
| 447 | ||
| 448 | // `C` copies the device code instead. | |
| 449 | h.terminal.takeOutput(); | |
| 450 | await h.press("C"); | |
| 451 | expect(h.terminal.takeOutput()).toContain( | |
| 452 | `\u001b]52;c;${Buffer.from("WXYZ-1234", "utf8").toString("base64")}\u0007`, | |
| 453 | ); | |
| 454 | ||
| 455 | // `o` cannot reach a browser from the far side of a connection. It no | |
| 456 | // longer says so and stops there: the URL goes to the reader's own | |
| 457 | // clipboard through their terminal, and the toast names the gesture | |
| 458 | // that opens the OSC 8 link already on screen. | |
| 459 | h.terminal.takeOutput(); | |
| 460 | await h.press("o"); | |
| 461 | expect(h.terminal.takeOutput()).toContain( | |
| 462 | `]52;c;${Buffer.from("https://example.invalid/device", "utf8").toString("base64")}`, | |
| 463 | ); | |
| 464 | // …and the link dialog shows the address plain, mouse released, so it | |
| 465 | // can be selected by hand where OSC 52 is not honoured. | |
| 466 | expect(h.text()).toContain("Link"); | |
| 467 | expect(h.text()).toContain("https://example.invalid/device"); | |
| 468 | await h.press("esc"); | |
| 469 | expect(h.text()).not.toContain("Select the address"); | |
| 470 | await h.press("esc"); | |
| 471 | } finally { | |
| 472 | delete process.env.FORGE_GITHUB_CLIENT_ID; | |
| 473 | delete process.env.FORGE_URL; | |
| 474 | h.stop(); | |
| 475 | } | |
| 476 | }, 60_000); | |
| 477 | ||
| 478 | test("at 80x24 the QR is dropped and the short link is kept", async () => { | |
| 479 | process.env.FORGE_GITHUB_CLIENT_ID = CLIENT_ID; | |
| 480 | process.env.FORGE_URL = FORGE_URL; | |
| 481 | resetConnectLimit(); | |
| 482 | const h = boot(forge, asAlice(), 80, 24); | |
| 483 | try { | |
| 484 | await openDeviceScreen(h); | |
| 485 | const text = h.text(); | |
| 486 | // Half a QR is not a smaller QR, so at this height there is none. | |
| 487 | expect(text).not.toContain("\u2588\u2580\u2580\u2580\u2580\u2580\u2588"); | |
| 488 | expect(text).toMatch(/https:\/\/forge\.example\.test\/go\/[0-9a-z]{10}/); | |
| 489 | expect(text).toContain("W X Y Z - 1 2 3 4"); | |
| 490 | // All three ways off the screen are named in the help bar. | |
| 491 | expect(text).toContain("copy link"); | |
| 492 | expect(text).toContain("copy code"); | |
| 493 | expect(text).toContain("open"); | |
| 494 | await h.press("esc"); | |
| 495 | } finally { | |
| 496 | delete process.env.FORGE_GITHUB_CLIENT_ID; | |
| 497 | delete process.env.FORGE_URL; | |
| 498 | h.stop(); | |
| 499 | } | |
| 500 | }, 60_000); | |
| 501 | ||
| 502 | test("an unconfigured GitHub explains how to configure it, where it failed", async () => { | |
| 503 | delete process.env.FORGE_GITHUB_CLIENT_ID; | |
| 504 | resetConnectLimit(); | |
| 505 | const h = boot(forge, asAlice(), 80, 24); | |
| 506 | try { | |
| 507 | await h.press("A"); | |
| 508 | await h.until("Account"); | |
| 509 | await h.press("o"); | |
| 510 | await h.until("Origins"); | |
| 511 | await h.press("c"); | |
| 512 | await h.until("Connect an origin"); | |
| 513 | await h.press("j", "enter"); | |
| 514 | await h.until("Connect GitHub"); | |
| 515 | await h.typeText(github.url); | |
| 516 | await h.press("enter"); | |
| 517 | ||
| 518 | await h.until("Set up GitHub"); | |
| 519 | const text = h.text(); | |
| 520 | expect(text).toContain("github.com/settings/developers"); | |
| 521 | expect(text).toContain("Enable Device Flow"); | |
| 522 | expect(text).toContain("FORGE_GITHUB_CLIENT_ID"); | |
| 523 | expect(text).toContain("/settings/origins/callback"); | |
| 524 | expect(text).toContain("a pasted token needs no setup"); | |
| 525 | await h.press("esc"); | |
| 526 | } finally { | |
| 527 | h.stop(); | |
| 528 | } | |
| 529 | }, 60_000); | |
| 530 | ||
| 531 | /** | |
| 532 | * Origins → the picker, filtered down to one row, which is where every | |
| 533 | * browse and every mirror starts. The filter is what makes `enter` mean a | |
| 534 | * named repository rather than "whatever sorted first". | |
| 535 | */ | |
| 536 | const openPicker = async (h: Harness, name = "widgets"): Promise<void> => { | |
| 537 | await h.press("A"); | |
| 538 | await h.until("Account"); | |
| 539 | await h.press("o"); | |
| 540 | await h.until(`${ORIGIN_LOGIN} @`); | |
| 541 | await h.press("r"); | |
| 542 | await h.until(`octo/${name}`); | |
| 543 | await h.press("/"); | |
| 544 | await h.typeText(name); | |
| 545 | await h.press("enter"); | |
| 546 | }; | |
| 547 | ||
| 548 | /** The `?` overlay: every binding this screen has, untruncated. */ | |
| 549 | const helpText = async (h: Harness): Promise<string> => { | |
| 550 | await h.press("?"); | |
| 551 | const text = h.text(); | |
| 552 | await h.press("esc"); | |
| 553 | return text; | |
| 554 | }; | |
| 555 | ||
| 556 | test("enter opens the repository live and downloads nothing", async () => { | |
| 557 | const host = new URL(fake.url).host; | |
| 558 | const h = boot(forge, asAlice()); | |
| 559 | try { | |
| 560 | await openPicker(h); | |
| 561 | expect(h.text()).toContain("public"); | |
| 562 | // The row is not mirrored yet, and enter is not going to change that. | |
| 563 | expect(h.text()).not.toContain("mirrored as"); | |
| 564 | expect(h.text()).toContain("browse — nothing is downloaded"); | |
| 565 | ||
| 566 | await h.press("enter"); | |
| 567 | // One settle: the README is on screen, read through the origin's API. | |
| 568 | await h.until("gears and things, read live"); | |
| 569 | const text = h.text(); | |
| 570 | ||
| 571 | // The where strip: three places, and only the first one has it. | |
| 572 | expect(text).toContain(`● ${host}`); | |
| 573 | expect(text).toContain("○ workbench"); | |
| 574 | expect(text).toContain("○ this machine"); | |
| 575 | expect(text).toContain( | |
| 576 | `reading live from ${host} — read-only · m mirror into your workbench`, | |
| 577 | ); | |
| 578 | expect(text).toContain("read-only"); | |
| 579 | // The tree came from the same place. | |
| 580 | expect(text).toContain("src/"); | |
| 581 | ||
| 582 | // No key that would write *here* exists — not disabled, absent — and | |
| 583 | // the keys that do make sense on somebody else's repository are | |
| 584 | // offered, forking at the origin among them. | |
| 585 | const help = await helpText(h); | |
| 586 | expect(help).toMatch(/m\s+mirror/); | |
| 587 | expect(help).toContain("open on origin"); | |
| 588 | expect(help).toContain(`fork at ${host}`); | |
| 589 | expect(help).not.toContain("settings"); | |
| 590 | expect(help).not.toContain("sync now"); | |
| 591 | ||
| 592 | // Pressing them anyway names the one key that leads somewhere: a live | |
| 593 | // browse has no bytes of ours to change, and `W` is where the copy that | |
| 594 | // does live. | |
| 595 | await h.press("e"); | |
| 596 | expect(h.text()).toContain("W to edit the workbench copy"); | |
| 597 | await h.press("c"); | |
| 598 | expect(h.text()).toContain("W to edit the workbench copy"); | |
| 599 | ||
| 600 | // And nothing at all was created here. | |
| 601 | expect(getRepo(forge.db, "alice", "widgets")).toBeUndefined(); | |
| 602 | } finally { | |
| 603 | h.stop(); | |
| 604 | } | |
| 605 | }, 60_000); | |
| 606 | ||
| 607 | test("going back and re-entering costs the origin nothing", async () => { | |
| 608 | const h = boot(forge, asAlice()); | |
| 609 | try { | |
| 610 | await openPicker(h); | |
| 611 | await h.press("enter"); | |
| 612 | await h.until("gears and things, read live"); | |
| 613 | // The workspace is the tree: opening the repository already listed it | |
| 614 | // and read a file out of it. | |
| 615 | await h.until("src/"); | |
| 616 | ||
| 617 | // ...and from here on, everything is a cache hit. Back to the picker, | |
| 618 | // in again, and every read is answered from the cache: the origin | |
| 619 | // hears nothing. | |
| 620 | const mark = fake.state.requests.length; | |
| 621 | await h.press("esc"); | |
| 622 | await h.until("octo/widgets"); | |
| 623 | await h.press("enter"); | |
| 624 | await h.until("gears and things, read live"); | |
| 625 | await h.until("src/"); | |
| 626 | expect(fake.state.requests.length).toBe(mark); | |
| 627 | } finally { | |
| 628 | h.stop(); | |
| 629 | } | |
| 630 | }, 60_000); | |
| 631 | ||
| 632 | test("m mirrors with a visible checklist, and the screen becomes the local one", async () => { | |
| 633 | const h = boot(forge, asAlice()); | |
| 634 | try { | |
| 635 | await openPicker(h); | |
| 636 | await h.press("enter"); | |
| 637 | await h.until("gears and things, read live"); | |
| 638 | ||
| 639 | // Two presses with no frame between them: the job layer dedupes the | |
| 640 | // work and the card dedupes itself, so there is exactly one card. | |
| 641 | h.burst("m", "m"); | |
| 642 | const card = h.text(); | |
| 643 | expect(card).toContain("Mirroring octo/widgets"); | |
| 644 | for (const stage of [ | |
| 645 | "creating", | |
| 646 | "fetching", | |
| 647 | "labels", | |
| 648 | "issues", | |
| 649 | "pulls", | |
| 650 | "comments", | |
| 651 | ]) | |
| 652 | expect(card).toContain(stage); | |
| 653 | expect(card).toContain("esc hides this"); | |
| 654 | expect(occurrences(crumbLine(h), "Mirroring")).toBe(1); | |
| 655 | ||
| 656 | // Done: the card goes, and the live screen underneath is replaced in | |
| 657 | // place by the same screen over the repository we now hold. | |
| 658 | await h.until("workbench (synced"); | |
| 659 | // The file list is the local one now: these bytes came out of our own | |
| 660 | // bare repository rather than off the origin's API. | |
| 661 | await h.until("README.md"); | |
| 662 | const after = h.text(); | |
| 663 | const strip = after.split("\n")[2] ?? ""; | |
| 664 | expect(strip).toMatch( | |
| 665 | /^● \S+ {2}· ● workbench \(synced .+\) {2}· ○ this machine/, | |
| 666 | ); | |
| 667 | expect(after).not.toContain("reading live from"); | |
| 668 | // Provenance lives on the about card (`I`) now that the repo's front | |
| 669 | // door is a workspace rather than a page. | |
| 670 | await h.press("I"); | |
| 671 | await h.settle(); | |
| 672 | expect(h.text()).toContain("mirrored from"); | |
| 673 | await h.press("esc"); | |
| 674 | await h.settle(); | |
| 675 | expect(getRepo(forge.db, "alice", "widgets")).toBeDefined(); | |
| 676 | ||
| 677 | // The write keys are here now, and the mirror key is gone. | |
| 678 | const help = await helpText(h); | |
| 679 | expect(help).toContain("settings"); | |
| 680 | expect(help).toContain("fork"); | |
| 681 | expect(help).toContain("sync now"); | |
| 682 | expect(help).not.toMatch(/m\s+mirror\b/); | |
| 683 | ||
| 684 | // Including the one that matters most: you can say something. | |
| 685 | await h.press("i"); | |
| 686 | await h.until("The gear slips"); | |
| 687 | await h.press("enter"); | |
| 688 | await h.until("The gear slips"); | |
| 689 | expect(await helpText(h)).toContain("comment"); | |
| 690 | } finally { | |
| 691 | h.stop(); | |
| 692 | } | |
| 693 | }, 120_000); | |
| 694 | ||
| 695 | /** | |
| 696 | * Esc is not cancel. The job is detached from the card that started it, so | |
| 697 | * hiding the card leaves the mirror running and the outcome still arrives — | |
| 698 | * and the screen you are on is *not* yanked out from under you, because by | |
| 699 | * then it may not be the screen the mirror was started from. | |
| 700 | */ | |
| 701 | test("esc hides the card and the mirror lands anyway", async () => { | |
| 702 | const h = boot(forge, asAlice()); | |
| 703 | try { | |
| 704 | await openPicker(h, "gadgets"); | |
| 705 | await h.press("enter"); | |
| 706 | await h.until("○ workbench"); | |
| 707 | ||
| 708 | h.burst("m"); | |
| 709 | expect(h.text()).toContain("Mirroring octo/gadgets"); | |
| 710 | await h.press("esc"); | |
| 711 | expect(crumbLine(h)).not.toContain("Mirroring"); | |
| 712 | expect(h.text()).toContain("mirroring in the background"); | |
| 713 | // Still the live browse: a hidden card never replaces a screen. | |
| 714 | expect(h.text()).toContain("reading live from"); | |
| 715 | ||
| 716 | await h.until("mirrored as alice/gadgets"); | |
| 717 | expect(getRepo(forge.db, "alice", "gadgets")).toBeDefined(); | |
| 718 | expect(h.text()).toContain("reading live from"); | |
| 719 | } finally { | |
| 720 | h.stop(); | |
| 721 | } | |
| 722 | }, 120_000); | |
| 723 | ||
| 724 | /** | |
| 725 | * The question the where-strip answers is "which of these two am I reading", | |
| 726 | * and `W` is the answer to "show me the other one". The dot moving is the | |
| 727 | * whole assertion: it is the same grammar on every repo screen, so if it | |
| 728 | * moves here it moves everywhere. | |
| 729 | */ | |
| 730 | test("W swaps a mirror between the workbench copy and the live origin", async () => { | |
| 731 | const h = boot(forge, asAlice()); | |
| 732 | try { | |
| 733 | // `widgets` was mirrored by the test above, so this repo has two sides. | |
| 734 | await h.press("/"); | |
| 735 | await h.typeText("widgets"); | |
| 736 | await h.press("enter"); | |
| 737 | await h.press("enter"); | |
| 738 | await h.until("workbench (synced"); | |
| 739 | // Both places hold the repo, so both wear a filled dot — what says | |
| 740 | // which one you are *reading* is the accent, so that is what is | |
| 741 | // asserted. (The colour, not the glyph: that is the strip's grammar.) | |
| 742 | expect(h.text().split("\n")[2]).toMatch(/^● \S+ {2}· ● workbench/); | |
| 743 | const workbenchAccent = h.app.screen.styleAt(0, 2).fg; | |
| 744 | expect(h.text()).not.toContain("reading live from"); | |
| 745 | expect(await helpText(h)).toContain("live on"); | |
| 746 | ||
| 747 | await h.press("W"); | |
| 748 | await h.until("reading live from"); | |
| 749 | // Same three places, same glyphs — and the accent has moved to the | |
| 750 | // first one, which is the whole of what `W` means. | |
| 751 | expect(h.text().split("\n")[2]).toMatch(/^● \S+ {2}· ● workbench/); | |
| 752 | expect(h.app.screen.styleAt(0, 2).fg).not.toBe(workbenchAccent); | |
| 753 | expect(h.text()).toContain("read-only"); | |
| 754 | ||
| 755 | // And back again, to the copy we hold. | |
| 756 | await h.press("W"); | |
| 757 | await h.until("workbench (synced"); | |
| 758 | expect(h.text()).not.toContain("reading live from"); | |
| 759 | } finally { | |
| 760 | h.stop(); | |
| 761 | } | |
| 762 | }, 120_000); | |
| 763 | ||
| 764 | test("the strip marks the third place when the repo is pinned", async () => { | |
| 765 | const h = boot(forge, asAlice()); | |
| 766 | try { | |
| 767 | await h.until("alice/widgets"); | |
| 768 | await h.press("/"); | |
| 769 | await h.typeText("widgets"); | |
| 770 | await h.press("enter"); | |
| 771 | await h.press("enter"); | |
| 772 | await h.until("workbench (synced"); | |
| 773 | expect(h.text()).toContain("○ this machine"); | |
| 774 | ||
| 775 | await h.press("P"); | |
| 776 | await h.until("◆ this machine"); | |
| 777 | expect(listPins(forge.db, alice.id)).toHaveLength(1); | |
| 778 | ||
| 779 | // Put it back, so the pin test below still starts from nothing. | |
| 780 | await h.press("P"); | |
| 781 | await h.until("○ this machine"); | |
| 782 | expect(listPins(forge.db, alice.id)).toHaveLength(0); | |
| 783 | } finally { | |
| 784 | h.stop(); | |
| 785 | } | |
| 786 | }, 60_000); | |
| 787 | ||
| 788 | test("the picker says which rows it already holds", async () => { | |
| 789 | const h = boot(forge, asAlice()); | |
| 790 | try { | |
| 791 | await openPicker(h); | |
| 792 | expect(h.text()).toContain("● mirrored as alice/widgets"); | |
| 793 | // A row already held is not mirrored twice. | |
| 794 | await h.press("m"); | |
| 795 | expect(h.text()).toContain("already mirrored"); | |
| 796 | } finally { | |
| 797 | h.stop(); | |
| 798 | } | |
| 799 | }, 60_000); | |
| 800 | ||
| 801 | test("an imported issue wears the origin's author, and a comment goes through", async () => { | |
| 802 | const h = boot(forge, asAlice()); | |
| 803 | try { | |
| 804 | await h.until("alice/widgets"); | |
| 805 | await h.press("/"); | |
| 806 | await h.typeText("widgets"); | |
| 807 | await h.press("enter"); | |
| 808 | await h.press("enter"); | |
| 809 | await h.until("workbench (synced"); | |
| 810 | await h.press("i"); | |
| 811 | await h.until("The gear slips"); | |
| 812 | // The upstream mark plus the login on the origin, never a local name. | |
| 813 | expect(h.text()).toContain("mona"); | |
| 814 | ||
| 815 | await h.press("enter"); | |
| 816 | await h.until("The gear slips"); | |
| 817 | expect(h.text()).toContain("mona"); | |
| 818 | ||
| 819 | const before = fake.state.requests.length; | |
| 820 | await h.press("c"); | |
| 821 | await h.until("Comment"); | |
| 822 | await h.typeText("from the terminal"); | |
| 823 | await h.press("ctrl+d"); | |
| 824 | await h.until("comment posted"); | |
| 825 | ||
| 826 | const wrote = fake.state.requests | |
| 827 | .slice(before) | |
| 828 | .some( | |
| 829 | (r) => | |
| 830 | r.method === "POST" && | |
| 831 | r.path.endsWith("/issues/7/comments") && | |
| 832 | r.body.includes("from the terminal"), | |
| 833 | ); | |
| 834 | expect(wrote).toBe(true); | |
| 835 | } finally { | |
| 836 | h.stop(); | |
| 837 | } | |
| 838 | }, 60_000); | |
| 839 | ||
| 840 | test("p pins a repository and the dashboard says it is on your machine", async () => { | |
| 841 | const h = boot(forge, asAlice()); | |
| 842 | try { | |
| 843 | await h.until("alice/widgets"); | |
| 844 | // More than one repo is mirrored by now, so name the one being pinned. | |
| 845 | await h.press("/"); | |
| 846 | await h.typeText("widgets"); | |
| 847 | await h.press("enter"); | |
| 848 | expect(listPins(forge.db, alice.id)).toHaveLength(0); | |
| 849 | await h.press("p"); | |
| 850 | await h.until("On your machine"); | |
| 851 | expect(listPins(forge.db, alice.id)).toHaveLength(1); | |
| 852 | expect(h.text()).toContain("forge sync"); | |
| 853 | // The marker rides the row itself, not a separate list. | |
| 854 | expect(h.text()).toContain("◆ alice/widgets"); | |
| 855 | ||
| 856 | await h.press("p"); | |
| 857 | await h.until("Organizations"); | |
| 858 | expect(listPins(forge.db, alice.id)).toHaveLength(0); | |
| 859 | } finally { | |
| 860 | h.stop(); | |
| 861 | } | |
| 862 | }, 60_000); | |
| 863 | }); |
test/tui-workflows.test.ts +4 −369
| @@ -8,35 +8,25 @@ | ||
| 8 | 8 | * against a real forge and asserts against the cell grid the renderer |
| 9 | 9 | * produced, with no sleeps: every wait is `settleApp`. |
| 10 | 10 | * |
| 11 | * Two halves, because they need two worlds. | |
| 12 | * | |
| 13 | * - **At an origin** — a fake Forgejo API in front of a *second real forge* | |
| 14 | * that is the git host behind its clone URLs. That is the only way to test | |
| 15 | * forking and proposing honestly: the fork is a real repository, the push | |
| 16 | * really leaves the machine, and the pull is opened on the upstream with | |
| 17 | * the `owner:branch` head form both forges take. | |
| 18 | * - **At home** — one forge, an org with two writers, and one pull. Verdicts, | |
| 19 | * the merge gate, and thread resolution are all about *who said what about | |
| 20 | * which head*, and nothing about that needs another machine. | |
| 11 | * One forge, an org with two writers, and one pull. Verdicts, the merge | |
| 12 | * gate, and thread resolution are all about *who said what about which | |
| 13 | * head*, and nothing about that needs another machine. | |
| 21 | 14 | */ |
| 22 | 15 | |
| 23 | 16 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 24 | 17 | import { mkdtemp, rm } from "node:fs/promises"; |
| 25 | 18 | import { tmpdir } from "node:os"; |
| 26 | 19 | import { join } from "node:path"; |
| 27 | import { connectOrigin, resetConnectLimit } from "../src/actions/origins.ts"; | |
| 28 | 20 | import { addTeamMember, createOrg, createTeam } from "../src/actions/orgs.ts"; |
| 29 | 21 | import { openPull } from "../src/actions/pulls.ts"; |
| 30 | 22 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 31 | 23 | import { signUp } from "../src/actions/users.ts"; |
| 32 | 24 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 33 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 25 | import type { Repo } from "../src/core/repos.ts"; | |
| 34 | 26 | import { listReviews } from "../src/core/reviews.ts"; |
| 35 | 27 | import { getPull } from "../src/core/pulls.ts"; |
| 36 | 28 | import type { User } from "../src/core/users.ts"; |
| 37 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 38 | 29 | import { quietLogs } from "./helpers/globals.ts"; |
| 39 | import { forgetProviders } from "../src/origins/providers/index.ts"; | |
| 40 | 30 | import type { App } from "../src/tui/app/app.ts"; |
| 41 | 31 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 42 | 32 | import type { TuiIdentity } from "../src/tui/session.ts"; |
| @@ -45,7 +35,6 @@ import { | ||
| 45 | 35 | type HeadlessTerminal, |
| 46 | 36 | } from "../src/tui/term/terminal.ts"; |
| 47 | 37 | import { settleApp } from "./helpers/tui.ts"; |
| 48 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; | |
| 49 | 38 | |
| 50 | 39 | quietLogs(); |
| 51 | 40 | |
| @@ -173,360 +162,6 @@ const identityFor = (user: User): TuiIdentity => ({ | ||
| 173 | 162 | |
| 174 | 163 | // --------------------------------------------------------------------------- |
| 175 | 164 | |
| 176 | describe("workflows at an origin", () => { | |
| 177 | let dataDir: string; | |
| 178 | let originDataDir: string; | |
| 179 | let forge: Forge; | |
| 180 | let originApp: Forge; | |
| 181 | let originServers: Servers; | |
| 182 | let fake: FakeForge; | |
| 183 | let alice: User; | |
| 184 | let originToken = ""; | |
| 185 | ||
| 186 | const ORIGIN_LOGIN = "me"; | |
| 187 | const UPSTREAM = "octo"; | |
| 188 | const PASSWORD = "workflow-account-passphrase"; | |
| 189 | ||
| 190 | const basic = (user: string): string => | |
| 191 | `Basic ${Buffer.from(`${user}:${PASSWORD}`).toString("base64")}`; | |
| 192 | ||
| 193 | const upstream = ( | |
| 194 | path: string, | |
| 195 | init: RequestInit & { as?: string } = {}, | |
| 196 | ): Promise<Response> => { | |
| 197 | const headers = new Headers(init.headers); | |
| 198 | if (init.body !== undefined) | |
| 199 | headers.set("content-type", "application/json"); | |
| 200 | headers.set("authorization", basic(init.as ?? UPSTREAM)); | |
| 201 | return fetch(`${originServers.url}${path}`, { ...init, headers }); | |
| 202 | }; | |
| 203 | ||
| 204 | async function mkAccount(username: string): Promise<string> { | |
| 205 | await fetch(`${originServers.url}/api/v1/users`, { | |
| 206 | method: "POST", | |
| 207 | headers: { "content-type": "application/json" }, | |
| 208 | body: JSON.stringify({ | |
| 209 | username, | |
| 210 | email: `${username}@upstream.test`, | |
| 211 | password: PASSWORD, | |
| 212 | }), | |
| 213 | }); | |
| 214 | const made = (await ( | |
| 215 | await fetch(`${originServers.url}/api/v1/tokens`, { | |
| 216 | method: "POST", | |
| 217 | headers: { "content-type": "application/json" }, | |
| 218 | body: JSON.stringify({ | |
| 219 | username, | |
| 220 | password: PASSWORD, | |
| 221 | name: "t", | |
| 222 | scope: "admin", | |
| 223 | }), | |
| 224 | }) | |
| 225 | ).json()) as { token: string }; | |
| 226 | return made.token; | |
| 227 | } | |
| 228 | ||
| 229 | async function seed(name: string, owner = UPSTREAM): Promise<void> { | |
| 230 | await upstream("/api/v1/repos", { | |
| 231 | as: owner, | |
| 232 | method: "POST", | |
| 233 | body: JSON.stringify({ name, visibility: "public" }), | |
| 234 | }); | |
| 235 | await upstream(`/api/v1/repos/${owner}/${name}/contents`, { | |
| 236 | as: owner, | |
| 237 | method: "POST", | |
| 238 | body: JSON.stringify({ | |
| 239 | branch: "main", | |
| 240 | message: "first", | |
| 241 | files: [ | |
| 242 | { path: "README.md", content: `# ${name}\n\nread live\n` }, | |
| 243 | { path: "a.txt", content: "one\n" }, | |
| 244 | ], | |
| 245 | }), | |
| 246 | }); | |
| 247 | } | |
| 248 | ||
| 249 | /** Dashboard → account → origins → browse this origin's repositories. */ | |
| 250 | const openPicker = async (h: Harness): Promise<void> => { | |
| 251 | await h.press("A"); | |
| 252 | await h.until("Account"); | |
| 253 | await h.press("o"); | |
| 254 | await h.until("Origins"); | |
| 255 | await h.press("r"); | |
| 256 | await h.until(`${UPSTREAM}/gears`); | |
| 257 | }; | |
| 258 | ||
| 259 | /** | |
| 260 | * Put the picker's cursor on a named row. `row()` marks the selected one | |
| 261 | * with `▸`, so this asserts on what the reader would look at rather than on | |
| 262 | * an index that shifts the moment the listing is sorted differently. | |
| 263 | */ | |
| 264 | const select = async (h: Harness, name: string): Promise<void> => { | |
| 265 | await h.press("g"); | |
| 266 | for (let i = 0; i < 20; i++) { | |
| 267 | if (h.rows().some((line) => line.includes(`▸ ${name} `))) return; | |
| 268 | await h.press("j"); | |
| 269 | } | |
| 270 | throw new Error(`never selected ${name}:\n${h.text()}`); | |
| 271 | }; | |
| 272 | ||
| 273 | beforeAll(async () => { | |
| 274 | dataDir = await mkdtemp(join(tmpdir(), "forge-tuiwf-")); | |
| 275 | originDataDir = await mkdtemp(join(tmpdir(), "forge-tuiwf-up-")); | |
| 276 | ||
| 277 | originApp = createApp({ dataDir: originDataDir, port: 0 }); | |
| 278 | originServers = await serve(originApp); | |
| 279 | await mkAccount(UPSTREAM); | |
| 280 | originToken = await mkAccount(ORIGIN_LOGIN); | |
| 281 | await seed("gears"); | |
| 282 | await seed("widgets"); | |
| 283 | await seed("own", ORIGIN_LOGIN); | |
| 284 | ||
| 285 | // `me` really forks both upstreams on the *git host*, so the clone URL | |
| 286 | // the fake hands back for a fork points at a repository that exists and | |
| 287 | // that `me` may push to. The fake's own state deliberately does not know | |
| 288 | // about them, so the TUI's `F` really does POST /forks and really does | |
| 289 | // poll for the repository the origin promised. | |
| 290 | for (const name of ["gears", "widgets"]) { | |
| 291 | const forked = await upstream(`/api/v1/repos/${UPSTREAM}/${name}/forks`, { | |
| 292 | as: ORIGIN_LOGIN, | |
| 293 | method: "POST", | |
| 294 | body: "{}", | |
| 295 | }); | |
| 296 | expect(forked.status).toBe(201); | |
| 297 | } | |
| 298 | ||
| 299 | const git = (name: string, owner = UPSTREAM): string => | |
| 300 | `${originServers.url}/${owner}/${name}.git`; | |
| 301 | const state = newFakeState("forgejo", ORIGIN_LOGIN, originToken); | |
| 302 | state.repos.push( | |
| 303 | { | |
| 304 | id: 1, | |
| 305 | fullName: `${UPSTREAM}/gears`, | |
| 306 | description: "cannot push here", | |
| 307 | cloneUrl: git("gears"), | |
| 308 | htmlUrl: `${originServers.url}/${UPSTREAM}/gears`, | |
| 309 | defaultBranch: "main", | |
| 310 | push: false, | |
| 311 | admin: false, | |
| 312 | }, | |
| 313 | { | |
| 314 | id: 2, | |
| 315 | fullName: `${UPSTREAM}/widgets`, | |
| 316 | description: "cannot push here either", | |
| 317 | cloneUrl: git("widgets"), | |
| 318 | htmlUrl: `${originServers.url}/${UPSTREAM}/widgets`, | |
| 319 | defaultBranch: "main", | |
| 320 | push: false, | |
| 321 | admin: false, | |
| 322 | }, | |
| 323 | { | |
| 324 | id: 3, | |
| 325 | fullName: `${ORIGIN_LOGIN}/own`, | |
| 326 | description: "mine, and I may push", | |
| 327 | cloneUrl: git("own", ORIGIN_LOGIN), | |
| 328 | htmlUrl: `${originServers.url}/${ORIGIN_LOGIN}/own`, | |
| 329 | defaultBranch: "main", | |
| 330 | push: true, | |
| 331 | admin: true, | |
| 332 | }, | |
| 333 | ); | |
| 334 | // The origin does not finish making a fork before it answers. | |
| 335 | state.forkDelayReads = 1; | |
| 336 | for (const name of [ | |
| 337 | `${UPSTREAM}/gears`, | |
| 338 | `${UPSTREAM}/widgets`, | |
| 339 | `${ORIGIN_LOGIN}/own`, | |
| 340 | ]) { | |
| 341 | state.branches.set(name, [ | |
| 342 | { name: "main", sha: "1".repeat(40), protected: false }, | |
| 343 | ]); | |
| 344 | state.files.set( | |
| 345 | name, | |
| 346 | new Map([ | |
| 347 | ["README.md", `# ${name.split("/")[1]}\n\nread live\n`], | |
| 348 | ["a.txt", "one\n"], | |
| 349 | ]), | |
| 350 | ); | |
| 351 | } | |
| 352 | fake = serveFakeForge(state); | |
| 353 | ||
| 354 | forge = createApp({ dataDir, port: 0 }); | |
| 355 | const created = await signUp(forge, { | |
| 356 | username: "alice", | |
| 357 | email: "[email protected]", | |
| 358 | password: "a-long-passphrase", | |
| 359 | }); | |
| 360 | if (!created.ok) throw new Error("signup failed"); | |
| 361 | alice = created.value; | |
| 362 | resetConnectLimit(); | |
| 363 | forgetProviders(dataDir); | |
| 364 | ||
| 365 | const connected = await connectOrigin(forge, alice, { | |
| 366 | host: fake.url, | |
| 367 | token: originToken, | |
| 368 | }); | |
| 369 | if (!connected.ok) | |
| 370 | throw new Error(`connect failed: ${String(connected.error)}`); | |
| 371 | }, 120_000); | |
| 372 | ||
| 373 | afterAll(async () => { | |
| 374 | fake.stop(); | |
| 375 | originServers.stop(); | |
| 376 | forge.close(); | |
| 377 | originApp.close(); | |
| 378 | forgetProviders(dataDir); | |
| 379 | await rm(dataDir, { recursive: true, force: true }); | |
| 380 | await rm(originDataDir, { recursive: true, force: true }); | |
| 381 | }, 60_000); | |
| 382 | ||
| 383 | test("F on a live browse forks at the origin and lands in the fork", async () => { | |
| 384 | const h = boot(forge, identityFor(alice)); | |
| 385 | try { | |
| 386 | await h.settle(); | |
| 387 | await openPicker(h); | |
| 388 | await select(h, `${UPSTREAM}/gears`); | |
| 389 | // Opened with the pointer: a click selects, a second click opens — | |
| 390 | // the picker's rows answer the mouse like every other list. | |
| 391 | const row = h.rowOf(`${UPSTREAM}/gears`); | |
| 392 | expect(row).toBeGreaterThan(0); | |
| 393 | await h.click(4, row, 2); | |
| 394 | await h.until("read live"); | |
| 395 | expect(h.text()).toContain("reading live from"); | |
| 396 | ||
| 397 | await h.press("F"); | |
| 398 | // The card says the whole sentence: two machines are involved. | |
| 399 | await h.until(`Fork ${UPSTREAM}/gears to your account`); | |
| 400 | expect(h.text()).toContain("and mirror it here?"); | |
| 401 | ||
| 402 | await h.press("enter"); | |
| 403 | // The mirror runs over the job the fork started; wait for the | |
| 404 | // outcome — the fork's own workspace — rather than the mirror card, | |
| 405 | // which can finish and be gone before the next poll ever sees it. | |
| 406 | await h.until(`fork of ${UPSTREAM}/gears`, 60_000); | |
| 407 | // The card was still shown: the harness saw it pushed. | |
| 408 | expect(h.opened().some((t) => t.startsWith("Mirroring"))).toBe(true); | |
| 409 | ||
| 410 | const fork = getRepo(forge.db, "alice", "gears"); | |
| 411 | expect(fork).toBeDefined(); | |
| 412 | // The about card says it too, in words, beside the clone URLs. | |
| 413 | await h.press("I"); | |
| 414 | await h.until("About"); | |
| 415 | expect(h.text()).toContain(`fork of ${UPSTREAM}/gears`); | |
| 416 | await h.press("esc"); | |
| 417 | ||
| 418 | const posts = fake.state.requests.filter( | |
| 419 | (r) => r.method === "POST" && r.path.endsWith("/gears/forks"), | |
| 420 | ); | |
| 421 | expect(posts).toHaveLength(1); | |
| 422 | } finally { | |
| 423 | h.stop(); | |
| 424 | } | |
| 425 | }, 120_000); | |
| 426 | ||
| 427 | test("a change on an unpushable upstream becomes a pull on it", async () => { | |
| 428 | const h = boot(forge, identityFor(alice)); | |
| 429 | try { | |
| 430 | await h.settle(); | |
| 431 | await openPicker(h); | |
| 432 | // Take a copy of `octo/widgets` first, so the pull that comes back has | |
| 433 | // somewhere here to be imported into — the "local import" half. | |
| 434 | await select(h, `${UPSTREAM}/widgets`); | |
| 435 | await h.press("m"); | |
| 436 | // Wait for the outcome, not the mirror card: on a fast machine the | |
| 437 | // job can finish before the next poll ever sees "Mirroring". | |
| 438 | await h.until("mirrored as alice/widgets", 60_000); | |
| 439 | expect(getRepo(forge.db, "alice", "widgets")).toBeDefined(); | |
| 440 | expect(h.opened()).toContain(`Mirroring ${UPSTREAM}/widgets`); | |
| 441 | ||
| 442 | // Now browse it live: read-only, and not ours to push to. | |
| 443 | await h.press("enter"); | |
| 444 | await h.until("read live"); | |
| 445 | expect(h.text()).toContain(`proposing to ${UPSTREAM}/widgets`); | |
| 446 | ||
| 447 | // Edit the README and propose it. | |
| 448 | await h.press("e"); | |
| 449 | await h.settle(); | |
| 450 | await h.type("hello from alice"); | |
| 451 | await h.press("esc"); | |
| 452 | await h.press("c"); | |
| 453 | await h.until("Propose a change"); | |
| 454 | const dialog = h.text(); | |
| 455 | // The proposal dialog, not the commit dialog. | |
| 456 | expect(dialog).not.toContain("commit to main"); | |
| 457 | expect(dialog).toContain("Branch in your fork"); | |
| 458 | expect(dialog).toContain(`Pull request`); | |
| 459 | expect(dialog).toContain(`${UPSTREAM}/widgets`); | |
| 460 | ||
| 461 | await h.press("ctrl+s"); | |
| 462 | await h.until(`opened pull #1 on ${UPSTREAM}/widgets`, 90_000); | |
| 463 | ||
| 464 | // The head form both forges take, with the fork's owner on the front. | |
| 465 | const opened = fake.state.requests.find( | |
| 466 | (r) => r.method === "POST" && r.path.endsWith("/widgets/pulls"), | |
| 467 | ); | |
| 468 | expect(opened).toBeDefined(); | |
| 469 | const body = JSON.parse(opened!.body) as { head: string; base: string }; | |
| 470 | expect(body.head).toContain(":"); | |
| 471 | expect(body.head.split(":")[0]).toBe(ORIGIN_LOGIN); | |
| 472 | expect(body.head.split(":").at(-1)).toContain("edit-"); | |
| 473 | expect(body.base).toBe("main"); | |
| 474 | ||
| 475 | // The branch really landed in the fork's git — asked of the git host | |
| 476 | // itself, not of anything this forge remembers about it. | |
| 477 | const branch = body.head.split(":").at(-1)!; | |
| 478 | const branches = (await ( | |
| 479 | await upstream(`/api/v1/repos/${ORIGIN_LOGIN}/widgets/branches`, { | |
| 480 | as: ORIGIN_LOGIN, | |
| 481 | }) | |
| 482 | ).json()) as { name: string }[]; | |
| 483 | expect(branches.map((b) => b.name)).toContain(branch); | |
| 484 | ||
| 485 | // And the pull was imported into our mirror of the upstream, so the | |
| 486 | // screen could follow the reader straight to it. | |
| 487 | await h.until("Merge", 30_000); | |
| 488 | expect(h.text()).toContain("#1"); | |
| 489 | expect(h.text()).toContain("Conversation"); | |
| 490 | } finally { | |
| 491 | h.stop(); | |
| 492 | } | |
| 493 | }, 180_000); | |
| 494 | ||
| 495 | test("a repository you may push to keeps the ordinary commit dialog", async () => { | |
| 496 | const h = boot(forge, identityFor(alice)); | |
| 497 | try { | |
| 498 | await h.settle(); | |
| 499 | await openPicker(h); | |
| 500 | await select(h, `${ORIGIN_LOGIN}/own`); | |
| 501 | // Mirror it, then work on the copy: this is the push-capable path. | |
| 502 | // Wait for the outcome, not the mirror card — see the fork test above. | |
| 503 | await h.press("m"); | |
| 504 | await h.until("mirrored as alice/own", 60_000); | |
| 505 | expect(h.opened()).toContain(`Mirroring ${ORIGIN_LOGIN}/own`); | |
| 506 | await h.press("enter"); | |
| 507 | await h.until("read live"); | |
| 508 | // The origin says this one is ours to push to, so nothing is proposed. | |
| 509 | expect(h.text()).not.toContain("proposing to"); | |
| 510 | ||
| 511 | // On the copy we hold, the ordinary dialog: a ref to commit onto. | |
| 512 | await h.press("W"); | |
| 513 | await h.until("workbench"); | |
| 514 | await h.press("e"); | |
| 515 | await h.settle(); | |
| 516 | await h.type("x"); | |
| 517 | await h.press("esc"); | |
| 518 | await h.press("c"); | |
| 519 | await h.until("commit to main"); | |
| 520 | expect(h.text()).not.toContain("Propose a change"); | |
| 521 | await h.press("esc"); | |
| 522 | } finally { | |
| 523 | h.stop(); | |
| 524 | } | |
| 525 | }, 180_000); | |
| 526 | }); | |
| 527 | ||
| 528 | // --------------------------------------------------------------------------- | |
| 529 | ||
| 530 | 165 | describe("reviews, threads and the merge gate", () => { |
| 531 | 166 | let dataDir: string; |
| 532 | 167 | let forge: Forge; |
new above
offline