Public: anyone can read this; the crew can change it.
Enter on a terminal event opens its issue or pull #8445
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8434-enter-on-a-terminal-event-opens-its-issu → main 2 commits fork
3 files
changed +81
−6
docs/features/events.md +1 −1
| @@ -4,4 +4,4 @@ | ||
| 4 | 4 | |
| 5 | 5 | 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. |
| 6 | 6 | |
| 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"; | ||
| 19 | 19 | import type { Session } from "../session.ts"; |
| 20 | 20 | import { emptyState } from "../ui/widgets.ts"; |
| 21 | 21 | 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"; | |
| 22 | 25 | |
| 23 | 26 | /** The one kind `s` keeps. */ |
| 24 | 27 | export const SETTINGS_KIND = "repo.settings_updated"; |
| @@ -49,6 +52,13 @@ function subjectOf(event: ForgeEvent): string { | ||
| 49 | 52 | return typeof ref === "string" ? ref.replace(/^refs\/heads\//, "") : ""; |
| 50 | 53 | } |
| 51 | 54 | |
| 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 | ||
| 52 | 62 | /** One row per event: `who verb: subject`, with its age beside it. */ |
| 53 | 63 | function eventRows(f: Session, events: readonly ForgeEvent[]): ListRow[] { |
| 54 | 64 | 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 { | ||
| 64 | 74 | const state = new ListState(); |
| 65 | 75 | let count = 0; |
| 66 | 76 | const rows: ListRow[] = []; |
| 77 | const opens: ({ pull: boolean; number: number } | undefined)[] = []; | |
| 67 | 78 | let next: string | null = null; |
| 68 | 79 | let kind: string | undefined; |
| 69 | 80 | const read = (cursor?: string): void => { |
| 70 | 81 | const page = eventsPage(f.db, repo.id, f.user, EVENTS_LIMIT_DEFAULT, cursor, kind); |
| 71 | 82 | count += page.items.length; |
| 72 | 83 | rows.push(...eventRows(f, page.items)); |
| 84 | opens.push(...page.items.map(opensOf)); | |
| 73 | 85 | next = page.next_cursor; |
| 74 | 86 | ctx.invalidate(); |
| 75 | 87 | }; |
| @@ -81,6 +93,7 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 81 | 93 | get keys() { |
| 82 | 94 | return [ |
| 83 | 95 | binding(["j", "k"], "move"), |
| 96 | ...(opens[state.index] === undefined ? [] : [binding(["enter"], "open")]), | |
| 84 | 97 | ...(next === null ? [] : [binding(["n"], "next page")]), |
| 85 | 98 | binding(["s"], kind === undefined ? "settings only" : "all events"), |
| 86 | 99 | binding(["esc", "q"], "back"), |
| @@ -100,10 +113,17 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 100 | 113 | if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 101 | 114 | else if (e.key === "n") { |
| 102 | 115 | 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 | } | |
| 103 | 122 | } else if (e.key === "s") { |
| 104 | 123 | kind = kind === undefined ? SETTINGS_KIND : undefined; |
| 105 | 124 | count = 0; |
| 106 | 125 | rows.length = 0; |
| 126 | opens.length = 0; | |
| 107 | 127 | state.index = 0; |
| 108 | 128 | state.offset = 0; |
| 109 | 129 | read(); |
test/tui-events.test.ts +60 −5
| @@ -3,12 +3,16 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; | ||
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | 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"; | |
| 7 | 9 | import { signUp } from "../src/actions/users.ts"; |
| 8 | 10 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 9 | 11 | import type { Repo } from "../src/core/repos.ts"; |
| 10 | 12 | import type { User } from "../src/core/users.ts"; |
| 11 | 13 | 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"; | |
| 12 | 16 | import { aboutScreen } from "../src/tui/screens/about.ts"; |
| 13 | 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 14 | 18 | import { localSource } from "../src/view/local.ts"; |
| @@ -37,7 +41,15 @@ beforeAll(async () => { | ||
| 37 | 41 | const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" }); |
| 38 | 42 | if (!r.ok) throw new Error("repo failed"); |
| 39 | 43 | 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"); | |
| 41 | 53 | forge.bus.append({ |
| 42 | 54 | kind: "webhook.created", |
| 43 | 55 | repoId: repo.id, |
| @@ -84,9 +96,9 @@ test("the About card offers v, and v lists the events newest first", async () => | ||
| 84 | 96 | test("n loads the next page past 50, ending at the oldest event", async () => { |
| 85 | 97 | const first = await open(alice, ["G"]); |
| 86 | 98 | 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"); | |
| 88 | 100 | 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"); | |
| 90 | 102 | expect(all.events).not.toContain("next page"); |
| 91 | 103 | }); |
| 92 | 104 | |
| @@ -97,5 +109,48 @@ test("an admin sees the webhook event; a reader does not, nor its URL", async () | ||
| 97 | 109 | const reader = await open(bob, ["n", "G"]); |
| 98 | 110 | expect(reader.events).not.toContain("added a webhook"); |
| 99 | 111 | 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(); | |
| 101 | 156 | }); |
new above
offline