Public: anyone can read this; the crew can change it.
/fleet: a server-rendered table of what today's selection grammar selects, with the query line in the URL #10071
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/565-fleet-a-server-rendered-table-of-what-to → main 5 commits fork 3 threads · 3 unresolved
16 files
changed +785
−4
docs/APPS.md +8
| @@ -1395,6 +1395,14 @@ The same numbers are `GET /fleet/graph.csv` and `GET /api/v1/fleet/graph`; | ||
| 1395 | 1395 | spend is the owner's fact, so a repository you cannot write is dropped |
| 1396 | 1396 | and counted with the names that do not exist, never told apart. |
| 1397 | 1397 | |
| 1398 | Before a fleet operation acts, `GET /fleet?q=<query line>` shows the rows | |
| 1399 | its query line selects, as a table: `repo autonomy:autonomous`, | |
| 1400 | `run state:queued`, `issue assignee:none`. The grammar and the rule that | |
| 1401 | a repository you cannot read is absent are `src/fleet/selection.ts`'s; a | |
| 1402 | line that does not parse shows why beside it. Headers sort by one column | |
| 1403 | (`&sort=<column>`, `-<column>` descending), without JavaScript. Past 500 | |
| 1404 | rows the page asks for a narrower query rather than cutting the list. | |
| 1405 | ||
| 1398 | 1406 | The agent runs Claude Code headless in its clone, with a prompt the forge |
| 1399 | 1407 | composes: who it is, what the task is, how to finish, and what will be |
| 1400 | 1408 | refused. Permissions are the forge's, passed as settings: file tools |
docs/design/audit.md +1
| @@ -94,6 +94,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 94 | 94 | - `/claim` — `pages/claim.ts` · claim a forge that was sent to me · none · colour 1. |
| 95 | 95 | - `/first-admin` — `pages/first-admin.ts` · the first person becomes admin · none · none seen. |
| 96 | 96 | - `/fleet/graph` — `pages/fleet-graph.ts` · the fleet as a graph · `/family`, `/forges` · none seen. |
| 97 | - `/fleet` — `pages/fleet.ts` · the rows a fleet query line selects, as a table · `/fleet/graph` · none seen. | |
| 97 | 98 | |
| 98 | 99 | ### Machine and operation (admin) |
| 99 | 100 |
docs/features/fleet.md added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | # Fleet rows | |
| 2 | ||
| 3 | Before a fleet operation acts, see the rows its query line selects. There are two doors, and both show the same rows. | |
| 4 | ||
| 5 | **On the web:** `GET /fleet?q=<query line>&sort=<column>` (`src/web/pages/fleet.ts`), linked from the fleet graph as "Rows". | |
| 6 | ||
| 7 | - The page shows a row count and a table. | |
| 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 | - A line that does not parse shows its reason beside the field, with status 200, and selects nothing. | |
| 10 | ||
| 11 | **In the terminal:** "Fleet rows" in the palette (`src/tui/screens/fleet.ts`). | |
| 12 | ||
| 13 | - It asks for the query line and shows the same table. | |
| 14 | - `/` writes a new line. `s` sorts by the next column and `r` reverses the sort. `j` and `k` scroll, and `esc` goes back. | |
| 15 | - A line that does not parse keeps the prompt open with its reason. | |
| 16 | ||
| 17 | **The query line:** a kind, then `field:value` terms that must all hold. A value starting with `!` negates its term. | |
| 18 | ||
| 19 | | Kind | Fields | Columns | | |
| 20 | | --- | --- | --- | | |
| 21 | | `repo` | `owner`, `name`, `repo`, `autonomy`, `visibility` | repository, autonomy, visibility, app | | |
| 22 | | `run` | `state`, `kind`, `agent`, `issue`, `repo` | started, repository, kind, agent, state, cost | | |
| 23 | | `issue` | `number`, `state`, `assignee` (`none` for unassigned), `label`, `limb`, `repo` | repository, #, title, state, assignee | | |
| 24 | ||
| 25 | Every kind also has `id`, and `repo` and `issue` share `owner` and `name`. Some examples: | |
| 26 | ||
| 27 | - `repo autonomy:autonomous` | |
| 28 | - `run state:queued repo:!trevato/forge` | |
| 29 | - `issue repo:engine label:bug assignee:none` | |
| 30 | ||
| 31 | **Shared code and rules:** | |
| 32 | ||
| 33 | - The grammar, and the rule that only rows the viewer may read appear, are `src/fleet/selection.ts`'s. A signed-out visitor sees public repositories only. | |
| 34 | - Both doors read through `viewFleet` in `src/fleet/rows.ts`. | |
| 35 | - Both use the columns and sort in `src/fleet/columns.ts`. | |
| 36 | - A selection of more than 500 rows is refused, with a request to narrow the query, rather than cut short. Nothing is paged. |
src/fleet/columns.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * The fleet table's columns, one set per kind, and its one-column sort: | |
| 3 | * shared by the web page (`web/fleet-table.ts`) and the terminal screen | |
| 4 | * (`tui/screens/fleet.ts`), so both doors show the same rows the same way. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { FleetRow } from "./rows.ts"; | |
| 8 | import type { FleetKind } from "./selection.ts"; | |
| 9 | ||
| 10 | export interface Column { | |
| 11 | readonly key: string; | |
| 12 | readonly label: string; | |
| 13 | readonly num?: boolean; | |
| 14 | } | |
| 15 | ||
| 16 | export const COLUMNS: Record<FleetKind, readonly Column[]> = { | |
| 17 | repo: [ | |
| 18 | { key: "repo", label: "repository" }, | |
| 19 | { key: "autonomy", label: "autonomy" }, | |
| 20 | { key: "visibility", label: "visibility" }, | |
| 21 | { key: "app", label: "app" }, | |
| 22 | ], | |
| 23 | run: [ | |
| 24 | { key: "created", label: "started" }, | |
| 25 | { key: "repo", label: "repository" }, | |
| 26 | { key: "kind", label: "kind" }, | |
| 27 | { key: "agent", label: "agent" }, | |
| 28 | { key: "state", label: "state" }, | |
| 29 | { key: "cost", label: "cost", num: true }, | |
| 30 | ], | |
| 31 | issue: [ | |
| 32 | { key: "repo", label: "repository" }, | |
| 33 | { key: "number", label: "#", num: true }, | |
| 34 | { key: "title", label: "title" }, | |
| 35 | { key: "state", label: "state" }, | |
| 36 | { key: "assignee", label: "assignee" }, | |
| 37 | ], | |
| 38 | }; | |
| 39 | ||
| 40 | export interface Sort { | |
| 41 | readonly key: string; | |
| 42 | readonly descending: boolean; | |
| 43 | } | |
| 44 | ||
| 45 | /** `<column>` or `-<column>`; anything else keeps the selection's order. */ | |
| 46 | export function readSort(kind: FleetKind, text: string | null): Sort | undefined { | |
| 47 | if (text === null) return undefined; | |
| 48 | const descending = text.startsWith("-"); | |
| 49 | const key = descending ? text.slice(1) : text; | |
| 50 | return COLUMNS[kind].some((c) => c.key === key) ? { key, descending } : undefined; | |
| 51 | } | |
| 52 | ||
| 53 | /** The sort as `?sort=` writes it. */ | |
| 54 | export const sortText = (sort: Sort): string => `${sort.descending ? "-" : ""}${sort.key}`; | |
| 55 | ||
| 56 | /** The rows in the sort's order; a copy, the input is left alone. Ties keep their order. */ | |
| 57 | export function sortRows(rows: readonly FleetRow[], sort: Sort | undefined): FleetRow[] { | |
| 58 | const out = [...rows]; | |
| 59 | if (sort === undefined) return out; | |
| 60 | const sign = sort.descending ? -1 : 1; | |
| 61 | out.sort((a, b) => { | |
| 62 | const x = a[sort.key] ?? ""; | |
| 63 | const y = b[sort.key] ?? ""; | |
| 64 | const by = | |
| 65 | typeof x === "number" && typeof y === "number" | |
| 66 | ? x - y | |
| 67 | : String(x).localeCompare(String(y)); | |
| 68 | return sign * by; | |
| 69 | }); | |
| 70 | return out; | |
| 71 | } | |
| 72 | ||
| 73 | /** A cell as text: cost in dollars, everything else as it is. */ | |
| 74 | export function cellText(column: Column, row: FleetRow): string { | |
| 75 | const value = row[column.key]; | |
| 76 | return column.key === "cost" && typeof value === "number" | |
| 77 | ? `$${value.toFixed(2)}` | |
| 78 | : String(value ?? ""); | |
| 79 | } |
src/fleet/rows.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | /** | |
| 2 | * The rows behind ids `selection.ts` selected: ids in, plain records out, | |
| 3 | * in the order the ids came. Reads nothing the selection did not already | |
| 4 | * clear for the viewer — it never widens, it only fills in columns. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import type { User } from "../core/users.ts"; | |
| 9 | import { parseQuery, selectRows, type FleetKind } from "./selection.ts"; | |
| 10 | ||
| 11 | export type Cell = string | number; | |
| 12 | export type FleetRow = Readonly<Record<string, Cell>>; | |
| 13 | ||
| 14 | const SQL: Record<FleetKind, string> = { | |
| 15 | repo: `SELECT repo.id AS id, user.username || '/' || repo.name AS repo, | |
| 16 | repo.visibility AS visibility, repo.autonomy AS autonomy, | |
| 17 | COALESCE(app.access, 'none') AS app | |
| 18 | FROM repo JOIN user ON user.id = repo.owner_id | |
| 19 | LEFT JOIN app ON app.repo_id = repo.id | |
| 20 | WHERE repo.id IN (SELECT value FROM json_each(?1))`, | |
| 21 | run: `SELECT run.id AS id, user.username || '/' || repo.name AS repo, | |
| 22 | run.kind AS kind, COALESCE(actor.username, '') AS agent, | |
| 23 | run.state AS state, run.cost_usd AS cost, run.created_at AS created | |
| 24 | FROM run JOIN repo ON repo.id = run.repo_id | |
| 25 | JOIN user ON user.id = repo.owner_id | |
| 26 | LEFT JOIN user AS actor ON actor.id = run.actor_id | |
| 27 | WHERE run.id IN (SELECT value FROM json_each(?1))`, | |
| 28 | issue: `SELECT issue.id AS id, user.username || '/' || repo.name AS repo, | |
| 29 | issue.number AS number, issue.title AS title, issue.state AS state, | |
| 30 | COALESCE(who.username, '') AS assignee | |
| 31 | FROM issue JOIN repo ON repo.id = issue.repo_id | |
| 32 | JOIN user ON user.id = repo.owner_id | |
| 33 | LEFT JOIN user AS who ON who.id = issue.assignee_id | |
| 34 | WHERE issue.id IN (SELECT value FROM json_each(?1))`, | |
| 35 | }; | |
| 36 | ||
| 37 | /** Each id's row, in the ids' order; an id gone since selection is dropped. */ | |
| 38 | export function fleetRows( | |
| 39 | db: Database, | |
| 40 | kind: FleetKind, | |
| 41 | ids: readonly string[], | |
| 42 | ): FleetRow[] { | |
| 43 | if (ids.length === 0) return []; | |
| 44 | const found = new Map( | |
| 45 | db | |
| 46 | .query<FleetRow & { id: string }, [string]>(SQL[kind]) | |
| 47 | .all(JSON.stringify(ids)) | |
| 48 | .map((r) => [r.id, r]), | |
| 49 | ); | |
| 50 | return ids.flatMap((id) => { | |
| 51 | const row = found.get(id); | |
| 52 | return row === undefined ? [] : [row]; | |
| 53 | }); | |
| 54 | } | |
| 55 | ||
| 56 | /** What a query line shows: its kind and rows, or why it shows nothing. */ | |
| 57 | export type FleetView = | |
| 58 | | { readonly ok: true; readonly kind: FleetKind; readonly rows: FleetRow[] } | |
| 59 | | { readonly ok: false; readonly error: string }; | |
| 60 | ||
| 61 | /** | |
| 62 | * A query line read, selected for `viewer` (undefined: signed out) and | |
| 63 | * filled in — what the web page and the terminal screen both show. | |
| 64 | */ | |
| 65 | export function viewFleet( | |
| 66 | db: Database, | |
| 67 | viewer: User | undefined, | |
| 68 | line: string, | |
| 69 | ): FleetView { | |
| 70 | const parsed = parseQuery(line); | |
| 71 | if (!parsed.ok) return { ok: false, error: parsed.error.message }; | |
| 72 | const ids = selectRows(db, viewer, parsed.value); | |
| 73 | if (!ids.ok) return { ok: false, error: ids.error.message }; | |
| 74 | const kind = parsed.value.kind; | |
| 75 | return { ok: true, kind, rows: fleetRows(db, kind, ids.value) }; | |
| 76 | } |
src/fleet/selection.ts +6 −4
| @@ -109,7 +109,7 @@ const FIELDS: Record<FleetKind, Record<string, Predicate>> = { | ||
| 109 | 109 | */ |
| 110 | 110 | function limbIds( |
| 111 | 111 | db: Database, |
| 112 | viewer: User, | |
| 112 | viewer: User | undefined, | |
| 113 | 113 | query: FleetQuery, |
| 114 | 114 | term: Term, |
| 115 | 115 | ): Result<string[], QueryError> { |
| @@ -180,16 +180,18 @@ export function parseQuery(text: string): Result<FleetQuery, QueryError> { | ||
| 180 | 180 | } |
| 181 | 181 | |
| 182 | 182 | /** |
| 183 | * The ids a query selects for `viewer`, in a stable order. More than | |
| 183 | * The ids a query selects for `viewer` (undefined: a signed-out visitor), | |
| 184 | * in a stable order. More than | |
| 184 | 185 | * `MAX_ROWS` is refused rather than cut: an operation over the first 500 |
| 185 | 186 | * of 900 rows is not what anyone meant. |
| 186 | 187 | */ |
| 187 | 188 | export function selectRows( |
| 188 | 189 | db: Database, |
| 189 | viewer: User, | |
| 190 | viewer: User | undefined, | |
| 190 | 191 | query: FleetQuery, |
| 191 | 192 | ): Result<string[], QueryError> { |
| 192 | const params: (string | number)[] = [viewer.id]; | |
| 193 | // Signed out, `?1` is an id no account has: only public repositories hold. | |
| 194 | const params: (string | number)[] = [viewer?.id ?? ""]; | |
| 193 | 195 | const bind = (value: string): string => { |
| 194 | 196 | params.push(value); |
| 195 | 197 | return `?${params.length}`; |
src/http/server.ts +2
| @@ -337,6 +337,7 @@ import { | ||
| 337 | 337 | fleetGraphLive, |
| 338 | 338 | fleetGraphPage, |
| 339 | 339 | } from "../web/pages/fleet-graph.ts"; |
| 340 | import { fleetPage } from "../web/pages/fleet.ts"; | |
| 340 | 341 | import { fileHoldUpdate } from "../web/pages/file_holds.ts"; |
| 341 | 342 | import { newPullPage, newPullSubmit } from "../web/pages/pulls.ts"; |
| 342 | 343 | import { |
| @@ -912,6 +913,7 @@ export function buildRouter(): Router<Handler> { | ||
| 912 | 913 | router.add("POST", "/maintenance/resume", maintenanceResume); |
| 913 | 914 | router.add("GET", "/resilience/nightly/:job", nightlyOutputPage); |
| 914 | 915 | // A selection's spend and runs over time; literal, above /:owner. |
| 916 | router.add("GET", "/fleet", fleetPage); | |
| 915 | 917 | router.add("GET", "/fleet/graph", fleetGraphPage); |
| 916 | 918 | router.add("GET", "/fleet/graph/live", fleetGraphLive); |
| 917 | 919 | router.add("GET", "/fleet/graph.csv", fleetGraphCsv); |
src/tui/forge.ts +2
| @@ -14,6 +14,7 @@ import { cleanQuery } from "../core/search.ts"; | ||
| 14 | 14 | import { guestCodeSearch } from "../http/strangers.ts"; |
| 15 | 15 | import { canRead, getRepo, type Repo } from "../core/repos.ts"; |
| 16 | 16 | import { sourceFor } from "../view/source.ts"; |
| 17 | import { fleetCommand } from "./screens/fleet.ts"; | |
| 17 | 18 | import { packsCommand } from "./screens/packs.ts"; |
| 18 | 19 | import { App, type Ctx, type Screen } from "./app/app.ts"; |
| 19 | 20 | import type { Command, PaletteItem, PaletteSearch } from "./app/palette.ts"; |
| @@ -207,6 +208,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 207 | 208 | run: () => openOnWeb(session, ctx, "/explore", "The explorer"), |
| 208 | 209 | }, |
| 209 | 210 | packsCommand(session, ctx), |
| 211 | fleetCommand(session, ctx), | |
| 210 | 212 | { |
| 211 | 213 | // A person's or an org's page is the web's (#636): the terminal |
| 212 | 214 | // hands over its address. |
src/tui/screens/fleet.ts added +137
| @@ -0,0 +1,137 @@ | ||
| 1 | /** | |
| 2 | * `/fleet` in the terminal: a query line in, the rows it selects as a | |
| 3 | * table, sorted by one column. The same read as the web page | |
| 4 | * (`fleet/rows.ts` `viewFleet`), so the grammar and who sees which row are | |
| 5 | * `fleet/selection.ts`'s, never this screen's; the same columns and sort | |
| 6 | * (`fleet/columns.ts`). Keys: `/` a new query line, `s` sort by the next | |
| 7 | * column, `r` reverse it, `j`/`k` scroll, `esc` back. | |
| 8 | */ | |
| 9 | ||
| 10 | import { cellText, COLUMNS, sortRows, type Sort } from "../../fleet/columns.ts"; | |
| 11 | import { viewFleet, type FleetView } from "../../fleet/rows.ts"; | |
| 12 | import type { FleetKind } from "../../fleet/selection.ts"; | |
| 13 | import type { FleetRow } from "../../fleet/rows.ts"; | |
| 14 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 15 | import { binding } from "../app/keys.ts"; | |
| 16 | import type { Session } from "../session.ts"; | |
| 17 | import { style } from "../term/style.ts"; | |
| 18 | import { pad, truncate } from "../term/width.ts"; | |
| 19 | import { promptScreen, Scroll } from "./common.ts"; | |
| 20 | ||
| 21 | /** No column is padded past this, so a long title does not push the rest away. */ | |
| 22 | const CELL_MAX = 40; | |
| 23 | ||
| 24 | /** The table as text lines: a header naming the sorted column, then one line a row. */ | |
| 25 | export function fleetLines( | |
| 26 | kind: FleetKind, | |
| 27 | rows: readonly FleetRow[], | |
| 28 | sort: Sort | undefined, | |
| 29 | ): string[] { | |
| 30 | const columns = COLUMNS[kind]; | |
| 31 | const sorted = sortRows(rows, sort); | |
| 32 | const label = (key: string, text: string) => | |
| 33 | sort?.key === key ? `${text} ${sort.descending ? "v" : "^"}` : text; | |
| 34 | const widths = columns.map((c) => | |
| 35 | Math.min( | |
| 36 | CELL_MAX, | |
| 37 | Math.max(label(c.key, c.label).length, ...sorted.map((r) => cellText(c, r).length)), | |
| 38 | ), | |
| 39 | ); | |
| 40 | const line = (cells: string[]) => | |
| 41 | cells.map((t, i) => pad(truncate(t, widths[i]!), widths[i]!)).join(" ").trimEnd(); | |
| 42 | return [ | |
| 43 | line(columns.map((c) => label(c.key, c.label))), | |
| 44 | ...sorted.map((r) => line(columns.map((c) => cellText(c, r) || "-"))), | |
| 45 | ]; | |
| 46 | } | |
| 47 | ||
| 48 | /** The next column to sort by: the one after the current, ascending; none sorted, the first. */ | |
| 49 | export function nextSort(kind: FleetKind, sort: Sort | undefined): Sort { | |
| 50 | const keys = COLUMNS[kind].map((c) => c.key); | |
| 51 | const at = sort === undefined ? -1 : keys.indexOf(sort.key); | |
| 52 | return { key: keys[(at + 1) % keys.length]!, descending: false }; | |
| 53 | } | |
| 54 | ||
| 55 | /** The screen, and the prompt that writes its query line. */ | |
| 56 | export function fleetScreen( | |
| 57 | session: Session, | |
| 58 | ctx: Ctx, | |
| 59 | line = "", | |
| 60 | ): { screen: Screen; ask: () => void } { | |
| 61 | const scroll = new Scroll(); | |
| 62 | let query = line; | |
| 63 | let view: FleetView | undefined = line === "" ? undefined : viewFleet(session.db, session.user, line); | |
| 64 | let sort: Sort | undefined; | |
| 65 | let lines: string[] = []; | |
| 66 | const screen: Screen = { | |
| 67 | title: "Fleet", | |
| 68 | keys: [ | |
| 69 | binding(["/"], "query"), | |
| 70 | binding(["s"], "sort"), | |
| 71 | binding(["r"], "reverse"), | |
| 72 | binding(["j", "k"], "scroll"), | |
| 73 | binding(["esc"], "back"), | |
| 74 | ], | |
| 75 | render(area, s) { | |
| 76 | const c = ctx.theme.c; | |
| 77 | const head = | |
| 78 | view === undefined | |
| 79 | ? "/ to write a query line: repo autonomy:autonomous" | |
| 80 | : view.ok | |
| 81 | ? `${query} ${view.rows.length} ${view.rows.length === 1 ? "row" : "rows"}` | |
| 82 | : `${query} ${view.error}`; | |
| 83 | 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); | |
| 84 | lines = view?.ok ? fleetLines(view.kind, view.rows, sort) : []; | |
| 85 | const [header, ...body] = lines; | |
| 86 | if (header !== undefined && area.h > 2) | |
| 87 | s.text(area.x + 1, area.y + 2, truncate(header, area.w - 2), style(c.mutedFg, c.bg), area.x + area.w); | |
| 88 | const room = Math.max(0, area.h - 3); | |
| 89 | const from = scroll.window(room, body.length); | |
| 90 | for (let i = 0; i < room; i++) { | |
| 91 | const text = body[from + i]; | |
| 92 | if (text !== undefined) | |
| 93 | s.text(area.x + 1, area.y + 3 + i, truncate(text, area.w - 2), style(c.fg, c.bg), area.x + area.w); | |
| 94 | } | |
| 95 | }, | |
| 96 | key(e) { | |
| 97 | if (e.ctrl || e.alt) return scroll.key(e, Math.max(0, lines.length - 1)); | |
| 98 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 99 | else if (e.key === "/") ask(); | |
| 100 | else if (e.key === "s" && view?.ok) sort = nextSort(view.kind, sort); | |
| 101 | else if (e.key === "r" && view?.ok && sort !== undefined) sort = { ...sort, descending: !sort.descending }; | |
| 102 | else return scroll.key(e, Math.max(0, lines.length - 1)); | |
| 103 | return true; | |
| 104 | }, | |
| 105 | }; | |
| 106 | const ask = () => | |
| 107 | ctx.push( | |
| 108 | promptScreen(ctx, screen, { | |
| 109 | title: "Fleet query", | |
| 110 | label: "kind, then field:value terms", | |
| 111 | placeholder: "repo autonomy:autonomous", | |
| 112 | value: query, | |
| 113 | onSubmit: (text) => { | |
| 114 | if (text === "") return "a query starts with what it selects: repo, run or issue"; | |
| 115 | const next = viewFleet(session.db, session.user, text); | |
| 116 | if (!next.ok) return next.error; | |
| 117 | query = text; | |
| 118 | view = next; | |
| 119 | sort = undefined; | |
| 120 | scroll.to(0, 0); | |
| 121 | return undefined; | |
| 122 | }, | |
| 123 | }), | |
| 124 | ); | |
| 125 | return { screen, ask }; | |
| 126 | } | |
| 127 | ||
| 128 | export const fleetCommand = (session: Session, ctx: Ctx) => ({ | |
| 129 | id: "fleet", | |
| 130 | title: "Fleet rows", | |
| 131 | hint: "the rows a fleet query line selects", | |
| 132 | run: () => { | |
| 133 | const { screen, ask } = fleetScreen(session, ctx); | |
| 134 | ctx.push(screen); | |
| 135 | ask(); | |
| 136 | }, | |
| 137 | }); |
src/web/fleet-table.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * The fleet table on the web: the rows a query line selected, in the | |
| 3 | * columns `fleet/columns.ts` names, sortable by one column through `?sort=` | |
| 4 | * links. Pure: rows in, HTML out — every cell goes through `html`, which | |
| 5 | * escapes it. | |
| 6 | */ | |
| 7 | ||
| 8 | import { | |
| 9 | cellText, | |
| 10 | COLUMNS, | |
| 11 | sortRows, | |
| 12 | sortText, | |
| 13 | type Column, | |
| 14 | type Sort, | |
| 15 | } from "../fleet/columns.ts"; | |
| 16 | import type { FleetRow } from "../fleet/rows.ts"; | |
| 17 | import type { FleetKind } from "../fleet/selection.ts"; | |
| 18 | import { html, type Html } from "./html.ts"; | |
| 19 | ||
| 20 | /** Where a cell leads, if anywhere. */ | |
| 21 | function href(column: Column, row: FleetRow): string | undefined { | |
| 22 | if (column.key === "repo") return `/${row.repo}`; | |
| 23 | if (column.key === "number") return `/${row.repo}/issues/${row.number}`; | |
| 24 | return undefined; | |
| 25 | } | |
| 26 | ||
| 27 | function cell(column: Column, row: FleetRow): Html | string { | |
| 28 | const text = cellText(column, row); | |
| 29 | if (text === "") return html`<span class="muted">—</span>`; | |
| 30 | const to = href(column, row); | |
| 31 | return to === undefined ? text : html`<a href="${to}">${text}</a>`; | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * The table for `rows` of `kind`, its headers linking to `?q=…&sort=…`. | |
| 36 | * The sorted column's link flips its direction. | |
| 37 | */ | |
| 38 | export function fleetTable( | |
| 39 | kind: FleetKind, | |
| 40 | query: string, | |
| 41 | rows: readonly FleetRow[], | |
| 42 | sort: Sort | undefined, | |
| 43 | ): Html { | |
| 44 | const columns = COLUMNS[kind]; | |
| 45 | const sorted = sortRows(rows, sort); | |
| 46 | const head = (c: Column): Html => { | |
| 47 | const on = sort?.key === c.key; | |
| 48 | const next = sortText({ key: c.key, descending: on && !sort.descending }); | |
| 49 | const to = `/fleet?${new URLSearchParams({ q: query, sort: next }).toString()}`; | |
| 50 | return html`<th scope="col"${c.num ? html` class="num"` : ""}${ | |
| 51 | on ? html` aria-sort="${sort.descending ? "descending" : "ascending"}"` : "" | |
| 52 | }><a href="${to}">${c.label}</a></th>`; | |
| 53 | }; | |
| 54 | return html`<div class="table-scroll"> | |
| 55 | <table class="table" data-dense> | |
| 56 | <caption class="sr-only">The ${kind} rows the query selects</caption> | |
| 57 | <thead><tr>${columns.map(head)}</tr></thead> | |
| 58 | <tbody> | |
| 59 | ${sorted.map( | |
| 60 | (row) => | |
| 61 | html`<tr>${columns.map( | |
| 62 | (c) => html`<td${c.num ? html` class="num"` : ""}>${cell(c, row)}</td>`, | |
| 63 | )}</tr>`, | |
| 64 | )} | |
| 65 | </tbody> | |
| 66 | </table> | |
| 67 | </div>`; | |
| 68 | } |
src/web/pages/fleet-graph.ts +5
| @@ -65,6 +65,11 @@ export function fleetGraphPage(ctx: Ctx): Response { | ||
| 65 | 65 | size: "sm", |
| 66 | 66 | variant: "secondary", |
| 67 | 67 | })} |
| 68 | ${btn("Rows", { | |
| 69 | href: "/fleet", | |
| 70 | size: "sm", | |
| 71 | variant: "secondary", | |
| 72 | })} | |
| 68 | 73 | <p class="muted"> |
| 69 | 74 | What a selection of repositories spent and how many runs ended, |
| 70 | 75 | hour by hour and day by day. |
src/web/pages/fleet.ts added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | /** | |
| 2 | * `GET /fleet?q=<query line>&sort=<column>`: the rows a fleet operation's | |
| 3 | * query line selects, as a table, so an operator sees them before acting. | |
| 4 | * The grammar and the visibility rule are `fleet/selection.ts`'s; a line | |
| 5 | * that does not parse shows its error beside the query line. The terminal | |
| 6 | * door is `tui/screens/fleet.ts`. | |
| 7 | */ | |
| 8 | ||
| 9 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 10 | import { readSort } from "../../fleet/columns.ts"; | |
| 11 | import { viewFleet, type FleetView } from "../../fleet/rows.ts"; | |
| 12 | import { MAX_ROWS } from "../../fleet/selection.ts"; | |
| 13 | import type { Ctx } from "../../http/context.ts"; | |
| 14 | import { fleetTable } from "../fleet-table.ts"; | |
| 15 | import { html } from "../html.ts"; | |
| 16 | import { page } from "../layout.ts"; | |
| 17 | import { btn, field } from "../ui.ts"; | |
| 18 | ||
| 19 | /** GET /fleet */ | |
| 20 | export function fleetPage(ctx: Ctx): Response { | |
| 21 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 22 | const q = (ctx.url.searchParams.get("q") ?? "").slice(0, 2000); | |
| 23 | const view: FleetView | undefined = | |
| 24 | q.trim() === "" ? undefined : viewFleet(ctx.app.db, ctx.auth.user, q); | |
| 25 | const rendering = performance.now(); | |
| 26 | const shown = view?.ok ? view : undefined; | |
| 27 | const sort = shown && readSort(shown.kind, ctx.url.searchParams.get("sort")); | |
| 28 | const response = page({ | |
| 29 | ctx, | |
| 30 | title: "Fleet", | |
| 31 | description: "The rows a fleet query line selects, before anything acts on them.", | |
| 32 | wide: true, | |
| 33 | body: html`<div class="page-head"> | |
| 34 | <h1>Fleet</h1> | |
| 35 | <p class="muted"> | |
| 36 | The rows a query line selects, at most ${MAX_ROWS}, before anything acts on them. | |
| 37 | </p> | |
| 38 | </div> | |
| 39 | <form method="get" action="/fleet" class="field-row mb"> | |
| 40 | ${field({ | |
| 41 | label: "Query", | |
| 42 | name: "q", | |
| 43 | id: "fleet-q", | |
| 44 | value: q, | |
| 45 | placeholder: "repo autonomy:autonomous", | |
| 46 | autocomplete: "off", | |
| 47 | mono: true, | |
| 48 | spellcheck: false, | |
| 49 | ...(view === undefined || view.ok ? {} : { error: view.error }), | |
| 50 | })} | |
| 51 | ${btn("Select", { type: "submit", variant: "secondary" })} | |
| 52 | </form> | |
| 53 | ${shown === undefined | |
| 54 | ? null | |
| 55 | : html`<p class="muted" data-count="${shown.rows.length}"> | |
| 56 | ${shown.rows.length} ${shown.rows.length === 1 ? "row" : "rows"} | |
| 57 | </p> | |
| 58 | ${shown.rows.length === 0 ? null : fleetTable(shown.kind, q, shown.rows, sort)}`}`, | |
| 59 | }); | |
outdated
Principle 1: this paragraph explains the grammar, and the Try paragraph below and the placeholder explain it again. Say what the page is for in one sentence and drop the rest. |
||
| 60 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 61 | return response; | |
| 62 | } |
test/fleet-table.test.ts added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | /** | |
| 2 | * `GET /fleet`: the rows `fleet/selection.ts` selects, as a table. Render, | |
| 3 | * a parse error beside the query line, sorting through header links, a | |
| 4 | * private repository absent for a visitor, escaping, a 1,000-row table | |
| 5 | * in full, and a page whose SQL does not grow with its rows (counted work, | |
| 6 | * not a clock: #7824). | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 14 | import { createSession } from "../src/core/sessions.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import type { FleetRow } from "../src/fleet/rows.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { readSort, sortRows } from "../src/fleet/columns.ts"; | |
| 19 | import { fleetTable } from "../src/web/fleet-table.ts"; | |
| 20 | import { promote } from "./helpers/members.ts"; | |
| 21 | import { expectWork, requestStatements } from "./helpers/work.ts"; | |
| 22 | ||
| 23 | describe("the fleet table", () => { | |
| 24 | let dataDir: string; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let alice: User; | |
| 28 | let aliceCookie: string; | |
| 29 | ||
| 30 | const person = async (username: string, adminIfFirst = false) => { | |
| 31 | const made = await createUser(app.db, { | |
| 32 | origin: "seed", | |
| 33 | trusted: true, | |
| 34 | username, | |
| 35 | allowReserved: true, | |
| 36 | email: `${username}@forge.local`, | |
| 37 | password: "a-good-passphrase", | |
| 38 | adminIfFirst, | |
| 39 | }); | |
| 40 | if (!made.ok) throw new Error(made.error); | |
| 41 | promote(app.db, made.value); | |
| 42 | return made.value; | |
| 43 | }; | |
| 44 | const repoOf = async (owner: User, name: string, visibility: "public" | "private") => { | |
| 45 | const made = await createRepo(app, owner, { name, visibility }); | |
| 46 | if (!made.ok) throw new Error(String(made.error)); | |
| 47 | await updateRepoSettings(app, owner, made.value, { | |
| 48 | autonomy: "assisted", | |
| 49 | autonomyBy: owner.id, | |
| 50 | }); | |
| 51 | return made.value; | |
| 52 | }; | |
| 53 | const get = (path: string, cookie?: string) => | |
| 54 | fetch(`${servers.url}${path}`, cookie ? { headers: { cookie } } : {}); | |
| 55 | const rowsOf = (body: string) => Number(body.match(/data-count="(\d+)"/)?.[1] ?? -1); | |
| 56 | ||
| 57 | beforeAll(async () => { | |
| 58 | dataDir = await mkdtemp(join(tmpdir(), "forge-fleet-table-")); | |
| 59 | app = createApp({ dataDir, port: 0 }); | |
| 60 | servers = await serve(app); | |
| 61 | await person("root", true); | |
| 62 | alice = await person("alice"); | |
| 63 | await repoOf(alice, "engine", "private"); | |
| 64 | await repoOf(alice, "tools", "public"); | |
| 65 | await repoOf(alice, "atlas", "public"); | |
| 66 | aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 67 | }, 30_000); | |
| 68 | ||
| 69 | afterAll(async () => { | |
| 70 | servers.stop(); | |
| 71 | app.close(); | |
| 72 | await rm(dataDir, { recursive: true, force: true }); | |
| 73 | }); | |
| 74 | ||
| 75 | test("renders the selected rows with a count, and the security headers", async () => { | |
| 76 | const res = await get(`/fleet?${new URLSearchParams({ q: "repo autonomy:assisted" })}`, aliceCookie); | |
| 77 | expect(res.status).toBe(200); | |
| 78 | expect(res.headers.get("content-security-policy")).toBeTruthy(); | |
| 79 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 80 | expect(res.headers.get("referrer-policy")).toBeTruthy(); | |
| 81 | const body = await res.text(); | |
| 82 | expect(rowsOf(body)).toBe(3); | |
| 83 | for (const name of ["alice/engine", "alice/tools", "alice/atlas"]) | |
| 84 | expect(body).toContain(`href="/${name}"`); | |
| 85 | expect(body).toContain('<th scope="col"><a href="/fleet?q=repo+autonomy%3Aassisted&sort=autonomy">'); | |
| 86 | }); | |
| 87 | ||
| 88 | test("an empty query line selects nothing and offers an example", async () => { | |
| 89 | const body = await (await get("/fleet")).text(); | |
| 90 | expect(body).toContain('name="q"'); | |
| 91 | expect(body).not.toContain("data-count="); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a query that does not parse shows its error beside the line, status 200, nothing selected", async () => { | |
| 95 | const res = await get(`/fleet?${new URLSearchParams({ q: "repo spent:4" })}`, aliceCookie); | |
| 96 | expect(res.status).toBe(200); | |
| 97 | const body = await res.text(); | |
| 98 | expect(body).toContain('aria-invalid="true"'); | |
| 99 | expect(body).toContain("repo has no field "spent""); | |
| 100 | expect(body).not.toContain("data-count="); | |
| 101 | expect(body).not.toContain("<table"); | |
| 102 | }); | |
| 103 | ||
| 104 | test("sorts by one column through the header links, and flips on the second click", async () => { | |
| 105 | const order = async (sort: string) => { | |
| 106 | const body = await ( | |
| 107 | await get(`/fleet?${new URLSearchParams({ q: "repo owner:alice", sort })}`, aliceCookie) | |
| 108 | ).text(); | |
| 109 | return [...body.matchAll(/<td><a href="\/(alice\/\w+)">/g)].map((m) => m[1]); | |
| 110 | }; | |
| 111 | expect(await order("repo")).toEqual(["alice/atlas", "alice/engine", "alice/tools"]); | |
| 112 | expect(await order("-repo")).toEqual(["alice/tools", "alice/engine", "alice/atlas"]); | |
| 113 | // private before public; ties keep the selection's own order. | |
| 114 | expect(await order("visibility")).toEqual(["alice/engine", "alice/atlas", "alice/tools"]); | |
| 115 | const body = await ( | |
| 116 | await get(`/fleet?${new URLSearchParams({ q: "repo owner:alice", sort: "repo" })}`, aliceCookie) | |
| 117 | ).text(); | |
| 118 | expect(body).toContain('aria-sort="ascending"><a href="/fleet?q=repo+owner%3Aalice&sort=-repo"'); | |
| 119 | expect(readSort("repo", "nonsense")).toBeUndefined(); | |
| 120 | expect(readSort("repo", "-app")).toEqual({ key: "app", descending: true }); | |
| 121 | }); | |
| 122 | ||
| 123 | test("a private repository is absent for a visitor, present for its owner", async () => { | |
| 124 | const q = `/fleet?${new URLSearchParams({ q: "repo owner:alice" })}`; | |
| 125 | const visitor = await (await get(q)).text(); | |
| 126 | expect(rowsOf(visitor)).toBe(2); | |
| 127 | expect(visitor).not.toContain("alice/engine"); | |
| 128 | const owner = await (await get(q, aliceCookie)).text(); | |
| 129 | expect(rowsOf(owner)).toBe(3); | |
| 130 | expect(owner).toContain("alice/engine"); | |
| 131 | }); | |
| 132 | ||
| 133 | test("every cell is escaped", () => { | |
| 134 | const rows: FleetRow[] = [ | |
| 135 | { id: "1", repo: "a/b", number: 1, title: "<script>alert(1)</script>", state: "open", assignee: '"x"' }, | |
| 136 | ]; | |
| 137 | const out = String(fleetTable("issue", "issue <b>", rows, undefined)); | |
| 138 | expect(out).not.toContain("<script>"); | |
| 139 | expect(out).toContain("<script>alert(1)</script>"); | |
| 140 | expect(out).toContain(""x""); | |
| 141 | expect(out).not.toContain("<b>"); | |
| 142 | }); | |
| 143 | ||
| 144 | test("a 1,000-row selection renders every row, sorted", () => { | |
| 145 | const rows: FleetRow[] = Array.from({ length: 1000 }, (_, i) => ({ | |
| 146 | id: String(i), | |
| 147 | created: `2026-11-09T00:00:${String(i % 60).padStart(2, "0")}Z`, | |
| 148 | repo: `owner${i % 37}/repo${i}`, | |
| 149 | kind: "agent", | |
| 150 | agent: `agent${i % 5}`, | |
| 151 | state: i % 3 === 0 ? "failed" : "ok", | |
| 152 | cost: (i * 7) % 13, | |
| 153 | })); | |
| 154 | const out = String(fleetTable("run", "run", rows, { key: "cost", descending: true })); | |
| 155 | expect((out.match(/<tr>/g) ?? []).length).toBe(1001); | |
| 156 | expect(out.indexOf("$12.00")).toBeLessThan(out.indexOf("$0.00")); | |
| 157 | expect(sortRows(rows, { key: "cost", descending: false })[0]!.cost).toBe(0); | |
| 158 | }); | |
| 159 | ||
| 160 | test("the page's SQL does not grow with its rows", async () => { | |
| 161 | const url = (q: string) => `${servers.url}/fleet?${new URLSearchParams({ q })}`; | |
| 162 | const init = { headers: { cookie: aliceCookie } }; | |
| 163 | await requestStatements(app.db, url("repo name:atlas"), init); // warm | |
| 164 | const one = await requestStatements(app.db, url("repo name:atlas"), init); | |
| 165 | const three = await requestStatements(app.db, url("repo owner:alice"), init); | |
| 166 | expectWork(three, { max: one }); | |
| 167 | }); | |
| 168 | }); |
test/forge-looks.test.ts +1
| @@ -27,6 +27,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 27 | 27 | "/llms.txt": "plain markdown for machines, not a page to picture", |
| 28 | 28 | "/agents": "/llms.txt drawn as a page for an assistant; no picture: the cap of 24 looks is spent, and test/agents-page.test.ts covers it", |
| 29 | 29 | "/packs": "no picture: the cap of 24 looks is spent; the page is covered by test/packs.test.ts", |
| 30 | "/fleet": "no picture: the cap of 24 looks is spent; the page is covered by test/fleet-table.test.ts", | |
| 30 | 31 | "/robots.txt": "plain text for crawlers", |
| 31 | 32 | "/openapi.json": "a redirect to the contract document", |
| 32 | 33 | "/favicon.ico": "the tab icon, an image", |
test/palette-roster.test.ts +4
| @@ -48,6 +48,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 48 | 48 | ["home", "Home", "your repositories"], |
| 49 | 49 | ["explore", "Explore", "on the web"], |
| 50 | 50 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 51 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], | |
| 51 | 52 | ["profile", "Open a profile", "username or org"], |
| 52 | 53 | ["repos", "Repositories", "the repositories you may write"], |
| 53 | 54 | ["displays", "Displays", "the displays you may read"], |
| @@ -161,6 +162,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 161 | 162 | ["home", "Home", "your repositories"], |
| 162 | 163 | ["explore", "Explore", "on the web"], |
| 163 | 164 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 165 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], | |
| 164 | 166 | ["profile", "Open a profile", "username or org"], |
| 165 | 167 | ["repos", "Repositories", "the repositories you may write"], |
| 166 | 168 | ["displays", "Displays", "the displays you may read"], |
| @@ -232,6 +234,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 232 | 234 | ["home", "Home", "your repositories"], |
| 233 | 235 | ["explore", "Explore", "on the web"], |
| 234 | 236 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 237 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], | |
| 235 | 238 | ["profile", "Open a profile", "username or org"], |
| 236 | 239 | ["repos", "Repositories", "the repositories you may write"], |
| 237 | 240 | ["displays", "Displays", "the displays you may read"], |
| @@ -299,6 +302,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 299 | 302 | ["home", "Home", "your repositories"], |
| 300 | 303 | ["explore", "Explore", "on the web"], |
| 301 | 304 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 305 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], | |
| 302 | 306 | ["profile", "Open a profile", "username or org"], |
| 303 | 307 | ["repo", "Open a repository", "owner/name"], |
| 304 | 308 | ["issues", "Issues in a repository", "owner/name"], |
test/tui-fleet.test.ts added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | /** | |
| 2 | * The terminal's fleet rows (#565): the palette's "Fleet rows" asks for a | |
| 3 | * query line and shows the rows `viewFleet` selects — the web page's read, | |
| 4 | * so a private repository is absent for a guest and present for its owner; | |
| 5 | * a line that does not parse keeps the prompt open with its error; `s` | |
| 6 | * sorts by the next column and `r` reverses it. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 14 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import { fleetLines, nextSort } from "../src/tui/screens/fleet.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { promote } from "./helpers/members.ts"; | |
| 20 | import { settleApp } from "./helpers/tui.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | let dataDir: string; | |
| 25 | let forge: Forge; | |
| 26 | ||
| 27 | function boot(user: User | undefined) { | |
| 28 | const { app } = createForgeApp( | |
| 29 | forge, | |
| 30 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 31 | { terminal: headlessTerminal(120, 32), exitHooks: false }, | |
| 32 | ); | |
| 33 | app.start(); | |
| 34 | const key = async (k: string, ctrl = false) => { | |
| 35 | app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 36 | await settleApp(app); | |
| 37 | }; | |
| 38 | const type = async (text: string) => { | |
| 39 | for (const ch of text) await key(ch); | |
| 40 | }; | |
| 41 | return { | |
| 42 | text: () => app.screen.toText(), | |
| 43 | key, | |
| 44 | /** Open "Fleet rows" from the palette and submit `line`. */ | |
| 45 | select: async (line: string) => { | |
| 46 | await key("k", true); | |
| 47 | await type("Fleet rows"); | |
| 48 | await key("enter"); | |
| 49 | await type(line); | |
| 50 | await key("enter"); | |
| 51 | }, | |
| 52 | }; | |
| 53 | } | |
| 54 | ||
| 55 | beforeAll(async () => { | |
| 56 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-fleet-")); | |
| 57 | forge = createApp({ dataDir, port: 0 }); | |
| 58 | for (const [name, admin] of [["root", true], ["alice", false]] as const) { | |
| 59 | const made = await createUser(forge.db, { | |
| 60 | origin: "seed", | |
| 61 | trusted: true, | |
| 62 | username: name, | |
| 63 | allowReserved: true, | |
| 64 | email: `${name}@forge.local`, | |
| 65 | password: "a-good-passphrase", | |
| 66 | adminIfFirst: admin, | |
| 67 | }); | |
| 68 | if (!made.ok) throw new Error(made.error); | |
| 69 | promote(forge.db, made.value); | |
| 70 | } | |
| 71 | const alice = getUserByName(forge.db, "alice")!; | |
| 72 | for (const [name, visibility] of [["engine", "private"], ["tools", "public"], ["atlas", "public"]] as const) { | |
| 73 | const made = await createRepo(forge, alice, { name, visibility }); | |
| 74 | if (!made.ok) throw new Error(String(made.error)); | |
| 75 | } | |
| 76 | }, 30_000); | |
| 77 | ||
| 78 | afterAll(async () => { | |
| 79 | forge.close(); | |
| 80 | await rm(dataDir, { recursive: true, force: true }); | |
| 81 | }); | |
| 82 | ||
| 83 | test("the owner sees all three rows; a guest does not see the private one", async () => { | |
| 84 | const owner = boot(getUserByName(forge.db, "alice")); | |
| 85 | await owner.select("repo owner:alice"); | |
| 86 | expect(owner.text()).toContain("3 rows"); | |
| 87 | for (const name of ["alice/engine", "alice/tools", "alice/atlas"]) expect(owner.text()).toContain(name); | |
| 88 | ||
| 89 | const guest = boot(undefined); | |
| 90 | await guest.select("repo owner:alice"); | |
| 91 | expect(guest.text()).toContain("2 rows"); | |
| 92 | expect(guest.text()).not.toContain("alice/engine"); | |
| 93 | expect(guest.text()).toContain("alice/tools"); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a line that does not parse keeps the prompt open with its error", async () => { | |
| 97 | const t = boot(getUserByName(forge.db, "alice")); | |
| 98 | await t.select("repo spent:4"); | |
| 99 | expect(t.text()).toContain('repo has no field "spent"'); | |
| 100 | expect(t.text()).toContain("Fleet query"); | |
| 101 | }); | |
| 102 | ||
| 103 | test("s sorts by the next column, r reverses it", async () => { | |
| 104 | const t = boot(getUserByName(forge.db, "alice")); | |
| 105 | await t.select("repo owner:alice"); | |
| 106 | const order = () => | |
| 107 | ["alice/atlas", "alice/engine", "alice/tools"].sort((a, b) => t.text().indexOf(a) - t.text().indexOf(b)); | |
| 108 | await t.key("s"); | |
| 109 | expect(t.text()).toContain("repository ^"); | |
| 110 | expect(order()).toEqual(["alice/atlas", "alice/engine", "alice/tools"]); | |
| 111 | await t.key("r"); | |
| 112 | expect(t.text()).toContain("repository v"); | |
| 113 | expect(order()).toEqual(["alice/tools", "alice/engine", "alice/atlas"]); | |
| 114 | }); | |
| 115 | ||
| 116 | test("fleetLines draws a header and one line a row, in the shared columns", () => { | |
| 117 | const lines = fleetLines( | |
| 118 | "issue", | |
| 119 | [ | |
| 120 | { id: "a", repo: "o/r", number: 2, title: "second", state: "open", assignee: "" }, | |
| 121 | { id: "b", repo: "o/r", number: 1, title: "first", state: "open", assignee: "bo" }, | |
| 122 | ], | |
| 123 | { key: "number", descending: false }, | |
| 124 | ); | |
| 125 | expect(lines[0]).toMatch(/^repository\s+# \^\s+title\s+state\s+assignee$/); | |
| 126 | expect(lines[1]).toContain("first"); | |
| 127 | expect(lines[2]).toMatch(/second\s+open\s+-$/); | |
| 128 | expect(nextSort("issue", undefined)).toEqual({ key: "repo", descending: false }); | |
| 129 | expect(nextSort("issue", { key: "assignee", descending: true })).toEqual({ key: "repo", descending: false }); | |
| 130 | }); |
Casting a fake object through unknown as User bypasses the type check. Make the viewer type in selection.ts explicitly allow a visitor, or pass null.