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

Contraction: remove the TUI's origins group #418

Merged opened this

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

Merged

scout/forge:scout/399-contraction-remove-the-tui-s-origins-gro → main 1 commit fork

  • Merged. scout/399-contraction-remove-the-tui-s-origins-gro was merged into main as 42b43df.

Review

Sign in to review this change.
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
496496 before any of this. The Preview tab is offered on Markdown only, and renders
497497 through the same `/preview` fragment the server will use for the file.
498498
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
505500 screen since before pins existed, so the toggle takes the shifted key there
506501 and the plain `p` everywhere a repo is a _row_ (the dashboard, explore).
507502
@@ -599,8 +594,8 @@ Four rules hold the gestures together:
599594 Clickable today: every list row, the dashboard's four panes, the workspace's
600595 explorer rows and tab chips (including the active tab's `✗`), the file pane
601596 (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
604599 hint in the help bar** — clicking a hint presses the key it names, taken from
605600 the binding itself so a label can never drift from the action. A list with no
606601 `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";
3838 import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
3939 import { newPullScreen } from "./screens/newpull.ts";
4040 import { newRepoScreen } from "./screens/newrepo.ts";
41 import { originsScreen } from "./screens/origins.ts";
4241 import { ownerScreen } from "./screens/owner.ts";
4342 import { repoScreen } from "./screens/repo.ts";
4443 import { settingsScreen } from "./screens/settings.ts";
@@ -177,12 +176,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
177176 title: "Account settings",
178177 run: () => ctx.push(accountScreen(session, ctx)),
179178 },
180 {
181 id: "origins",
182 title: "Origins",
183 hint: "connected forges",
184 run: () => ctx.push(originsScreen(session, ctx)),
185 },
186179 );
187180 if (session.user?.isAdmin === true)
188181 commands.push({
src/tui/screens/account.ts −10
@@ -39,7 +39,6 @@ import {
3939 runAsync,
4040 } from "./common.ts";
4141 import { crewScreen } from "./crew.ts";
42 import { originsScreen } from "./origins.ts";
4342
4443 const TABS = ["Profile", "Tokens", "SSH keys"] as const;
4544
@@ -68,7 +67,6 @@ export function accountScreen(f: Session, ctx: Ctx): Screen {
6867 binding(["d"], "display name"),
6968 binding(["e"], "email"),
7069 binding(["p"], "password"),
71 binding(["o"], "origins"),
7270 ...crew,
7371 ];
7472 if (tab === 1)
@@ -76,14 +74,12 @@ export function accountScreen(f: Session, ctx: Ctx): Screen {
7674 binding(["tab"], "next tab"),
7775 binding(["n"], "new token"),
7876 binding(["D"], "revoke"),
79 binding(["o"], "origins"),
8077 ...crew,
8178 ];
8279 return [
8380 binding(["tab"], "next tab"),
8481 binding(["n"], "add key"),
8582 binding(["D"], "delete key"),
86 binding(["o"], "origins"),
8783 ...crew,
8884 ];
8985 },
@@ -148,12 +144,6 @@ export function accountScreen(f: Session, ctx: Ctx): Screen {
148144 state.index = 0;
149145 return true;
150146 }
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 }
157147 if (e.key === "c" && f.user?.isAdmin === true) {
158148 ctx.push(crewScreen(f, ctx));
159149 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", () => {
435435 await h.settle();
436436 await openRepo(h, "lighthouse");
437437 // 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.)
439439 await h.press("O");
440440 expect(h.text()).toContain("no origin page");
441441 } 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 @@
88 * against a real forge and asserts against the cell grid the renderer
99 * produced, with no sleeps: every wait is `settleApp`.
1010 *
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.
2114 */
2215
2316 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2417 import { mkdtemp, rm } from "node:fs/promises";
2518 import { tmpdir } from "node:os";
2619 import { join } from "node:path";
27 import { connectOrigin, resetConnectLimit } from "../src/actions/origins.ts";
2820 import { addTeamMember, createOrg, createTeam } from "../src/actions/orgs.ts";
2921 import { openPull } from "../src/actions/pulls.ts";
3022 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
3123 import { signUp } from "../src/actions/users.ts";
3224 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";
3426 import { listReviews } from "../src/core/reviews.ts";
3527 import { getPull } from "../src/core/pulls.ts";
3628 import type { User } from "../src/core/users.ts";
37 import { serve, type Servers } from "../src/http/server.ts";
3829 import { quietLogs } from "./helpers/globals.ts";
39 import { forgetProviders } from "../src/origins/providers/index.ts";
4030 import type { App } from "../src/tui/app/app.ts";
4131 import { createForgeApp } from "../src/tui/forge.ts";
4232 import type { TuiIdentity } from "../src/tui/session.ts";
@@ -45,7 +35,6 @@ import {
4535 type HeadlessTerminal,
4636 } from "../src/tui/term/terminal.ts";
4737 import { settleApp } from "./helpers/tui.ts";
48 import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
4938
5039 quietLogs();
5140
@@ -173,360 +162,6 @@ const identityFor = (user: User): TuiIdentity => ({
173162
174163 // ---------------------------------------------------------------------------
175164
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
530165 describe("reviews, threads and the merge gate", () => {
531166 let dataDir: string;
532167 let forge: Forge;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs