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

Merged opened this in answer to

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

Merged

scout/forge:scout/448-fleet-page-ui-select-many-preview-confir → main 3 commits fork

  • Merged. scout/448-fleet-page-ui-select-many-preview-confir was merged into main as 7a2dc51.

Review

Sign in to review this change.
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
1010
1111 **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`).
1212
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.
1314 - 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.
1415 - 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.
1516 - 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
2122 - It asks for the query line and shows the same table.
2223 - `/` writes a new line. `s` sorts by the next column and `r` reverses the sort. `j` and `k` scroll, and `esc` goes back.
2324 - 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`).
2526
2627 **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.
2728
src/actions/fleet.ts +25 −3
@@ -353,6 +353,7 @@ function makePlan(
353353 actor: User | undefined,
354354 query: string,
355355 action: unknown,
356 picked: readonly string[] = [],
356357 ): Result<Plan & { verbOf: Verb; targets: Map<string, Target> }, FleetError> {
357358 if (!actor) return fail("unauthorized", "sign in to act on the fleet");
358359 const parsed = parseQuery(query);
@@ -361,7 +362,26 @@ function makePlan(
361362 if (!act.ok) return act;
362363 const ids = selectRows(app.db, actor, parsed.value);
363364 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));
365385 }
366386
367387 /** The plan for these rows: the one place a row's before, after and refusal are decided. */
@@ -484,8 +504,9 @@ export function previewFleetOp(
484504 actor: User | undefined,
485505 query: string,
486506 action: unknown,
507 picked: readonly string[] = [],
487508 ): Result<Plan, FleetError> {
488 const plan = makePlan(app, actor, query, action);
509 const plan = makePlan(app, actor, query, action, picked);
489510 if (!plan.ok) return plan;
490511 const { verbOf: _verb, targets: _targets, ...shape } = plan.value;
491512 return ok(shape);
@@ -566,10 +587,11 @@ export async function applyPreviewed(
566587 query: string,
567588 action: unknown,
568589 digest: string,
590 picked: readonly string[] = [],
569591 ): Promise<Result<Operation, FleetError>> {
570592 const person = requirePerson(actor);
571593 if (!person.ok) return person;
572 const plan = makePlan(app, person.value, query, action);
594 const plan = makePlan(app, person.value, query, action, picked);
573595 if (!plan.ok) return plan;
574596 if (digestOf(plan.value.rows) !== digest)
575597 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 @@
66 * (`fleet/columns.ts`). Keys: `/` a new query line, `s` sort by the next
77 * column, `r` reverse it, `j`/`k` scroll, `esc` back. A signed-in person (not
88 * 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.
1113 */
1214
1315 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";
1517 import { digestOf } from "../../fleet/digest.ts";
1618 import { actionOf } from "../../fleet/reading.ts";
1719
@@ -29,11 +31,16 @@ import { confirmScreen, later, promptScreen, Scroll } from "./common.ts";
2931 /** No column is padded past this, so a long title does not push the rest away. */
3032 const CELL_MAX = 40;
3133
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 */
3339 export function fleetLines(
3440 kind: FleetKind,
3541 rows: readonly FleetRow[],
3642 sort: Sort | undefined,
43 marked?: { ids: ReadonlySet<string>; at: number },
3744 ): string[] {
3845 const columns = COLUMNS[kind];
3946 const sorted = sortRows(rows, sort);
@@ -47,12 +54,22 @@ export function fleetLines(
4754 );
4855 const line = (cells: string[]) =>
4956 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)) ? "*" : " "} `;
5061 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) || "-"))),
5364 ];
5465 }
5566
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
5673 /**
5774 * The grammar as the web page shows it (`fleet/selection.ts`): the sentence
5875 * wrapped to `width`, one line a kind naming its fields, then the examples.
@@ -102,11 +119,15 @@ export function fleetScreen(
102119 let lines: string[] = [];
103120 let note = "";
104121 let last: Operation | undefined;
122 /** Marked row ids; cleared with each new query line. */
123 const marked = new Set<string>();
105124 const screen: Screen = {
106125 title: "Fleet",
107126 keys: [
108127 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 : []),
110131 binding(["s"], "sort"),
111132 binding(["r"], "reverse"),
112133 binding(["j", "k"], "scroll"),
@@ -118,7 +139,7 @@ export function fleetScreen(
118139 view === undefined
119140 ? "/ to write a query line: repo autonomy:autonomous"
120141 : 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`}`
122143 : `${query} ${view.error}`;
123144 const shown = note === "" ? head : `${head} ${note}`;
124145 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(
129150 s.text(area.x + 1, area.y + 2 + i, truncate(text, area.w - 2), style(c.mutedFg, c.bg), area.x + area.w);
130151 });
131152 }
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 : [];
133156 const [header, ...body] = lines;
134157 if (header !== undefined && area.h > 2)
135158 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(
145168 if (e.ctrl || e.alt) return scroll.key(e, Math.max(0, lines.length - 1));
146169 if (e.key === "esc" || e.key === "q") ctx.pop();
147170 else if (e.key === "/") ask();
171 else if ((e.key === "x" || e.key === " ") && view?.ok && mayAct(session.user)) mark(view.rows);
148172 else if (e.key === "v" && view?.ok && mayAct(session.user)) verb();
149173 else if (e.key === "u" && last !== undefined && mayAct(session.user)) undo();
150174 else if (e.key === "s" && view?.ok) sort = nextSort(view.kind, sort);
@@ -153,6 +177,12 @@ export function fleetScreen(
153177 return true;
154178 },
155179 };
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 };
156186 const refresh = () => {
157187 if (query !== "") view = viewFleet(session.db, session.user, query);
158188 };
@@ -187,19 +217,25 @@ export function fleetScreen(
187217 const value = rest.join(" ");
188218 if (name === "") return "name a verb";
189219 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);
191222 if (!plan.ok) return plan.error.message;
192223 const p = plan.value;
193224 const n = p.counts.change;
225 const total = view?.ok ? view.rows.length : p.rows.length;
194226 return later(ctx, () =>
195227 confirmScreen(ctx, screen, {
196228 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 ],
198234 actions: n === 0 ? [{ label: "Close" }] : [{ label: `Apply to ${n} ${n === 1 ? "row" : "rows"}` }, { label: "Cancel", variant: "outline" }],
199235 width: 76,
200236 onChoose: (i) => {
201237 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) => {
203239 note = r.ok ? "Applied. u undoes." : r.error.message;
204240 if (r.ok) last = r.value;
205241 refresh();
@@ -224,6 +260,7 @@ export function fleetScreen(
224260 if (!next.ok) return next.error;
225261 query = text;
226262 view = next;
263 marked.clear();
227264 sort = undefined;
228265 scroll.to(0, 0);
229266 return undefined;
src/web/fleet-act.ts +31 −9
@@ -4,12 +4,12 @@
44 * and operations in, HTML out. The writes are `actions/fleet.ts`'s.
55 */
66
7 import { pickedWords, UNDO_WINDOW_MS, type Plan } from "../actions/fleet.ts";
78 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";
109 import { digestOf } from "../fleet/digest.ts";
11 import { VERBS } from "../fleet/verbs.ts";
1210 import type { FleetKind } from "../fleet/selection.ts";
11 import { VERBS } from "../fleet/verbs.ts";
12 import { ACT_FORM } from "./fleet-table.ts";
1313 import { html, type Html } from "./html.ts";
1414 import { btn, field, selectField } from "./ui.ts";
1515
@@ -27,9 +27,19 @@ export const valueWords = (value: FleetValue): string =>
2727 export const verbsOf = (kind: FleetKind): string[] =>
2828 Object.keys(VERBS).filter((v) => VERBS[v]!.kind === kind);
2929
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>
3343 <input type="hidden" name="q" value="${q}" />
3444 <input type="hidden" name="step" value="preview" />
3545 ${selectField({
@@ -78,12 +88,23 @@ function lines(rows: readonly Line[]): Html {
7888 )}</ul>`;
7989 }
8090
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 {
83102 const { change } = plan.counts;
84103 return html`<section data-preview>
85104 <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>
87108 ${lines(plan.rows)}
88109 ${change === 0
89110 ? html`<p class="muted">Nothing to apply.</p>`
@@ -93,6 +114,7 @@ export function previewSection(plan: Plan, rawValue: string): Html {
93114 <input type="hidden" name="verb" value="${plan.verb}" />
94115 <input type="hidden" name="value" value="${rawValue}" />
95116 <input type="hidden" name="digest" value="${digestOf(plan.rows)}" />
117 ${carried(picked)}
96118 ${btn(`Apply to ${change} ${change === 1 ? "row" : "rows"}`, { type: "submit" })}
97119 </form>`}
98120 </section>`;
src/web/fleet-table.ts +19 −3
@@ -31,15 +31,31 @@ function cell(column: Column, row: FleetRow): Html | string {
3131 return to === undefined ? text : html`<a href="${to}">${text}</a>`;
3232 }
3333
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
3448 /**
3549 * 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.
3752 */
3853 export function fleetTable(
3954 kind: FleetKind,
4055 query: string,
4156 rows: readonly FleetRow[],
4257 sort: Sort | undefined,
58 picked?: ReadonlySet<string>,
4359 ): Html {
4460 const columns = COLUMNS[kind];
4561 const sorted = sortRows(rows, sort);
@@ -54,11 +70,11 @@ export function fleetTable(
5470 return html`<div class="table-scroll">
5571 <table class="table" data-dense>
5672 <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>
5874 <tbody>
5975 ${sorted.map(
6076 (row) =>
61 html`<tr>${columns.map(
77 html`<tr>${picked === undefined ? null : pickBox(row, picked)}${columns.map(
6278 (c) => html`<td${c.num ? html` class="num"` : ""}>${cell(c, row)}</td>`,
6379 )}</tr>`,
6480 )}
src/web/pages/fleet-ops.ts +46 −7
@@ -13,10 +13,12 @@ import {
1313 undoFleetOp,
1414 } from "../../actions/fleet.ts";
1515 import { actionOf } from "../../fleet/reading.ts";
16 import { viewFleet } from "../../fleet/rows.ts";
17 import { MAX_ROWS } from "../../fleet/selection.ts";
1618 import type { Ctx } from "../../http/context.ts";
1719 import { notFoundPage, signInFirst } from "../chrome.ts";
1820 import { previewSection, receiptSection, undoable } from "../fleet-act.ts";
19 import { field, readForm } from "../form.ts";
21 import { field, readForm, type Form } from "../form.ts";
2022 import { html } from "../html.ts";
2123 import { flashTo, page } from "../layout.ts";
2224 import { canAct, renderFleet } from "./fleet.ts";
@@ -31,21 +33,58 @@ export async function fleetAct(ctx: Ctx): Promise<Response> {
3133 const verb = field(form, "verb").slice(0, 40);
3234 const text = field(form, "value").slice(0, 2000);
3335 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.`, []);
3439 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);
3742 return flashTo(ctx, `/fleet/ops/${done.value.id}`, { message: "Applied." });
3843 }
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 });
4252 }
4353
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 {
4583 return renderFleet(ctx, q, {
4684 html: html`<p class="muted" data-refused>${message}</p>`,
4785 verb,
4886 value,
87 picked,
4988 });
5089 }
5190
src/web/pages/fleet.ts +5 −4
@@ -27,13 +27,14 @@ export function fleetPage(ctx: Ctx): Response {
2727 export function renderFleet(
2828 ctx: Ctx,
2929 q: string,
30 below?: { html: Html; verb?: string; value?: string },
30 below?: { html: Html; verb?: string; value?: string; picked?: readonly string[] },
3131 ): Response {
3232 const view: FleetView | undefined =
3333 q.trim() === "" ? undefined : viewFleet(ctx.app.db, ctx.auth.user, q);
3434 const rendering = performance.now();
3535 const shown = view?.ok ? view : undefined;
3636 const sort = shown && readSort(shown.kind, ctx.url.searchParams.get("sort"));
37 const acting = shown !== undefined && shown.rows.length > 0 && canAct(ctx);
3738 const response = page({
3839 ctx,
3940 title: "Fleet",
@@ -75,10 +76,10 @@ export function renderFleet(
7576 : html`<p class="muted" data-count="${shown.rows.length}">
7677 ${shown.rows.length} ${shown.rows.length === 1 ? "row" : "rows"}
7778 </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
8080 ? 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}`}
8283 ${below?.html ?? null}`,
8384 });
8485 notePhase(ctx.request, "render", performance.now() - rendering);
test/fleet-act.test.ts +92
@@ -18,6 +18,8 @@ import { labelsFor } from "../src/core/issues.ts";
1818 import { createUser, getUserByName, type User } from "../src/core/users.ts";
1919 import { serve, type Servers } from "../src/http/server.ts";
2020 import { promote } from "./helpers/members.ts";
21 import { MAX_ROWS } from "../src/fleet/selection.ts";
22 import { components } from "../src/web/style/components.ts";
2123
2224 describe("fleet verbs on the web", () => {
2325 let dataDir: string;
@@ -202,4 +204,94 @@ describe("fleet verbs on the web", () => {
202204 expect(ok.status).toBe(303);
203205 expect(labelsFor(app.db, second.value.id).map((l) => l.name)).toEqual(["urgent"]);
204206 });
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 });
205297 });
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
175175 expect(guest.text()).not.toContain("Verb");
176176 expect(names()).toEqual([]);
177177 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs