Public: anyone can read this; the crew can change it.
`/fleet` applies one verb to the rows it selects, with a preview first and Undo after #10147
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10133-fleet-applies-one-verb-to-the-rows-it-se → main 4 commits fork 4 threads · 4 unresolved
12 files
changed +644
−7
docs/features/fleet.md +9
| @@ -8,11 +8,20 @@ Before a fleet operation acts, see the rows its query line selects. There are tw | ||
| 8 | 8 | - Click a column header to sort by it, and click again to reverse. This works through `?sort=<column>` and `?sort=-<column>` links, so no JavaScript is needed. |
| 9 | 9 | - A line that does not parse shows its reason beside the field, with status 200, and selects nothing. |
| 10 | 10 | |
| 11 | **Acting on the rows (web):** under the table, a signed-in person picks a verb for the kind and a value, and chooses Preview (`src/web/pages/fleet-ops.ts`, `src/web/fleet-act.ts`). | |
| 12 | ||
| 13 | - Preview is a same-origin `POST /fleet` with `step=preview`. It shows each row's change, and which rows are unchanged or refused, each with its sentence in a muted line. Nothing is written. | |
| 14 | - Apply is `POST /fleet` with `step=apply`. It carries a digest of the rows the preview showed (`src/fleet/digest.ts`) and runs `applyPreviewed`: if the rows now plan differently, nothing is written and a muted line asks for a new preview. Otherwise it lands on `GET /fleet/ops/:id`, the result. | |
| 15 | - The result has Undo, `POST /fleet/ops/:id/undo`, which runs `undoFleetOp` while the operation is within `UNDO_WINDOW_MS` (a day) and not yet undone. | |
| 16 | - No JavaScript is needed. A cross-origin POST is refused with 403. A signed-out visitor and an agent are not offered the form. | |
| 17 | - A value reads as a flag (`true`, `false`), a number, or text, whichever the verb accepts. Leave it empty for a verb that takes none. | |
| 18 | ||
| 11 | 19 | **In the terminal:** "Fleet rows" in the palette (`src/tui/screens/fleet.ts`). |
| 12 | 20 | |
| 13 | 21 | - It asks for the query line and shows the same table. |
| 14 | 22 | - `/` writes a new line. `s` sorts by the next column and `r` reverses the sort. `j` and `k` scroll, and `esc` goes back. |
| 15 | 23 | - A line that does not parse keeps the prompt open with its reason. |
| 24 | - Signed in as a person (a guest or an agent is not offered these): `v` asks for a verb and its value (`label urgent`), then shows each row's change in a dialog. Apply writes through `applyPreviewed` with the digest of what the dialog showed. `u` undoes the last result. Same operations and refusals as the page (`src/tui/screens/fleet.ts`). | |
| 16 | 25 | |
| 17 | 26 | **The query line:** a kind, then `field:value` terms that must all hold. Both doors show the same grammar under the line: one sentence, then each kind with its fields (`GRAMMAR` and `fieldsOf` in `src/fleet/selection.ts`). |
| 18 | 27 |
src/actions/fleet.ts +21
| @@ -56,6 +56,7 @@ import { | ||
| 56 | 56 | selectRows, |
| 57 | 57 | type FleetKind, |
| 58 | 58 | } from "../fleet/selection.ts"; |
| 59 | import { digestOf } from "../fleet/digest.ts"; | |
| 59 | 60 | import { VERBS, type Target, type Verb } from "../fleet/verbs.ts"; |
| 60 | 61 | import { NO_SUCH_OPERATION } from "./asks_words.ts"; |
| 61 | 62 | |
| @@ -555,6 +556,26 @@ export async function applyFleetOp( | ||
| 555 | 556 | return ok(await carryOutAndRecord(app, person.value, plan.value)); |
| 556 | 557 | } |
| 557 | 558 | |
| 559 | /** | |
| 560 | * Apply only what was previewed: plan again, and if the rows now plan | |
| 561 | * differently from the `digest` the preview carried, write nothing. | |
| 562 | */ | |
| 563 | export async function applyPreviewed( | |
| 564 | app: App, | |
| 565 | actor: User | undefined, | |
| 566 | query: string, | |
| 567 | action: unknown, | |
| 568 | digest: string, | |
| 569 | ): Promise<Result<Operation, FleetError>> { | |
| 570 | const person = requirePerson(actor); | |
| 571 | if (!person.ok) return person; | |
| 572 | const plan = makePlan(app, person.value, query, action); | |
| 573 | if (!plan.ok) return plan; | |
| 574 | if (digestOf(plan.value.rows) !== digest) | |
| 575 | return fail("expired", "The rows changed since the preview. Preview again to see them."); | |
| 576 | return ok(await carryOutAndRecord(app, person.value, plan.value)); | |
| 577 | } | |
| 578 | ||
| 558 | 579 | /** How long a bulk action on asks can be undone: a day. */ |
| 559 | 580 | export const UNDO_WINDOW_MS = 86_400_000; |
| 560 | 581 |
src/fleet/digest.ts added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | /** | |
| 2 | * What a person saw when they previewed: one short digest of the rows, what | |
| 3 | * each would become, and what was said of it. Apply carries it back and is | |
| 4 | * refused if the rows now plan differently, so nothing is written that was | |
| 5 | * not shown. Plain data in, a string out. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { FleetValue } from "../core/fleet_ops.ts"; | |
| 9 | ||
| 10 | export interface Shown { | |
| 11 | readonly targetId: string; | |
| 12 | readonly after: FleetValue; | |
| 13 | readonly outcome: string; | |
| 14 | } | |
| 15 | ||
| 16 | /** A digest of the plan's rows, in the order given. */ | |
| 17 | export function digestOf(rows: readonly Shown[]): string { | |
| 18 | const hasher = new Bun.CryptoHasher("sha256"); | |
| 19 | for (const r of rows) hasher.update(`${r.targetId}\n${r.outcome}\n${JSON.stringify(r.after)}\n`); | |
| 20 | return hasher.digest("hex").slice(0, 32); | |
| 21 | } |
src/fleet/reading.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | /** | |
| 2 | * What a typed value means to a verb, for the doors that take text (the web | |
| 3 | * form, the terminal prompt): nothing, a flag, a number, or the text itself, | |
| 4 | * whichever the verb accepts first. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { App } from "../app.ts"; | |
| 8 | import type { FleetValue } from "../core/fleet_ops.ts"; | |
| 9 | import { VERBS } from "./verbs.ts"; | |
| 10 | ||
| 11 | /** The readings of a text, most specific first. */ | |
| 12 | export function valuesOf(text: string): FleetValue[] { | |
| 13 | const t = text.trim(); | |
| 14 | if (t === "") return [null]; | |
| 15 | const out: FleetValue[] = []; | |
| 16 | if (t === "true" || t === "false") out.push(t === "true"); | |
| 17 | else if (/^-?\d+(\.\d+)?$/.test(t)) out.push(Number(t)); | |
| 18 | out.push(t); | |
| 19 | return out; | |
| 20 | } | |
| 21 | ||
| 22 | /** The action for a verb and a text: the first reading the verb accepts. */ | |
| 23 | export function actionOf(app: App, verb: string, text: string): { verb: string; value: FleetValue } { | |
| 24 | const known = Object.hasOwn(VERBS, verb) ? VERBS[verb] : undefined; | |
| 25 | const options = valuesOf(text); | |
| 26 | const value = | |
| 27 | known === undefined | |
| 28 | ? options[0]! | |
| 29 | : (options.find((v) => known.parse(app, v).ok) ?? options[0]!); | |
| 30 | return { verb, value: value ?? null }; | |
| 31 | } |
src/http/server.ts +4
| @@ -26,6 +26,7 @@ import { prerenderComments } from "../web/prerender.ts"; | ||
| 26 | 26 | import { systemPage, systemStream } from "../web/pages/system.ts"; |
| 27 | 27 | import { contributePage } from "../web/pages/contribute.ts"; |
| 28 | 28 | import { pipelinePage } from "../web/pages/pipeline.ts"; |
| 29 | import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts"; | |
| 29 | 30 | import { maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts"; |
| 30 | 31 | import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts"; |
| 31 | 32 | import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; |
| @@ -924,6 +925,9 @@ export function buildRouter(): Router<Handler> { | ||
| 924 | 925 | router.add("GET", "/resilience/nightly/:job", nightlyOutputPage); |
| 925 | 926 | // A selection's spend and runs over time; literal, above /:owner. |
| 926 | 927 | router.add("GET", "/fleet", fleetPage); |
| 928 | router.add("POST", "/fleet", fleetAct); | |
| 929 | router.add("GET", "/fleet/ops/:id", fleetReceipt); | |
| 930 | router.add("POST", "/fleet/ops/:id/undo", fleetUndo); | |
| 927 | 931 | router.add("GET", "/fleet/graph", fleetGraphPage); |
| 928 | 932 | router.add("GET", "/fleet/graph/live", fleetGraphLive); |
| 929 | 933 | router.add("GET", "/fleet/graph.csv", fleetGraphCsv); |
src/tui/screens/fleet.ts +89 −3
| @@ -4,9 +4,17 @@ | ||
| 4 | 4 | * (`fleet/rows.ts` `viewFleet`), so the grammar and who sees which row are |
| 5 | 5 | * `fleet/selection.ts`'s, never this screen's; the same columns and sort |
| 6 | 6 | * (`fleet/columns.ts`). Keys: `/` a new query line, `s` sort by the next |
| 7 | * column, `r` reverse it, `j`/`k` scroll, `esc` back. | |
| 7 | * column, `r` reverse it, `j`/`k` scroll, `esc` back. A signed-in person (not | |
| 8 | * an agent, as on the page) also has `v`: a verb and its value, the preview | |
| 9 | * of each row's change, Apply, and `u` to undo the last result. The writes | |
| 10 | * are `actions/fleet.ts`'s, as the page's are. | |
| 8 | 11 | */ |
| 9 | 12 | |
| 13 | import type { Operation } from "../../core/fleet_ops.ts"; | |
| 14 | import { applyPreviewed, previewFleetOp, undoFleetOp, type Plan } from "../../actions/fleet.ts"; | |
| 15 | import { digestOf } from "../../fleet/digest.ts"; | |
| 16 | import { actionOf } from "../../fleet/reading.ts"; | |
| 17 | ||
| 10 | 18 | import { cellText, COLUMNS, sortRows, type Sort } from "../../fleet/columns.ts"; |
| 11 | 19 | import { viewFleet, type FleetView } from "../../fleet/rows.ts"; |
| 12 | 20 | import { fieldsOf, GRAMMAR, GRAMMAR_EXAMPLES, FLEET_KINDS, type FleetKind } from "../../fleet/selection.ts"; |
| @@ -16,7 +24,7 @@ import { binding } from "../app/keys.ts"; | ||
| 16 | 24 | import type { Session } from "../session.ts"; |
| 17 | 25 | import { style } from "../term/style.ts"; |
| 18 | 26 | import { pad, truncate, wrap } from "../term/width.ts"; |
| 19 | import { promptScreen, Scroll } from "./common.ts"; | |
| 27 | import { confirmScreen, later, promptScreen, Scroll } from "./common.ts"; | |
| 20 | 28 | |
| 21 | 29 | /** No column is padded past this, so a long title does not push the rest away. */ |
| 22 | 30 | const CELL_MAX = 40; |
| @@ -66,6 +74,21 @@ export function nextSort(kind: FleetKind, sort: Sort | undefined): Sort { | ||
| 66 | 74 | return { key: keys[(at + 1) % keys.length]!, descending: false }; |
| 67 | 75 | } |
| 68 | 76 | |
| 77 | /** A row's change in one line: label, before → after, and what becomes of it. */ | |
| 78 | export function planLines(plan: Plan, width: number, most = 12): string[] { | |
| 79 | const word = (v: unknown) => (v === null ? "nothing" : Array.isArray(v) ? v.join(", ") || "none" : String(v)); | |
| 80 | const rows = plan.rows.slice(0, most).map((r) => | |
| 81 | truncate( | |
| 82 | `${r.label}: ${word(r.before)} -> ${word(r.after)} ${r.outcome === "change" ? "" : r.outcome}${r.reason === "" ? "" : ` - ${r.reason}`}`.trimEnd(), | |
| 83 | width, | |
| 84 | ), | |
| 85 | ); | |
| 86 | return plan.rows.length > most ? [...rows, `and ${plan.rows.length - most} more`] : rows; | |
| 87 | } | |
| 88 | ||
| 89 | /** A person (not a guest, not an agent) may act, as on the page. */ | |
| 90 | export const mayAct = (user: Session["user"]): boolean => user !== undefined && user.kind !== "agent"; | |
| 91 | ||
| 69 | 92 | /** The screen, and the prompt that writes its query line. */ |
| 70 | 93 | export function fleetScreen( |
| 71 | 94 | session: Session, |
| @@ -77,10 +100,13 @@ export function fleetScreen( | ||
| 77 | 100 | let view: FleetView | undefined = line === "" ? undefined : viewFleet(session.db, session.user, line); |
| 78 | 101 | let sort: Sort | undefined; |
| 79 | 102 | let lines: string[] = []; |
| 103 | let note = ""; | |
| 104 | let last: Operation | undefined; | |
| 80 | 105 | const screen: Screen = { |
| 81 | 106 | title: "Fleet", |
| 82 | 107 | keys: [ |
| 83 | 108 | binding(["/"], "query"), |
| 109 | ...(mayAct(session.user) ? [binding(["v"], "verb"), binding(["u"], "undo")] : []), | |
| 84 | 110 | binding(["s"], "sort"), |
| 85 | 111 | binding(["r"], "reverse"), |
| 86 | 112 | binding(["j", "k"], "scroll"), |
| @@ -94,7 +120,8 @@ export function fleetScreen( | ||
| 94 | 120 | : view.ok |
| 95 | 121 | ? `${query} ${view.rows.length} ${view.rows.length === 1 ? "row" : "rows"}` |
| 96 | 122 | : `${query} ${view.error}`; |
| 97 | s.text(area.x + 1, area.y, truncate(head, area.w - 2), style(view?.ok === false ? c.danger : c.mutedFg, c.bg), area.x + area.w); | |
| 123 | const shown = note === "" ? head : `${head} ${note}`; | |
| 124 | s.text(area.x + 1, area.y, truncate(shown, area.w - 2), style(view?.ok === false ? c.danger : c.mutedFg, c.bg), area.x + area.w); | |
| 98 | 125 | if (view?.ok !== true) { |
| 99 | 126 | // No rows to show: the grammar fills the space, as it sits under the web form. |
| 100 | 127 | grammarLines(Math.max(20, area.w - 2)).forEach((text, i) => { |
| @@ -118,12 +145,71 @@ export function fleetScreen( | ||
| 118 | 145 | if (e.ctrl || e.alt) return scroll.key(e, Math.max(0, lines.length - 1)); |
| 119 | 146 | if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 120 | 147 | else if (e.key === "/") ask(); |
| 148 | else if (e.key === "v" && view?.ok && mayAct(session.user)) verb(); | |
| 149 | else if (e.key === "u" && last !== undefined && mayAct(session.user)) undo(); | |
| 121 | 150 | else if (e.key === "s" && view?.ok) sort = nextSort(view.kind, sort); |
| 122 | 151 | else if (e.key === "r" && view?.ok && sort !== undefined) sort = { ...sort, descending: !sort.descending }; |
| 123 | 152 | else return scroll.key(e, Math.max(0, lines.length - 1)); |
| 124 | 153 | return true; |
| 125 | 154 | }, |
| 126 | 155 | }; |
| 156 | const refresh = () => { | |
| 157 | if (query !== "") view = viewFleet(session.db, session.user, query); | |
| 158 | }; | |
| 159 | const undo = () => { | |
| 160 | const op = last; | |
| 161 | if (op === undefined) return; | |
| 162 | ctx.push( | |
| 163 | confirmScreen(ctx, screen, { | |
| 164 | title: "Undo", | |
| 165 | body: [`Put back what ${op.verb} changed.`], | |
| 166 | actions: [{ label: "Undo" }, { label: "Cancel", variant: "outline" }], | |
| 167 | onChoose: (i) => { | |
| 168 | if (i !== 0) return; | |
| 169 | void undoFleetOp(session.app, session.user, op.id).then((r) => { | |
| 170 | note = r.ok ? "Undone." : r.error.message; | |
| 171 | if (r.ok) last = undefined; | |
| 172 | refresh(); | |
| 173 | }); | |
| 174 | }, | |
| 175 | }), | |
| 176 | ); | |
| 177 | }; | |
| 178 | const verb = () => | |
| 179 | ctx.push( | |
| 180 | promptScreen(ctx, screen, { | |
| 181 | title: "Verb", | |
| 182 | label: "a verb, then its value (some take none)", | |
| 183 | placeholder: "label urgent", | |
| 184 | hint: "Nothing is written until you apply.", | |
| 185 | onSubmit: (text) => { | |
| 186 | const [name = "", ...rest] = text.trim().split(/\s+/); | |
| 187 | const value = rest.join(" "); | |
| 188 | if (name === "") return "name a verb"; | |
| 189 | const action = actionOf(session.app, name, value); | |
| 190 | const plan = previewFleetOp(session.app, session.user, query, action); | |
| 191 | if (!plan.ok) return plan.error.message; | |
| 192 | const p = plan.value; | |
| 193 | const n = p.counts.change; | |
| 194 | return later(ctx, () => | |
| 195 | confirmScreen(ctx, screen, { | |
| 196 | title: `Preview: ${p.verb}`, | |
| 197 | body: [...planLines(p, 70), "", n === 0 ? "Nothing to apply." : "Nothing is written until you apply."], | |
| 198 | actions: n === 0 ? [{ label: "Close" }] : [{ label: `Apply to ${n} ${n === 1 ? "row" : "rows"}` }, { label: "Cancel", variant: "outline" }], | |
| 199 | width: 76, | |
| 200 | onChoose: (i) => { | |
| 201 | if (i !== 0 || n === 0) return; | |
| 202 | void applyPreviewed(session.app, session.user, query, action, digestOf(p.rows)).then((r) => { | |
| 203 | note = r.ok ? "Applied. u undoes." : r.error.message; | |
| 204 | if (r.ok) last = r.value; | |
| 205 | refresh(); | |
| 206 | }); | |
| 207 | }, | |
| 208 | }), | |
| 209 | ); | |
| 210 | }, | |
| 211 | }), | |
| 212 | ); | |
| 127 | 213 | const ask = () => |
| 128 | 214 | ctx.push( |
| 129 | 215 | promptScreen(ctx, screen, { |
src/web/fleet-act.ts added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | /** | |
| 2 | * The write side of `/fleet`, as HTML: the verb form under the table, the | |
| 3 | * preview of what applying would do, and the receipt with Undo. Pure: plans | |
| 4 | * and operations in, HTML out. The writes are `actions/fleet.ts`'s. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { FleetValue, Operation } from "../core/fleet_ops.ts"; | |
| 8 | import type { Plan } from "../actions/fleet.ts"; | |
| 9 | import { UNDO_WINDOW_MS } from "../actions/fleet.ts"; | |
| 10 | import { digestOf } from "../fleet/digest.ts"; | |
| 11 | import { VERBS } from "../fleet/verbs.ts"; | |
| 12 | import type { FleetKind } from "../fleet/selection.ts"; | |
| 13 | import { html, type Html } from "./html.ts"; | |
| 14 | import { btn, field, selectField } from "./ui.ts"; | |
| 15 | ||
| 16 | /** A value in words. */ | |
| 17 | export const valueWords = (value: FleetValue): string => | |
| 18 | value === null | |
| 19 | ? "nothing" | |
| 20 | : Array.isArray(value) | |
| 21 | ? value.length === 0 | |
| 22 | ? "none" | |
| 23 | : value.join(", ") | |
| 24 | : String(value); | |
| 25 | ||
| 26 | /** The verbs a kind takes, by name. */ | |
| 27 | export const verbsOf = (kind: FleetKind): string[] => | |
| 28 | Object.keys(VERBS).filter((v) => VERBS[v]!.kind === kind); | |
| 29 | ||
| 30 | /** The form that asks for a verb and its value; posts to the preview. */ | |
| 31 | export function actForm(kind: FleetKind, q: string, chosen = "", value = ""): Html { | |
| 32 | return html`<form method="post" action="/fleet" class="field-row mb" data-act> | |
| 33 | <input type="hidden" name="q" value="${q}" /> | |
| 34 | <input type="hidden" name="step" value="preview" /> | |
| 35 | ${selectField({ | |
| 36 | label: "Verb", | |
| 37 | name: "verb", | |
| 38 | id: "fleet-verb", | |
| 39 | options: verbsOf(kind).map((v) => ({ value: v, label: v, selected: v === chosen })), | |
| 40 | })} | |
| 41 | ${field({ | |
| 42 | label: "Value", | |
| 43 | name: "value", | |
| 44 | id: "fleet-value", | |
| 45 | value, | |
| 46 | autocomplete: "off", | |
| 47 | mono: true, | |
| 48 | spellcheck: false, | |
| 49 | hint: "Some verbs take none.", | |
| 50 | })} | |
| 51 | ${btn("Preview", { type: "submit", variant: "secondary" })} | |
| 52 | </form>`; | |
| 53 | } | |
| 54 | ||
| 55 | const outcomeWords: Record<string, string> = { | |
| 56 | change: "will change", | |
| 57 | unchanged: "unchanged", | |
| 58 | refused: "refused", | |
| 59 | ok: "changed", | |
| 60 | failed: "failed", | |
| 61 | }; | |
| 62 | ||
| 63 | interface Line { | |
| 64 | label: string; | |
| 65 | before: FleetValue; | |
| 66 | after: FleetValue; | |
| 67 | outcome: string; | |
| 68 | reason: string; | |
| 69 | } | |
| 70 | ||
| 71 | function lines(rows: readonly Line[]): Html { | |
| 72 | return html`<ul class="list" data-fleet-op>${rows.map( | |
| 73 | (r) => html`<li data-outcome="${r.outcome}"> | |
| 74 | <span>${r.label}</span> | |
| 75 | <span class="mono">${valueWords(r.before)} → ${valueWords(r.after)}</span> | |
| 76 | <span class="muted">${outcomeWords[r.outcome] ?? r.outcome}${r.reason === "" ? null : ` — ${r.reason}`}</span> | |
| 77 | </li>`, | |
| 78 | )}</ul>`; | |
| 79 | } | |
| 80 | ||
| 81 | /** The dry run: each row's change, and one button to apply it. Nothing is written yet. */ | |
| 82 | export function previewSection(plan: Plan, rawValue: string): Html { | |
| 83 | const { change } = plan.counts; | |
| 84 | return html`<section data-preview> | |
| 85 | <h2>Preview: ${plan.verb}</h2> | |
| 86 | <p class="muted">Nothing is written until you apply.</p> | |
| 87 | ${lines(plan.rows)} | |
| 88 | ${change === 0 | |
| 89 | ? html`<p class="muted">Nothing to apply.</p>` | |
| 90 | : html`<form method="post" action="/fleet" class="form-actions"> | |
| 91 | <input type="hidden" name="q" value="${plan.query}" /> | |
| 92 | <input type="hidden" name="step" value="apply" /> | |
| 93 | <input type="hidden" name="verb" value="${plan.verb}" /> | |
| 94 | <input type="hidden" name="value" value="${rawValue}" /> | |
| 95 | <input type="hidden" name="digest" value="${digestOf(plan.rows)}" /> | |
| 96 | ${btn(`Apply to ${change} ${change === 1 ? "row" : "rows"}`, { type: "submit" })} | |
| 97 | </form>`} | |
| 98 | </section>`; | |
| 99 | } | |
Principle 1: the summary sentence repeats the preview table and the button label ('Apply to N rows'). Say it once. |
||
| 100 | ||
| 101 | /** Whether an Undo is still offered for a receipt. */ | |
| 102 | export const undoable = (op: Operation, now = Date.now()): boolean => | |
| 103 | op.reversible && | |
| 104 | op.undoOf === null && | |
| 105 | op.undoneBy === null && | |
| 106 | op.rows.some((r) => r.outcome === "ok") && | |
| 107 | now - Date.parse(op.createdAt) <= UNDO_WINDOW_MS; | |
| 108 | ||
| 109 | /** The result of an operation, with Undo while it can be undone. */ | |
| 110 | export function receiptSection(op: Operation, canUndo: boolean): Html { | |
| 111 | return html`<section data-receipt="${op.id}"> | |
| 112 | <h2>${op.undoOf === null ? "Applied" : "Undone"}: ${op.verb}</h2> | |
outdated
Principle 1: the receipt counts repeat what the table beneath shows; keep one. |
||
| 113 | ${op.undoneBy === null ? null : html`<p class="muted">This was undone.</p>`} | |
| 114 | ${lines(op.rows)} | |
| 115 | ${canUndo | |
| 116 | ? html`<form method="post" action="/fleet/ops/${op.id}/undo" class="form-actions"> | |
| 117 | ${btn("Undo", { type: "submit", variant: "outline" })} | |
| 118 | </form>` | |
| 119 | : null} | |
| 120 | </section>`; | |
| 121 | } |
src/web/pages/fleet-ops.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** | |
| 2 | * `/fleet` writes: `POST /fleet` previews (`step=preview`) or applies | |
| 3 | * (`step=apply`) one verb over the rows a query line selects, | |
| 4 | * `GET /fleet/ops/:id` is the receipt, `POST /fleet/ops/:id/undo` undoes it. | |
| 5 | * The operations are `actions/fleet.ts`'s; nothing here writes. Every POST | |
| 6 | * is same-origin (`readForm`), and only a signed-in person acts. | |
| 7 | */ | |
| 8 | ||
| 9 | import { | |
| 10 | applyPreviewed, | |
| 11 | getFleetOp, | |
| 12 | previewFleetOp, | |
| 13 | undoFleetOp, | |
| 14 | } from "../../actions/fleet.ts"; | |
| 15 | import { actionOf } from "../../fleet/reading.ts"; | |
| 16 | import type { Ctx } from "../../http/context.ts"; | |
| 17 | import { notFoundPage, signInFirst } from "../chrome.ts"; | |
| 18 | import { previewSection, receiptSection, undoable } from "../fleet-act.ts"; | |
| 19 | import { field, readForm } from "../form.ts"; | |
| 20 | import { html } from "../html.ts"; | |
| 21 | import { flashTo, page } from "../layout.ts"; | |
| 22 | import { canAct, renderFleet } from "./fleet.ts"; | |
| 23 | ||
| 24 | /** POST /fleet */ | |
| 25 | export async function fleetAct(ctx: Ctx): Promise<Response> { | |
| 26 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 27 | if (!canAct(ctx)) return notFoundPage(ctx); | |
| 28 | const form = await readForm(ctx, "rw"); | |
| 29 | if (form instanceof Response) return form; | |
| 30 | const q = field(form, "q").slice(0, 2000); | |
| 31 | const verb = field(form, "verb").slice(0, 40); | |
| 32 | const text = field(form, "value").slice(0, 2000); | |
| 33 | const action = actionOf(ctx.app, verb, text); | |
| 34 | if (field(form, "step") === "apply") { | |
| 35 | const done = await applyPreviewed(ctx.app, ctx.auth.user, q, action, field(form, "digest").slice(0, 64)); | |
| 36 | if (!done.ok) return refusal(ctx, q, verb, text, done.error.message); | |
| 37 | return flashTo(ctx, `/fleet/ops/${done.value.id}`, { message: "Applied." }); | |
| 38 | } | |
| 39 | const plan = previewFleetOp(ctx.app, ctx.auth.user, q, action); | |
| 40 | if (!plan.ok) return refusal(ctx, q, verb, text, plan.error.message); | |
| 41 | return renderFleet(ctx, q, { html: previewSection(plan.value, text), verb, value: text }); | |
| 42 | } | |
| 43 | ||
| 44 | function refusal(ctx: Ctx, q: string, verb: string, value: string, message: string): Response { | |
| 45 | return renderFleet(ctx, q, { | |
| 46 | html: html`<p class="muted" data-refused>${message}</p>`, | |
| 47 | verb, | |
| 48 | value, | |
| 49 | }); | |
| 50 | } | |
outdated
Apply re-selects rows from q instead of the previewed set; rows can differ from what the person saw. Pin the previewed ids (digest) and refuse on mismatch. |
||
| 51 | ||
| 52 | /** GET /fleet/ops/:id */ | |
| 53 | export function fleetReceipt(ctx: Ctx): Response { | |
| 54 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 55 | const found = getFleetOp(ctx.app, ctx.auth.user, ctx.params.id ?? ""); | |
| 56 | if (!found.ok) return notFoundPage(ctx); | |
| 57 | const op = found.value; | |
| 58 | return page({ | |
| 59 | ctx, | |
| 60 | title: "Fleet result", | |
| 61 | wide: true, | |
| 62 | body: html`<div class="page-head"><h1>Fleet result</h1> | |
| 63 | <p class="muted"><code>${op.query}</code></p></div> | |
| 64 | ${receiptSection(op, canAct(ctx) && undoable(op))} | |
| 65 | <p><a href="/fleet?${new URLSearchParams({ q: op.query }).toString()}">Back to the rows</a></p>`, | |
| 66 | }); | |
| 67 | } | |
| 68 | ||
| 69 | /** POST /fleet/ops/:id/undo */ | |
| 70 | export async function fleetUndo(ctx: Ctx): Promise<Response> { | |
| 71 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 72 | if (!canAct(ctx)) return notFoundPage(ctx); | |
| 73 | const form = await readForm(ctx, "rw"); | |
| 74 | if (form instanceof Response) return form; | |
| 75 | const id = ctx.params.id ?? ""; | |
| 76 | const undone = await undoFleetOp(ctx.app, ctx.auth.user, id); | |
| 77 | if (!undone.ok) | |
| 78 | return flashTo(ctx, `/fleet/ops/${encodeURIComponent(id)}`, { | |
| 79 | tone: "danger", | |
| 80 | message: undone.error.message, | |
| 81 | }); | |
| 82 | return flashTo(ctx, `/fleet/ops/${undone.value.id}`, { message: "Undone." }); | |
| 83 | } |
src/web/pages/fleet.ts +20 −3
| @@ -12,14 +12,23 @@ import { viewFleet, type FleetView } from "../../fleet/rows.ts"; | ||
| 12 | 12 | import { fieldsOf, GRAMMAR, GRAMMAR_EXAMPLES, FLEET_KINDS, MAX_ROWS } from "../../fleet/selection.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | 14 | import { fleetTable } from "../fleet-table.ts"; |
| 15 | import { html } from "../html.ts"; | |
| 15 | import { actForm } from "../fleet-act.ts"; | |
| 16 | import { html, type Html } from "../html.ts"; | |
| 16 | 17 | import { page } from "../layout.ts"; |
| 17 | 18 | import { btn, field } from "../ui.ts"; |
| 18 | 19 | |
| 19 | 20 | /** GET /fleet */ |
| 20 | 21 | export function fleetPage(ctx: Ctx): Response { |
| 21 | 22 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 22 | const q = (ctx.url.searchParams.get("q") ?? "").slice(0, 2000); | |
| 23 | return renderFleet(ctx, (ctx.url.searchParams.get("q") ?? "").slice(0, 2000)); | |
| 24 | } | |
| 25 | ||
| 26 | /** The page for a query line; `below` is a preview or a receipt, drawn under the form. */ | |
| 27 | export function renderFleet( | |
| 28 | ctx: Ctx, | |
| 29 | q: string, | |
| 30 | below?: { html: Html; verb?: string; value?: string }, | |
| 31 | ): Response { | |
| 23 | 32 | const view: FleetView | undefined = |
| 24 | 33 | q.trim() === "" ? undefined : viewFleet(ctx.app.db, ctx.auth.user, q); |
| 25 | 34 | const rendering = performance.now(); |
| @@ -66,8 +75,16 @@ export function fleetPage(ctx: Ctx): Response { | ||
| 66 | 75 | : html`<p class="muted" data-count="${shown.rows.length}"> |
| 67 | 76 | ${shown.rows.length} ${shown.rows.length === 1 ? "row" : "rows"} |
| 68 | 77 | </p> |
| 69 | ${shown.rows.length === 0 ? null : fleetTable(shown.kind, q, shown.rows, sort)}`}`, | |
| 78 | ${shown.rows.length === 0 ? null : fleetTable(shown.kind, q, shown.rows, sort)} | |
| 79 | ${shown.rows.length === 0 || !canAct(ctx) | |
| 80 | ? null | |
| 81 | : actForm(shown.kind, q, below?.verb, below?.value)}`} | |
| 82 | ${below?.html ?? null}`, | |
| 70 | 83 | }); |
| 71 | 84 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 72 | 85 | return response; |
| 73 | 86 | } |
| 87 | ||
| 88 | /** A signed-in person may act; a signed-out visitor, and an agent, only read. */ | |
| 89 | export const canAct = (ctx: Ctx): boolean => | |
| 90 | ctx.auth.user !== undefined && ctx.auth.user.kind !== "agent"; |
test/fleet-act.test.ts added +205
| @@ -0,0 +1,205 @@ | ||
| 1 | /** | |
| 2 | * `/fleet` writes (#10133): a same-origin POST previews a verb over the | |
| 3 | * selected rows and writes nothing; apply writes through `applyFleetOp` and | |
| 4 | * lands on a receipt; Undo calls `undoFleetOp`. A cross-origin POST is | |
| 5 | * refused and a signed-out reader is not offered the form. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { createAgent } from "../src/actions/crew.ts"; | |
| 14 | import { createLabel, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 16 | import { getRepo } from "../src/core/repos.ts"; | |
| 17 | import { labelsFor } from "../src/core/issues.ts"; | |
| 18 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | import { promote } from "./helpers/members.ts"; | |
| 21 | ||
| 22 | describe("fleet verbs on the web", () => { | |
| 23 | let dataDir: string; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let cookie: string; | |
| 27 | const ids: string[] = []; | |
| 28 | const q = "issue repo:alice/engine state:open"; | |
| 29 | ||
| 30 | const person = async (username: string, adminIfFirst = false) => { | |
| 31 | const made = await createUser(app.db, { | |
| 32 | origin: "seed", trusted: true, username, allowReserved: true, | |
| 33 | email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst, | |
| 34 | }); | |
| 35 | if (!made.ok) throw new Error(made.error); | |
| 36 | promote(app.db, made.value); | |
| 37 | return made.value; | |
| 38 | }; | |
| 39 | const post = (path: string, body: Record<string, string>, headers: Record<string, string> = {}) => | |
| 40 | fetch(`${servers.url}${path}`, { | |
| 41 | method: "POST", | |
| 42 | redirect: "manual", | |
| 43 | headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded", ...headers }, | |
| 44 | body: new URLSearchParams(body), | |
| 45 | }); | |
| 46 | /** Preview, then apply with the digest the preview carried: what a person's browser does. */ | |
| 47 | const apply = async (body: Record<string, string>, headers: Record<string, string> = {}) => { | |
| 48 | const shown = await (await post("/fleet", { ...body, step: "preview" }, headers)).text(); | |
| 49 | const digest = /name="digest" value="([0-9a-f]+)"/.exec(shown)?.[1] ?? ""; | |
| 50 | return post("/fleet", { ...body, step: "apply", digest }, headers); | |
| 51 | }; | |
| 52 | const labelled = () => ids.map((id) => labelsFor(app.db, id).map((l) => l.name)); | |
| 53 | ||
| 54 | beforeAll(async () => { | |
| 55 | dataDir = await mkdtemp(join(tmpdir(), "forge-fleet-act-")); | |
| 56 | app = createApp({ dataDir, port: 0 }); | |
| 57 | servers = await serve(app); | |
| 58 | await person("root", true); | |
| 59 | const alice: User = await person("alice"); | |
| 60 | const repo = await createRepo(app, alice, { name: "engine", visibility: "public" }); | |
| 61 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 62 | for (const n of [1, 2]) { | |
| 63 | const made = openIssue(app, alice, repo.value, { title: `Task ${n}` }); | |
| 64 | if (!made.ok) throw new Error(String(made.error)); | |
| 65 | ids.push(made.value.id); | |
| 66 | } | |
| 67 | createLabel(app, alice, repo.value, "urgent", "ff0000"); | |
| 68 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 69 | }, 30_000); | |
| 70 | ||
| 71 | afterAll(async () => { | |
| 72 | servers.stop(); | |
| 73 | app.close(); | |
| 74 | await rm(dataDir, { recursive: true, force: true }); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a reader is not offered the form; a writer is", async () => { | |
| 78 | const path = `/fleet?${new URLSearchParams({ q })}`; | |
| 79 | const reader = await (await fetch(`${servers.url}${path}`)).text(); | |
| 80 | expect(reader).toContain("data-count=\"2\""); | |
| 81 | expect(reader).not.toContain("data-act"); | |
| 82 | const writer = await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); | |
| 83 | expect(writer).toContain("data-act"); | |
| 84 | expect(writer).toContain('value="label"'); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a signed-out POST is not a write", async () => { | |
| 88 | const res = await fetch(`${servers.url}/fleet`, { | |
| 89 | method: "POST", redirect: "manual", | |
| 90 | headers: { origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, | |
| 91 | body: new URLSearchParams({ q, step: "apply", verb: "label", value: "urgent" }), | |
| 92 | }); | |
| 93 | expect([303, 401, 403]).toContain(res.status); | |
| 94 | expect(labelled()).toEqual([[], []]); | |
| 95 | }); | |
| 96 | ||
| 97 | test("a cross-origin POST is refused and writes nothing", async () => { | |
| 98 | const res = await post("/fleet", { q, step: "apply", verb: "label", value: "urgent" }, { origin: "https://evil.example" }); | |
| 99 | expect(res.status).toBe(403); | |
| 100 | expect(labelled()).toEqual([[], []]); | |
| 101 | }); | |
| 102 | ||
| 103 | test("preview shows each row's change and writes nothing; a refusal is a muted line", async () => { | |
| 104 | const res = await post("/fleet", { q, step: "preview", verb: "label", value: "urgent" }); | |
| 105 | expect(res.status).toBe(200); | |
| 106 | const body = await res.text(); | |
| 107 | expect(body).toContain("data-preview"); | |
| 108 | expect(body).toContain("Apply to 2 rows"); | |
| 109 | expect(body).toContain("will change"); | |
| 110 | expect(body).toContain("none → urgent"); | |
| 111 | expect(labelled()).toEqual([[], []]); | |
| 112 | const none = await (await post("/fleet", { q, step: "preview", verb: "label", value: "nope" })).text(); | |
| 113 | expect(none).toContain("no label named nope in alice/engine"); | |
| 114 | expect(none).toContain('class="muted"'); | |
| 115 | const bad = await (await post("/fleet", { q, step: "preview", verb: "archive", value: "true" })).text(); | |
| 116 | expect(bad).toContain("data-refused"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("apply writes through the operation and lands on a receipt; Undo puts it back, once", async () => { | |
| 120 | const applied = await apply({ q, verb: "label", value: "urgent" }); | |
| 121 | expect(applied.status).toBe(303); | |
| 122 | const at = applied.headers.get("location")!; | |
| 123 | expect(at).toMatch(/^\/fleet\/ops\//); | |
| 124 | expect(labelled()).toEqual([["urgent"], ["urgent"]]); | |
| 125 | const receipt = await (await fetch(`${servers.url}${at}`, { headers: { cookie } })).text(); | |
| 126 | expect(receipt).toContain("data-receipt"); | |
| 127 | expect(receipt).toContain(`action="${at}/undo"`); | |
| 128 | ||
| 129 | const undone = await post(`${at}/undo`, {}); | |
| 130 | expect(undone.status).toBe(303); | |
| 131 | expect(labelled()).toEqual([[], []]); | |
| 132 | const after = await (await fetch(`${servers.url}${at}`, { headers: { cookie } })).text(); | |
| 133 | expect(after).not.toContain("/undo\""); | |
| 134 | await post(`${at}/undo`, {}); | |
| 135 | expect(labelled()).toEqual([[], []]); | |
| 136 | }); | |
| 137 | ||
| 138 | test("a cross-origin undo is refused", async () => { | |
| 139 | const applied = await apply({ q, verb: "label", value: "urgent" }); | |
| 140 | const at = applied.headers.get("location")!; | |
| 141 | const res = await post(`${at}/undo`, {}, { origin: "https://evil.example" }); | |
| 142 | expect(res.status).toBe(403); | |
| 143 | expect(labelled()).toEqual([["urgent"], ["urgent"]]); | |
| 144 | }); | |
| 145 | ||
| 146 | test("an agent's POST is not a write", async () => { | |
| 147 | const made = await createAgent(app, getUserByName(app.db, "root")!, { username: "botty", brief: "Be brief." }); | |
| 148 | if (!made.ok) throw new Error(String(made.error)); | |
| 149 | const agent = `forge_session=${createSession(app.db, made.value.user.id).token}`; | |
| 150 | const res = await post("/fleet", { q, step: "apply", verb: "label", value: "urgent", digest: "x" }, { cookie: agent }); | |
| 151 | expect(res.status).toBe(404); | |
| 152 | expect(labelled()).toEqual([["urgent"], ["urgent"]]); | |
| 153 | const page = await (await fetch(`${servers.url}/fleet?${new URLSearchParams({ q })}`, { headers: { cookie: agent } })).text(); | |
| 154 | expect(page).not.toContain("data-act"); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a row alice does not administer is refused in apply and left alone", async () => { | |
| 158 | const bob = await person("bob"); | |
| 159 | const repo = await createRepo(app, bob, { name: "other", visibility: "public" }); | |
| 160 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 161 | const issue = openIssue(app, bob, repo.value, { title: "Theirs" }); | |
| 162 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 163 | createLabel(app, bob, repo.value, "urgent", "ff0000"); | |
| 164 | const alice = getUserByName(app.db, "alice")!; | |
| 165 | const engine = getRepo(app.db, "alice", "engine")!; | |
| 166 | const mineToo = openIssue(app, alice, engine, { title: "Mine" }); | |
| 167 | if (!mineToo.ok) throw new Error(String(mineToo.error)); | |
| 168 | const mine = "issue repo:alice/engine,bob/other"; | |
| 169 | const applied = await apply({ q: mine, verb: "label", value: "urgent" }); | |
| 170 | const at = applied.headers.get("location")!; | |
| 171 | const receipt = await (await fetch(`${servers.url}${at}`, { headers: { cookie } })).text(); | |
| 172 | expect(receipt).toContain('data-outcome="refused"'); | |
| 173 | expect(labelsFor(app.db, issue.value.id)).toEqual([]); | |
| 174 | expect(labelsFor(app.db, mineToo.value.id).map((l) => l.name)).toEqual(["urgent"]); | |
| 175 | }); | |
| 176 | ||
| 177 | test("apply is bound to what was previewed: rows that moved are refused, nothing written", async () => { | |
| 178 | const alice = getUserByName(app.db, "alice")!; | |
| 179 | const repo = await createRepo(app, alice, { name: "fresh", visibility: "public" }); | |
| 180 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 181 | createLabel(app, alice, repo.value, "urgent", "ff0000"); | |
| 182 | const first = openIssue(app, alice, repo.value, { title: "One" }); | |
| 183 | if (!first.ok) throw new Error(String(first.error)); | |
| 184 | const fq = "issue repo:alice/fresh"; | |
| 185 | const body = { q: fq, verb: "label", value: "urgent" }; | |
| 186 | const preview = await (await post("/fleet", { ...body, step: "preview" })).text(); | |
| 187 | const digest = /name="digest" value="([0-9a-f]+)"/.exec(preview)?.[1]; | |
| 188 | expect(digest).toBeDefined(); | |
| 189 | const second = openIssue(app, alice, repo.value, { title: "Two" }); | |
| 190 | if (!second.ok) throw new Error(String(second.error)); | |
| 191 | const stale = await post("/fleet", { ...body, step: "apply", digest: digest! }); | |
| 192 | expect(stale.status).toBe(200); | |
| 193 | expect(await stale.text()).toContain("rows changed since the preview"); | |
| 194 | expect(labelsFor(app.db, first.value.id)).toEqual([]); | |
| 195 | expect(labelsFor(app.db, second.value.id)).toEqual([]); | |
| 196 | const missing = await post("/fleet", { ...body, step: "apply" }); | |
| 197 | expect(missing.status).toBe(200); | |
| 198 | expect(labelsFor(app.db, first.value.id)).toEqual([]); | |
| 199 | const fresh = await (await post("/fleet", { ...body, step: "preview" })).text(); | |
| 200 | const again = /name="digest" value="([0-9a-f]+)"/.exec(fresh)![1]!; | |
| 201 | const ok = await post("/fleet", { ...body, step: "apply", digest: again }); | |
| 202 | expect(ok.status).toBe(303); | |
| 203 | expect(labelsFor(app.db, second.value.id).map((l) => l.name)).toEqual(["urgent"]); | |
| 204 | }); | |
| 205 | }); |
test/security-floor-routes.test.ts +1
| @@ -55,6 +55,7 @@ const UNSEEDED: Record<string, string> = { | ||
| 55 | 55 | "/:owner/-/teams/:team": ORG, |
| 56 | 56 | "/talk/:id": TALK, |
| 57 | 57 | "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing", |
| 58 | "/fleet/ops/:id": FLEET, | |
| 58 | 59 | "/:owner/:name/bets/:id": BET, |
| 59 | 60 | "/:owner/:name/builds/:label/cart": CART, |
| 60 | 61 | "/:owner/:name/builds/:label/cart.png": CART, |
test/tui-fleet.test.ts +39 −1
| @@ -9,8 +9,10 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; | ||
| 9 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { createLabel, createRepo } from "../src/actions/repos.ts"; | |
| 13 | 14 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 15 | import { labelsFor } from "../src/core/issues.ts"; | |
| 14 | 16 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 15 | 17 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 16 | 18 | import { fleetLines, nextSort } from "../src/tui/screens/fleet.ts"; |
| @@ -137,3 +139,39 @@ test("fleetLines draws a header and one line a row, in the shared columns", () = | ||
| 137 | 139 | expect(nextSort("issue", undefined)).toEqual({ key: "repo", descending: false }); |
| 138 | 140 | expect(nextSort("issue", { key: "assignee", descending: true })).toEqual({ key: "repo", descending: false }); |
| 139 | 141 | }); |
| 142 | ||
| 143 | test("v previews a verb, Apply writes it, u undoes it; a guest is offered none of it", async () => { | |
| 144 | const alice = getUserByName(forge.db, "alice")!; | |
| 145 | const repo = await createRepo(forge, alice, { name: "board", visibility: "public" }); | |
| 146 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 147 | createLabel(forge, alice, repo.value, "urgent", "ff0000"); | |
| 148 | const issue = openIssue(forge, alice, repo.value, { title: "Fix it" }); | |
| 149 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 150 | const names = () => labelsFor(forge.db, issue.value.id).map((l) => l.name); | |
| 151 | ||
| 152 | const t = boot(alice); | |
| 153 | await t.select("issue repo:alice/board"); | |
| 154 | await t.key("v"); | |
| 155 | for (const ch of "label urgent") await t.key(ch); | |
| 156 | await t.key("enter"); | |
| 157 | expect(t.text()).toContain("Preview: label"); | |
| 158 | expect(t.text()).toContain("none -> urgent"); | |
| 159 | expect(names()).toEqual([]); | |
| 160 | await t.key("enter"); | |
| 161 | await Bun.sleep(20); | |
| 162 | await t.key("j"); | |
| 163 | expect(names()).toEqual(["urgent"]); | |
| 164 | expect(t.text()).toContain("Applied"); | |
| 165 | await t.key("u"); | |
| 166 | await t.key("enter"); | |
| 167 | await Bun.sleep(20); | |
| 168 | await t.key("j"); | |
| 169 | expect(names()).toEqual([]); | |
| 170 | expect(t.text()).toContain("Undone."); | |
| 171 | ||
| 172 | const guest = boot(undefined); | |
| 173 | await guest.select("issue repo:alice/board"); | |
| 174 | await guest.key("v"); | |
| 175 | expect(guest.text()).not.toContain("Verb"); | |
| 176 | expect(names()).toEqual([]); | |
| 177 | }); |
Both doors: the terminal has no way to preview/apply/undo; this section must gain the TUI how-to once the screen is added.