Public: anyone can read this; the crew can change it.
The terminal's Adapter screen runs a pull now with one key #10146
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10126-the-terminal-s-adapter-screen-runs-a-pul → main 2 commits fork
3 files
changed +83
−5
docs/features/adapters-card.md +3 −1
| @@ -9,7 +9,9 @@ when), the **next pull** and the **cursor** of the last good pull. | ||
| 9 | 9 | (`canSeeOwnerFacts`) get it. |
| 10 | 10 | - **Terminal:** open the repository, press the palette key and choose |
| 11 | 11 | "<owner>/<repo> — Adapter" (`src/tui/screens/adapter.ts`). Same gate, same |
| 12 | rows. | |
| 12 | rows. A writer presses `p` there to pull now (`pullNow`): the rows are | |
| 13 | read again, and a refusal shows its `PULL_NOW_REFUSALS` sentence in the | |
| 14 | status line. Anyone else is shown no key and `p` does nothing. | |
| 13 | 15 | - Both list the **last ten pull runs** under the facts (`pullRunLines`, |
| 14 | 16 | limit 10): when, ok or failed, written/skipped/failed and, for a failed |
| 15 | 17 | run, its error sentence. With no runs, one muted line says "No pull runs |
src/tui/screens/adapter.ts +32 −4
| @@ -3,12 +3,17 @@ | ||
| 3 | 3 | * (`web/studio/adapter.ts`), read from the same `adapterFactsOf` and |
| 4 | 4 | * gated the same way — owner facts (`canSeeOwnerFacts`) only. The palette |
| 5 | 5 | * offers it while a repository with an app is open; anyone else is offered |
| 6 | * nothing and, were the screen reached, sees no rows. Read-only. | |
| 6 | * nothing and, were the screen reached, sees no rows. A writer presses `p` | |
| 7 | * to run a pull now (`pullNow`, #10126): the rows are read again, a refusal | |
| 8 | * is told in `PULL_NOW_REFUSALS`' words. Anyone else is shown no key and | |
| 9 | * `p` does nothing. | |
| 7 | 10 | */ |
| 8 | 11 | |
| 12 | import { pullNow, PULL_NOW_REFUSALS } from "../../actions/app_pull.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 9 | 14 | import { NO_RUNS_WORDS, adapterFactsOf, adapterRows, adapterRunRows, type AdapterFacts } from "../../apps/adapter_facts.ts"; |
| 10 | 15 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; |
| 11 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; | |
| 16 | import { canSeeOwnerFacts, canWrite, type Repo } from "../../core/repos.ts"; | |
| 12 | 17 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 18 | import type { Command } from "../app/palette.ts"; |
| 14 | 19 | import type { Session } from "../session.ts"; |
| @@ -37,7 +42,10 @@ export function adapterScreen( | ||
| 37 | 42 | ctx: Ctx, |
| 38 | 43 | repo: Repo, |
| 39 | 44 | facts: (record: AppRecord) => Promise<AdapterFacts | undefined> = (record) => adapterFactsOf(f.app, record), |
| 45 | pull: typeof pullNow = pullNow, | |
| 40 | 46 | ): Screen { |
| 47 | const mayPull = (): boolean => f.user !== undefined && canWrite(f.db, repo, f.user); | |
| 48 | let pulling = false; | |
| 41 | 49 | let rows: Array<[string, string]> | undefined; |
| 42 | 50 | let loaded = false; |
| 43 | 51 | const load = (): void => { |
| @@ -68,7 +76,9 @@ export function adapterScreen( | ||
| 68 | 76 | load(); |
| 69 | 77 | return { |
| 70 | 78 | title: `${repo.ownerName}/${repo.name} — Adapter`, |
| 71 | keys: [], | |
| 79 | get keys() { | |
| 80 | return mayPull() ? [binding(["p"], "pull now")] : []; | |
| 81 | }, | |
| 72 | 82 | render(area, s) { |
| 73 | 83 | const c = ctx.theme.c; |
| 74 | 84 | if (rows === undefined) { |
| @@ -78,6 +88,24 @@ export function adapterScreen( | ||
| 78 | 88 | } |
| 79 | 89 | drawFacts(s, area, c, rows); |
| 80 | 90 | }, |
| 81 | key: () => false, | |
| 91 | key(e) { | |
| 92 | if (e.key !== "p" || !mayPull()) return false; | |
| 93 | if (pulling) return true; | |
| 94 | pulling = true; | |
| 95 | void pull(f.app, f.user, repo).then( | |
| 96 | (done) => { | |
| 97 | pulling = false; | |
| 98 | if (done.ok) { | |
| 99 | ctx.toast(`pulled: ${done.value.state}`, "success"); | |
| 100 | load(); | |
| 101 | } else ctx.toast(PULL_NOW_REFUSALS[done.error], "danger"); | |
| 102 | }, | |
| 103 | () => { | |
| 104 | pulling = false; | |
| 105 | ctx.toast("The pull could not be run.", "danger"); | |
| 106 | }, | |
| 107 | ); | |
| 108 | return true; | |
| 109 | }, | |
| 82 | 110 | }; |
| 83 | 111 | } |
test/adapter-tui.test.ts +48
| @@ -14,6 +14,10 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 14 | 14 | import { createUser, type User } from "../src/core/users.ts"; |
| 15 | 15 | import { recordPull } from "../src/apps/pull.ts"; |
| 16 | 16 | import { shownRuns, type AdapterFacts } from "../src/apps/adapter_facts.ts"; |
| 17 | import { PULL_NOW_REFUSALS } from "../src/actions/app_pull.ts"; | |
| 18 | import type { Ctx } from "../src/tui/app/app.ts"; | |
| 19 | import type { Session } from "../src/tui/session.ts"; | |
| 20 | import { theme } from "../src/tui/ui/theme.ts"; | |
| 17 | 21 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 18 | 22 | import { adapterCommands, adapterScreen } from "../src/tui/screens/adapter.ts"; |
| 19 | 23 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -124,3 +128,47 @@ test("a guest gets no entry, and the screen reads nothing", async () => { | ||
| 124 | 128 | expect(seen.text).not.toContain("c-secret-9"); |
| 125 | 129 | expect(seen.text).not.toContain("upstream said no"); |
| 126 | 130 | }); |
| 131 | ||
| 132 | // Pull now (#10126): one key for a writer, none for anyone else. | |
| 133 | function press(user: User, pull: Parameters<typeof adapterScreen>[4], reads: { n: number }) { | |
| 134 | const toasts: string[] = []; | |
| 135 | const ctx = { | |
| 136 | theme: theme("dark"), | |
| 137 | invalidate() {}, | |
| 138 | toast: (t: string) => toasts.push(t), | |
| 139 | } as unknown as Ctx; | |
| 140 | const f = { app, db: app.db, user } as unknown as Session; | |
| 141 | const screen = adapterScreen(f, ctx, repo, async () => (reads.n++, facts), pull); | |
| 142 | return { screen, toasts, send: (k: string) => screen.key({ key: k } as never) }; | |
| 143 | } | |
| 144 | const settle = () => new Promise((r) => setTimeout(r, 10)); | |
| 145 | ||
| 146 | test("a writer sees the p key, pulls now, and the rows are read again", async () => { | |
| 147 | const reads = { n: 0 }; | |
| 148 | const calls: string[] = []; | |
| 149 | const run = { state: "ok" } as never; | |
| 150 | const t = press(olive, async (_a, actor) => (calls.push(actor!.username), { ok: true, value: run }), reads); | |
| 151 | expect(t.screen.keys.map((b) => b.keys[0])).toEqual(["p"]); | |
| 152 | expect(t.send("p")).toBe(true); | |
| 153 | await settle(); | |
| 154 | expect(calls).toEqual(["olive"]); | |
| 155 | expect(reads.n).toBe(2); | |
| 156 | expect(t.toasts).toEqual(["pulled: ok"]); | |
| 157 | }); | |
| 158 | ||
| 159 | test("a refusal shows its sentence", async () => { | |
| 160 | const t = press(olive, async () => ({ ok: false, error: "asleep" }), { n: 0 }); | |
| 161 | t.send("p"); | |
| 162 | await settle(); | |
| 163 | expect(t.toasts).toEqual([PULL_NOW_REFUSALS.asleep]); | |
| 164 | }); | |
| 165 | ||
| 166 | test("a reader is shown no key and p does nothing", async () => { | |
| 167 | const calls: number[] = []; | |
| 168 | const t = press(otto, async () => (calls.push(1), { ok: false, error: "forbidden" }), { n: 0 }); | |
| 169 | expect(t.screen.keys).toEqual([]); | |
| 170 | expect(t.send("p")).toBe(false); | |
| 171 | await settle(); | |
| 172 | expect(calls).toEqual([]); | |
| 173 | expect(t.toasts).toEqual([]); | |
| 174 | }); |