Public: anyone can read this; the crew can change it.
Fleet page UI: select many, preview, confirm, receipt — fast and transparent on desk and phone #10180
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/448-fleet-page-ui-select-many-preview-confir → main 3 commits fork
9 files
changed +321
−39
docs/features/fleet.md +2 −1
| @@ -10,6 +10,7 @@ Before a fleet operation acts, see the rows its query line selects. There are fo | ||
| 10 | 10 | |
| 11 | 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 | 12 | |
| 13 | - Each row leads with a box (`name="pick"`, belonging to the act form by `form="fleet-act"`, no script). With no box ticked the verb acts on all N matching rows, and the line above the form says so; with some ticked it acts on those alone. On a phone (`pointer: coarse`) each box is a 44px target centred in its cell (`.check` in `src/web/style/components.ts`). Picks only narrow: `pickWithin` in `src/actions/fleet.ts` keeps the ticked ids that the query selects for the viewer and drops the rest, and the preview says "n picked of N". The picks ride along to apply, and the digest binds them. | |
| 13 | 14 | - 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 | 15 | - 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 | 16 | - 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. |
| @@ -21,7 +22,7 @@ Before a fleet operation acts, see the rows its query line selects. There are fo | ||
| 21 | 22 | - It asks for the query line and shows the same table. |
| 22 | 23 | - `/` writes a new line. `s` sorts by the next column and `r` reverses the sort. `j` and `k` scroll, and `esc` goes back. |
| 23 | 24 | - 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`). | |
| 25 | - 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. `x` (or space) marks the top row in view, as a ticked box does on the page: the row in reach shows `>`, a marked one `*`, the head counts them, and `v` then acts on the marked rows alone with "n picked of N" in the dialog. A new query line clears the marks. Same operations and refusals as the page (`src/tui/screens/fleet.ts`). | |
| 25 | 26 | |
| 26 | 27 | **In the API:** `GET /api/v1/fleet/rows?q=<query line>&sort=<column>` answers `{ kind, columns, items }`: the same rows `/fleet` shows, for the caller, public repositories only when signed out. |
| 27 | 28 |
src/actions/fleet.ts +25 −3
| @@ -353,6 +353,7 @@ function makePlan( | ||
| 353 | 353 | actor: User | undefined, |
| 354 | 354 | query: string, |
| 355 | 355 | action: unknown, |
| 356 | picked: readonly string[] = [], | |
| 356 | 357 | ): Result<Plan & { verbOf: Verb; targets: Map<string, Target> }, FleetError> { |
| 357 | 358 | if (!actor) return fail("unauthorized", "sign in to act on the fleet"); |
| 358 | 359 | const parsed = parseQuery(query); |
| @@ -361,7 +362,26 @@ function makePlan( | ||
| 361 | 362 | if (!act.ok) return act; |
| 362 | 363 | const ids = selectRows(app.db, actor, parsed.value); |
| 363 | 364 | if (!ids.ok) return err(ids.error); |
| 364 | return ok(planOver(app, actor, parsed.value.kind, ids.value, query.trim(), act.value)); | |
| 365 | const rows = pickWithin(ids.value, picked); | |
| 366 | if (picked.length > 0 && rows.length === 0) return fail("bad_query", "None of the picked rows is in the selection any more."); | |
| 367 | return ok(planOver(app, actor, parsed.value.kind, rows, query.trim(), act.value)); | |
| 368 | } | |
| 369 | ||
| 370 | /** How many rows an act reaches, in words: every matching row, or the picked ones of them. */ | |
| 371 | export const pickedWords = (total: number, picked: number): string => | |
| 372 | picked === 0 | |
| 373 | ? `All ${total} matching ${total === 1 ? "row" : "rows"}` | |
| 374 | : `${picked} picked of ${total}`; | |
| 375 | ||
| 376 | /** | |
| 377 | * The selected ids narrowed to those picked, in the selection's order; no | |
| 378 | * picks means every selected row. A pick outside the selection is dropped: | |
| 379 | * picking narrows, it never widens what the query (and the viewer) can reach. | |
| 380 | */ | |
| 381 | export function pickWithin(selected: readonly string[], picked: readonly string[]): string[] { | |
| 382 | if (picked.length === 0) return [...selected]; | |
| 383 | const want = new Set(picked); | |
| 384 | return selected.filter((id) => want.has(id)); | |
| 365 | 385 | } |
| 366 | 386 | |
| 367 | 387 | /** The plan for these rows: the one place a row's before, after and refusal are decided. */ |
| @@ -484,8 +504,9 @@ export function previewFleetOp( | ||
| 484 | 504 | actor: User | undefined, |
| 485 | 505 | query: string, |
| 486 | 506 | action: unknown, |
| 507 | picked: readonly string[] = [], | |
| 487 | 508 | ): Result<Plan, FleetError> { |
| 488 | const plan = makePlan(app, actor, query, action); | |
| 509 | const plan = makePlan(app, actor, query, action, picked); | |
| 489 | 510 | if (!plan.ok) return plan; |
| 490 | 511 | const { verbOf: _verb, targets: _targets, ...shape } = plan.value; |
| 491 | 512 | return ok(shape); |
| @@ -566,10 +587,11 @@ export async function applyPreviewed( | ||
| 566 | 587 | query: string, |
| 567 | 588 | action: unknown, |
| 568 | 589 | digest: string, |
| 590 | picked: readonly string[] = [], | |
| 569 | 591 | ): Promise<Result<Operation, FleetError>> { |
| 570 | 592 | const person = requirePerson(actor); |
| 571 | 593 | if (!person.ok) return person; |
| 572 | const plan = makePlan(app, person.value, query, action); | |
| 594 | const plan = makePlan(app, person.value, query, action, picked); | |
| 573 | 595 | if (!plan.ok) return plan; |
| 574 | 596 | if (digestOf(plan.value.rows) !== digest) |
| 575 | 597 | return fail("expired", "The rows changed since the preview. Preview again to see them."); |
src/tui/screens/fleet.ts +49 −12
| @@ -6,12 +6,14 @@ | ||
| 6 | 6 | * (`fleet/columns.ts`). Keys: `/` a new query line, `s` sort by the next |
| 7 | 7 | * column, `r` reverse it, `j`/`k` scroll, `esc` back. A signed-in person (not |
| 8 | 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. | |
| 9 | * of each row's change, Apply, and `u` to undo the last result. `x` (or | |
| 10 | * space) marks the top row in view, as a ticked box does on the page: with | |
| 11 | * rows marked, `v` acts on them alone, else on every row the line selects. | |
| 12 | * The writes are `actions/fleet.ts`'s, as the page's are. | |
| 11 | 13 | */ |
| 12 | 14 | |
| 13 | 15 | import type { Operation } from "../../core/fleet_ops.ts"; |
| 14 | import { applyPreviewed, previewFleetOp, undoFleetOp, type Plan } from "../../actions/fleet.ts"; | |
| 16 | import { applyPreviewed, pickedWords, previewFleetOp, undoFleetOp, type Plan } from "../../actions/fleet.ts"; | |
| 15 | 17 | import { digestOf } from "../../fleet/digest.ts"; |
| 16 | 18 | import { actionOf } from "../../fleet/reading.ts"; |
| 17 | 19 | |
| @@ -29,11 +31,16 @@ import { confirmScreen, later, promptScreen, Scroll } from "./common.ts"; | ||
| 29 | 31 | /** No column is padded past this, so a long title does not push the rest away. */ |
| 30 | 32 | const CELL_MAX = 40; |
| 31 | 33 | |
| 32 | /** The table as text lines: a header naming the sorted column, then one line a row. */ | |
| 34 | /** | |
| 35 | * The table as text lines: a header naming the sorted column, then one line | |
| 36 | * a row. With `marked`, each line leads with `*` for a marked row and `>` | |
| 37 | * for the row at `at` (the one `x` marks). | |
| 38 | */ | |
| 33 | 39 | export function fleetLines( |
| 34 | 40 | kind: FleetKind, |
| 35 | 41 | rows: readonly FleetRow[], |
| 36 | 42 | sort: Sort | undefined, |
| 43 | marked?: { ids: ReadonlySet<string>; at: number }, | |
| 37 | 44 | ): string[] { |
| 38 | 45 | const columns = COLUMNS[kind]; |
| 39 | 46 | const sorted = sortRows(rows, sort); |
| @@ -47,12 +54,22 @@ export function fleetLines( | ||
| 47 | 54 | ); |
| 48 | 55 | const line = (cells: string[]) => |
| 49 | 56 | cells.map((t, i) => pad(truncate(t, widths[i]!), widths[i]!)).join(" ").trimEnd(); |
| 57 | const lead = (r: FleetRow | undefined, i: number): string => | |
| 58 | marked === undefined | |
| 59 | ? "" | |
| 60 | : `${i === marked.at ? ">" : " "}${r !== undefined && marked.ids.has(String(r.id)) ? "*" : " "} `; | |
| 50 | 61 | return [ |
| 51 | line(columns.map((c) => label(c.key, c.label))), | |
| 52 | ...sorted.map((r) => line(columns.map((c) => cellText(c, r) || "-"))), | |
| 62 | lead(undefined, -1) + line(columns.map((c) => label(c.key, c.label))), | |
| 63 | ...sorted.map((r, i) => lead(r, i) + line(columns.map((c) => cellText(c, r) || "-"))), | |
| 53 | 64 | ]; |
| 54 | 65 | } |
| 55 | 66 | |
| 67 | /** The id of the row at `at` in `rows` as the table sorts them. */ | |
| 68 | export function rowAt(rows: readonly FleetRow[], sort: Sort | undefined, at: number): string | undefined { | |
| 69 | const row = sortRows(rows, sort)[at]; | |
| 70 | return row === undefined ? undefined : String(row.id); | |
| 71 | } | |
| 72 | ||
| 56 | 73 | /** |
| 57 | 74 | * The grammar as the web page shows it (`fleet/selection.ts`): the sentence |
| 58 | 75 | * wrapped to `width`, one line a kind naming its fields, then the examples. |
| @@ -102,11 +119,15 @@ export function fleetScreen( | ||
| 102 | 119 | let lines: string[] = []; |
| 103 | 120 | let note = ""; |
| 104 | 121 | let last: Operation | undefined; |
| 122 | /** Marked row ids; cleared with each new query line. */ | |
| 123 | const marked = new Set<string>(); | |
| 105 | 124 | const screen: Screen = { |
| 106 | 125 | title: "Fleet", |
| 107 | 126 | keys: [ |
| 108 | 127 | binding(["/"], "query"), |
| 109 | ...(mayAct(session.user) ? [binding(["v"], "verb"), binding(["u"], "undo")] : []), | |
| 128 | ...(mayAct(session.user) | |
| 129 | ? [binding(["x"], "mark"), binding(["v"], "verb"), binding(["u"], "undo")] | |
| 130 | : []), | |
| 110 | 131 | binding(["s"], "sort"), |
| 111 | 132 | binding(["r"], "reverse"), |
| 112 | 133 | binding(["j", "k"], "scroll"), |
| @@ -118,7 +139,7 @@ export function fleetScreen( | ||
| 118 | 139 | view === undefined |
| 119 | 140 | ? "/ to write a query line: repo autonomy:autonomous" |
| 120 | 141 | : view.ok |
| 121 | ? `${query} ${view.rows.length} ${view.rows.length === 1 ? "row" : "rows"}` | |
| 142 | ? `${query} ${view.rows.length} ${view.rows.length === 1 ? "row" : "rows"}${marked.size === 0 ? "" : `, ${marked.size} marked`}` | |
| 122 | 143 | : `${query} ${view.error}`; |
| 123 | 144 | const shown = note === "" ? head : `${head} ${note}`; |
| 124 | 145 | 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); |
| @@ -129,7 +150,9 @@ export function fleetScreen( | ||
| 129 | 150 | s.text(area.x + 1, area.y + 2 + i, truncate(text, area.w - 2), style(c.mutedFg, c.bg), area.x + area.w); |
| 130 | 151 | }); |
| 131 | 152 | } |
| 132 | lines = view?.ok ? fleetLines(view.kind, view.rows, sort) : []; | |
| 153 | lines = view?.ok | |
| 154 | ? fleetLines(view.kind, view.rows, sort, mayAct(session.user) ? { ids: marked, at: scroll.top } : undefined) | |
| 155 | : []; | |
| 133 | 156 | const [header, ...body] = lines; |
| 134 | 157 | if (header !== undefined && area.h > 2) |
| 135 | 158 | s.text(area.x + 1, area.y + 2, truncate(header, area.w - 2), style(c.mutedFg, c.bg), area.x + area.w); |
| @@ -145,6 +168,7 @@ export function fleetScreen( | ||
| 145 | 168 | if (e.ctrl || e.alt) return scroll.key(e, Math.max(0, lines.length - 1)); |
| 146 | 169 | if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 147 | 170 | else if (e.key === "/") ask(); |
| 171 | else if ((e.key === "x" || e.key === " ") && view?.ok && mayAct(session.user)) mark(view.rows); | |
| 148 | 172 | else if (e.key === "v" && view?.ok && mayAct(session.user)) verb(); |
| 149 | 173 | else if (e.key === "u" && last !== undefined && mayAct(session.user)) undo(); |
| 150 | 174 | else if (e.key === "s" && view?.ok) sort = nextSort(view.kind, sort); |
| @@ -153,6 +177,12 @@ export function fleetScreen( | ||
| 153 | 177 | return true; |
| 154 | 178 | }, |
| 155 | 179 | }; |
| 180 | const mark = (rows: readonly FleetRow[]) => { | |
| 181 | const id = rowAt(rows, sort, scroll.top); | |
| 182 | if (id === undefined) return; | |
| 183 | if (marked.has(id)) marked.delete(id); | |
| 184 | else marked.add(id); | |
| 185 | }; | |
| 156 | 186 | const refresh = () => { |
| 157 | 187 | if (query !== "") view = viewFleet(session.db, session.user, query); |
| 158 | 188 | }; |
| @@ -187,19 +217,25 @@ export function fleetScreen( | ||
| 187 | 217 | const value = rest.join(" "); |
| 188 | 218 | if (name === "") return "name a verb"; |
| 189 | 219 | const action = actionOf(session.app, name, value); |
| 190 | const plan = previewFleetOp(session.app, session.user, query, action); | |
| 220 | const picked = [...marked]; | |
| 221 | const plan = previewFleetOp(session.app, session.user, query, action, picked); | |
| 191 | 222 | if (!plan.ok) return plan.error.message; |
| 192 | 223 | const p = plan.value; |
| 193 | 224 | const n = p.counts.change; |
| 225 | const total = view?.ok ? view.rows.length : p.rows.length; | |
| 194 | 226 | return later(ctx, () => |
| 195 | 227 | confirmScreen(ctx, screen, { |
| 196 | 228 | title: `Preview: ${p.verb}`, |
| 197 | body: [...planLines(p, 70), "", n === 0 ? "Nothing to apply." : "Nothing is written until you apply."], | |
| 229 | body: [ | |
| 230 | ...planLines(p, 70), | |
| 231 | "", | |
| 232 | `${pickedWords(total, picked.length === 0 ? 0 : p.rows.length)}. ${n === 0 ? "Nothing to apply." : "Nothing is written until you apply."}`, | |
| 233 | ], | |
| 198 | 234 | actions: n === 0 ? [{ label: "Close" }] : [{ label: `Apply to ${n} ${n === 1 ? "row" : "rows"}` }, { label: "Cancel", variant: "outline" }], |
| 199 | 235 | width: 76, |
| 200 | 236 | onChoose: (i) => { |
| 201 | 237 | if (i !== 0 || n === 0) return; |
| 202 | void applyPreviewed(session.app, session.user, query, action, digestOf(p.rows)).then((r) => { | |
| 238 | void applyPreviewed(session.app, session.user, query, action, digestOf(p.rows), picked).then((r) => { | |
| 203 | 239 | note = r.ok ? "Applied. u undoes." : r.error.message; |
| 204 | 240 | if (r.ok) last = r.value; |
| 205 | 241 | refresh(); |
| @@ -224,6 +260,7 @@ export function fleetScreen( | ||
| 224 | 260 | if (!next.ok) return next.error; |
| 225 | 261 | query = text; |
| 226 | 262 | view = next; |
| 263 | marked.clear(); | |
| 227 | 264 | sort = undefined; |
| 228 | 265 | scroll.to(0, 0); |
| 229 | 266 | return undefined; |
src/web/fleet-act.ts +31 −9
| @@ -4,12 +4,12 @@ | ||
| 4 | 4 | * and operations in, HTML out. The writes are `actions/fleet.ts`'s. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { pickedWords, UNDO_WINDOW_MS, type Plan } from "../actions/fleet.ts"; | |
| 7 | 8 | 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 | 9 | import { digestOf } from "../fleet/digest.ts"; |
| 11 | import { VERBS } from "../fleet/verbs.ts"; | |
| 12 | 10 | import type { FleetKind } from "../fleet/selection.ts"; |
| 11 | import { VERBS } from "../fleet/verbs.ts"; | |
| 12 | import { ACT_FORM } from "./fleet-table.ts"; | |
| 13 | 13 | import { html, type Html } from "./html.ts"; |
| 14 | 14 | import { btn, field, selectField } from "./ui.ts"; |
| 15 | 15 | |
| @@ -27,9 +27,19 @@ export const valueWords = (value: FleetValue): string => | ||
| 27 | 27 | export const verbsOf = (kind: FleetKind): string[] => |
| 28 | 28 | Object.keys(VERBS).filter((v) => VERBS[v]!.kind === kind); |
| 29 | 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> | |
| 30 | /** The picks a later step carries forward, as hidden fields. */ | |
| 31 | const carried = (picked: readonly string[]): Html[] => | |
| 32 | picked.map((id) => html`<input type="hidden" name="pick" value="${id}" />`); | |
| 33 | ||
| 34 | /** | |
| 35 | * The form that asks for a verb and its value; posts to the preview. The | |
| 36 | * table's boxes belong to it by `form=`; none ticked means every row. | |
| 37 | */ | |
| 38 | export function actForm(kind: FleetKind, q: string, chosen = "", value = "", total = 0): Html { | |
| 39 | return html`<p class="muted small" data-scope> | |
| 40 | Acts on all ${total} matching ${total === 1 ? "row" : "rows"}, or only on the rows you tick. | |
| 41 | </p> | |
| 42 | <form method="post" action="/fleet" class="field-row mb" id="${ACT_FORM}" data-act> | |
| 33 | 43 | <input type="hidden" name="q" value="${q}" /> |
| 34 | 44 | <input type="hidden" name="step" value="preview" /> |
| 35 | 45 | ${selectField({ |
| @@ -78,12 +88,23 @@ function lines(rows: readonly Line[]): Html { | ||
| 78 | 88 | )}</ul>`; |
| 79 | 89 | } |
| 80 | 90 | |
| 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 { | |
| 91 | /** | |
| 92 | * The dry run: each row's change, and one button to apply it. Nothing is | |
| 93 | * written yet. With rows picked, it says how many of the `total` matching | |
| 94 | * ones it covers; without, the line above the form already said "all". | |
| 95 | */ | |
| 96 | export function previewSection( | |
| 97 | plan: Plan, | |
| 98 | rawValue: string, | |
| 99 | picked: readonly string[], | |
| 100 | total: number, | |
| 101 | ): Html { | |
| 83 | 102 | const { change } = plan.counts; |
| 84 | 103 | return html`<section data-preview> |
| 85 | 104 | <h2>Preview: ${plan.verb}</h2> |
| 86 | <p class="muted">Nothing is written until you apply.</p> | |
| 105 | <p class="muted"> | |
| 106 | ${picked.length === 0 ? null : html`<span data-preview-scope>${pickedWords(total, plan.rows.length)}.</span> `}Nothing is written until you apply. | |
| 107 | </p> | |
| 87 | 108 | ${lines(plan.rows)} |
| 88 | 109 | ${change === 0 |
| 89 | 110 | ? html`<p class="muted">Nothing to apply.</p>` |
| @@ -93,6 +114,7 @@ export function previewSection(plan: Plan, rawValue: string): Html { | ||
| 93 | 114 | <input type="hidden" name="verb" value="${plan.verb}" /> |
| 94 | 115 | <input type="hidden" name="value" value="${rawValue}" /> |
| 95 | 116 | <input type="hidden" name="digest" value="${digestOf(plan.rows)}" /> |
| 117 | ${carried(picked)} | |
| 96 | 118 | ${btn(`Apply to ${change} ${change === 1 ? "row" : "rows"}`, { type: "submit" })} |
| 97 | 119 | </form>`} |
| 98 | 120 | </section>`; |
src/web/fleet-table.ts +19 −3
| @@ -31,15 +31,31 @@ function cell(column: Column, row: FleetRow): Html | string { | ||
| 31 | 31 | return to === undefined ? text : html`<a href="${to}">${text}</a>`; |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | /** The act form a row's box belongs to (`form="…"`): forms do not nest. */ | |
| 35 | export const ACT_FORM = "fleet-act"; | |
| 36 | ||
| 37 | /** A row's box: ticked rows narrow what the act form acts on. */ | |
| 38 | const pickBox = (row: FleetRow, picked: ReadonlySet<string>): Html => | |
| 39 | html`<td><label class="check"><input | |
| 40 | type="checkbox" | |
| 41 | name="pick" | |
| 42 | value="${String(row.id)}" | |
| 43 | form="${ACT_FORM}" | |
| 44 | ${picked.has(String(row.id)) ? html`checked` : null} | |
| 45 | aria-label="Pick ${String(row.repo)}${row.number === undefined ? "" : ` #${row.number}`}" | |
| 46 | /></label></td>`; | |
| 47 | ||
| 34 | 48 | /** |
| 35 | 49 | * The table for `rows` of `kind`, its headers linking to `?q=…&sort=…`. |
| 36 | * The sorted column's link flips its direction. | |
| 50 | * The sorted column's link flips its direction. With `picked`, each row | |
| 51 | * leads with a box that belongs to the act form. | |
| 37 | 52 | */ |
| 38 | 53 | export function fleetTable( |
| 39 | 54 | kind: FleetKind, |
| 40 | 55 | query: string, |
| 41 | 56 | rows: readonly FleetRow[], |
| 42 | 57 | sort: Sort | undefined, |
| 58 | picked?: ReadonlySet<string>, | |
| 43 | 59 | ): Html { |
| 44 | 60 | const columns = COLUMNS[kind]; |
| 45 | 61 | const sorted = sortRows(rows, sort); |
| @@ -54,11 +70,11 @@ export function fleetTable( | ||
| 54 | 70 | return html`<div class="table-scroll"> |
| 55 | 71 | <table class="table" data-dense> |
| 56 | 72 | <caption class="sr-only">The ${kind} rows the query selects</caption> |
| 57 | <thead><tr>${columns.map(head)}</tr></thead> | |
| 73 | <thead><tr>${picked === undefined ? null : html`<th scope="col"><span class="sr-only">Pick</span></th>`}${columns.map(head)}</tr></thead> | |
| 58 | 74 | <tbody> |
| 59 | 75 | ${sorted.map( |
| 60 | 76 | (row) => |
| 61 | html`<tr>${columns.map( | |
| 77 | html`<tr>${picked === undefined ? null : pickBox(row, picked)}${columns.map( | |
| 62 | 78 | (c) => html`<td${c.num ? html` class="num"` : ""}>${cell(c, row)}</td>`, |
| 63 | 79 | )}</tr>`, |
| 64 | 80 | )} |
src/web/pages/fleet-ops.ts +46 −7
| @@ -13,10 +13,12 @@ import { | ||
| 13 | 13 | undoFleetOp, |
| 14 | 14 | } from "../../actions/fleet.ts"; |
| 15 | 15 | import { actionOf } from "../../fleet/reading.ts"; |
| 16 | import { viewFleet } from "../../fleet/rows.ts"; | |
| 17 | import { MAX_ROWS } from "../../fleet/selection.ts"; | |
| 16 | 18 | import type { Ctx } from "../../http/context.ts"; |
| 17 | 19 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 18 | 20 | import { previewSection, receiptSection, undoable } from "../fleet-act.ts"; |
| 19 | import { field, readForm } from "../form.ts"; | |
| 21 | import { field, readForm, type Form } from "../form.ts"; | |
| 20 | 22 | import { html } from "../html.ts"; |
| 21 | 23 | import { flashTo, page } from "../layout.ts"; |
| 22 | 24 | import { canAct, renderFleet } from "./fleet.ts"; |
| @@ -31,21 +33,58 @@ export async function fleetAct(ctx: Ctx): Promise<Response> { | ||
| 31 | 33 | const verb = field(form, "verb").slice(0, 40); |
| 32 | 34 | const text = field(form, "value").slice(0, 2000); |
| 33 | 35 | const action = actionOf(ctx.app, verb, text); |
| 36 | const picked = picksOf(form); | |
| 37 | if (picked === undefined) | |
| 38 | return refusal(ctx, q, verb, text, `Pick at most ${MAX_ROWS} rows.`, []); | |
| 34 | 39 | 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); | |
| 40 | const done = await applyPreviewed(ctx.app, ctx.auth.user, q, action, field(form, "digest").slice(0, 64), picked); | |
| 41 | if (!done.ok) return refusal(ctx, q, verb, text, done.error.message, picked); | |
| 37 | 42 | return flashTo(ctx, `/fleet/ops/${done.value.id}`, { message: "Applied." }); |
| 38 | 43 | } |
| 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 }); | |
| 44 | const plan = previewFleetOp(ctx.app, ctx.auth.user, q, action, picked); | |
| 45 | if (!plan.ok) return refusal(ctx, q, verb, text, plan.error.message, picked); | |
| 46 | return renderFleet(ctx, q, { | |
| 47 | html: previewSection(plan.value, text, picked, matching(ctx, q)), | |
| 48 | verb, | |
| 49 | value: text, | |
| 50 | picked, | |
| 51 | }); | |
| 42 | 52 | } |
| 43 | 53 | |
| 44 | function refusal(ctx: Ctx, q: string, verb: string, value: string, message: string): Response { | |
| 54 | const ID = /^[0-9A-Za-z_-]{1,64}$/; | |
| 55 | ||
| 56 | /** | |
| 57 | * The ticked rows' ids, distinct, each shaped like an id; `undefined` when | |
| 58 | * there are more than a selection can hold. Whether each is in the | |
| 59 | * selection is `pickWithin`'s to decide, so nothing here widens it. | |
| 60 | */ | |
| 61 | export function picksOf(form: Form): string[] | undefined { | |
| 62 | const all = form.getAll("pick"); | |
| 63 | if (all.length > MAX_ROWS) return undefined; | |
| 64 | const ids = new Set<string>(); | |
| 65 | for (const v of all) if (typeof v === "string" && ID.test(v)) ids.add(v); | |
| 66 | return [...ids]; | |
| 67 | } | |
| 68 | ||
| 69 | /** How many rows the query line selects for this viewer. */ | |
| 70 | const matching = (ctx: Ctx, q: string): number => { | |
| 71 | const view = viewFleet(ctx.app.db, ctx.auth.user, q); | |
| 72 | return view.ok ? view.rows.length : 0; | |
| 73 | }; | |
| 74 | ||
| 75 | function refusal( | |
| 76 | ctx: Ctx, | |
| 77 | q: string, | |
| 78 | verb: string, | |
| 79 | value: string, | |
| 80 | message: string, | |
| 81 | picked: readonly string[], | |
| 82 | ): Response { | |
| 45 | 83 | return renderFleet(ctx, q, { |
| 46 | 84 | html: html`<p class="muted" data-refused>${message}</p>`, |
| 47 | 85 | verb, |
| 48 | 86 | value, |
| 87 | picked, | |
| 49 | 88 | }); |
| 50 | 89 | } |
| 51 | 90 |
src/web/pages/fleet.ts +5 −4
| @@ -27,13 +27,14 @@ export function fleetPage(ctx: Ctx): Response { | ||
| 27 | 27 | export function renderFleet( |
| 28 | 28 | ctx: Ctx, |
| 29 | 29 | q: string, |
| 30 | below?: { html: Html; verb?: string; value?: string }, | |
| 30 | below?: { html: Html; verb?: string; value?: string; picked?: readonly string[] }, | |
| 31 | 31 | ): Response { |
| 32 | 32 | const view: FleetView | undefined = |
| 33 | 33 | q.trim() === "" ? undefined : viewFleet(ctx.app.db, ctx.auth.user, q); |
| 34 | 34 | const rendering = performance.now(); |
| 35 | 35 | const shown = view?.ok ? view : undefined; |
| 36 | 36 | const sort = shown && readSort(shown.kind, ctx.url.searchParams.get("sort")); |
| 37 | const acting = shown !== undefined && shown.rows.length > 0 && canAct(ctx); | |
| 37 | 38 | const response = page({ |
| 38 | 39 | ctx, |
| 39 | 40 | title: "Fleet", |
| @@ -75,10 +76,10 @@ export function renderFleet( | ||
| 75 | 76 | : html`<p class="muted" data-count="${shown.rows.length}"> |
| 76 | 77 | ${shown.rows.length} ${shown.rows.length === 1 ? "row" : "rows"} |
| 77 | 78 | </p> |
| 78 | ${shown.rows.length === 0 ? null : fleetTable(shown.kind, q, shown.rows, sort)} | |
| 79 | ${shown.rows.length === 0 || !canAct(ctx) | |
| 79 | ${shown.rows.length === 0 | |
| 80 | 80 | ? null |
| 81 | : actForm(shown.kind, q, below?.verb, below?.value)}`} | |
| 81 | : fleetTable(shown.kind, q, shown.rows, sort, acting ? new Set(below?.picked ?? []) : undefined)} | |
| 82 | ${acting ? actForm(shown.kind, q, below?.verb, below?.value, shown.rows.length) : null}`} | |
| 82 | 83 | ${below?.html ?? null}`, |
| 83 | 84 | }); |
| 84 | 85 | notePhase(ctx.request, "render", performance.now() - rendering); |
test/fleet-act.test.ts +92
| @@ -18,6 +18,8 @@ import { labelsFor } from "../src/core/issues.ts"; | ||
| 18 | 18 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 19 | 19 | import { serve, type Servers } from "../src/http/server.ts"; |
| 20 | 20 | import { promote } from "./helpers/members.ts"; |
| 21 | import { MAX_ROWS } from "../src/fleet/selection.ts"; | |
| 22 | import { components } from "../src/web/style/components.ts"; | |
| 21 | 23 | |
| 22 | 24 | describe("fleet verbs on the web", () => { |
| 23 | 25 | let dataDir: string; |
| @@ -202,4 +204,94 @@ describe("fleet verbs on the web", () => { | ||
| 202 | 204 | expect(ok.status).toBe(303); |
| 203 | 205 | expect(labelsFor(app.db, second.value.id).map((l) => l.name)).toEqual(["urgent"]); |
| 204 | 206 | }); |
| 207 | ||
| 208 | /** Each `<input name="pick">` on a page, as its attributes. */ | |
| 209 | const pickBoxes = (page: string) => | |
| 210 | [...page.matchAll(/<input\b([^>]*\bname="pick"[^>]*)>/g)].map((m) => { | |
| 211 | const attrs: Record<string, string | true> = {}; | |
| 212 | for (const a of m[1]!.matchAll(/([a-z-]+)(?:="([^"]*)")?/g)) attrs[a[1]!] = a[2] ?? true; | |
| 213 | return attrs; | |
| 214 | }); | |
| 215 | const send = (b: URLSearchParams) => | |
| 216 | fetch(`${servers.url}/fleet`, { | |
| 217 | method: "POST", redirect: "manual", | |
| 218 | headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, | |
| 219 | body: b, | |
| 220 | }); | |
| 221 | const pickRepo = async (name: string, count: number) => { | |
| 222 | const alice = getUserByName(app.db, "alice")!; | |
| 223 | const repo = await createRepo(app, alice, { name, visibility: "public" }); | |
| 224 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 225 | createLabel(app, alice, repo.value, "urgent", "ff0000"); | |
| 226 | return Array.from({ length: count }, (_, n) => { | |
| 227 | const one = openIssue(app, alice, repo.value, { title: `Pick ${n}` }); | |
| 228 | if (!one.ok) throw new Error(String(one.error)); | |
| 229 | return one.value.id; | |
| 230 | }); | |
| 231 | }; | |
| 232 | ||
| 233 | test("ticked rows narrow the act to themselves; a pick outside the selection never widens it", async () => { | |
| 234 | const made = await pickRepo("picks", 3); | |
| 235 | const bob = getUserByName(app.db, "bob")!; | |
| 236 | const outside = openIssue(app, bob, getRepo(app.db, "bob", "other")!, { title: "Not selected" }); | |
| 237 | if (!outside.ok) throw new Error(String(outside.error)); | |
| 238 | const pq = "issue repo:alice/picks"; | |
| 239 | ||
| 240 | const page = await (await fetch(`${servers.url}/fleet?${new URLSearchParams({ q: pq })}`, { headers: { cookie } })).text(); | |
| 241 | const boxes = pickBoxes(page).filter((b) => b.type === "checkbox"); | |
| 242 | expect(boxes.map((b) => b.value).sort()).toEqual([...made].sort()); | |
| 243 | expect(boxes.every((b) => b.form === "fleet-act" && b.checked === undefined)).toBe(true); | |
| 244 | expect(page).toContain('id="fleet-act"'); | |
| 245 | expect(page).toContain("Acts on all 3 matching rows"); | |
| 246 | const reader = await (await fetch(`${servers.url}/fleet?${new URLSearchParams({ q: pq })}`)).text(); | |
| 247 | expect(pickBoxes(reader)).toEqual([]); | |
| 248 | ||
| 249 | const junk = "<script>x</script>"; | |
| 250 | const body = new URLSearchParams({ q: pq, verb: "label", value: "urgent", step: "preview" }); | |
| 251 | for (const id of [made[1]!, outside.value.id, junk]) body.append("pick", id); | |
| 252 | const preview = await (await send(body)).text(); | |
| 253 | expect(preview).toContain("Apply to 1 row"); | |
| 254 | expect(preview).toContain("1 picked of 3"); | |
| 255 | expect(preview).not.toContain("script>x"); | |
| 256 | const shown = pickBoxes(preview); | |
| 257 | expect(shown.find((b) => b.type === "checkbox" && b.value === made[1])?.checked).toBe(true); | |
| 258 | expect(shown.filter((b) => b.type === "checkbox" && b.checked === true).length).toBe(1); | |
| 259 | expect(shown.filter((b) => b.type === "hidden").map((b) => b.value)).toEqual([made[1]!, outside.value.id]); | |
| 260 | const digest = /name="digest" value="([0-9a-f]+)"/.exec(preview)![1]!; | |
| 261 | body.set("step", "apply"); | |
| 262 | body.set("digest", digest); | |
| 263 | const applied = await send(body); | |
| 264 | expect(applied.status).toBe(303); | |
| 265 | expect(made.map((id) => labelsFor(app.db, id).map((l) => l.name))).toEqual([[], ["urgent"], []]); | |
| 266 | expect(labelsFor(app.db, outside.value.id)).toEqual([]); | |
| 267 | ||
| 268 | const none = new URLSearchParams({ q: pq, verb: "label", value: "urgent", step: "preview", pick: outside.value.id }); | |
| 269 | expect(await (await send(none)).text()).toContain("None of the picked rows is in the selection"); | |
| 270 | }); | |
| 271 | ||
| 272 | test("apply with picks other than the previewed ones is refused as expired, and writes nothing", async () => { | |
| 273 | const made = await pickRepo("swap", 2); | |
| 274 | const pq = "issue repo:alice/swap"; | |
| 275 | const body = new URLSearchParams({ q: pq, verb: "label", value: "urgent", step: "preview", pick: made[0]! }); | |
| 276 | const digest = /name="digest" value="([0-9a-f]+)"/.exec(await (await send(body)).text())![1]!; | |
| 277 | const swapped = new URLSearchParams({ q: pq, verb: "label", value: "urgent", step: "apply", digest, pick: made[1]! }); | |
| 278 | const res = await send(swapped); | |
| 279 | expect(res.status).toBe(200); | |
| 280 | expect(await res.text()).toContain("rows changed since the preview"); | |
| 281 | expect(made.map((id) => labelsFor(app.db, id))).toEqual([[], []]); | |
| 282 | }); | |
| 283 | ||
| 284 | test("more picks than a selection can hold are refused before anything is read", async () => { | |
| 285 | const body = new URLSearchParams({ q, verb: "label", value: "urgent", step: "preview" }); | |
| 286 | for (let i = 0; i <= MAX_ROWS; i++) body.append("pick", `id${i}`); | |
| 287 | const page = await (await send(body)).text(); | |
| 288 | expect(page).toContain(`Pick at most ${MAX_ROWS} rows.`); | |
| 289 | expect(page).not.toContain("data-preview"); | |
| 290 | }); | |
| 291 | ||
| 292 | test("on a phone the row's box is a 44px target, centred in its cell", () => { | |
| 293 | const coarse = [...components.matchAll(/@media \(pointer: coarse\) \{([\s\S]*?)\n\}/g)].map((m) => m[1]).join("\n"); | |
| 294 | expect(coarse).toMatch(/\.check \{[^}]*min-height: 44px;[^}]*min-width: 44px;/); | |
| 295 | expect(coarse).toMatch(/\.table \.check \{[^}]*justify-content: center;/); | |
| 296 | }); | |
| 205 | 297 | }); |
test/tui-fleet.test.ts +52
| @@ -175,3 +175,55 @@ test("v previews a verb, Apply writes it, u undoes it; a guest is offered none o | ||
| 175 | 175 | expect(guest.text()).not.toContain("Verb"); |
| 176 | 176 | expect(names()).toEqual([]); |
| 177 | 177 | }); |
| 178 | ||
| 179 | test("x marks the top row in view; v then acts on the marked rows alone, and says so", async () => { | |
| 180 | const alice = getUserByName(forge.db, "alice")!; | |
| 181 | const repo = await createRepo(forge, alice, { name: "marks", visibility: "public" }); | |
| 182 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 183 | createLabel(forge, alice, repo.value, "urgent", "ff0000"); | |
| 184 | const ids = ["One", "Two", "Three"].map((title) => { | |
| 185 | const made = openIssue(forge, alice, repo.value, { title }); | |
| 186 | if (!made.ok) throw new Error(String(made.error)); | |
| 187 | return made.value.id; | |
| 188 | }); | |
| 189 | const labelled = () => ids.map((id) => labelsFor(forge.db, id).map((l) => l.name)); | |
| 190 | ||
| 191 | const t = boot(alice); | |
| 192 | await t.select("issue repo:alice/marks"); | |
| 193 | await t.key("j"); | |
| 194 | await t.key("x"); | |
| 195 | expect(t.text()).toContain("3 rows, 1 marked"); | |
| 196 | expect(t.text()).toMatch(/>\* alice\/marks\s+2\s+Two/); | |
| 197 | await t.key("v"); | |
| 198 | for (const ch of "label urgent") await t.key(ch); | |
| 199 | await t.key("enter"); | |
| 200 | expect(t.text()).toContain("1 picked of 3"); | |
| 201 | expect(t.text()).toContain("Apply to 1 row"); | |
| 202 | await t.key("enter"); | |
| 203 | await Bun.sleep(20); | |
| 204 | await t.key("k"); | |
| 205 | expect(labelled()).toEqual([[], ["urgent"], []]); | |
| 206 | ||
| 207 | // x again on the same row unmarks it. | |
| 208 | await t.key("j"); | |
| 209 | await t.key("x"); | |
| 210 | expect(t.text()).toContain("3 rows "); | |
| 211 | expect(t.text()).not.toContain("marked"); | |
| 212 | ||
| 213 | const guest = boot(undefined); | |
| 214 | await guest.select("issue repo:alice/marks"); | |
| 215 | await guest.key("x"); | |
| 216 | expect(guest.text()).not.toContain("marked"); | |
| 217 | }); | |
| 218 | ||
| 219 | test("fleetLines leads each row with its mark and the row x would mark", () => { | |
| 220 | const rows = [ | |
| 221 | { id: "a", repo: "o/r", number: 1, title: "A", state: "open", assignee: "" }, | |
| 222 | { id: "b", repo: "o/r", number: 2, title: "B", state: "open", assignee: "" }, | |
| 223 | ]; | |
| 224 | const [head, first, second] = fleetLines("issue", rows, undefined, { ids: new Set(["b"]), at: 0 }); | |
| 225 | expect(head!.startsWith(" ")).toBe(true); | |
| 226 | expect(first!.startsWith("> o/r")).toBe(true); | |
| 227 | expect(second!.startsWith(" * o/r")).toBe(true); | |
| 228 | expect(fleetLines("issue", rows, undefined)[1]!.startsWith("o/r")).toBe(true); | |
| 229 | }); |