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

Enter on a terminal event opens its issue or pull #8445

Merged opened this

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

Merged

torque/forge:torque/8434-enter-on-a-terminal-event-opens-its-issu → main 2 commits fork

  • Merged. torque/8434-enter-on-a-terminal-event-opens-its-issu was merged into main as 4451c5b.

Review

Sign in to review this change.
3 files changed +81 −6
docs/features/events.md +1 −1
@@ -4,4 +4,4 @@
44
55 The audience is the same as MCP `events` and `GET …/events`, because the page reads the same `eventsPage`: a webhook's URL and an app's variable names are the admins'. A public repository answers a guest with 200; a private one answers 404 to anyone who cannot read it. A row links to its page when the event has one (an issue, a push's commits) and is a plain row otherwise.
66
7 The terminal's version is `v` on the About card (see the README).
7 The terminal's version is `v` on the About card (see the README). `enter` on a row opens the issue or pull the event names (a `pull.*` event opens the pull, any other the issue); the issue and pull screens check access themselves, and `esc` returns to the list at the same row. A push or a settings row names no number and opens nothing, and the key bar shows `enter open` only when the selected row can open something.
src/tui/screens/events.ts +20
@@ -19,6 +19,9 @@ import { binding } from "../app/keys.ts";
1919 import type { Session } from "../session.ts";
2020 import { emptyState } from "../ui/widgets.ts";
2121 import { ListState, ago, drawList, type ListRow } from "./common.ts";
22 import { issueScreen } from "./issue.ts";
23 import { pullScreen } from "./pull.ts";
24 import { local } from "./where.ts";
2225
2326 /** The one kind `s` keeps. */
2427 export const SETTINGS_KIND = "repo.settings_updated";
@@ -49,6 +52,13 @@ function subjectOf(event: ForgeEvent): string {
4952 return typeof ref === "string" ? ref.replace(/^refs\/heads\//, "") : "";
5053 }
5154
55 /** The issue or pull number an event names, and which; the web page's rule. */
56 export function opensOf(event: ForgeEvent): { pull: boolean; number: number } | undefined {
57 const number = event.payload["number"];
58 if (typeof number !== "number" || !Number.isInteger(number) || number < 1) return undefined;
59 return { pull: event.kind.startsWith("pull."), number };
60 }
61
5262 /** One row per event: `who verb: subject`, with its age beside it. */
5363 function eventRows(f: Session, events: readonly ForgeEvent[]): ListRow[] {
5464 const actors = getUsersByIds(f.db, events.map((e) => e.actorId ?? "").filter(Boolean));
@@ -64,12 +74,14 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
6474 const state = new ListState();
6575 let count = 0;
6676 const rows: ListRow[] = [];
77 const opens: ({ pull: boolean; number: number } | undefined)[] = [];
6778 let next: string | null = null;
6879 let kind: string | undefined;
6980 const read = (cursor?: string): void => {
7081 const page = eventsPage(f.db, repo.id, f.user, EVENTS_LIMIT_DEFAULT, cursor, kind);
7182 count += page.items.length;
7283 rows.push(...eventRows(f, page.items));
84 opens.push(...page.items.map(opensOf));
7385 next = page.next_cursor;
7486 ctx.invalidate();
7587 };
@@ -81,6 +93,7 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
8193 get keys() {
8294 return [
8395 binding(["j", "k"], "move"),
96 ...(opens[state.index] === undefined ? [] : [binding(["enter"], "open")]),
8497 ...(next === null ? [] : [binding(["n"], "next page")]),
8598 binding(["s"], kind === undefined ? "settings only" : "all events"),
8699 binding(["esc", "q"], "back"),
@@ -100,10 +113,17 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
100113 if (e.key === "esc" || e.key === "q") ctx.pop();
101114 else if (e.key === "n") {
102115 if (next !== null) read(next);
116 } else if (e.key === "enter") {
117 const target = opens[state.index];
118 if (target !== undefined) {
119 const src = local(f, repo);
120 ctx.push(target.pull ? pullScreen(f, ctx, src, target.number) : issueScreen(f, ctx, src, target.number));
121 }
103122 } else if (e.key === "s") {
104123 kind = kind === undefined ? SETTINGS_KIND : undefined;
105124 count = 0;
106125 rows.length = 0;
126 opens.length = 0;
107127 state.index = 0;
108128 state.offset = 0;
109129 read();
test/tui-events.test.ts +60 −5
@@ -3,12 +3,16 @@ import { afterAll, beforeAll, expect, test } from "bun:test";
33 import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
6 import { createRepo } from "../src/actions/repos.ts";
6 import { openIssue } from "../src/actions/issues.ts";
7 import { openPull } from "../src/actions/pulls.ts";
8 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
79 import { signUp } from "../src/actions/users.ts";
810 import { createApp, type App as Forge } from "../src/app.ts";
911 import type { Repo } from "../src/core/repos.ts";
1012 import type { User } from "../src/core/users.ts";
1113 import { createForgeApp } from "../src/tui/forge.ts";
14 import type { ForgeEvent } from "../src/core/events.ts";
15 import { opensOf } from "../src/tui/screens/events.ts";
1216 import { aboutScreen } from "../src/tui/screens/about.ts";
1317 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1418 import { localSource } from "../src/view/local.ts";
@@ -37,7 +41,15 @@ beforeAll(async () => {
3741 const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" });
3842 if (!r.ok) throw new Error("repo failed");
3943 repo = r.value;
40 forge.bus.append({ kind: "issue.opened", repoId: repo.id, actorId: alice.id, payload: { number: 1 } });
44 for (const branch of ["main", "feat"]) {
45 const c = await commitToBranch(forge, alice, repo, { branch, message: branch, files: [{ path: `${branch}.md`, content: "x\n" }] });
46 if (!c.ok) throw new Error("commit failed");
47 }
48 // Opening the pull appends its `pull.opened` event: the row `enter` must open.
49 const pull = await openPull(forge, alice, repo, { title: "Brighter bulb", body: "", baseBranch: "main", headBranch: "feat" });
50 if (!pull.ok) throw new Error("pull failed");
51 const issue = openIssue(forge, alice, repo, { title: "The lamp flickers" });
52 if (!issue.ok) throw new Error("issue failed");
4153 forge.bus.append({
4254 kind: "webhook.created",
4355 repoId: repo.id,
@@ -84,9 +96,9 @@ test("the About card offers v, and v lists the events newest first", async () =>
8496 test("n loads the next page past 50, ending at the oldest event", async () => {
8597 const first = await open(alice, ["G"]);
8698 expect(first.events).toContain("alice pushed: b10");
87 expect(first.events).not.toContain("alice opened an issue: #1");
99 expect(first.events).not.toContain("alice opened an issue: #2");
88100 const all = await open(alice, ["n", "G"]);
89 expect(all.events).toContain("alice opened an issue: #1");
101 expect(all.events).toContain("alice opened an issue: #2");
90102 expect(all.events).not.toContain("next page");
91103 });
92104
@@ -97,5 +109,48 @@ test("an admin sees the webhook event; a reader does not, nor its URL", async ()
97109 const reader = await open(bob, ["n", "G"]);
98110 expect(reader.events).not.toContain("added a webhook");
99111 expect(reader.events).not.toContain("hooks.example");
100 expect(reader.events).toContain("alice opened an issue: #1");
112 expect(reader.events).toContain("alice opened an issue: #2");
113 });
114
115 /** Open the events list and press the keys; return the screen's text. */
116 async function press(keys: string[]): Promise<string> {
117 const { events } = await open(alice, keys);
118 return events;
119 }
120
121 // Newest first, the oldest last: created, push main, push feat, pull, issue (#2), webhook, then 60 pushes.
122 const END = ["n", "G"];
123 const up = (n: number): string[] => Array<string>(n).fill("k");
124
125 test("enter on an issue row opens the issue, on a pull row the pull, esc returns", async () => {
126 const issue = await press([...END, ...up(4), "enter"]);
127 expect(issue).toContain("The lamp flickers");
128 expect(issue).not.toContain("alice pushed");
129 const pull = await press([...END, ...up(3), "enter"]);
130 expect(pull).toContain("Brighter bulb");
131 expect(pull).not.toContain("The lamp flickers");
132 expect(pull).not.toContain("not found");
133 const back = await press([...END, ...up(4), "enter", "esc"]);
134 expect(back).toContain("About › Events");
135 expect(back).toContain("▸ alice opened an issue: #2");
136 });
137
138 test("enter on a row with nothing to open does nothing, and the key bar offers open only where it works", async () => {
139 const push = await press(["enter"]);
140 expect(push).toContain("About › Events");
141 expect(push).not.toMatch(/enter\s+open/);
142 expect((await press([...END, "enter"]))).toContain("About › Events");
143 expect(await press([...END, ...up(3)])).toMatch(/enter\s+open/);
144 });
145
146 test("opensOf names the number and the screen, and nothing for a number that is not one", () => {
147 const ev = (kind: string, number?: unknown) =>
148 ({ kind, payload: number === undefined ? {} : { number } }) as unknown as ForgeEvent;
149 expect(opensOf(ev("issue.opened", 1))).toEqual({ pull: false, number: 1 });
150 expect(opensOf(ev("pull.merged", 7))).toEqual({ pull: true, number: 7 });
151 expect(opensOf(ev("issue.opened", 0))).toBeUndefined();
152 expect(opensOf(ev("pull.opened", 1.5))).toBeUndefined();
153 expect(opensOf(ev("issue.opened", "3"))).toBeUndefined();
154 expect(opensOf(ev("issue.opened"))).toBeUndefined();
155 expect(opensOf(ev("push"))).toBeUndefined();
101156 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs