Public: anyone can read this; the crew can change it.
Contraction: fold /big into the board as its one wall mode #411
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/394-contraction-fold-big-into-the-board-as-i → main 1 commit fork
10 files
changed +140
−2777
docs/STUDIO.md +4
| @@ -387,6 +387,10 @@ three statements, `standings`). Every region | ||
| 387 | 387 | is live — it swaps itself when a matching event lands, and the dot in the |
| 388 | 388 | corner says whether the stream is connected. `?apps=a,b` narrows it to some |
| 389 | 389 | apps. Keys: `f` fullscreen, `1`–`4` one region full-bleed, `0` all. |
| 390 | `?wall` is its one wall mode, for a screen across the room that nobody | |
| 391 | touches: the same regions in larger type, no key hints, no pointer. The | |
| 392 | old big view folded into it — `/big` and every address it had land on | |
| 393 | `/board?wall`, keeping `?app=owner/name` as the board's `?apps=`. | |
| 390 | 394 | Everything links somewhere; nothing needs a click. The board is a wall for |
| 391 | 395 | apps you can write to: a public app someone else owns can still appear in |
| 392 | 396 | it (`listReposVisibleTo` includes every public repo), but its cost, and |
src/http/server.ts +3 −4
| @@ -157,8 +157,7 @@ import { | ||
| 157 | 157 | runsList, |
| 158 | 158 | } from "../web/pages/runs.ts"; |
| 159 | 159 | import { lookImage } from "../web/pages/looks.ts"; |
| 160 | import { boardPage } from "../web/pages/board.ts"; | |
| 161 | import { bigPage } from "../web/pages/big.ts"; | |
| 160 | import { bigRedirect, boardPage } from "../web/pages/board.ts"; | |
| 162 | 161 | import { |
| 163 | 162 | agentCreate, |
| 164 | 163 | agentDelete, |
| @@ -436,8 +435,8 @@ export function buildRouter(): Router<Handler> { | ||
| 436 | 435 | router.add("GET", "/board", boardPage); |
| 437 | 436 | // One screen for "is everything fine?": what asks the viewer (docs/STUDIO.md). |
| 438 | 437 | router.add("GET", "/asks", asksPage); |
| 439 | // The big view: the whole forge as one keyboard-first frame. | |
| 440 | router.add("GET", "/big", bigPage); | |
| 438 | // The big view, folded into the board: every old address lands on its wall mode. | |
| 439 | router.add("GET", "/big", bigRedirect); | |
| 441 | 440 | router.add("GET", "/search", searchPage); |
| 442 | 441 | // The JS-free half of every destructive action. Two literal segments, so it |
| 443 | 442 | // is registered before /:owner/:name can claim the shape. |
src/web/big-model.ts deleted −222
| @@ -1,222 +0,0 @@ | ||
| 1 | /** | |
| 2 | * The big view's pure half: where a movement lands, what a typed command | |
| 3 | * means, and the key sheet every view is drawn from. No DOM in here, so | |
| 4 | * the page renders the footer and the sheet from the same table the client | |
| 5 | * acts on, and a test can hold them against each other. The client script | |
| 6 | * (`big.ts`) carries these functions by their source (`String(fn)`), so | |
| 7 | * the browser runs exactly what the tests ran. | |
| 8 | */ | |
| 9 | ||
| 10 | export type View = "home" | "app" | "run" | "board" | "map" | "list"; | |
| 11 | ||
| 12 | export type ListKind = "runs" | "issues" | "pulls" | "map"; | |
| 13 | ||
| 14 | // ---- where things are ---- | |
| 15 | ||
| 16 | /** The one shape of a big-view address: what is open, and how deep. */ | |
| 17 | export interface Where { | |
| 18 | readonly app?: string | undefined; | |
| 19 | readonly list?: ListKind | undefined; | |
| 20 | readonly run?: string | undefined; | |
| 21 | readonly view?: "board" | "map" | undefined; | |
| 22 | } | |
| 23 | ||
| 24 | export function bigUrl(where: Where): string { | |
| 25 | const q = new URLSearchParams(); | |
| 26 | if (where.run !== undefined) q.set("run", where.run); | |
| 27 | else if (where.app !== undefined) { | |
| 28 | q.set("app", where.app); | |
| 29 | if (where.list !== undefined) q.set("list", where.list); | |
| 30 | } else if (where.view !== undefined) q.set("view", where.view); | |
| 31 | const text = q.toString(); | |
| 32 | return text === "" ? "/big" : `/big?${text}`; | |
| 33 | } | |
| 34 | ||
| 35 | /** One level up: a list or a run to its app, an app or a wall to home. */ | |
| 36 | export function backUrl(where: Where, runApp?: string): string { | |
| 37 | if (where.run !== undefined) | |
| 38 | return runApp === undefined ? "/big" : bigUrl({ app: runApp }); | |
| 39 | if (where.app !== undefined && where.list !== undefined) | |
| 40 | return bigUrl({ app: where.app }); | |
| 41 | return "/big"; | |
| 42 | } | |
| 43 | ||
| 44 | // ---- the focus ---- | |
| 45 | ||
| 46 | /** | |
| 47 | * Where a step of `step` rows lands, over `count` rows, from `at` (-1 for | |
| 48 | * no focus yet). A first step down lands on the first row, a first step up | |
| 49 | * on the last; nothing ever wraps, as in vim. | |
| 50 | */ | |
| 51 | export function moveIndex(count: number, at: number, step: number): number { | |
| 52 | if (count === 0) return -1; | |
| 53 | const next = at < 0 ? (step > 0 ? 0 : count - 1) : at + step; | |
| 54 | return Math.max(0, Math.min(count - 1, next)); | |
| 55 | } | |
| 56 | ||
| 57 | /** The next pane over, wrapping: tab from the last pane lands on the first. */ | |
| 58 | export function nextPane(count: number, at: number, step: number): number { | |
| 59 | if (count === 0) return -1; | |
| 60 | return ((((at < 0 ? 0 : at) + step) % count) + count) % count; | |
| 61 | } | |
| 62 | ||
| 63 | /** A half page: half the rows a pane shows, and one at least. */ | |
| 64 | export function halfPage(visible: number): number { | |
| 65 | return Math.max(1, Math.floor(visible / 2)); | |
| 66 | } | |
| 67 | ||
| 68 | // ---- the command line ---- | |
| 69 | ||
| 70 | export interface Command { | |
| 71 | readonly name: string; | |
| 72 | readonly arg?: string | undefined; | |
| 73 | readonly does: string; | |
| 74 | } | |
| 75 | ||
| 76 | /** What `:` takes. `:app` is the one that needs a word after it. */ | |
| 77 | export const COMMANDS: readonly Command[] = [ | |
| 78 | { name: "app", arg: "name", does: "open an app" }, | |
| 79 | { name: "home", does: "every app" }, | |
| 80 | { name: "board", does: "the wall: working, landed, decided, today" }, | |
| 81 | { name: "map", does: "every app's map, one lane each" }, | |
| 82 | { name: "runs", does: "this app's runs" }, | |
| 83 | { name: "issues", does: "this app's open issues" }, | |
| 84 | { name: "pulls", does: "this app's open pulls" }, | |
| 85 | { name: "help", does: "the key sheet" }, | |
| 86 | { name: "q", does: "leave for the web UI" }, | |
| 87 | ]; | |
| 88 | ||
| 89 | export type Parsed = | |
| 90 | | { readonly kind: "go"; readonly url: string } | |
| 91 | | { readonly kind: "help" } | |
| 92 | | { readonly kind: "none" } | |
| 93 | | { readonly kind: "error"; readonly message: string }; | |
| 94 | ||
| 95 | /** The app a word names, from the ones the viewer can see (`owner/name`). */ | |
| 96 | export function findApp( | |
| 97 | word: string, | |
| 98 | apps: readonly string[], | |
| 99 | ): string | undefined { | |
| 100 | const w = word.toLowerCase(); | |
| 101 | const exact = apps.find( | |
| 102 | (a) => a.toLowerCase() === w || a.toLowerCase().split("/")[1] === w, | |
| 103 | ); | |
| 104 | if (exact !== undefined) return exact; | |
| 105 | return apps.find( | |
| 106 | (a) => | |
| 107 | a.toLowerCase().startsWith(w) || | |
| 108 | (a.toLowerCase().split("/")[1] ?? "").startsWith(w), | |
| 109 | ); | |
| 110 | } | |
| 111 | ||
| 112 | /** | |
| 113 | * What a typed command means. `:app notes` and a bare `:notes` both open | |
| 114 | * the app; the list commands need an app open; anything else is an error | |
| 115 | * that says so, never a guess. | |
| 116 | */ | |
| 117 | export function parseCommand( | |
| 118 | text: string, | |
| 119 | here: { readonly app?: string | undefined; readonly apps: readonly string[] }, | |
| 120 | ): Parsed { | |
| 121 | const words = text.trim().replace(/^:/, "").split(/\s+/).filter(Boolean); | |
| 122 | const head = (words[0] ?? "").toLowerCase(); | |
| 123 | if (head === "") return { kind: "none" }; | |
| 124 | const q = (w: Record<string, string>): string => { | |
| 125 | const p = new URLSearchParams(w); | |
| 126 | return `/big?${p.toString()}`; | |
| 127 | }; | |
| 128 | if (head === "app") { | |
| 129 | const word = words[1]; | |
| 130 | if (word === undefined) return { kind: "error", message: "app which?" }; | |
| 131 | const app = findApp(word, here.apps); | |
| 132 | return app === undefined | |
| 133 | ? { kind: "error", message: `no app called ${word}` } | |
| 134 | : { kind: "go", url: q({ app }) }; | |
| 135 | } | |
| 136 | if (head === "home") return { kind: "go", url: "/big" }; | |
| 137 | if (head === "board") return { kind: "go", url: q({ view: "board" }) }; | |
| 138 | if (head === "map") | |
| 139 | return { | |
| 140 | kind: "go", | |
| 141 | url: | |
| 142 | here.app === undefined | |
| 143 | ? q({ view: "map" }) | |
| 144 | : q({ app: here.app, list: "map" }), | |
| 145 | }; | |
| 146 | if (head === "runs" || head === "issues" || head === "pulls") | |
| 147 | return here.app === undefined | |
| 148 | ? { kind: "error", message: `open an app first, then :${head}` } | |
| 149 | : { kind: "go", url: q({ app: here.app, list: head }) }; | |
| 150 | if (head === "help" || head === "?") return { kind: "help" }; | |
| 151 | if (head === "q" || head === "quit") | |
| 152 | return { | |
| 153 | kind: "go", | |
| 154 | url: here.app === undefined ? "/" : `/${here.app}`, | |
| 155 | }; | |
| 156 | const app = findApp(head, here.apps); | |
| 157 | return app === undefined | |
| 158 | ? { kind: "error", message: `unknown: ${head}` } | |
| 159 | : { kind: "go", url: q({ app }) }; | |
| 160 | } | |
| 161 | ||
| 162 | // ---- the keys ---- | |
| 163 | ||
| 164 | export interface KeyHint { | |
| 165 | /** The chips, as typed: `["j", "k"]`, `["ctrl-d"]`, `["1"–"9"]`. */ | |
| 166 | readonly keys: readonly string[]; | |
| 167 | readonly does: string; | |
| 168 | /** Shown in the footer, not only on the sheet. */ | |
| 169 | readonly foot?: boolean; | |
| 170 | } | |
| 171 | ||
| 172 | const MOVE: readonly KeyHint[] = [ | |
| 173 | { keys: ["h", "j", "k", "l"], does: "move", foot: true }, | |
| 174 | { keys: ["↑", "↓", "←", "→"], does: "move" }, | |
| 175 | { keys: ["g", "G"], does: "top, bottom" }, | |
| 176 | { keys: ["ctrl-d", "ctrl-u"], does: "half a page" }, | |
| 177 | { keys: ["tab", "1"], does: "next pane, pane 1–9" }, | |
| 178 | { keys: ["enter"], does: "open", foot: true }, | |
| 179 | { keys: ["esc"], does: "back", foot: true }, | |
| 180 | { keys: ["o"], does: "open in the web UI, in a new tab" }, | |
| 181 | { keys: [":"], does: "command", foot: true }, | |
| 182 | { keys: ["/"], does: "filter", foot: true }, | |
| 183 | { keys: ["?"], does: "help", foot: true }, | |
| 184 | { keys: ["f"], does: "fullscreen" }, | |
| 185 | ]; | |
| 186 | ||
| 187 | const BY_VIEW: Readonly<Record<View, readonly KeyHint[]>> = { | |
| 188 | home: [{ keys: ["t"], does: "tell the focused app's crew", foot: true }], | |
| 189 | app: [ | |
| 190 | { keys: ["s"], does: "ship the focused green pull", foot: true }, | |
| 191 | { | |
| 192 | keys: ["x"], | |
| 193 | does: "stop the focused run, close the focused ask", | |
| 194 | foot: true, | |
| 195 | }, | |
| 196 | { keys: ["t"], does: "tell the crew, or the focused ask", foot: true }, | |
| 197 | { keys: ["r"], does: "runs", foot: true }, | |
| 198 | { keys: ["i"], does: "issues", foot: true }, | |
| 199 | { keys: ["p"], does: "pulls", foot: true }, | |
| 200 | { keys: ["m"], does: "the map", foot: true }, | |
| 201 | ], | |
| 202 | run: [ | |
| 203 | { keys: ["t"], does: "steer it", foot: true }, | |
| 204 | { keys: ["x"], does: "stop it", foot: true }, | |
| 205 | ], | |
| 206 | board: [{ keys: ["0"], does: "every region" }], | |
| 207 | map: [{ keys: ["enter"], does: "open the app" }], | |
| 208 | list: [ | |
| 209 | { keys: ["s"], does: "ship a green pull", foot: true }, | |
| 210 | { keys: ["x"], does: "close it", foot: true }, | |
| 211 | ], | |
| 212 | }; | |
| 213 | ||
| 214 | /** Every key for a view: the shared movement first, then the view's own. */ | |
| 215 | export function keysFor(view: View): readonly KeyHint[] { | |
| 216 | return [...MOVE, ...BY_VIEW[view]]; | |
| 217 | } | |
| 218 | ||
| 219 | /** The footer's line: what fits on one row, in order. */ | |
| 220 | export function footKeysFor(view: View): readonly KeyHint[] { | |
| 221 | return keysFor(view).filter((k) => k.foot === true); | |
| 222 | } |
src/web/big.ts deleted −467
| @@ -1,467 +0,0 @@ | ||
| 1 | /** | |
| 2 | * The big view's client: one focus model for the keyboard and a gamepad, | |
| 3 | * one line for a command, a filter or a word to the crew, a small stack of | |
| 4 | * where you came from, and the clocks. Everything a key does is a thing on | |
| 5 | * the page already — a row's link, a row's button, the footer's own chips | |
| 6 | * — so the sheet cannot promise what the page does not have. | |
| 7 | * | |
| 8 | * The pure parts (`big-model.ts`) are carried in by their source, so the | |
| 9 | * browser runs exactly what the tests ran. Nothing here animates: the | |
| 10 | * clock ticks once a second, and the rest waits for a key. | |
| 11 | */ | |
| 12 | ||
| 13 | import { | |
| 14 | findApp, | |
| 15 | halfPage, | |
| 16 | moveIndex, | |
| 17 | nextPane, | |
| 18 | parseCommand, | |
| 19 | } from "./big-model.ts"; | |
| 20 | ||
| 21 | const shared = [moveIndex, nextPane, halfPage, findApp, parseCommand] | |
| 22 | .map(String) | |
| 23 | .join("\n"); | |
| 24 | ||
| 25 | export const bigJs = /* js */ ` | |
| 26 | ${shared} | |
| 27 | (function () { | |
| 28 | var doc = document; | |
| 29 | var big = doc.getElementById('big'); | |
| 30 | if (!big) return; | |
| 31 | var here = big.getAttribute('data-here') || ''; | |
| 32 | var apps = (big.getAttribute('data-apps') || '').split(' ').filter(Boolean); | |
| 33 | var where = location.pathname + location.search; | |
| 34 | ||
| 35 | // ---- memory: this browser's tab remembers where you were ---- | |
| 36 | function remember(k, v) { try { sessionStorage.setItem(k, JSON.stringify(v)); } catch (e) {} } | |
| 37 | function recall(k) { try { var v = sessionStorage.getItem(k); return v ? JSON.parse(v) : null; } catch (e) { return null; } } | |
| 38 | ||
| 39 | // ---- panes and rows ---- | |
| 40 | function panes() { return Array.prototype.slice.call(big.querySelectorAll('.big-pane')); } | |
| 41 | function shown(el) { return !el.hasAttribute('data-filtered') && !el.closest('[hidden]'); } | |
| 42 | function rowsOf(pane) { return pane ? Array.prototype.filter.call(pane.querySelectorAll('[data-key]'), shown) : []; } | |
| 43 | function nameOf(pane) { return pane ? pane.getAttribute('data-pane') || '' : ''; } | |
| 44 | var paneAt = 0; | |
| 45 | var keys = {}; | |
| 46 | function current() { return panes()[paneAt] || null; } | |
| 47 | function rowByKey(pane, key) { | |
| 48 | var rows = rowsOf(pane); | |
| 49 | for (var i = 0; i < rows.length; i++) if (rows[i].getAttribute('data-key') === key) return rows[i]; | |
| 50 | return null; | |
| 51 | } | |
| 52 | function focusedRow() { var p = current(); return p ? rowByKey(p, keys[nameOf(p)] || '') : null; } | |
| 53 | function paint() { | |
| 54 | panes().forEach(function (p, i) { | |
| 55 | p.classList.toggle('is-pane', i === paneAt); | |
| 56 | var k = keys[nameOf(p)] || ''; | |
| 57 | Array.prototype.forEach.call(p.querySelectorAll('.is-focus'), function (el) { | |
| 58 | if (el.getAttribute('data-key') !== k) el.classList.remove('is-focus'); | |
| 59 | }); | |
| 60 | var el = rowByKey(p, k); | |
| 61 | if (el) el.classList.add('is-focus'); | |
| 62 | }); | |
| 63 | glance(); | |
| 64 | remember('big:' + where, { pane: paneAt, keys: keys }); | |
| 65 | } | |
| 66 | function settle(pane, row, scroll) { | |
| 67 | if (!pane) return; | |
| 68 | keys[nameOf(pane)] = row ? row.getAttribute('data-key') : ''; | |
| 69 | paint(); | |
| 70 | if (row && !mode) { | |
| 71 | row.focus({ preventScroll: true }); | |
| 72 | if (scroll !== false) row.scrollIntoView({ block: 'nearest' }); | |
| 73 | } | |
| 74 | } | |
| 75 | function moveRow(step) { | |
| 76 | var p = current(); if (!p) return; | |
| 77 | var rows = rowsOf(p), r = focusedRow(); | |
| 78 | if (rows.length === 0) { | |
| 79 | // A pane with no rows — a transcript — scrolls by the line instead. | |
| 80 | var box = p.querySelector('.run-log') || p.querySelector('.big-scroll'); | |
| 81 | if (box) box.scrollTop += step * 40; | |
| 82 | return; | |
| 83 | } | |
| 84 | var next = moveIndex(rows.length, r ? rows.indexOf(r) : -1, step); | |
| 85 | if (next >= 0) settle(p, rows[next]); | |
| 86 | } | |
| 87 | function movePane(step) { | |
| 88 | var all = panes(); | |
| 89 | paneAt = nextPane(all.length, paneAt, step); | |
| 90 | var p = current(); | |
| 91 | settle(p, focusedRow() || rowsOf(p)[0] || null); | |
| 92 | } | |
| 93 | function toPane(n) { | |
| 94 | if (n >= panes().length) return; | |
| 95 | paneAt = n; | |
| 96 | var p = current(); | |
| 97 | settle(p, focusedRow() || rowsOf(p)[0] || null); | |
| 98 | } | |
| 99 | function jump(end) { | |
| 100 | var p = current(); if (!p) return; | |
| 101 | var rows = rowsOf(p); | |
| 102 | settle(p, end === 'top' ? rows[0] || null : rows[rows.length - 1] || null); | |
| 103 | } | |
| 104 | function pageRows(p) { | |
| 105 | var rows = rowsOf(p); | |
| 106 | if (rows.length === 0) return 1; | |
| 107 | var h = rows[0].getBoundingClientRect().height || 24; | |
| 108 | var box = p.querySelector('.big-scroll') || p; | |
| 109 | return Math.max(1, Math.floor(box.clientHeight / h)); | |
| 110 | } | |
| 111 | ||
| 112 | // ---- the glance: beside the apps, the focused app at a glance ---- | |
| 113 | function glance() { | |
| 114 | var pane = big.querySelector('[data-pane="glance"]'); | |
| 115 | if (!pane) return; | |
| 116 | var k = keys['apps'] || '', on = null; | |
| 117 | Array.prototype.forEach.call(pane.querySelectorAll('[data-for]'), function (g) { | |
| 118 | var hit = g.getAttribute('data-for') === k; | |
| 119 | g.hidden = !hit; | |
| 120 | if (hit) on = g; | |
| 121 | }); | |
| 122 | var empty = pane.querySelector('.big-glance-empty'); | |
| 123 | if (empty) empty.hidden = !!on; | |
| 124 | var note = pane.querySelector('.big-h-note'); | |
| 125 | if (note) note.textContent = on ? on.getAttribute('data-name') || '' : ''; | |
| 126 | } | |
| 127 | ||
| 128 | // ---- navigation: a stack of where you came from ---- | |
| 129 | function go(url) { | |
| 130 | var stack = recall('big:stack') || []; | |
| 131 | stack.push(where); | |
| 132 | if (stack.length > 30) stack.shift(); | |
| 133 | remember('big:stack', stack); | |
| 134 | location.href = url; | |
| 135 | } | |
| 136 | function back() { | |
| 137 | var stack = recall('big:stack') || []; | |
| 138 | var prev = stack.pop(); | |
| 139 | remember('big:stack', stack); | |
| 140 | location.href = prev && prev !== where ? prev : big.getAttribute('data-back') || '/big'; | |
| 141 | } | |
| 142 | // What a row opens: where it says, else — for a run — its page here, | |
| 143 | // else the link it is or holds. | |
| 144 | function hrefOf(row) { | |
| 145 | if (row.getAttribute('data-href')) return row.getAttribute('data-href'); | |
| 146 | var key = row.getAttribute('data-key') || ''; | |
| 147 | if (key.indexOf('run:') === 0) return '/big?run=' + encodeURIComponent(key.slice(4)); | |
| 148 | if (row.tagName.toLowerCase() === 'a') return row.getAttribute('href'); | |
| 149 | var a = row.querySelector('a[href]'); | |
| 150 | return a ? a.getAttribute('href') : null; | |
| 151 | } | |
| 152 | function openRow() { | |
| 153 | var r = focusedRow(); if (!r) return; | |
| 154 | var href = hrefOf(r); if (!href) return; | |
| 155 | if (href.indexOf('/big') === 0) go(href); else location.href = href; | |
| 156 | } | |
| 157 | function openWeb() { | |
| 158 | var r = focusedRow(); | |
| 159 | var href = r ? r.getAttribute('data-open') || hrefOf(r) : big.getAttribute('data-open'); | |
| 160 | if (href && href.indexOf('/big') !== 0) window.open(href, '_blank', 'noopener'); | |
| 161 | } | |
| 162 | ||
| 163 | // ---- actions: a key presses a real control ---- | |
| 164 | function hint(text, tone) { | |
| 165 | var el = doc.getElementById('big-hint'); if (!el) return; | |
| 166 | el.textContent = text || ''; | |
| 167 | if (tone) el.setAttribute('data-tone', tone); else el.removeAttribute('data-tone'); | |
| 168 | if (text) setTimeout(function () { if (el.textContent === text) { el.textContent = ''; el.removeAttribute('data-tone'); } }, 6000); | |
| 169 | } | |
| 170 | function act(key) { | |
| 171 | var r = focusedRow(), p = current(); | |
| 172 | var btn = (r && r.querySelector('[data-act="' + key + '"]')) || (p && p.querySelector('.big-h [data-act="' + key + '"], .big-run [data-act="' + key + '"]')); | |
| 173 | if (!btn) return false; | |
| 174 | btn.click(); | |
| 175 | return true; | |
| 176 | } | |
| 177 | function post(url, body) { | |
| 178 | return fetch(url, { method: 'POST', body: body, headers: { accept: 'application/json' }, credentials: 'same-origin' }) | |
| 179 | .then(function (r) { | |
| 180 | var type = r.headers.get('content-type') || ''; | |
| 181 | if (type.indexOf('application/json') !== -1) | |
| 182 | return r.json().then(function (out) { return { ok: !!out.ok, message: out.flash || out.error || (out.ok ? 'done' : 'that could not be done') }; }); | |
| 183 | return { ok: r.ok, message: r.ok ? 'done' : 'that could not be done (' + r.status + ')' }; | |
| 184 | }, function () { return { ok: false, message: 'the forge could not be reached' }; }); | |
| 185 | } | |
| 186 | // A pane's form posts in place; the stream brings what changed. The | |
| 187 | // run's own composer keeps its own script. | |
| 188 | big.addEventListener('submit', function (ev) { | |
| 189 | var form = ev.target; | |
| 190 | if (!form || form.id === 'big-line' || form.id === 'run-steer' || !window.fetch) return; | |
| 191 | ev.preventDefault(); | |
| 192 | var body = new FormData(form); | |
| 193 | var pressed = ev.submitter; | |
| 194 | if (pressed && pressed.name) body.append(pressed.name, pressed.value); | |
| 195 | var row = pressed && pressed.closest ? pressed.closest('[data-key]') : null; | |
| 196 | if (row) row.setAttribute('data-busy', ''); | |
| 197 | hint('…'); | |
| 198 | post(form.getAttribute('action') || where, body).then(function (out) { | |
| 199 | if (row) row.removeAttribute('data-busy'); | |
| 200 | hint(out.message, out.ok ? 'success' : 'danger'); | |
| 201 | }); | |
| 202 | }); | |
| 203 | ||
| 204 | // ---- the line: a command, a filter, or a word ---- | |
| 205 | var line = doc.getElementById('big-line'); | |
| 206 | var input = doc.getElementById('big-line-in'); | |
| 207 | var mark = doc.getElementById('big-line-mark'); | |
| 208 | var note = doc.getElementById('big-line-note'); | |
| 209 | var mode = null; | |
| 210 | var filters = {}; | |
| 211 | function openLine(m, action, placeholder) { | |
| 212 | if (!line || !input) return; | |
| 213 | mode = m; | |
| 214 | line.hidden = false; | |
| 215 | line.setAttribute('data-mode', m); | |
| 216 | line.setAttribute('action', action || ''); | |
| 217 | if (mark) mark.textContent = m === 'cmd' ? ':' : m === 'filter' ? '/' : '\\u203a'; | |
| 218 | input.value = m === 'filter' ? filters[nameOf(current())] || '' : ''; | |
| 219 | input.placeholder = placeholder || ''; | |
| 220 | if (note) { note.textContent = ''; note.removeAttribute('data-tone'); } | |
| 221 | input.focus(); | |
| 222 | } | |
| 223 | function closeLine() { | |
| 224 | mode = null; | |
| 225 | if (line) line.hidden = true; | |
| 226 | if (input) input.blur(); | |
| 227 | var r = focusedRow(); | |
| 228 | if (r) r.focus({ preventScroll: true }); | |
| 229 | } | |
| 230 | function applyFilter(p) { | |
| 231 | if (!p) return; | |
| 232 | var q = (filters[nameOf(p)] || '').toLowerCase(); | |
| 233 | Array.prototype.forEach.call(p.querySelectorAll('[data-key]'), function (row) { | |
| 234 | var hit = q === '' || (row.textContent || '').toLowerCase().indexOf(q) !== -1; | |
| 235 | if (hit) row.removeAttribute('data-filtered'); else row.setAttribute('data-filtered', ''); | |
| 236 | }); | |
| 237 | var h = p.querySelector('.big-h'); | |
| 238 | if (h) h.setAttribute('data-q', q); | |
| 239 | if (!focusedRow()) settle(p, rowsOf(p)[0] || null, false); | |
| 240 | } | |
| 241 | function fail(message) { if (note) { note.textContent = message; note.setAttribute('data-tone', 'danger'); } } | |
| 242 | function submitLine() { | |
| 243 | if (!input) return; | |
| 244 | var text = input.value; | |
| 245 | if (mode === 'cmd') { | |
| 246 | var parsed = parseCommand(text, { app: here || undefined, apps: apps }); | |
| 247 | if (parsed.kind === 'go') { closeLine(); go(parsed.url); } | |
| 248 | else if (parsed.kind === 'help') { closeLine(); openHelp(); } | |
| 249 | else if (parsed.kind === 'error') fail(parsed.message); | |
| 250 | else closeLine(); | |
| 251 | } else if (mode === 'say') { | |
| 252 | var said = text.trim(); | |
| 253 | if (!said) return; | |
| 254 | input.disabled = true; | |
| 255 | post(line.getAttribute('action'), new URLSearchParams({ text: said })).then(function (out) { | |
| 256 | input.disabled = false; | |
| 257 | if (out.ok) { closeLine(); hint(out.message, 'success'); } else { fail(out.message); input.focus(); } | |
| 258 | }); | |
| 259 | } else closeLine(); | |
| 260 | } | |
| 261 | if (line) line.addEventListener('submit', function (e) { e.preventDefault(); submitLine(); }); | |
| 262 | if (input) input.addEventListener('input', function () { | |
| 263 | if (mode !== 'filter') return; | |
| 264 | var p = current(); | |
| 265 | filters[nameOf(p)] = input.value; | |
| 266 | applyFilter(p); | |
| 267 | }); | |
| 268 | function say() { | |
| 269 | var steer = doc.getElementById('steer-text'); | |
| 270 | if (steer) { steer.focus(); return; } | |
| 271 | var r = focusedRow(), p = current(); | |
| 272 | var action = (r && r.getAttribute('data-say')) || (p && p.getAttribute('data-say')) || big.getAttribute('data-say'); | |
| 273 | if (!action) { hint('nothing here takes a word', 'danger'); return; } | |
| 274 | var who = (r && r.getAttribute('data-say-who')) || (p && p.getAttribute('data-say-who')) || big.getAttribute('data-say-who') || 'the crew'; | |
| 275 | openLine('say', action, 'tell ' + who + ' \\u2014 enter sends, esc keeps quiet'); | |
| 276 | } | |
| 277 | ||
| 278 | // ---- the sheet ---- | |
| 279 | function openHelp() { | |
| 280 | var dlg = doc.getElementById('big-help'); | |
| 281 | if (!dlg || !dlg.showModal) return; | |
| 282 | if (dlg.open) dlg.close(); else dlg.showModal(); | |
| 283 | } | |
| 284 | function fullscreen() { | |
| 285 | if (doc.fullscreenElement) { if (doc.exitFullscreen) doc.exitFullscreen(); } | |
| 286 | else if (doc.documentElement.requestFullscreen) doc.documentElement.requestFullscreen().catch(function () {}); | |
| 287 | } | |
| 288 | ||
| 289 | // ---- keys ---- | |
| 290 | function chipGo(key) { | |
| 291 | var chip = doc.querySelector('#big-foot [data-k="' + key + '"][data-go]'); | |
| 292 | if (!chip) return false; | |
| 293 | go(chip.getAttribute('data-go')); | |
| 294 | return true; | |
| 295 | } | |
| 296 | function handle(key, shift) { | |
| 297 | switch (key) { | |
| 298 | case 'j': case 'ArrowDown': moveRow(1); return true; | |
| 299 | case 'k': case 'ArrowUp': moveRow(-1); return true; | |
| 300 | case 'h': case 'ArrowLeft': movePane(-1); return true; | |
| 301 | case 'l': case 'ArrowRight': movePane(1); return true; | |
| 302 | case 'Tab': movePane(shift ? -1 : 1); return true; | |
| 303 | case 'g': jump('top'); return true; | |
| 304 | case 'G': jump('bottom'); return true; | |
| 305 | case 'ctrl-d': moveRow(halfPage(pageRows(current()))); return true; | |
| 306 | case 'ctrl-u': moveRow(-halfPage(pageRows(current()))); return true; | |
| 307 | case 'Enter': openRow(); return true; | |
| 308 | case 'Escape': case 'Backspace': back(); return true; | |
| 309 | case ':': openLine('cmd', '', 'app <name> \\u00b7 home \\u00b7 board \\u00b7 map \\u00b7 runs \\u00b7 issues \\u00b7 pulls \\u00b7 q'); return true; | |
| 310 | case '/': openLine('filter', '', 'filter this pane as you type'); return true; | |
| 311 | case '?': openHelp(); return true; | |
| 312 | case 'f': fullscreen(); return true; | |
| 313 | case 'o': openWeb(); return true; | |
| 314 | case 't': say(); return true; | |
| 315 | case 's': case 'x': if (!act(key)) hint('nothing focused takes ' + key, 'danger'); return true; | |
| 316 | default: | |
| 317 | if (/^[1-9]$/.test(key)) { toPane(Number(key) - 1); return true; } | |
| 318 | if (/^[a-z]$/.test(key)) return chipGo(key); | |
| 319 | return false; | |
| 320 | } | |
| 321 | } | |
| 322 | function typing(t) { return !!(t && t.matches && t.matches('input, textarea, select, [contenteditable]')); } | |
| 323 | doc.addEventListener('keydown', function (e) { | |
| 324 | if (e.metaKey || e.altKey) return; | |
| 325 | var t = e.target; | |
| 326 | var dlg = doc.querySelector('dialog[open]'); | |
| 327 | if (dlg) { | |
| 328 | if (dlg.id === 'big-help' && (e.key === 'Escape' || e.key === '?')) { e.preventDefault(); e.stopPropagation(); dlg.close(); } | |
| 329 | return; | |
| 330 | } | |
| 331 | if (typing(t)) { | |
| 332 | if (e.key === 'Escape') { | |
| 333 | e.preventDefault(); e.stopPropagation(); | |
| 334 | if (mode) closeLine(); else { t.blur(); var r = focusedRow(); if (r) r.focus({ preventScroll: true }); } | |
| 335 | } | |
| 336 | return; | |
| 337 | } | |
| 338 | if (e.ctrlKey) { | |
| 339 | if (e.key === 'd' || e.key === 'u') { e.preventDefault(); e.stopPropagation(); handle('ctrl-' + e.key, false); } | |
| 340 | return; | |
| 341 | } | |
| 342 | if (handle(e.key, e.shiftKey)) { e.preventDefault(); e.stopPropagation(); } | |
| 343 | }, true); | |
| 344 | // A click lands the focus where the pointer is; the link or button under | |
| 345 | // it still does its own thing. A tell chip opens the line for its row. | |
| 346 | big.addEventListener('mousedown', function (e) { | |
| 347 | var row = e.target && e.target.closest ? e.target.closest('[data-key]') : null; | |
| 348 | if (!row) return; | |
| 349 | var pane = row.closest('.big-pane'); | |
| 350 | var at = panes().indexOf(pane); | |
| 351 | if (at !== -1) { paneAt = at; settle(pane, row, false); } | |
| 352 | }); | |
| 353 | big.addEventListener('click', function (e) { | |
| 354 | var chip = e.target && e.target.closest ? e.target.closest('[data-say-open]') : null; | |
| 355 | if (chip) { e.preventDefault(); say(); } | |
| 356 | }); | |
| 357 | ||
| 358 | // ---- the gamepad: the same moves, from the couch ---- | |
| 359 | var BTN = { 0: 'Enter', 1: 'Escape', 3: '?', 4: 'h', 5: 'l', 8: '/', 9: ':', 12: 'k', 13: 'j', 14: 'h', 15: 'l' }; | |
| 360 | var held = {}, loop = null; | |
| 361 | function fire(k) { | |
| 362 | if (mode) { if (k === 'Escape') closeLine(); else if (k === 'Enter') submitLine(); return; } | |
| 363 | var dlg = doc.querySelector('dialog[open]'); | |
| 364 | if (dlg) { if (k === 'Escape' || k === '?') dlg.close(); return; } | |
| 365 | handle(k, false); | |
| 366 | } | |
| 367 | function poll() { | |
| 368 | var list = navigator.getGamepads ? navigator.getGamepads() : []; | |
| 369 | var any = false, now = Date.now(); | |
| 370 | for (var i = 0; i < list.length; i++) { | |
| 371 | var gp = list[i]; if (!gp) continue; | |
| 372 | any = true; | |
| 373 | var down = {}; | |
| 374 | for (var b = 0; b < gp.buttons.length; b++) if (gp.buttons[b].pressed && BTN[b]) down[BTN[b]] = true; | |
| 375 | var ax = gp.axes[0] || 0, ay = gp.axes[1] || 0; | |
| 376 | if (ay < -0.5) down['k'] = true; else if (ay > 0.5) down['j'] = true; | |
| 377 | if (ax < -0.5) down['h'] = true; else if (ax > 0.5) down['l'] = true; | |
| 378 | Object.keys(down).forEach(function (k) { | |
| 379 | var h = held[k]; | |
| 380 | if (!h) { held[k] = { next: now + 380 }; fire(k); } | |
| 381 | else if (now >= h.next && 'jkhl'.indexOf(k) !== -1) { h.next = now + 110; fire(k); } | |
| 382 | }); | |
| 383 | Object.keys(held).forEach(function (k) { if (!down[k]) delete held[k]; }); | |
| 384 | } | |
| 385 | if (any) loop = requestAnimationFrame(poll); else { loop = null; big.removeAttribute('data-pad'); } | |
| 386 | } | |
| 387 | window.addEventListener('gamepadconnected', function () { | |
| 388 | big.setAttribute('data-pad', ''); | |
| 389 | if (loop === null) loop = requestAnimationFrame(poll); | |
| 390 | }); | |
| 391 | ||
| 392 | // ---- clocks: the corner, every elapsed, every relative time ---- | |
| 393 | var clock = doc.getElementById('big-clock'); | |
| 394 | function two(n) { return (n < 10 ? '0' : '') + n; } | |
| 395 | function span(s) { | |
| 396 | return s < 60 ? s + 's' | |
| 397 | : s < 3600 ? Math.floor(s / 60) + 'm ' + two(s % 60) + 's' | |
| 398 | : Math.floor(s / 3600) + 'h ' + two(Math.floor((s % 3600) / 60)) + 'm'; | |
| 399 | } | |
| 400 | function ago(s) { | |
| 401 | if (s < 60) return 'just now'; | |
| 402 | if (s < 3600) return Math.floor(s / 60) + 'm ago'; | |
| 403 | if (s < 86400) return Math.floor(s / 3600) + 'h ago'; | |
| 404 | if (s < 86400 * 30) return Math.floor(s / 86400) + 'd ago'; | |
| 405 | if (s < 86400 * 365) return Math.floor(s / (86400 * 30)) + 'mo ago'; | |
| 406 | return Math.floor(s / (86400 * 365)) + 'y ago'; | |
| 407 | } | |
| 408 | function tick() { | |
| 409 | var now = Date.now(); | |
| 410 | if (clock) { | |
| 411 | var d = new Date(now); | |
| 412 | clock.textContent = two(d.getHours()) + ':' + two(d.getMinutes()); | |
| 413 | clock.setAttribute('datetime', d.toISOString()); | |
| 414 | } | |
| 415 | Array.prototype.forEach.call(big.querySelectorAll('[data-started]:not(#run-elapsed)'), function (el) { | |
| 416 | var t = Date.parse(el.getAttribute('data-started') || ''); | |
| 417 | if (t) el.textContent = span(Math.max(0, Math.round((now - t) / 1000))); | |
| 418 | }); | |
| 419 | Array.prototype.forEach.call(big.querySelectorAll('time[datetime]'), function (el) { | |
| 420 | if (el === clock) return; | |
| 421 | var t = Date.parse(el.getAttribute('datetime') || ''); | |
| 422 | if (t) el.textContent = ago(Math.max(0, (now - t) / 1000)); | |
| 423 | }); | |
| 424 | } | |
| 425 | window.setInterval(tick, 1000); | |
| 426 | tick(); | |
| 427 | ||
| 428 | // ---- live: the dot, and a swap that keeps the focus where it was ---- | |
| 429 | doc.addEventListener('forge:live', function (e) { | |
| 430 | var live = doc.getElementById('big-live'); | |
| 431 | if (live && e.detail) live.setAttribute('data-state', e.detail.state); | |
| 432 | }); | |
| 433 | doc.addEventListener('forge:swapped', function (e) { | |
| 434 | var pane = e.target && e.target.closest ? e.target.closest('.big-pane') : null; | |
| 435 | if (pane) { | |
| 436 | applyFilter(pane); | |
| 437 | pane.setAttribute('data-fresh', ''); | |
| 438 | window.setTimeout(function () { pane.removeAttribute('data-fresh'); }, 1500); | |
| 439 | } | |
| 440 | paint(); | |
| 441 | tick(); | |
| 442 | var r = focusedRow(), p = current(); | |
| 443 | if (!r && p) settle(p, rowsOf(p)[0] || null, false); | |
| 444 | else if (r && !mode && doc.activeElement !== r && !typing(doc.activeElement)) r.focus({ preventScroll: true }); | |
| 445 | }); | |
| 446 | ||
| 447 | // ---- the map pans to where the crew is ---- | |
| 448 | (function () { | |
| 449 | var working = big.querySelector('.map-node[data-state="working"]'); | |
| 450 | if (!working) return; | |
| 451 | var was = doc.activeElement; | |
| 452 | working.focus({ preventScroll: true }); | |
| 453 | if (was && was !== doc.body && was.focus) was.focus({ preventScroll: true }); else working.blur(); | |
| 454 | })(); | |
| 455 | ||
| 456 | // ---- restore: the pane and row this tab was on here ---- | |
| 457 | (function () { | |
| 458 | var saved = recall('big:' + where); | |
| 459 | if (saved) { | |
| 460 | paneAt = Math.min(saved.pane || 0, Math.max(0, panes().length - 1)); | |
| 461 | keys = saved.keys || {}; | |
| 462 | } | |
| 463 | var p = current(); | |
| 464 | settle(p, focusedRow() || rowsOf(p)[0] || null, false); | |
| 465 | })(); | |
| 466 | })(); | |
| 467 | `; |
src/web/client.ts +2 −2
| @@ -2045,8 +2045,8 @@ export const liveRegionsJs = /* js */ ` | ||
| 2045 | 2045 | var mine = document.getElementById(id); |
| 2046 | 2046 | var theirs = fresh.getElementById(id); |
| 2047 | 2047 | if (!mine || !theirs) return; |
| 2048 | // A card mid-action (the big view marks it data-busy while its post | |
| 2049 | // is in flight) is never pulled out from under the click; the next | |
| 2048 | // A region mid-action (something in it marked data-busy while its | |
| 2049 | // post is in flight) is never pulled out from under the click; the next | |
| 2050 | 2050 | // matching event tries the swap again. |
| 2051 | 2051 | if (mine.querySelector('[data-busy]')) return; |
| 2052 | 2052 | var active = document.activeElement; |
src/web/pages/big.ts deleted −1303
| @@ -1,1303 +0,0 @@ | ||
| 1 | /** | |
| 2 | * The big view: the whole forge as one full-screen, keyboard-first frame | |
| 3 | * — a header line, a body of panes, a footer line of keys — with the feel | |
| 4 | * of k9s, lazygit and btop, in the browser, and from a couch with a | |
| 5 | * gamepad. A second front door, not a re-skin: every pane reads the same | |
| 6 | * tables the studio and the board read, and every action is one key that | |
| 7 | * presses a real control. | |
| 8 | * | |
| 9 | * Views are server-rendered pages under one shell (`/big`, `?app=`, | |
| 10 | * `?run=`, `?view=board|map`, `&list=runs|issues|pulls|map`); the client | |
| 11 | * script is the same on every one, the focus is remembered per address, | |
| 12 | * and the live regions keep the page fresh in place. | |
| 13 | */ | |
| 14 | ||
| 15 | import { getAppByRepo, appsForRepos } from "../../core/apps.ts"; | |
| 16 | import { listDecisionsForRepos } from "../../core/decisions.ts"; | |
| 17 | import { criticalPath, readyNow, repoGraph } from "../../core/dependencies.ts"; | |
| 18 | import { | |
| 19 | getIssue, | |
| 20 | getIssueById, | |
| 21 | getIssuesByIds, | |
| 22 | listIssues, | |
| 23 | type Issue, | |
| 24 | } from "../../core/issues.ts"; | |
| 25 | import { listPulls, mergedPullsForRepos, type Pull } from "../../core/pulls.ts"; | |
| 26 | import { | |
| 27 | canRead, | |
| 28 | canWrite, | |
| 29 | getRepo, | |
| 30 | getRepoById, | |
| 31 | listReposVisibleTo, | |
| 32 | type Repo, | |
| 33 | } from "../../core/repos.ts"; | |
| 34 | import { | |
| 35 | getRun, | |
| 36 | isTerminal, | |
| 37 | latestRunsForIssues, | |
| 38 | listActiveRuns, | |
| 39 | listRuns, | |
| 40 | type Run, | |
| 41 | } from "../../core/runs.ts"; | |
| 42 | import { getUsersByIds, type User } from "../../core/users.ts"; | |
| 43 | import { autonomyStatus } from "../../crew/autonomy.ts"; | |
| 44 | import type { Ctx } from "../../http/context.ts"; | |
| 45 | import { | |
| 46 | backUrl, | |
| 47 | bigUrl, | |
| 48 | COMMANDS, | |
| 49 | footKeysFor, | |
| 50 | keysFor, | |
| 51 | type ListKind, | |
| 52 | type View, | |
| 53 | type Where, | |
| 54 | } from "../big-model.ts"; | |
| 55 | import { bigJs } from "../big.ts"; | |
| 56 | import { notFoundPage, repoBase, signInFirst } from "../chrome.ts"; | |
| 57 | import { liveRegionsJs, runLogJs } from "../client.ts"; | |
| 58 | import { html, when, type Html } from "../html.ts"; | |
| 59 | import { page } from "../layout.ts"; | |
| 60 | import { bigCss } from "../style/big.ts"; | |
| 61 | import { avatar, runStateBadge, time } from "../ui.ts"; | |
| 62 | import { boardCss, boardRegions, boardScope } from "./board.ts"; | |
| 63 | import { | |
| 64 | countWords, | |
| 65 | drawMap, | |
| 66 | drawingWidth, | |
| 67 | emphasisFor, | |
| 68 | globalLanes, | |
| 69 | laneHtml, | |
| 70 | layout, | |
| 71 | mapCss, | |
| 72 | mapViewJs, | |
| 73 | marksFor, | |
| 74 | wordFor as mapWordFor, | |
| 75 | } from "./map.ts"; | |
| 76 | import { | |
| 77 | costText, | |
| 78 | durationText, | |
| 79 | logLine, | |
| 80 | reasonWords, | |
| 81 | runTitle, | |
| 82 | } from "./runs.ts"; | |
| 83 | import { runLog } from "../../actions/runs.ts"; | |
| 84 | ||
| 85 | // ---- words ---- | |
| 86 | ||
| 87 | const money = (n: number): string => `$${n.toFixed(2)}`; | |
| 88 | const pct = (n: number): number => | |
| 89 | Math.max(0, Math.min(100, Math.round(n * 100))); | |
| 90 | ||
| 91 | type RunWord = "working" | "queued" | "paused"; | |
| 92 | function runWord(run: Run): RunWord { | |
| 93 | if (run.state === "running") return "working"; | |
| 94 | return run.notBefore === null ? "queued" : "paused"; | |
| 95 | } | |
| 96 | const startedAt = (r: Run): string => r.startedAt ?? r.createdAt; | |
| 97 | ||
| 98 | /** Why a run exists, as a person reads it beside its clock. */ | |
| 99 | function why(run: Run): string { | |
| 100 | switch (run.reason) { | |
| 101 | case "assigned": | |
| 102 | return "building it"; | |
| 103 | case "feedback": | |
| 104 | return "on what you said"; | |
| 105 | case "check_failed": | |
| 106 | return "fixing the check"; | |
| 107 | case "conflict": | |
| 108 | return "catching up with main"; | |
| 109 | case "review": | |
| 110 | return "reviewing it"; | |
| 111 | case "": | |
| 112 | return "working"; | |
| 113 | default: | |
| 114 | return run.reason.replace(/_/g, " "); | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | const KIND_WORDS = { | |
| 119 | next: "next issue", | |
| 120 | land: "landing", | |
| 121 | retry: "try again", | |
| 122 | lean: "a lean", | |
| 123 | }; | |
| 124 | ||
| 125 | // ---- the shell ---- | |
| 126 | ||
| 127 | interface Shell { | |
| 128 | readonly view: View; | |
| 129 | readonly layout: "home" | "app" | "run" | "wall" | "lanes" | "list"; | |
| 130 | readonly title: string; | |
| 131 | /** After `home`: the path down to here. */ | |
| 132 | readonly crumbs: readonly { label: string; href?: string }[]; | |
| 133 | readonly panes: Html; | |
| 134 | readonly where: Where; | |
| 135 | /** The app open here, `owner/name`, for the command line and the footer. */ | |
| 136 | readonly here?: string | undefined; | |
| 137 | readonly back: string; | |
| 138 | /** Where a word goes when nothing more specific is focused. */ | |
| 139 | readonly say?: string | undefined; | |
| 140 | readonly sayWho?: string | undefined; | |
| 141 | /** The web UI's page for this view, for `o`. */ | |
| 142 | readonly open?: string | undefined; | |
| 143 | readonly apps: readonly string[]; | |
| 144 | readonly css?: string | undefined; | |
| 145 | readonly js?: string | undefined; | |
| 146 | } | |
| 147 | ||
| 148 | const chips = (keys: readonly string[]): Html => | |
| 149 | html`${keys.map((k) => html`<kbd>${k}</kbd>`)}`; | |
| 150 | ||
| 151 | /** The footer: what fits on one line. A letter that opens a view carries where. */ | |
| 152 | function foot(s: Shell): Html { | |
| 153 | const go: Record<string, string> = {}; | |
| 154 | if (s.here !== undefined) { | |
| 155 | go["r"] = bigUrl({ app: s.here, list: "runs" }); | |
| 156 | go["i"] = bigUrl({ app: s.here, list: "issues" }); | |
| 157 | go["p"] = bigUrl({ app: s.here, list: "pulls" }); | |
| 158 | go["m"] = bigUrl({ app: s.here, list: "map" }); | |
| 159 | } | |
| 160 | return html`<footer class="big-foot" id="big-foot"> | |
| 161 | ${footKeysFor(s.view).map((k) => { | |
| 162 | const first = k.keys[0] ?? ""; | |
| 163 | const target = k.keys.length === 1 ? go[first] : undefined; | |
| 164 | return html`<span | |
| 165 | class="big-key" | |
| 166 | data-k="${first}" | |
| 167 | ${target === undefined ? null : html`data-go="${target}"`} | |
| 168 | >${chips(k.keys)}${k.does}</span | |
| 169 | >`; | |
| 170 | })} | |
| 171 | <span class="spacer"></span> | |
| 172 | <span class="big-foot-view">${s.view}</span> | |
| 173 | </footer>`; | |
| 174 | } | |
| 175 | ||
| 176 | /** The sheet `?` opens: every key for this view, then the command line's words. */ | |
| 177 | function sheet(s: Shell): Html { | |
| 178 | return html`<dialog class="modal big-help" id="big-help" aria-label="Keys"> | |
| 179 | <div class="big-help-cols"> | |
| 180 | <div> | |
| 181 | <h2>Keys · ${s.view}</h2> | |
| 182 | <table> | |
| 183 | <tbody> | |
| 184 | ${keysFor(s.view).map( | |
| 185 | (k) => | |
| 186 | html`<tr> | |
| 187 | <td>${chips(k.keys)}</td> | |
| 188 | <td>${k.does}</td> | |
| 189 | </tr>`, | |
| 190 | )} | |
| 191 | </tbody> | |
| 192 | </table> | |
| 193 | </div> | |
| 194 | <div> | |
| 195 | <h2>Commands · <kbd>:</kbd></h2> | |
| 196 | <table> | |
| 197 | <tbody> | |
| 198 | ${COMMANDS.map( | |
| 199 | (c) => | |
| 200 | html`<tr> | |
| 201 | <td> | |
| 202 | <span class="big-cmd" | |
| 203 | >:${c.name}${c.arg === undefined | |
| 204 | ? null | |
| 205 | : html` <i>${c.arg}</i>`}</span | |
| 206 | > | |
| 207 | </td> | |
| 208 | <td>${c.does}</td> | |
| 209 | </tr>`, | |
| 210 | )} | |
| 211 | </tbody> | |
| 212 | </table> | |
| 213 | <p> | |
| 214 | A gamepad works too: the d-pad and left stick move, A opens, B goes | |
| 215 | back, Y is this sheet, the bumpers switch panes. | |
| 216 | </p> | |
| 217 | </div> | |
| 218 | </div> | |
| 219 | </dialog>`; | |
| 220 | } | |
| 221 | ||
| 222 | function shell(ctx: Ctx, s: Shell): Response { | |
| 223 | const body = html`<div | |
| 224 | class="big" | |
| 225 | id="big" | |
| 226 | data-view="${s.view}" | |
| 227 | data-back="${s.back}" | |
| 228 | data-here="${s.here ?? ""}" | |
| 229 | data-apps="${s.apps.join(" ")}" | |
| 230 | ${when(s.say, (v) => html`data-say="${v}"`)} | |
| 231 | ${when(s.sayWho, (v) => html`data-say-who="${v}"`)} | |
| 232 | ${when(s.open, (v) => html`data-open="${v}"`)} | |
| 233 | > | |
| 234 | <header class="big-top"> | |
| 235 | <a class="big-brand" href="/"><span class="mark"></span>forge</a> | |
| 236 | <nav class="big-crumbs" aria-label="Where you are"> | |
| 237 | <a href="/big">home</a> | |
| 238 | ${s.crumbs.map( | |
| 239 | (c, i) => | |
| 240 | html`<span aria-hidden="true">›</span>${i === s.crumbs.length - 1 || | |
| 241 | c.href === undefined | |
| 242 | ? html`<b>${c.label}</b>` | |
| 243 | : html`<a href="${c.href}">${c.label}</a>`}`, | |
| 244 | )} | |
| 245 | </nav> | |
| 246 | <span class="spacer"></span> | |
| 247 | <span | |
| 248 | class="big-hint" | |
| 249 | id="big-hint" | |
| 250 | role="status" | |
| 251 | aria-live="polite" | |
| 252 | ></span> | |
| 253 | <span class="big-pad" aria-hidden="true">gamepad</span> | |
| 254 | <span | |
| 255 | class="big-live" | |
| 256 | id="big-live" | |
| 257 | data-state="connecting" | |
| 258 | title="The live stream" | |
| 259 | ><span class="big-dot"></span | |
| 260 | ></span> | |
| 261 | <time class="big-clock" id="big-clock"></time> | |
| 262 | </header> | |
| 263 | <div class="big-body" data-layout="${s.layout}">${s.panes}</div> | |
| 264 | ${foot(s)} | |
| 265 | <form class="big-line" id="big-line" method="post" action="" hidden> | |
| 266 | <span class="big-line-mark" id="big-line-mark" aria-hidden="true">:</span> | |
| 267 | <label class="sr-only" for="big-line-in">The line</label> | |
| 268 | <input | |
| 269 | class="big-line-in" | |
| 270 | id="big-line-in" | |
| 271 | name="text" | |
| 272 | autocomplete="off" | |
| 273 | spellcheck="false" | |
| 274 | /> | |
| 275 | <span class="big-line-note" id="big-line-note"></span> | |
| 276 | </form> | |
| 277 | ${sheet(s)} | |
| 278 | </div>`; | |
| 279 | return page({ | |
| 280 | ctx, | |
| 281 | title: s.title, | |
| 282 | body, | |
| 283 | bare: true, | |
| 284 | theme: "dark", | |
| 285 | styleCss: `${bigCss}\n${s.css ?? ""}`, | |
| 286 | scriptJs: `${liveRegionsJs}\n${s.js ?? ""}\n${bigJs}`, | |
| 287 | }); | |
| 288 | } | |
| 289 | ||
| 290 | /** One pane: a numbered heading, then rows that scroll. */ | |
| 291 | function pane(o: { | |
| 292 | name: string; | |
| 293 | n: number; | |
| 294 | title: string; | |
| 295 | count?: number | undefined; | |
| 296 | note?: Html | undefined; | |
| 297 | body: Html; | |
| 298 | live?: string | undefined; | |
| 299 | repo?: Repo | undefined; | |
| 300 | say?: string | undefined; | |
| 301 | sayWho?: string | undefined; | |
| 302 | cls?: string | undefined; | |
| 303 | }): Html { | |
| 304 | return html`<section | |
| 305 | class="big-pane${o.cls === undefined ? "" : ` ${o.cls}`}" | |
| 306 | data-pane="${o.name}" | |
| 307 | data-n="${o.n}" | |
| 308 | id="big-${o.name}" | |
| 309 | ${when(o.live, (v) => html`data-live="${v}"`)} | |
| 310 | ${when(o.repo, (r) => html`data-live-repo="${r.ownerName}/${r.name}"`)} | |
| 311 | ${when(o.say, (v) => html`data-say="${v}"`)} | |
| 312 | ${when(o.sayWho, (v) => html`data-say-who="${v}"`)} | |
| 313 | aria-labelledby="big-${o.name}-h" | |
| 314 | > | |
| 315 | <h2 class="big-h" id="big-${o.name}-h"> | |
| 316 | <kbd aria-hidden="true">${o.n}</kbd>${o.title} | |
| 317 | ${when( | |
| 318 | o.count !== undefined && o.count > 0, | |
| 319 | () => html`<span class="big-count">${o.count}</span>`, | |
| 320 | )} | |
| 321 | <span class="spacer"></span> | |
| 322 | ${when(o.note, (v) => html`<span class="big-h-note">${v}</span>`)} | |
| 323 | </h2> | |
| 324 | <div class="big-scroll">${o.body}</div> | |
| 325 | </section>`; | |
| 326 | } | |
| 327 | ||
| 328 | const rows = (items: Html[], quiet: string): Html => | |
| 329 | items.length === 0 | |
| 330 | ? html`<p class="big-quiet">${quiet}</p>` | |
| 331 | : html`<ul class="big-rows"> | |
| 332 | ${items} | |
| 333 | </ul>`; | |
| 334 | ||
| 335 | /** A `<kbd>` chip on a control, so the key and the button are one thing. */ | |
| 336 | const act = (key: string, label: string, form: string, lead = false): Html => | |
| 337 | html`<button | |
| 338 | class="big-act" | |
| 339 | type="submit" | |
| 340 | form="${form}" | |
| 341 | data-act="${key}" | |
| 342 | ${lead ? html`data-lead=""` : null} | |
| 343 | > | |
| 344 | <kbd>${key}</kbd>${label} | |
| 345 | </button>`; | |
| 346 | ||
| 347 | const sayChip = (lead = false): Html => | |
| 348 | html`<button | |
| 349 | class="big-act" | |
| 350 | type="button" | |
| 351 | data-say-open="" | |
| 352 | ${lead ? html`data-lead=""` : null} | |
| 353 | > | |
| 354 | <kbd>t</kbd>tell | |
| 355 | </button>`; | |
| 356 | ||
| 357 | // ---- the route ---- | |
| 358 | ||
| 359 | /** The apps the viewer can see, as repositories: the big view's whole world. */ | |
| 360 | function visibleApps(ctx: Ctx, user: User): Repo[] { | |
| 361 | const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 }); | |
| 362 | const records = appsForRepos( | |
| 363 | ctx.app.db, | |
| 364 | repos.map((r) => r.id), | |
| 365 | ); | |
| 366 | return repos.filter((r) => records.has(r.id)); | |
| 367 | } | |
| 368 | ||
| 369 | const slug = (repo: Repo): string => `${repo.ownerName}/${repo.name}`; | |
| 370 | ||
| 371 | const isList = (v: string | null): v is ListKind => | |
| 372 | v === "runs" || v === "issues" || v === "pulls" || v === "map"; | |
| 373 | ||
| 374 | /** GET /big — signed in. `?app=owner/name`, `?run=id`, `?view=board|map`. */ | |
| 375 | export function bigPage(ctx: Ctx): Response { | |
| 376 | const user = ctx.auth.user; | |
| 377 | if (!user) return signInFirst(ctx); | |
| 378 | const q = ctx.url.searchParams; | |
| 379 | const apps = visibleApps(ctx, user); | |
| 380 | const names = apps.map(slug); | |
| 381 | ||
| 382 | const runId = q.get("run"); | |
| 383 | if (runId !== null) { | |
| 384 | const run = getRun(ctx.app.db, runId); | |
| 385 | const repo = | |
| 386 | run === undefined ? undefined : getRepoById(ctx.app.db, run.repoId); | |
| 387 | if (!run || !repo || !canRead(ctx.app.db, repo, user)) | |
| 388 | return notFoundPage(ctx); | |
| 389 | return runView(ctx, user, repo, run, names); | |
| 390 | } | |
| 391 | const appName = q.get("app"); | |
| 392 | if (appName !== null) { | |
| 393 | const [owner, name] = appName.split("/"); | |
| 394 | const repo = owner && name ? getRepo(ctx.app.db, owner, name) : undefined; | |
| 395 | if (!repo || !canRead(ctx.app.db, repo, user)) return notFoundPage(ctx); | |
| 396 | if (getAppByRepo(ctx.app.db, repo.id) === undefined) | |
| 397 | return notFoundPage(ctx); | |
| 398 | const list = q.get("list"); | |
| 399 | return isList(list) | |
| 400 | ? listView(ctx, user, repo, list, names) | |
| 401 | : appView(ctx, user, repo, names); | |
| 402 | } | |
| 403 | const view = q.get("view"); | |
| 404 | if (view === "board") return boardView(ctx, user, apps, names); | |
| 405 | if (view === "map") return mapView(ctx, user, names); | |
| 406 | return homeView(ctx, user, apps, names); | |
| 407 | } | |
| 408 | ||
| 409 | // ---- home: every app, and the focused one at a glance ---- | |
| 410 | ||
| 411 | function homeView( | |
| 412 | ctx: Ctx, | |
| 413 | user: User, | |
| 414 | apps: readonly Repo[], | |
| 415 | names: readonly string[], | |
| 416 | ): Response { | |
| 417 | const db = ctx.app.db; | |
| 418 | const ids = apps.map((r) => r.id); | |
| 419 | const running = listActiveRuns(db, ids); | |
| 420 | const actors = getUsersByIds( | |
| 421 | db, | |
| 422 | running.map((r) => r.actorId).filter((id): id is string => id !== null), | |
| 423 | ); | |
| 424 | const about = getIssuesByIds( | |
| 425 | db, | |
| 426 | running.map((r) => r.issueId).filter((id): id is string => id !== null), | |
| 427 | ); | |
| 428 | const runsBy = new Map<string, Run[]>(); | |
| 429 | for (const r of running) | |
| 430 | runsBy.set(r.repoId, [...(runsBy.get(r.repoId) ?? []), r]); | |
| 431 | const working = running.filter((r) => r.state === "running").length; | |
| 432 | const waiting = running.length - working; | |
| 433 | ||
| 434 | const appRows = apps.map((repo) => { | |
| 435 | const key = `app:${slug(repo)}`; | |
| 436 | const flying = runsBy.get(repo.id) ?? []; | |
| 437 | const working = flying.filter((r) => r.state === "running").length; | |
| 438 | const status = | |
| 439 | repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo); | |
| 440 | const share = | |
| 441 | status === undefined || status.ceilingUsd <= 0 | |
| 442 | ? 0 | |
| 443 | : status.spentTodayUsd / status.ceilingUsd; | |
| 444 | return html`<li | |
| 445 | class="big-row" | |
| 446 | data-kind="app" | |
| 447 | data-key="${key}" | |
| 448 | data-href="${bigUrl({ app: slug(repo) })}" | |
| 449 | data-open="${repoBase(repo)}" | |
| 450 | data-say="${repoBase(repo)}/tell" | |
| 451 | data-say-who="${repo.name}'s crew" | |
| 452 | tabindex="-1" | |
| 453 | > | |
| 454 | <span class="big-t" | |
| 455 | >${repo.name}${repo.description === "" | |
| 456 | ? null | |
| 457 | : html` <small>· ${repo.description}</small>`}</span | |
| 458 | > | |
| 459 | <span | |
| 460 | class="big-word" | |
| 461 | data-word="${status?.paused === "ceiling" ? "paused" : repo.autonomy}" | |
| 462 | >${status?.paused === "ceiling" ? "at ceiling" : repo.autonomy}</span | |
| 463 | > | |
| 464 | <span | |
| 465 | class="big-n" | |
| 466 | ${working === 0 ? html`data-zero=""` : html`data-hot=""`} | |
| 467 | ><b>${working}</b> working</span | |
| 468 | > | |
| 469 | ${status === undefined | |
| 470 | ? html`<span class="big-spend"><span></span><span>—</span></span>` | |
| 471 | : html`<span class="big-spend" | |
| 472 | ><span class="big-bar" | |
| 473 | ><span | |
| 474 | class="big-fill" | |
| 475 | data-w="${pct(share)}" | |
| 476 | ${share >= 0.9 ? html`data-near=""` : null} | |
| 477 | ></span></span | |
| 478 | ><span | |
| 479 | >${money(status.spentTodayUsd)} / | |
| 480 | $${status.ceilingUsd.toFixed(0)}</span | |
| 481 | ></span | |
| 482 | >`} | |
| 483 | </li>`; | |
| 484 | }); | |
| 485 | ||
| 486 | const glances = apps.map((repo) => { | |
| 487 | const base = repoBase(repo); | |
| 488 | const flying = runsBy.get(repo.id) ?? []; | |
| 489 | const landed = mergedPullsForRepos(db, [repo.id], 3); | |
| 490 | const decided = listDecisionsForRepos(db, [repo.id], 3); | |
| 491 | const decidedAbout = getIssuesByIds( | |
| 492 | db, | |
| 493 | decided.map((d) => d.issueId).filter((id): id is string => id !== null), | |
| 494 | ); | |
| 495 | const status = | |
| 496 | repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo); | |
| 497 | const direction = | |
| 498 | repo.autonomyDirection.split("\n")[0]?.trim() || | |
| 499 | repo.description || | |
| 500 | "No direction yet."; | |
| 501 | return html`<div | |
| 502 | class="big-glance" | |
| 503 | data-for="app:${slug(repo)}" | |
| 504 | data-name="${repo.name}" | |
| 505 | hidden | |
| 506 | > | |
| 507 | <p class="big-glance-dir"> | |
| 508 | ${direction} | |
| 509 | <small | |
| 510 | >${repo.autonomy === "off" | |
| 511 | ? "not running on its own" | |
| 512 | : `${repo.autonomy} · ${money(status?.spentTodayUsd ?? 0)} of $${(status?.ceilingUsd ?? 0).toFixed(0)} today · ${status?.active ?? 0} of ${status?.slots ?? 0} slots`}</small | |
| 513 | > | |
| 514 | </p> | |
| 515 | <h3 class="big-sub">Working</h3> | |
| 516 | ${rows( | |
| 517 | flying.map((r) => runRow(base, r, actors, about, undefined)), | |
| 518 | "Nobody is working here right now.", | |
| 519 | )} | |
| 520 | <h3 class="big-sub">Landed</h3> | |
| 521 | ${rows( | |
| 522 | landed.map((p) => landedRow(base, p)), | |
| 523 | "Nothing has shipped yet.", | |
| 524 | )} | |
| 525 | <h3 class="big-sub">Decided</h3> | |
| 526 | ${rows( | |
| 527 | decided.map((d) => decisionRow(db, repo, d, decidedAbout)), | |
| 528 | "Nothing decided yet.", | |
| 529 | )} | |
| 530 | </div>`; | |
| 531 | }); | |
| 532 | ||
| 533 | const live = "run.*,pull.*,issue.*,check.*"; | |
| 534 | const panes = html`${pane({ | |
| 535 | name: "apps", | |
| 536 | n: 1, | |
| 537 | title: "Apps", | |
| 538 | count: apps.length, | |
| 539 | note: html`${working} | |
| 540 | working${waiting > 0 ? html` · ${waiting} waiting` : null}`, | |
| 541 | body: rows(appRows, "No apps yet. Make one at /apps/new."), | |
| 542 | live, | |
| 543 | })} | |
| 544 | ${pane({ | |
| 545 | name: "glance", | |
| 546 | n: 2, | |
| 547 | title: "At a glance", | |
| 548 | body: html`${glances} | |
| 549 | <div class="big-glance-empty">Pick an app</div>`, | |
| 550 | live, | |
| 551 | })}`; | |
| 552 | return shell(ctx, { | |
| 553 | view: "home", | |
| 554 | layout: "home", | |
| 555 | title: "Big", | |
| 556 | crumbs: [], | |
| 557 | panes, | |
| 558 | where: {}, | |
| 559 | back: "/", | |
| 560 | open: "/", | |
| 561 | apps: names, | |
| 562 | }); | |
| 563 | } | |
| 564 | ||
| 565 | // ---- rows the views share ---- | |
| 566 | ||
| 567 | function runRow( | |
| 568 | base: string, | |
| 569 | run: Run, | |
| 570 | actors: ReadonlyMap<string, User>, | |
| 571 | about: ReadonlyMap<string, Issue>, | |
| 572 | stopForm: string | undefined, | |
| 573 | ): Html { | |
| 574 | const who = | |
| 575 | run.actorId === null | |
| 576 | ? "crew" | |
| 577 | : (actors.get(run.actorId)?.username ?? "crew"); | |
| 578 | const issue = run.issueId === null ? undefined : about.get(run.issueId); | |
| 579 | const word = runWord(run); | |
| 580 | const live = run.state === "running" || run.state === "queued"; | |
| 581 | return html`<li | |
| 582 | class="big-row" | |
| 583 | data-kind="run" | |
| 584 | data-key="run:${run.id}" | |
| 585 | data-href="${bigUrl({ run: run.id })}" | |
| 586 | data-open="${base}/runs/${run.id}" | |
| 587 | data-state="${live ? word : run.state}" | |
| 588 | tabindex="-1" | |
| 589 | > | |
| 590 | ${avatar(who)} | |
| 591 | <span class="big-m"><b>${who}</b></span> | |
| 592 | <span class="big-t" | |
| 593 | >${issue?.title ?? (run.summary || "on the repository")} | |
| 594 | <small class="big-m" | |
| 595 | >· | |
| 596 | ${live | |
| 597 | ? word === "paused" | |
| 598 | ? "resumes soon" | |
| 599 | : why(run) | |
| 600 | : run.summary || run.state}</small | |
| 601 | ></span | |
| 602 | > | |
| 603 | ${live | |
| 604 | ? html`<span class="big-el" data-started="${startedAt(run)}"></span>` | |
| 605 | : html`<span class="big-el">${durationText(run) ?? "—"}</span>`} | |
| 606 | <span class="big-cost">${run.costUsd > 0 ? money(run.costUsd) : ""}</span> | |
| 607 | <span class="big-acts" | |
| 608 | >${live | |
| 609 | ? html`<span class="big-word" data-word="${word}">${word}</span>` | |
| 610 | : html`<span class="big-word" data-word="${run.state}" | |
| 611 | >${run.state === "ok" ? "done" : run.state}</span | |
| 612 | >`}${stopForm === undefined | |
| 613 | ? null | |
| 614 | : act("x", run.state === "running" ? "stop" : "cancel", stopForm)}</span | |
| 615 | > | |
| 616 | </li>`; | |
| 617 | } | |
| 618 | ||
| 619 | function landedRow(base: string, pull: Pull): Html { | |
| 620 | return html`<li | |
| 621 | class="big-row" | |
| 622 | data-kind="landed" | |
| 623 | data-key="pull:${pull.id}" | |
| 624 | data-href="${base}/pulls/${pull.number}" | |
| 625 | tabindex="-1" | |
| 626 | > | |
| 627 | <span class="big-t">${pull.title}</span> | |
| 628 | <span class="big-m">${pull.authorName}</span> | |
| 629 | <span class="big-when">${time(pull.mergedAt ?? pull.createdAt)}</span> | |
| 630 | </li>`; | |
| 631 | } | |
| 632 | ||
| 633 | function decisionRow( | |
| 634 | db: Ctx["app"]["db"], | |
| 635 | repo: Repo, | |
| 636 | d: ReturnType<typeof listDecisionsForRepos>[number], | |
| 637 | about: ReadonlyMap<string, Issue>, | |
| 638 | ): Html { | |
| 639 | const base = repoBase(repo); | |
| 640 | let issue: Issue | undefined = | |
| 641 | d.issueId === null ? undefined : about.get(d.issueId); | |
| 642 | if (d.kind === "next" && /^\d+$/.test(d.answer)) | |
| 643 | issue = getIssue(db, repo.id, Number(d.answer)) ?? issue; | |
| 644 | const href = | |
| 645 | issue === undefined | |
| 646 | ? base | |
| 647 | : `${base}/${issue.kind === "pull" ? "pulls" : "issues"}/${issue.number}`; | |
| 648 | const answer = | |
| 649 | d.kind === "next" && issue !== undefined ? `#${issue.number}` : d.answer; | |
| 650 | return html`<li | |
| 651 | class="big-row" | |
| 652 | data-kind="decision" | |
| 653 | data-key="decision:${d.id}" | |
| 654 | data-href="${href}" | |
| 655 | tabindex="-1" | |
| 656 | > | |
| 657 | <span class="big-word">${KIND_WORDS[d.kind]}</span> | |
| 658 | <span class="big-t">${answer}</span> | |
| 659 | <span class="big-m" | |
| 660 | >${issue?.title ?? repo.name} · | |
| 661 | ${d.by === "rule" ? "by the rule" : `by ${d.by}`}</span | |
| 662 | > | |
| 663 | <span class="big-p" title="how sure">${Math.round(d.p * 100)}%</span> | |
| 664 | <span class="big-word" data-word="${d.outcome ?? ""}" | |
| 665 | >${d.outcome ?? "—"}</span | |
| 666 | > | |
| 667 | <span class="big-when">${time(d.createdAt)}</span> | |
| 668 | </li>`; | |
| 669 | } | |
| 670 | ||
| 671 | /** The hidden form a run's stop chip submits, when the viewer may. */ | |
| 672 | function stopFormFor(ctx: Ctx, repo: Repo, run: Run): string | undefined { | |
| 673 | if (run.kind !== "agent") return undefined; | |
| 674 | if (run.state !== "running" && run.state !== "queued") return undefined; | |
| 675 | if (!canWrite(ctx.app.db, repo, ctx.auth.user)) return undefined; | |
| 676 | return `stop-${run.id}`; | |
| 677 | } | |
| 678 | ||
| 679 | const stopForms = ( | |
| 680 | base: string, | |
| 681 | runs: readonly Run[], | |
| 682 | ids: ReadonlyMap<string, string>, | |
| 683 | ): Html => | |
| 684 | html`${runs.map((r) => | |
| 685 | when( | |
| 686 | ids.get(r.id), | |
| 687 | (id) => | |
| 688 | html`<form | |
| 689 | id="${id}" | |
| 690 | method="post" | |
| 691 | action="${base}/runs/${r.id}/cancel" | |
| 692 | hidden | |
| 693 | ></form>`, | |
| 694 | ), | |
| 695 | )}`; | |
| 696 | ||
| 697 | // ---- app: working, asks, the map, landed ---- | |
| 698 | ||
| 699 | function appView( | |
| 700 | ctx: Ctx, | |
| 701 | user: User, | |
| 702 | repo: Repo, | |
| 703 | names: readonly string[], | |
| 704 | ): Response { | |
| 705 | const db = ctx.app.db; | |
| 706 | const base = repoBase(repo); | |
| 707 | const running = listActiveRuns(db, [repo.id]); | |
| 708 | running.sort((a, b) => { | |
| 709 | if ((a.state === "running") !== (b.state === "running")) | |
| 710 | return a.state === "running" ? -1 : 1; | |
| 711 | return startedAt(a).localeCompare(startedAt(b)); | |
| 712 | }); | |
| 713 | const actors = getUsersByIds( | |
| 714 | db, | |
| 715 | running.map((r) => r.actorId).filter((id): id is string => id !== null), | |
| 716 | ); | |
| 717 | const about = getIssuesByIds( | |
| 718 | db, | |
| 719 | running.map((r) => r.issueId).filter((id): id is string => id !== null), | |
| 720 | ); | |
| 721 | const stops = new Map<string, string>(); | |
| 722 | for (const r of running) { | |
| 723 | const id = stopFormFor(ctx, repo, r); | |
| 724 | if (id !== undefined) stops.set(r.id, id); | |
| 725 | } | |
| 726 | const status = | |
| 727 | repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo); | |
| 728 | const landed = mergedPullsForRepos(db, [repo.id], 12); | |
| 729 | ||
| 730 | const graph = repoGraph(db, repo.id); | |
| 731 | const ready = readyNow(graph); | |
| 732 | const marks = marksFor(db, repo.id, graph, running); | |
| 733 | const readySet = new Set(ready.map((r) => r.issue.id)); | |
| 734 | const counts = countWords( | |
| 735 | graph.issues.map((i) => mapWordFor(i, readySet, marks)), | |
| 736 | ); | |
| 737 | ||
| 738 | const panes = html`${pane({ | |
| 739 | name: "working", | |
| 740 | n: 1, | |
| 741 | title: "Working now", | |
| 742 | count: running.length, | |
| 743 | note: | |
| 744 | status === undefined | |
| 745 | ? html`${repo.autonomy}` | |
| 746 | : html`<b>${status.active}</b> of ${status.slots} slots · | |
| 747 | <b>${money(status.spentTodayUsd)}</b> of | |
| 748 | $${status.ceilingUsd.toFixed(0)} | |
| 749 | today${status.paused === null | |
| 750 | ? null | |
| 751 | : html` · paused: ${status.paused}`}`, | |
| 752 | body: html`${rows( | |
| 753 | running.map((r) => runRow(base, r, actors, about, stops.get(r.id))), | |
| 754 | "Nobody is working right now. Tell the crew something.", | |
| 755 | )}${stopForms(base, running, stops)}`, | |
| 756 | live: "run.*", | |
| 757 | repo, | |
| 758 | })} | |
| 759 | ${pane({ | |
| 760 | name: "map", | |
| 761 | n: 3, | |
| 762 | title: "Map", | |
| 763 | note: html`<b>${counts.ready}</b> ready · <b>${counts.working}</b> working · | |
| 764 | <b>${counts.blocked}</b> blocked${counts.waiting > 0 | |
| 765 | ? html` · <b>${counts.waiting}</b> waiting` | |
| 766 | : null}`, | |
| 767 | body: drawMap( | |
| 768 | repo, | |
| 769 | graph, | |
| 770 | layout(graph), | |
| 771 | emphasisFor(ready, criticalPath(graph), marks), | |
| 772 | { id: "big-map-canvas", cap: 0.42 }, | |
| 773 | ), | |
| 774 | live: "run.*,issue.*,pull.*", | |
| 775 | repo, | |
| 776 | })} | |
| 777 | ${pane({ | |
| 778 | name: "landed", | |
| 779 | n: 4, | |
| 780 | title: "Landed", | |
| 781 | count: landed.length, | |
| 782 | body: rows( | |
| 783 | landed.map((p) => landedRow(base, p)), | |
| 784 | "Nothing has shipped yet.", | |
| 785 | ), | |
| 786 | live: "pull.merged,run.finished", | |
| 787 | repo, | |
| 788 | })}`; | |
| 789 | return shell(ctx, { | |
| 790 | view: "app", | |
| 791 | layout: "app", | |
| 792 | title: `${repo.name} · big`, | |
| 793 | crumbs: [{ label: repo.name }], | |
| 794 | panes, | |
| 795 | where: { app: slug(repo) }, | |
| 796 | here: slug(repo), | |
| 797 | back: "/big", | |
| 798 | say: `${base}/tell`, | |
| 799 | sayWho: `${repo.name}'s crew`, | |
| 800 | open: base, | |
| 801 | apps: names, | |
| 802 | css: mapCss, | |
| 803 | js: mapViewJs, | |
| 804 | }); | |
| 805 | } | |
| 806 | ||
| 807 | // ---- lists: runs, issues, pulls, the map alone ---- | |
| 808 | ||
| 809 | function listView( | |
| 810 | ctx: Ctx, | |
| 811 | user: User, | |
| 812 | repo: Repo, | |
| 813 | kind: ListKind, | |
| 814 | names: readonly string[], | |
| 815 | ): Response { | |
| 816 | const db = ctx.app.db; | |
| 817 | const base = repoBase(repo); | |
| 818 | const where: Where = { app: slug(repo), list: kind }; | |
| 819 | const common = { | |
| 820 | where, | |
| 821 | here: slug(repo), | |
| 822 | back: backUrl(where), | |
| 823 | say: `${base}/tell`, | |
| 824 | sayWho: `${repo.name}'s crew`, | |
| 825 | apps: names, | |
| 826 | crumbs: [ | |
| 827 | { label: repo.name, href: bigUrl({ app: slug(repo) }) }, | |
| 828 | { label: kind }, | |
| 829 | ], | |
| 830 | }; | |
| 831 | if (kind === "map") { | |
| 832 | const graph = repoGraph(db, repo.id); | |
| 833 | const ready = readyNow(graph); | |
| 834 | const marks = marksFor(db, repo.id, graph, listActiveRuns(db, [repo.id])); | |
| 835 | return shell(ctx, { | |
| 836 | ...common, | |
| 837 | view: "list", | |
| 838 | layout: "list", | |
| 839 | title: `Map · ${repo.name} · big`, | |
| 840 | open: `${base}/map`, | |
| 841 | panes: pane({ | |
| 842 | name: "map", | |
| 843 | n: 1, | |
| 844 | title: "Map", | |
| 845 | note: html`what waits for what · the top edge is ready now`, | |
| 846 | body: drawMap( | |
| 847 | repo, | |
| 848 | graph, | |
| 849 | layout(graph), | |
| 850 | emphasisFor(ready, criticalPath(graph), marks), | |
| 851 | { id: "big-map-canvas", cap: 0.8 }, | |
| 852 | ), | |
| 853 | live: "run.*,issue.*,pull.*", | |
| 854 | repo, | |
| 855 | }), | |
| 856 | css: mapCss, | |
| 857 | js: mapViewJs, | |
| 858 | }); | |
| 859 | } | |
| 860 | if (kind === "runs") { | |
| 861 | const runs = listRuns(db, repo.id, { limit: 60 }); | |
| 862 | const actors = getUsersByIds( | |
| 863 | db, | |
| 864 | runs.map((r) => r.actorId).filter((id): id is string => id !== null), | |
| 865 | ); | |
| 866 | const about = getIssuesByIds( | |
| 867 | db, | |
| 868 | runs.map((r) => r.issueId).filter((id): id is string => id !== null), | |
| 869 | ); | |
| 870 | const stops = new Map<string, string>(); | |
| 871 | for (const r of runs) { | |
| 872 | const id = stopFormFor(ctx, repo, r); | |
| 873 | if (id !== undefined) stops.set(r.id, id); | |
| 874 | } | |
| 875 | return shell(ctx, { | |
| 876 | ...common, | |
| 877 | view: "list", | |
| 878 | layout: "list", | |
| 879 | title: `Runs · ${repo.name} · big`, | |
| 880 | open: `${base}/runs`, | |
| 881 | panes: pane({ | |
| 882 | name: "runs", | |
| 883 | n: 1, | |
| 884 | title: "Runs", | |
| 885 | count: runs.length, | |
| 886 | body: html`${rows( | |
| 887 | runs.map((r) => runRow(base, r, actors, about, stops.get(r.id))), | |
| 888 | "No runs yet.", | |
| 889 | )}${stopForms(base, runs, stops)}`, | |
| 890 | live: "run.*", | |
| 891 | repo, | |
| 892 | }), | |
| 893 | }); | |
| 894 | } | |
| 895 | if (kind === "issues") { | |
| 896 | const issues = listIssues(db, repo.id, { state: "open", limit: 60 }); | |
| 897 | const active = new Set( | |
| 898 | listActiveRuns(db, [repo.id]) | |
| 899 | .map((r) => r.issueId) | |
| 900 | .filter((id): id is string => id !== null), | |
| 901 | ); | |
| 902 | return shell(ctx, { | |
| 903 | ...common, | |
| 904 | view: "list", | |
| 905 | layout: "list", | |
| 906 | title: `Issues · ${repo.name} · big`, | |
| 907 | open: `${base}/issues`, | |
| 908 | panes: pane({ | |
| 909 | name: "issues", | |
| 910 | n: 1, | |
| 911 | title: "Open issues", | |
| 912 | count: issues.length, | |
| 913 | body: rows( | |
| 914 | issues.map( | |
| 915 | (i) => | |
| 916 | html`<li | |
| 917 | class="big-row" | |
| 918 | data-kind="issue" | |
| 919 | data-key="issue:${i.id}" | |
| 920 | data-href="${base}/issues/${i.number}" | |
| 921 | tabindex="-1" | |
| 922 | > | |
| 923 | <span class="big-num">#${i.number}</span> | |
| 924 | <span class="big-t">${i.title}</span> | |
| 925 | <span class="big-m">${i.assigneeName ?? "nobody on it"}</span> | |
| 926 | <span | |
| 927 | class="big-word" | |
| 928 | data-word="${active.has(i.id) ? "working" : "waiting"}" | |
| 929 | >${active.has(i.id) ? "working" : "open"}</span | |
| 930 | > | |
| 931 | <span class="big-when">${time(i.createdAt)}</span> | |
| 932 | </li>`, | |
| 933 | ), | |
| 934 | "Nothing open. Tell the crew something.", | |
| 935 | ), | |
| 936 | live: "run.*,issue.*", | |
| 937 | repo, | |
| 938 | }), | |
| 939 | }); | |
| 940 | } | |
| 941 | const pulls = listPulls(db, repo.id, { state: "open", limit: 60 }); | |
| 942 | const checks = latestRunsForIssues( | |
| 943 | db, | |
| 944 | pulls.map((p) => p.id), | |
| 945 | ); | |
| 946 | const writer = canWrite(db, repo, user); | |
| 947 | const pullWord = (p: Pull): string => { | |
| 948 | const c = checks.get(p.id); | |
| 949 | return c?.state === "ok" | |
| 950 | ? "green" | |
| 951 | : c?.state === "failed" | |
| 952 | ? "red" | |
| 953 | : "checking"; | |
| 954 | }; | |
| 955 | return shell(ctx, { | |
| 956 | ...common, | |
| 957 | view: "list", | |
| 958 | layout: "list", | |
| 959 | title: `Pulls · ${repo.name} · big`, | |
| 960 | open: `${base}/pulls`, | |
| 961 | panes: pane({ | |
| 962 | name: "pulls", | |
| 963 | n: 1, | |
| 964 | title: "Open pulls", | |
| 965 | count: pulls.length, | |
| 966 | body: html`${rows( | |
| 967 | pulls.map((p) => { | |
| 968 | const word = pullWord(p); | |
| 969 | return html`<li | |
| 970 | class="big-row" | |
| 971 | data-kind="pull" | |
| 972 | data-key="pull:${p.id}" | |
| 973 | data-href="${base}/pulls/${p.number}" | |
| 974 | data-say="${base}/pulls/${p.number}/say" | |
| 975 | data-say-who="${p.authorName}" | |
| 976 | tabindex="-1" | |
| 977 | > | |
| 978 | <span class="big-num">#${p.number}</span> | |
| 979 | <span class="big-t">${p.title}</span> | |
| 980 | <span class="big-m">${p.authorName}</span> | |
| 981 | <span class="big-acts" | |
| 982 | ><span class="big-word" data-word="${word}">${word}</span | |
| 983 | >${writer && word === "green" | |
| 984 | ? act("s", "ship", `ship-${p.id}`, true) | |
| 985 | : null}${writer | |
| 986 | ? act("x", "close", `close-${p.id}`) | |
| 987 | : null}</span | |
| 988 | > | |
| 989 | <span class="big-when">${time(p.createdAt)}</span> | |
| 990 | </li>`; | |
| 991 | }), | |
| 992 | "No open pulls.", | |
| 993 | )}${when( | |
| 994 | writer, | |
| 995 | () => | |
| 996 | html`${pulls.map( | |
| 997 | (p) => | |
| 998 | html`<form | |
| 999 | id="ship-${p.id}" | |
| 1000 | method="post" | |
| 1001 | action="${base}/pulls/${p.number}/ship" | |
| 1002 | hidden | |
| 1003 | > | |
| 1004 | <input type="hidden" name="pull" value="${p.number}" /> | |
| 1005 | </form> | |
| 1006 | <form | |
| 1007 | id="close-${p.id}" | |
| 1008 | method="post" | |
| 1009 | action="${base}/pulls/${p.number}/state" | |
| 1010 | hidden | |
| 1011 | > | |
| 1012 | <input type="hidden" name="state" value="closed" /> | |
| 1013 | </form>`, | |
| 1014 | )}`, | |
| 1015 | )}`, | |
| 1016 | live: "run.*,pull.*,check.*", | |
| 1017 | repo, | |
| 1018 | }), | |
| 1019 | }); | |
| 1020 | } | |
| 1021 | ||
| 1022 | // ---- run: the transcript, followed, and the steer line ---- | |
| 1023 | ||
| 1024 | function runView( | |
| 1025 | ctx: Ctx, | |
| 1026 | user: User, | |
| 1027 | repo: Repo, | |
| 1028 | run: Run, | |
| 1029 | names: readonly string[], | |
| 1030 | ): Response { | |
| 1031 | const found = runLog(ctx.app, user, repo, run.id); | |
| 1032 | if (!found.ok) return notFoundPage(ctx); | |
| 1033 | const { lines, truncated } = found.value; | |
| 1034 | const db = ctx.app.db; | |
| 1035 | const base = repoBase(repo); | |
| 1036 | const actor = | |
| 1037 | run.actorId === null | |
| 1038 | ? undefined | |
| 1039 | : getUsersByIds(db, [run.actorId]).get(run.actorId); | |
| 1040 | const issue = | |
| 1041 | run.issueId === null ? undefined : getIssueById(db, run.issueId); | |
| 1042 | const title = runTitle(run, actor?.username, issue); | |
| 1043 | const unfinished = !isTerminal(run.state); | |
| 1044 | const writer = canWrite(db, repo, user); | |
| 1045 | const duration = durationText(run); | |
| 1046 | const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`; | |
| 1047 | const who = actor?.username ?? "agent"; | |
| 1048 | const where: Where = { run: run.id }; | |
| 1049 | ||
| 1050 | const body = html`<div class="big-run"> | |
| 1051 | <div class="big-run-meta"> | |
| 1052 | <h1>${title}</h1> | |
| 1053 | <span id="run-state" data-kind="${run.kind}" | |
| 1054 | >${runStateBadge(run.state, run.kind)}</span | |
| 1055 | > | |
| 1056 | <span>${reasonWords(run.reason)}</span> | |
| 1057 | ${when( | |
| 1058 | issue, | |
| 1059 | (i) => | |
| 1060 | html`<a | |
| 1061 | href="${base}/${i.kind === "pull" ? "pulls" : "issues"}/${i.number}" | |
| 1062 | >#${i.number}</a | |
| 1063 | >`, | |
| 1064 | )} | |
| 1065 | <span class="num" id="run-cost" ${run.costUsd > 0 ? null : html`hidden`} | |
| 1066 | >${costText(run.costUsd)} spent</span | |
| 1067 | > | |
| 1068 | <span class="num" id="run-turns" ${run.turns > 0 ? null : html`hidden`} | |
| 1069 | >${run.turns} turn${run.turns === 1 ? "" : "s"}</span | |
| 1070 | > | |
| 1071 | ${when( | |
| 1072 | duration, | |
| 1073 | (d) => | |
| 1074 | html`<span | |
| 1075 | class="num" | |
| 1076 | id="run-elapsed" | |
| 1077 | data-started="${run.startedAt ?? ""}" | |
| 1078 | data-finished="${run.finishedAt ?? ""}" | |
| 1079 | >${run.finishedAt === null ? "running for" : "took"} ${d}</span | |
| 1080 | >`, | |
| 1081 | )} | |
| 1082 | ${when( | |
| 1083 | writer && unfinished && issue === undefined, | |
| 1084 | () => | |
| 1085 | html`<div class="run-actions" id="run-cancel"> | |
| 1086 | <form method="post" action="${base}/runs/${run.id}/cancel"> | |
| 1087 | <button class="big-act" type="submit" data-act="x"> | |
| 1088 | <kbd>x</kbd>${run.state === "running" ? "stop" : "cancel"} | |
| 1089 | </button> | |
| 1090 | </form> | |
| 1091 | </div>`, | |
| 1092 | )} | |
| 1093 | ${when( | |
| 1094 | run.summary !== "", | |
| 1095 | () => html`<p class="run-summary" id="run-summary">${run.summary}</p>`, | |
| 1096 | )} | |
| 1097 | ${when( | |
| 1098 | truncated, | |
| 1099 | () => | |
| 1100 | html`<p class="run-summary">Showing the last 512 KB of the log.</p>`, | |
| 1101 | )} | |
| 1102 | </div> | |
| 1103 | ${when( | |
| 1104 | lines.length === 0, | |
| 1105 | () => | |
| 1106 | html`<p class="big-quiet" id="run-empty"> | |
| 1107 | Nothing has been logged yet. | |
| 1108 | </p>`, | |
| 1109 | )} | |
| 1110 | <div class="run-live" id="run-live" data-state="${run.state}"> | |
| 1111 | <div | |
| 1112 | class="run-log" | |
| 1113 | id="run-log" | |
| 1114 | data-who="${who}" | |
| 1115 | ${unfinished ? html`data-stream="${stream}"` : null} | |
| 1116 | > | |
| 1117 | ${lines.map((l) => logLine(l, who))} | |
| 1118 | <button type="button" class="run-jump" id="run-jump" hidden> | |
| 1119 | newer below | |
| 1120 | </button> | |
| 1121 | </div> | |
| 1122 | ${when( | |
| 1123 | writer && run.kind === "agent" && issue !== undefined, | |
| 1124 | () => | |
| 1125 | html`<form | |
| 1126 | class="run-steer" | |
| 1127 | id="run-steer" | |
| 1128 | method="post" | |
| 1129 | action="${base}/runs/${run.id}/steer" | |
| 1130 | data-api="/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/steer" | |
| 1131 | data-running="${run.state === "running" ? "1" : "0"}" | |
| 1132 | > | |
| 1133 | <label class="sr-only" for="steer-text" | |
| 1134 | >Say something to ${who}</label | |
| 1135 | > | |
| 1136 | <textarea | |
| 1137 | class="input" | |
| 1138 | id="steer-text" | |
| 1139 | name="text" | |
| 1140 | rows="1" | |
| 1141 | required | |
| 1142 | placeholder="${run.state === "running" | |
| 1143 | ? `steer ${who} — enter sends, esc returns to the keys` | |
| 1144 | : `tell ${who} for its next run — enter sends`}" | |
| 1145 | ></textarea> | |
| 1146 | <div class="bar"> | |
| 1147 | <span class="muted small" id="run-steer-note" | |
| 1148 | >${run.state === "running" | |
| 1149 | ? "Heard after the step it is on. Every word is kept in the log." | |
| 1150 | : "The run is over; this goes on the conversation and starts a run."}</span | |
| 1151 | > | |
| 1152 | <span class="spacer"></span> | |
| 1153 | ${when( | |
| 1154 | unfinished, | |
| 1155 | () => | |
| 1156 | html`<button | |
| 1157 | type="submit" | |
| 1158 | class="big-act" | |
| 1159 | id="run-stop" | |
| 1160 | data-act="x" | |
| 1161 | formaction="${base}/runs/${run.id}/cancel" | |
| 1162 | formnovalidate | |
| 1163 | > | |
| 1164 | <kbd>x</kbd>${run.state === "running" ? "stop" : "cancel"} | |
| 1165 | </button>`, | |
| 1166 | )} | |
| 1167 | <button type="submit" class="big-act" data-lead=""> | |
| 1168 | <kbd>enter</kbd>send | |
| 1169 | </button> | |
| 1170 | </div> | |
| 1171 | </form>`, | |
| 1172 | )} | |
| 1173 | </div> | |
| 1174 | </div>`; | |
| 1175 | return shell(ctx, { | |
| 1176 | view: "run", | |
| 1177 | layout: "run", | |
| 1178 | title: `${title} · big`, | |
| 1179 | crumbs: [ | |
| 1180 | { label: repo.name, href: bigUrl({ app: slug(repo) }) }, | |
| 1181 | { label: who === "agent" ? "run" : `${who}'s run` }, | |
| 1182 | ], | |
| 1183 | panes: pane({ | |
| 1184 | name: "run", | |
| 1185 | n: 1, | |
| 1186 | title: | |
| 1187 | run.kind === "agent" | |
| 1188 | ? "Run" | |
| 1189 | : run.kind === "check" | |
| 1190 | ? "Check" | |
| 1191 | : "Deploy", | |
| 1192 | note: html`${run.state === "running" | |
| 1193 | ? "following the tail" | |
| 1194 | : run.state}${run.costUsd > 0 ? html` · ${money(run.costUsd)}` : null}`, | |
| 1195 | body, | |
| 1196 | cls: "big-run-pane", | |
| 1197 | }), | |
| 1198 | where, | |
| 1199 | here: slug(repo), | |
| 1200 | back: backUrl(where, slug(repo)), | |
| 1201 | open: `${base}/runs/${run.id}`, | |
| 1202 | apps: names, | |
| 1203 | js: runLogJs, | |
| 1204 | }); | |
| 1205 | } | |
| 1206 | ||
| 1207 | // ---- board: the wall's four regions, focusable ---- | |
| 1208 | ||
| 1209 | function boardView( | |
| 1210 | ctx: Ctx, | |
| 1211 | user: User, | |
| 1212 | apps: readonly Repo[], | |
| 1213 | names: readonly string[], | |
| 1214 | ): Response { | |
| 1215 | const scope = boardScope(ctx, apps); | |
| 1216 | const regions = boardRegions(ctx, scope); | |
| 1217 | const wrap = (name: string, n: number, region: Html): Html => | |
| 1218 | html`<section | |
| 1219 | class="big-pane big-wall" | |
| 1220 | data-pane="${name}" | |
| 1221 | data-n="${n}" | |
| 1222 | aria-label="${name}" | |
| 1223 | > | |
| 1224 | <div class="big-scroll">${region}</div> | |
| 1225 | </section>`; | |
| 1226 | return shell(ctx, { | |
| 1227 | view: "board", | |
| 1228 | layout: "wall", | |
| 1229 | title: "Board · big", | |
| 1230 | crumbs: [{ label: "board" }], | |
| 1231 | panes: html`${wrap("working", 1, regions.working)}${wrap( | |
| 1232 | "landed", | |
| 1233 | 2, | |
| 1234 | regions.landed, | |
| 1235 | )}${wrap("decided", 3, regions.decided)}${wrap("today", 4, regions.today)}`, | |
| 1236 | where: { view: "board" }, | |
| 1237 | back: "/big", | |
| 1238 | open: "/board", | |
| 1239 | apps: names, | |
| 1240 | css: boardCss, | |
| 1241 | }); | |
| 1242 | } | |
| 1243 | ||
| 1244 | // ---- map: every app, one lane each ---- | |
| 1245 | ||
| 1246 | function mapView(ctx: Ctx, user: User, names: readonly string[]): Response { | |
| 1247 | const lanes = globalLanes(ctx.app, user); | |
| 1248 | const width = Math.max(0, ...lanes.map((l) => drawingWidth(l.placed))); | |
| 1249 | const body = | |
| 1250 | lanes.length === 0 | |
| 1251 | ? html`<p class="big-quiet"> | |
| 1252 | Nothing to map: no open issues anywhere you can see, and nobody | |
| 1253 | working. | |
| 1254 | </p>` | |
| 1255 | : html`<div class="big-rows"> | |
| 1256 | ${lanes.map( | |
| 1257 | (lane) => | |
| 1258 | html`<div | |
| 1259 | class="big-row big-lane" | |
| 1260 | data-key="lane:${slug(lane.repo)}" | |
| 1261 | data-href="${bigUrl({ app: slug(lane.repo) })}" | |
| 1262 | data-open="${repoBase(lane.repo)}/map" | |
| 1263 | tabindex="-1" | |
| 1264 | > | |
| 1265 | ${laneHtml(lane, width)} | |
| 1266 | </div>`, | |
| 1267 | )} | |
| 1268 | </div>`; | |
| 1269 | return shell(ctx, { | |
| 1270 | view: "map", | |
| 1271 | layout: "lanes", | |
| 1272 | title: "Map · big", | |
| 1273 | crumbs: [{ label: "map" }], | |
| 1274 | panes: html`<section | |
| 1275 | class="big-pane" | |
| 1276 | data-pane="lanes" | |
| 1277 | data-n="1" | |
| 1278 | id="map-lanes" | |
| 1279 | data-live="run.*,issue.*,pull.*" | |
| 1280 | aria-labelledby="big-lanes-h" | |
| 1281 | > | |
| 1282 | <h2 class="big-h" id="big-lanes-h"> | |
| 1283 | <kbd aria-hidden="true">1</kbd>Every app | |
| 1284 | ${when( | |
| 1285 | lanes.length > 0, | |
| 1286 | () => html`<span class="big-count">${lanes.length}</span>`, | |
| 1287 | )} | |
| 1288 | <span class="spacer"></span> | |
| 1289 | <span class="big-h-note" | |
| 1290 | >the top edge of each lane is ready now · a ringed card is where the | |
| 1291 | crew is</span | |
| 1292 | > | |
| 1293 | </h2> | |
| 1294 | <div class="big-scroll">${body}</div> | |
| 1295 | </section>`, | |
| 1296 | where: { view: "map" }, | |
| 1297 | back: "/big", | |
| 1298 | open: "/map", | |
| 1299 | apps: names, | |
| 1300 | css: mapCss, | |
| 1301 | js: mapViewJs, | |
| 1302 | }); | |
| 1303 | } |
src/web/pages/board.ts +84 −60
| @@ -7,6 +7,10 @@ | ||
| 7 | 7 | * forge's health, and each subscription's windows. Nothing on it |
| 8 | 8 | * needs a click, and everything on it links somewhere. It reads the same |
| 9 | 9 | * tables as the studio and adds no state of its own. |
| 10 | * | |
| 11 | * `?wall` is its one wall mode, for a screen across the room that nobody | |
| 12 | * touches: the same regions in larger type, without the key hints or a | |
| 13 | * pointer. The old big view (`/big`, every address it had) lands here. | |
| 10 | 14 | */ |
| 11 | 15 | |
| 12 | 16 | import { appsForRepos, type AppRecord } from "../../core/apps.ts"; |
| @@ -36,7 +40,7 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 36 | 40 | import { repoBase, signInFirst } from "../chrome.ts"; |
| 37 | 41 | import { boardJs, liveRegionsJs } from "../client.ts"; |
| 38 | 42 | import { html, when, type Html } from "../html.ts"; |
| 39 | import { page } from "../layout.ts"; | |
| 43 | import { page, redirect } from "../layout.ts"; | |
| 40 | 44 | import { avatar, icon, time } from "../ui.ts"; |
| 41 | 45 | import { voiceFor } from "../studio/voice.ts"; |
| 42 | 46 | import { |
| @@ -89,7 +93,7 @@ const pct = (n: number): number => | ||
| 89 | 93 | Math.max(0, Math.min(100, Math.round(n * 100))); |
| 90 | 94 | |
| 91 | 95 | /** The apps on the board: every one the viewer can see, or the named few. */ |
| 92 | export interface BoardScope { | |
| 96 | interface BoardScope { | |
| 93 | 97 | readonly repos: readonly Repo[]; |
| 94 | 98 | readonly ids: readonly string[]; |
| 95 | 99 | readonly byId: ReadonlyMap<string, Repo>; |
| @@ -100,12 +104,12 @@ export interface BoardScope { | ||
| 100 | 104 | readonly ownerFacts: ReadonlySet<string>; |
| 101 | 105 | } |
| 102 | 106 | |
| 103 | /** The scope for some app repositories: the board's, or the big view's. */ | |
| 104 | export function boardScope(ctx: Ctx, repos: readonly Repo[]): BoardScope { | |
| 105 | const records = appsForRepos( | |
| 106 | ctx.app.db, | |
| 107 | repos.map((r) => r.id), | |
| 108 | ); | |
| 107 | /** The scope for some repositories: the ones among them that are apps. */ | |
| 108 | function boardScope( | |
| 109 | ctx: Ctx, | |
| 110 | repos: readonly Repo[], | |
| 111 | records: ReadonlyMap<string, AppRecord>, | |
| 112 | ): BoardScope { | |
| 109 | 113 | const kept = repos.filter((r) => records.has(r.id)); |
| 110 | 114 | return { |
| 111 | 115 | repos: kept, |
| @@ -120,22 +124,9 @@ export function boardScope(ctx: Ctx, repos: readonly Repo[]): BoardScope { | ||
| 120 | 124 | }; |
| 121 | 125 | } |
| 122 | 126 | |
| 123 | /** The four regions, for a page that frames them its own way. */ | |
| 124 | export function boardRegions( | |
| 125 | ctx: Ctx, | |
| 126 | scope: BoardScope, | |
| 127 | ): { working: Html; landed: Html; decided: Html; today: Html } { | |
| 128 | return { | |
| 129 | working: working(ctx, scope), | |
| 130 | landed: landed(ctx, scope), | |
| 131 | decided: decided(ctx, scope), | |
| 132 | today: today(ctx, scope), | |
| 133 | }; | |
| 134 | } | |
| 135 | ||
| 136 | 127 | // ---- the page ---- |
| 137 | 128 | |
| 138 | /** GET /board — signed in; every app the viewer can see, or `?apps=` some. */ | |
| 129 | /** GET /board — signed in; every app the viewer can see, or `?apps=` some; `?wall` for the wall mode. */ | |
| 139 | 130 | export function boardPage(ctx: Ctx): Response { |
| 140 | 131 | const user = ctx.auth.user; |
| 141 | 132 | if (!user) return signInFirst(ctx); |
| @@ -155,21 +146,24 @@ export function boardPage(ctx: Ctx): Response { | ||
| 155 | 146 | ctx, |
| 156 | 147 | visible.filter( |
| 157 | 148 | (r) => |
| 158 | records.has(r.id) && | |
| 159 | (wanted.size === 0 || | |
| 160 | wanted.has(r.name.toLowerCase()) || | |
| 161 | wanted.has(`${r.ownerName}/${r.name}`.toLowerCase())), | |
| 149 | wanted.size === 0 || | |
| 150 | wanted.has(r.name.toLowerCase()) || | |
| 151 | wanted.has(`${r.ownerName}/${r.name}`.toLowerCase()), | |
| 162 | 152 | ), |
| 153 | records, | |
| 163 | 154 | ); |
| 164 | 155 | const repos = scope.repos; |
| 156 | const wall = ctx.url.searchParams.has("wall"); | |
| 165 | 157 | |
| 166 | const body = html`<div class="board"> | |
| 158 | const body = html`<div class="board" ${wall ? html`data-wall=""` : null}> | |
| 167 | 159 | <header class="board-top"> |
| 168 | 160 | <a class="board-brand" href="/"><span class="mark"></span>forge</a> |
| 169 | 161 | <span class="board-scope" |
| 170 | >${repos.length === 1 | |
| 171 | ? repos[0]!.name | |
| 172 | : `${repos.length} app${repos.length === 1 ? "" : "s"}`}</span | |
| 162 | >${ | |
| 163 | repos.length === 1 | |
| 164 | ? repos[0]!.name | |
| 165 | : `${repos.length} app${repos.length === 1 ? "" : "s"}` | |
| 166 | }</span | |
| 173 | 167 | > |
| 174 | 168 | <span class="spacer"></span> |
| 175 | 169 | <span class="board-keys" aria-hidden="true" |
| @@ -223,14 +217,34 @@ function region( | ||
| 223 | 217 | () => html`<span class="board-count">${rows.length}</span>`, |
| 224 | 218 | )} |
| 225 | 219 | </h2> |
| 226 | ${rows.length === 0 | |
| 227 | ? html`<p class="board-quiet">${quiet}</p>` | |
| 228 | : html`<ul class="board-list"> | |
| 229 | ${rows} | |
| 230 | </ul>`} | |
| 220 | ${ | |
| 221 | rows.length === 0 | |
| 222 | ? html`<p class="board-quiet">${quiet}</p>` | |
| 223 | : html`<ul class="board-list"> | |
| 224 | ${rows} | |
| 225 | </ul>` | |
| 226 | } | |
| 231 | 227 | </section>`; |
| 232 | 228 | } |
| 233 | 229 | |
| 230 | /** An app's address as the big view wrote it: `owner/name`, nothing else. */ | |
| 231 | const APP_SLUG = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/; | |
| 232 | ||
| 233 | /** | |
| 234 | * GET /big — the big view, folded into the board. Every address it had | |
| 235 | * (`?app=`, `&list=`, `?run=`, `?view=board|map`) lands on the wall mode; | |
| 236 | * an app it named stays the scope, as `?apps=`. The board decides what the | |
| 237 | * viewer may see, so this reads nothing and says nothing about what exists. | |
| 238 | */ | |
| 239 | export function bigRedirect(ctx: Ctx): Response { | |
| 240 | const app = ctx.url.searchParams.get("app") ?? ""; | |
| 241 | return redirect( | |
| 242 | APP_SLUG.test(app) | |
| 243 | ? `/board?apps=${encodeURIComponent(app)}&wall` | |
| 244 | : "/board?wall", | |
| 245 | ); | |
| 246 | } | |
| 247 | ||
| 234 | 248 | // ---- the forge ---- |
| 235 | 249 | |
| 236 | 250 | /** Where a need can be acted on, when it can: a stuck run's page. */ |
| @@ -263,14 +277,16 @@ function forgeStrip(ctx: Ctx): Html | null { | ||
| 263 | 277 | data-ok="${v.ok ? "yes" : "no"}" |
| 264 | 278 | role="status" |
| 265 | 279 | > |
| 266 | ${v.ok | |
| 267 | ? html`<span class="board-forge-ok">All fine</span>` | |
| 268 | : v.needs.map((need) => { | |
| 269 | const href = needHref(need, v); | |
| 270 | return href === undefined | |
| 271 | ? html`<span class="board-forge-need">${need}</span>` | |
| 272 | : html`<a class="board-forge-need" href="${href}">${need}</a>`; | |
| 273 | })} | |
| 280 | ${ | |
| 281 | v.ok | |
| 282 | ? html`<span class="board-forge-ok">All fine</span>` | |
| 283 | : v.needs.map((need) => { | |
| 284 | const href = needHref(need, v); | |
| 285 | return href === undefined | |
| 286 | ? html`<span class="board-forge-need">${need}</span>` | |
| 287 | : html`<a class="board-forge-need" href="${href}">${need}</a>`; | |
| 288 | }) | |
| 289 | } | |
| 274 | 290 | </div>`; |
| 275 | 291 | } |
| 276 | 292 | |
| @@ -323,11 +339,13 @@ function windowsStrip(ctx: Ctx): Html | null { | ||
| 323 | 339 | const words = windowWords(w); |
| 324 | 340 | return html`<span class="board-window" data-window="${w.window}" |
| 325 | 341 | ><span class="board-window-name">${words.name}</span> |
| 326 | ${words.usedPct === null | |
| 327 | ? null | |
| 328 | : html`<span class="board-bar" | |
| 329 | ><span class="board-fill" data-w="${words.usedPct}"></span | |
| 330 | ></span>`} | |
| 342 | ${ | |
| 343 | words.usedPct === null | |
| 344 | ? null | |
| 345 | : html`<span class="board-bar" | |
| 346 | ><span class="board-fill" data-w="${words.usedPct}"></span | |
| 347 | ></span>` | |
| 348 | } | |
| 331 | 349 | <span class="board-window-nums" |
| 332 | 350 | >${figures([words.used, words.remaining, words.resets])}</span |
| 333 | 351 | ></span |
| @@ -389,9 +407,9 @@ function working(ctx: Ctx, scope: BoardScope): Html { | ||
| 389 | 407 | <span class="board-run-meta"> |
| 390 | 408 | <span class="board-state">${word}</span> |
| 391 | 409 | <span |
| 392 | >${word === "paused" | |
| 393 | ? "resumes soon" | |
| 394 | : reasonWords(run.reason)}</span | |
| 410 | >${ | |
| 411 | word === "paused" ? "resumes soon" : reasonWords(run.reason) | |
| 412 | }</span | |
| 395 | 413 | > |
| 396 | 414 | ${when( |
| 397 | 415 | run.costUsd > 0 && scope.ownerFacts.has(repo.id), |
| @@ -456,15 +474,17 @@ function landed(ctx: Ctx, scope: BoardScope): Html { | ||
| 456 | 474 | href="${repoBase(repo)}/pulls/${pull.number}" |
| 457 | 475 | > |
| 458 | 476 | <span class="board-thumb" |
| 459 | >${picture === undefined | |
| 460 | ? icon("merge") | |
| 461 | : html`<img | |
| 462 | src="${picture}" | |
| 463 | width="34" | |
| 464 | height="46" | |
| 465 | alt="" | |
| 466 | loading="lazy" | |
| 467 | />`}</span | |
| 477 | >${ | |
| 478 | picture === undefined | |
| 479 | ? icon("merge") | |
| 480 | : html`<img | |
| 481 | src="${picture}" | |
| 482 | width="34" | |
| 483 | height="46" | |
| 484 | alt="" | |
| 485 | loading="lazy" | |
| 486 | />` | |
| 487 | }</span | |
| 468 | 488 | > |
| 469 | 489 | <span class="board-landed-title">${pull.title}</span> |
| 470 | 490 | <span class="board-when">${time(pull.mergedAt ?? pull.createdAt)}</span> |
| @@ -770,6 +790,10 @@ ${widths} | ||
| 770 | 790 | } |
| 771 | 791 | /* One region, full bleed. */ |
| 772 | 792 | .board[data-focus] .board-region { display: none; } |
| 793 | /* The wall mode: read from across the room, and nobody at the keys. */ | |
| 794 | .board[data-wall] { font-size: 20px; cursor: none; } | |
| 795 | .board[data-wall] .board-keys { display: none; } | |
| 796 | @media (min-width: 1600px) { .board[data-wall] { font-size: 23px; } } | |
| 773 | 797 | .board[data-focus="working"] #working, .board[data-focus="landed"] #landed, |
| 774 | 798 | .board[data-focus="decided"] #decided, .board[data-focus="today"] #today { display: flex; } |
| 775 | 799 | } |
src/web/style/big.ts deleted −245
| @@ -1,245 +0,0 @@ | ||
| 1 | /** | |
| 2 | * The big view's stylesheet: a k9s frame in the browser. One header line, | |
| 3 | * a body of panes that fill the window and scroll on their own, one footer | |
| 4 | * line of key chips. Dense, calm, dark by default; the forge's tokens | |
| 5 | * throughout, so every theme gets it. Nothing here animates on its own — | |
| 6 | * a focus ring moves, a hover tints, and that is all the motion there is. | |
| 7 | */ | |
| 8 | ||
| 9 | /** Width rules for the bars: a whole percentage as `data-w`, since an inline style is not allowed. */ | |
| 10 | const widths = Array.from( | |
| 11 | { length: 101 }, | |
| 12 | (_, n) => `.big-fill[data-w="${n}"] { width: ${n}%; }`, | |
| 13 | ).join("\n"); | |
| 14 | ||
| 15 | export const bigCss = /* css */ ` | |
| 16 | .big { | |
| 17 | --gap: 10px; --line: 30px; | |
| 18 | height: 100dvh; display: grid; grid-template-rows: var(--line) minmax(0, 1fr) var(--line); | |
| 19 | font-size: 13px; line-height: 1.4; overflow: hidden; | |
| 20 | font-variant-numeric: tabular-nums; | |
| 21 | } | |
| 22 | .big kbd { height: 18px; min-width: 18px; padding: 0 4px; font-size: 10.5px; border-bottom-width: 1px; } | |
| 23 | .big .num, .big .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } | |
| 24 | .big .spacer { flex: 1; } | |
| 25 | ||
| 26 | /* ---- the header line ---- */ | |
| 27 | .big-top { display: flex; align-items: center; gap: 12px; padding: 0 12px; border-bottom: 1px solid var(--border); color: var(--muted-fg); font-size: 12.5px; white-space: nowrap; min-width: 0; } | |
| 28 | .big-brand { display: inline-flex; align-items: center; gap: 7px; font-weight: 650; letter-spacing: -0.03em; font-size: 13.5px; color: var(--fg); } | |
| 29 | .big-brand .mark { width: 9px; height: 9px; border-radius: 3px; background: linear-gradient(135deg, var(--accent), var(--merged)); } | |
| 30 | .big-crumbs { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; } | |
| 31 | .big-crumbs a { color: var(--muted-fg); } | |
| 32 | .big-crumbs a:hover { color: var(--fg); } | |
| 33 | .big-crumbs > span { color: var(--faint); } | |
| 34 | .big-crumbs b { color: var(--fg); font-weight: 600; overflow: hidden; text-overflow: ellipsis; } | |
| 35 | .big-hint { font-size: 12px; color: var(--muted-fg); overflow: hidden; text-overflow: ellipsis; } | |
| 36 | .big-hint[data-tone="danger"] { color: var(--danger); } | |
| 37 | .big-hint[data-tone="success"] { color: var(--success); } | |
| 38 | .big-live { display: inline-flex; align-items: center; } | |
| 39 | .big-dot { width: 7px; height: 7px; border-radius: 999px; background: var(--faint); } | |
| 40 | .big-live[data-state="open"] .big-dot { background: var(--success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 22%, transparent); } | |
| 41 | .big-live[data-state="reconnecting"] .big-dot { background: var(--attn); } | |
| 42 | .big-clock { font-family: var(--mono); font-size: 12.5px; color: var(--muted-fg); min-width: 5ch; text-align: right; } | |
| 43 | .big-pad { display: none; font-size: 11px; color: var(--faint); } | |
| 44 | .big[data-pad] .big-pad { display: inline; } | |
| 45 | ||
| 46 | /* ---- the body of panes ---- */ | |
| 47 | .big-body { display: grid; gap: var(--gap); padding: var(--gap); min-height: 0; } | |
| 48 | .big-body[data-layout="home"] { grid-template-columns: minmax(0, 11fr) minmax(0, 9fr); } | |
| 49 | .big-body[data-layout="app"], .big-body[data-layout="wall"] { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); } | |
| 50 | .big-body[data-layout="run"], .big-body[data-layout="lanes"], .big-body[data-layout="list"] { grid-template-columns: minmax(0, 1fr); } | |
| 51 | .big-pane { | |
| 52 | min-width: 0; min-height: 0; display: flex; flex-direction: column; | |
| 53 | border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); | |
| 54 | } | |
| 55 | .big-pane.is-pane { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); } | |
| 56 | .big-h { | |
| 57 | flex: none; display: flex; align-items: center; gap: 8px; margin: 0; padding: 0 10px; height: 28px; | |
| 58 | border-bottom: 1px solid var(--border); | |
| 59 | font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--muted-fg); | |
| 60 | white-space: nowrap; overflow: hidden; | |
| 61 | } | |
| 62 | .big-pane.is-pane .big-h { color: var(--fg); } | |
| 63 | .big-h kbd { letter-spacing: 0; } | |
| 64 | .big-count { font-weight: 500; letter-spacing: 0; color: var(--faint); } | |
| 65 | .big-h-note { font-weight: 500; letter-spacing: 0; text-transform: none; color: var(--faint); overflow: hidden; text-overflow: ellipsis; } | |
| 66 | .big-h-note b { color: var(--fg); font-weight: 600; } | |
| 67 | .big-scroll { flex: 1; min-height: 0; overflow: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; overscroll-behavior: contain; } | |
| 68 | .big-rows { margin: 0; padding: 4px 0; list-style: none; } | |
| 69 | .big-quiet { margin: 0; padding: 10px 12px; color: var(--faint); } | |
| 70 | .big-pane[data-fresh] .big-h { color: var(--accent); } | |
| 71 | ||
| 72 | /* ---- rows ---- */ | |
| 73 | .big-row { | |
| 74 | position: relative; display: grid; align-items: center; gap: 2px 10px; | |
| 75 | padding: 5px 10px 5px 12px; border-left: 2px solid transparent; color: inherit; outline: none; | |
| 76 | cursor: default; | |
| 77 | } | |
| 78 | .big-row:hover { background: color-mix(in srgb, var(--fg) 4%, transparent); } | |
| 79 | .big-row.is-focus { background: color-mix(in srgb, var(--accent) 13%, transparent); border-left-color: var(--accent); } | |
| 80 | .big-row a { color: inherit; } | |
| 81 | .big-row[data-busy] { opacity: .5; } | |
| 82 | .big-t { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } | |
| 83 | .big-m { color: var(--muted-fg); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } | |
| 84 | .big-m b { color: var(--fg); font-weight: 600; } | |
| 85 | .big-when { color: var(--faint); font-size: 12px; white-space: nowrap; } | |
| 86 | .big-acts { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; } | |
| 87 | .big-act { | |
| 88 | display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 0 6px 0 3px; | |
| 89 | border: 1px solid var(--border); border-radius: var(--radius-sm); background: transparent; color: var(--muted-fg); | |
| 90 | font: inherit; font-size: 11.5px; cursor: pointer; | |
| 91 | } | |
| 92 | .big-act kbd { height: 14px; min-width: 14px; font-size: 10px; padding: 0 3px; background: var(--muted); } | |
| 93 | .big-act:hover { color: var(--fg); border-color: var(--border-strong); } | |
| 94 | .big-act[data-lead] { color: var(--fg); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); } | |
| 95 | .big-row.is-focus .big-act { color: var(--fg); } | |
| 96 | .big-bar { display: block; height: 4px; border-radius: 999px; background: var(--muted); overflow: hidden; } | |
| 97 | .big-fill { display: block; height: 100%; border-radius: inherit; background: var(--muted-fg); } | |
| 98 | .big-fill[data-near] { background: var(--attn); } | |
| 99 | ${widths} | |
| 100 | .big-word { font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted-fg); white-space: nowrap; } | |
| 101 | .big-word[data-word="working"], .big-word[data-word="autonomous"] { color: var(--accent); } | |
| 102 | .big-word[data-word="queued"], .big-word[data-word="assisted"], .big-word[data-word="waiting"] { color: var(--muted-fg); } | |
| 103 | .big-word[data-word="paused"], .big-word[data-word="stopped"], .big-word[data-word="failed"], .big-word[data-word="red"] { color: var(--attn); } | |
| 104 | .big-word[data-word="look"], .big-word[data-word="green"], .big-word[data-word="shipped"], .big-word[data-word="ok"] { color: var(--success); } | |
| 105 | .big-word[data-word="off"], .big-word[data-word="cancelled"] { color: var(--faint); } | |
| 106 | .big-row .avatar { width: 20px; height: 20px; font-size: 9px; } | |
| 107 | ||
| 108 | /* app rows: name · autonomy · working · asks · spend */ | |
| 109 | .big-row[data-kind="app"] { grid-template-columns: minmax(0, 1fr) 7em 5.5em 5.5em 9em; } | |
| 110 | .big-row[data-kind="app"] .big-t { font-size: 13.5px; } | |
| 111 | .big-row[data-kind="app"] .big-t small { color: var(--muted-fg); font-weight: 500; font-size: 12px; } | |
| 112 | .big-n { font-family: var(--mono); font-size: 12px; color: var(--muted-fg); white-space: nowrap; } | |
| 113 | .big-n b { color: var(--fg); font-weight: 600; } | |
| 114 | .big-n[data-zero] { color: var(--faint); } | |
| 115 | .big-n[data-hot] b { color: var(--accent); } | |
| 116 | .big-spend { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; font-family: var(--mono); font-size: 11.5px; color: var(--muted-fg); } | |
| 117 | ||
| 118 | /* run rows: who · title · elapsed · cost */ | |
| 119 | .big-row[data-kind="run"] { grid-template-columns: auto 6.5em minmax(0, 1fr) 6ch 6ch auto; } | |
| 120 | .big-row[data-kind="run"] .big-t { grid-column: 3; } | |
| 121 | .big-el { font-family: var(--mono); font-size: 12.5px; text-align: right; white-space: nowrap; } | |
| 122 | .big-row[data-state="working"] .big-el { color: var(--accent); } | |
| 123 | .big-row[data-state="queued"] .big-el, .big-row[data-state="paused"] .big-el { color: var(--faint); } | |
| 124 | .big-cost { font-family: var(--mono); font-size: 12px; color: var(--muted-fg); text-align: right; white-space: nowrap; } | |
| 125 | ||
| 126 | /* ask rows: mark · title + short · actions */ | |
| 127 | .big-row[data-kind="ask"] { grid-template-columns: 5.5em minmax(0, 1fr) auto; } | |
| 128 | .big-row[data-kind="ask"] .big-m { grid-column: 2; } | |
| 129 | .big-row[data-kind="ask"] .big-acts { grid-row: 1 / span 2; grid-column: 3; } | |
| 130 | .big-row[data-kind="ask"] .big-word { grid-row: 1 / span 2; } | |
| 131 | ||
| 132 | /* landed rows: title · who · when */ | |
| 133 | .big-row[data-kind="landed"] { grid-template-columns: minmax(0, 1fr) auto auto; } | |
| 134 | /* decision rows: kind · answer · about · sure · outcome · when */ | |
| 135 | .big-row[data-kind="decision"] { grid-template-columns: 6em 5em minmax(0, 1fr) 4ch 5em auto; } | |
| 136 | .big-row[data-kind="decision"] .big-p { font-family: var(--mono); font-size: 11.5px; color: var(--muted-fg); text-align: right; } | |
| 137 | /* issue and pull rows: number · title · who · word · when */ | |
| 138 | .big-row[data-kind="issue"], .big-row[data-kind="pull"] { grid-template-columns: 4.5ch minmax(0, 1fr) auto auto auto; } | |
| 139 | .big-num { font-family: var(--mono); font-size: 12px; color: var(--faint); } | |
| 140 | ||
| 141 | /* ---- the glance: the focused app at a glance ---- */ | |
| 142 | .big-glance { display: flex; flex-direction: column; min-height: 0; } | |
| 143 | .big-glance-dir { margin: 0; padding: 10px 12px 8px; font-size: 14px; font-weight: 550; letter-spacing: -0.01em; text-wrap: pretty; border-bottom: 1px solid var(--border); } | |
| 144 | .big-glance-dir small { display: block; margin-top: 2px; font-size: 12px; font-weight: 500; color: var(--muted-fg); } | |
| 145 | .big-sub { margin: 0; padding: 8px 12px 2px; font-size: 10.5px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--faint); } | |
| 146 | .big-glance-empty { display: grid; place-items: center; height: 100%; color: var(--faint); } | |
| 147 | ||
| 148 | /* ---- the map inside a pane ---- */ | |
| 149 | .big-pane .map-canvas { border: 0; border-radius: 0; background: var(--card); flex: 1; min-height: 0; } | |
| 150 | .big-pane .map-empty { border: 0; padding: 24px 12px; } | |
| 151 | .big-pane .map-node.is-focus .map-card { stroke: var(--accent); stroke-width: 2; } | |
| 152 | .big-pane .map-tools { display: none; } | |
| 153 | ||
| 154 | /* ---- the lanes: the global map, one row per app ---- */ | |
| 155 | .big-lane { padding: 0; border-left-width: 2px; } | |
| 156 | .big-lane .map-lane { border: 0; border-radius: 0; background: transparent; box-shadow: none; } | |
| 157 | .big-lane + .big-lane .map-lane { border-top: 1px solid var(--border); } | |
| 158 | .big-lane .map-lane-head { padding: 8px 12px; } | |
| 159 | .big-lane .map-lane-name { font-size: 13.5px; } | |
| 160 | .big-lane .map-lane-head .map-counts { font-size: 11.5px; } | |
| 161 | ||
| 162 | /* ---- the board's regions inside panes ---- */ | |
| 163 | .big-wall .board-region { padding: 8px 10px; } | |
| 164 | .big-wall .board-h { position: static; padding: 0 0 6px; background: transparent; font-size: 11px; } | |
| 165 | .big-wall .board-list { gap: 4px; } | |
| 166 | .big-wall .board-card { padding: 7px 10px; border-color: transparent; border-radius: var(--radius-sm); background: transparent; outline: none; } | |
| 167 | .big-wall .board-card:hover { border-color: var(--border); } | |
| 168 | .big-wall .board-card.is-focus { background: color-mix(in srgb, var(--accent) 13%, transparent); border-color: color-mix(in srgb, var(--accent) 45%, transparent); } | |
| 169 | .big-wall .board-run { padding: 7px 10px; } | |
| 170 | .big-wall .board-run .avatar { width: 26px; height: 26px; font-size: 11px; margin-top: 1px; } | |
| 171 | .big-wall .board-run-title { font-size: 1em; -webkit-line-clamp: 1; } | |
| 172 | .big-wall .board-elapsed { font-size: 1.15em; } | |
| 173 | .big-wall .board-run-meta, .big-wall .board-run-who { font-size: .92em; } | |
| 174 | .big-wall .board-landed-title { font-size: 1em; } | |
| 175 | .big-wall .board-thumb { width: 24px; height: 32px; } | |
| 176 | .big-wall .board-landed .board-thumb img { width: 100%; height: 100%; } | |
| 177 | .big-wall .board-app-name { font-size: 1em; } | |
| 178 | .big-wall .board-quiet { padding: 4px 0; color: var(--faint); } | |
| 179 | ||
| 180 | /* ---- the run: the transcript fills the pane ---- */ | |
| 181 | .big-run { display: flex; flex-direction: column; min-height: 0; flex: 1; } | |
| 182 | .big-run .run-live { margin: 0; flex: 1; min-height: 0; display: flex; flex-direction: column; } | |
| 183 | .big-run .run-log { flex: 1; min-height: 0; max-height: none !important; border: 0; border-radius: 0; background: transparent; font-size: 13px; padding: 8px 12px; } | |
| 184 | .big-run .run-steer { border: 0; border-top: 1px solid var(--border); border-radius: 0; background: var(--card); padding: 8px 12px; } | |
| 185 | .big-run .run-steer textarea { min-height: 32px; font-size: 13px; } | |
| 186 | .big-run .run-actions { margin: 0; } | |
| 187 | .big-run-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12.5px; color: var(--muted-fg); } | |
| 188 | .big-run-meta h1 { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; margin: 0; } | |
| 189 | .big-run-meta .num { font-family: var(--mono); } | |
| 190 | .big-run-meta .run-summary { flex-basis: 100%; margin: 0; font-size: 12.5px; color: var(--muted-fg); } | |
| 191 | ||
| 192 | /* ---- the footer line ---- */ | |
| 193 | .big-foot { display: flex; align-items: center; gap: 4px 14px; padding: 0 12px; border-top: 1px solid var(--border); color: var(--muted-fg); font-size: 12px; white-space: nowrap; overflow: hidden; } | |
| 194 | .big-key { display: inline-flex; align-items: center; gap: 3px; } | |
| 195 | .big-key kbd + kbd { margin-left: -1px; } | |
| 196 | .big-key kbd:last-of-type { margin-right: 3px; } | |
| 197 | .big-foot .spacer { flex: 1; } | |
| 198 | .big-foot .big-foot-view { color: var(--faint); font-family: var(--mono); font-size: 11.5px; } | |
| 199 | ||
| 200 | /* ---- the line: command, filter, or a word to the crew ---- */ | |
| 201 | .big-line { | |
| 202 | position: fixed; left: var(--gap); right: var(--gap); bottom: var(--line); z-index: 5; | |
| 203 | display: flex; align-items: center; gap: 8px; padding: 6px 10px; | |
| 204 | background: var(--card); border: 1px solid color-mix(in srgb, var(--accent) 55%, var(--border)); border-radius: var(--radius-sm); | |
| 205 | box-shadow: var(--shadow-2); | |
| 206 | } | |
| 207 | .big-line-mark { font-family: var(--mono); font-weight: 600; color: var(--accent); min-width: 1ch; } | |
| 208 | .big-line[data-mode="say"] .big-line-mark { color: var(--success); } | |
| 209 | .big-line-in { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--fg); font: inherit; font-family: var(--mono); font-size: 13px; outline: none; padding: 0; } | |
| 210 | .big-line-note { font-size: 12px; color: var(--muted-fg); white-space: nowrap; } | |
| 211 | .big-line-note[data-tone="danger"] { color: var(--danger); } | |
| 212 | ||
| 213 | /* ---- the key sheet ---- */ | |
| 214 | .big-help { width: min(720px, 92vw); max-height: 86dvh; overflow: auto; } | |
| 215 | .big-help h2 { font-size: 13px; margin: 0 0 8px; } | |
| 216 | .big-help h2 + h2 { margin-top: 16px; } | |
| 217 | .big-help table { width: 100%; border-collapse: collapse; } | |
| 218 | .big-help td { padding: 4px 10px 4px 0; vertical-align: top; font-size: 13px; } | |
| 219 | .big-help td:first-child { width: 12em; white-space: nowrap; } | |
| 220 | .big-help kbd + kbd { margin-left: 3px; } | |
| 221 | .big-help .big-cmd { font-family: var(--mono); color: var(--fg); } | |
| 222 | .big-help .big-cmd i { color: var(--muted-fg); font-style: normal; } | |
| 223 | .big-help .big-help-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 24px; } | |
| 224 | .big-help p { margin: 10px 0 0; color: var(--faint); font-size: 12px; } | |
| 225 | ||
| 226 | /* ---- filtered away ---- */ | |
| 227 | .big [data-filtered] { display: none; } | |
| 228 | ||
| 229 | /* ---- narrow: stacked, the body scrolls ---- */ | |
| 230 | @media (max-width: 899px) { | |
| 231 | .big { height: auto; min-height: 100dvh; grid-template-rows: var(--line) minmax(0, 1fr) var(--line); overflow: visible; } | |
| 232 | .big-body[data-layout] { grid-template-columns: minmax(0, 1fr); grid-template-rows: none; } | |
| 233 | .big-pane { min-height: 220px; max-height: 60dvh; } | |
| 234 | .big-row[data-kind="app"] { grid-template-columns: minmax(0, 1fr) auto auto; } | |
| 235 | .big-row[data-kind="app"] .big-spend, .big-row[data-kind="app"] .big-word { display: none; } | |
| 236 | .big-row[data-kind="run"] { grid-template-columns: auto minmax(0, 1fr) auto; } | |
| 237 | .big-row[data-kind="run"] .big-t { grid-column: 2; } | |
| 238 | .big-row[data-kind="run"] .big-cost, .big-row[data-kind="run"] .big-word { display: none; } | |
| 239 | .big-row[data-kind="decision"] { grid-template-columns: 6em minmax(0, 1fr) auto; } | |
| 240 | .big-row[data-kind="decision"] .big-p, .big-row[data-kind="decision"] .big-when { display: none; } | |
| 241 | .big-foot { overflow: auto; } | |
| 242 | .big-foot .big-foot-view { display: none; } | |
| 243 | } | |
| 244 | @media (min-width: 1600px) { .big { font-size: 14px; } } | |
| 245 | `; |
test/big.test.ts deleted −474
| @@ -1,474 +0,0 @@ | ||
| 1 | /** | |
| 2 | * The big view: one keyboard-first frame over the whole forge. Anonymous | |
| 3 | * is sent to sign in; every view renders the shell — header, panes, | |
| 4 | * footer — with live regions; the app view lists a run in flight and an | |
| 5 | * ask with its keys; the command line's targets are real pages; and the | |
| 6 | * sheet lists every key the footer shows. The pure model is tested on | |
| 7 | * its own: where a step lands, what a command means. | |
| 8 | */ | |
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { enableApp } from "../src/actions/apps.ts"; | |
| 15 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 16 | import { openIssue } from "../src/actions/issues.ts"; | |
| 17 | import { openPull } from "../src/actions/pulls.ts"; | |
| 18 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 19 | import { updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 20 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 21 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 22 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 23 | import { | |
| 24 | backUrl, | |
| 25 | bigUrl, | |
| 26 | COMMANDS, | |
| 27 | findApp, | |
| 28 | footKeysFor, | |
| 29 | halfPage, | |
| 30 | keysFor, | |
| 31 | moveIndex, | |
| 32 | nextPane, | |
| 33 | parseCommand, | |
| 34 | } from "../src/web/big-model.ts"; | |
| 35 | ||
| 36 | // ---- the model ---- | |
| 37 | ||
| 38 | describe("the big view's model", () => { | |
| 39 | test("a step lands where vim would: first row on the way down, last on the way up, never past an end", () => { | |
| 40 | expect(moveIndex(0, -1, 1)).toBe(-1); | |
| 41 | expect(moveIndex(3, -1, 1)).toBe(0); | |
| 42 | expect(moveIndex(3, -1, -1)).toBe(2); | |
| 43 | expect(moveIndex(3, 0, 1)).toBe(1); | |
| 44 | expect(moveIndex(3, 2, 1)).toBe(2); | |
| 45 | expect(moveIndex(3, 0, -1)).toBe(0); | |
| 46 | expect(moveIndex(10, 2, 5)).toBe(7); | |
| 47 | expect(moveIndex(10, 8, 5)).toBe(9); | |
| 48 | expect(halfPage(9)).toBe(4); | |
| 49 | expect(halfPage(1)).toBe(1); | |
| 50 | }); | |
| 51 | ||
| 52 | test("panes wrap both ways", () => { | |
| 53 | expect(nextPane(4, 3, 1)).toBe(0); | |
| 54 | expect(nextPane(4, 0, -1)).toBe(3); | |
| 55 | expect(nextPane(4, -1, 1)).toBe(1); | |
| 56 | expect(nextPane(0, 0, 1)).toBe(-1); | |
| 57 | }); | |
| 58 | ||
| 59 | test("addresses and their parents", () => { | |
| 60 | expect(bigUrl({})).toBe("/big"); | |
| 61 | expect(bigUrl({ app: "alice/notes" })).toBe("/big?app=alice%2Fnotes"); | |
| 62 | expect(bigUrl({ app: "alice/notes", list: "runs" })).toBe( | |
| 63 | "/big?app=alice%2Fnotes&list=runs", | |
| 64 | ); | |
| 65 | expect(bigUrl({ run: "r1" })).toBe("/big?run=r1"); | |
| 66 | expect(bigUrl({ view: "board" })).toBe("/big?view=board"); | |
| 67 | expect(backUrl({ app: "alice/notes", list: "runs" })).toBe( | |
| 68 | "/big?app=alice%2Fnotes", | |
| 69 | ); | |
| 70 | expect(backUrl({ app: "alice/notes" })).toBe("/big"); | |
| 71 | expect(backUrl({ run: "r1" }, "alice/notes")).toBe( | |
| 72 | "/big?app=alice%2Fnotes", | |
| 73 | ); | |
| 74 | expect(backUrl({ view: "map" })).toBe("/big"); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a command means one thing, or says why not", () => { | |
| 78 | const apps = ["alice/notes", "alice/shop", "bob/tempo"]; | |
| 79 | expect(findApp("tempo", apps)).toBe("bob/tempo"); | |
| 80 | expect(findApp("Alice/Shop", apps)).toBe("alice/shop"); | |
| 81 | expect(findApp("no", apps)).toBe("alice/notes"); | |
| 82 | expect(findApp("zz", apps)).toBeUndefined(); | |
| 83 | const here = { apps }; | |
| 84 | expect(parseCommand("app tempo", here)).toEqual({ | |
| 85 | kind: "go", | |
| 86 | url: "/big?app=bob%2Ftempo", | |
| 87 | }); | |
| 88 | expect(parseCommand(":tempo", here)).toEqual({ | |
| 89 | kind: "go", | |
| 90 | url: "/big?app=bob%2Ftempo", | |
| 91 | }); | |
| 92 | expect(parseCommand("map", here)).toEqual({ | |
| 93 | kind: "go", | |
| 94 | url: "/big?view=map", | |
| 95 | }); | |
| 96 | expect(parseCommand("map", { ...here, app: "alice/shop" })).toEqual({ | |
| 97 | kind: "go", | |
| 98 | url: "/big?app=alice%2Fshop&list=map", | |
| 99 | }); | |
| 100 | expect(parseCommand("runs", here)).toEqual({ | |
| 101 | kind: "error", | |
| 102 | message: "open an app first, then :runs", | |
| 103 | }); | |
| 104 | expect(parseCommand("pulls", { ...here, app: "alice/shop" })).toEqual({ | |
| 105 | kind: "go", | |
| 106 | url: "/big?app=alice%2Fshop&list=pulls", | |
| 107 | }); | |
| 108 | expect(parseCommand("app", here)).toEqual({ | |
| 109 | kind: "error", | |
| 110 | message: "app which?", | |
| 111 | }); | |
| 112 | expect(parseCommand("app nothing", here).kind).toBe("error"); | |
| 113 | expect(parseCommand("help", here)).toEqual({ kind: "help" }); | |
| 114 | expect(parseCommand("q", here)).toEqual({ kind: "go", url: "/" }); | |
| 115 | expect(parseCommand("q", { ...here, app: "alice/shop" })).toEqual({ | |
| 116 | kind: "go", | |
| 117 | url: "/alice/shop", | |
| 118 | }); | |
| 119 | expect(parseCommand(" ", here)).toEqual({ kind: "none" }); | |
| 120 | expect(parseCommand("frobnicate", here).kind).toBe("error"); | |
| 121 | }); | |
| 122 | ||
| 123 | test("the footer is a subset of the sheet, for every view", () => { | |
| 124 | for (const view of [ | |
| 125 | "home", | |
| 126 | "app", | |
| 127 | "run", | |
| 128 | "board", | |
| 129 | "map", | |
| 130 | "list", | |
| 131 | ] as const) { | |
| 132 | const sheet = new Set(keysFor(view).map((k) => k.keys.join(" "))); | |
| 133 | for (const k of footKeysFor(view)) | |
| 134 | expect(sheet.has(k.keys.join(" "))).toBe(true); | |
| 135 | } | |
| 136 | }); | |
| 137 | }); | |
| 138 | ||
| 139 | // ---- the pages ---- | |
| 140 | ||
| 141 | describe("the big view", () => { | |
| 142 | let dataDir: string; | |
| 143 | let app: App; | |
| 144 | let servers: Servers; | |
| 145 | let base: string; | |
| 146 | let alice: User; | |
| 147 | let notes: Repo; | |
| 148 | let shop: Repo; | |
| 149 | let cookie: string; | |
| 150 | ||
| 151 | const get = (path: string, extra: Record<string, string> = {}) => | |
| 152 | fetch(`${base}${path}`, { | |
| 153 | headers: { cookie, ...extra }, | |
| 154 | redirect: "manual", | |
| 155 | }); | |
| 156 | ||
| 157 | async function makeApp(name: string): Promise<Repo> { | |
| 158 | const made = await createRepo(app, alice, { name, visibility: "private" }); | |
| 159 | if (!made.ok) throw new Error(String(made.error)); | |
| 160 | const seeded = await commitToBranch(app, alice, made.value, { | |
| 161 | message: "Start", | |
| 162 | files: [ | |
| 163 | { | |
| 164 | path: "package.json", | |
| 165 | content: `{"name":"${name}","scripts":{"start":"bun run server.ts"}}\n`, | |
| 166 | }, | |
| 167 | { | |
| 168 | path: "server.ts", | |
| 169 | content: | |
| 170 | "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n", | |
| 171 | }, | |
| 172 | ], | |
| 173 | }); | |
| 174 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 175 | const enabled = await enableApp(app, alice, made.value, { slug: name }); | |
| 176 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 177 | return made.value; | |
| 178 | } | |
| 179 | ||
| 180 | /** The text of every `<kbd>` inside the element with this id. */ | |
| 181 | function kbdsIn(html: string, id: string): Set<string> { | |
| 182 | const start = html.indexOf(`id="${id}"`); | |
| 183 | const open = html.lastIndexOf("<", start); | |
| 184 | const tag = html.slice(open + 1, html.indexOf(" ", open)); | |
| 185 | const close = html.indexOf(`</${tag}>`, start); | |
| 186 | const slice = html.slice(start, close); | |
| 187 | return new Set( | |
| 188 | [...slice.matchAll(/<kbd[^>]*>([^<]*)<\/kbd>/g)].map((m) => m[1]!), | |
| 189 | ); | |
| 190 | } | |
| 191 | ||
| 192 | beforeAll(async () => { | |
| 193 | dataDir = await mkdtemp(join(tmpdir(), "forge-big-")); | |
| 194 | app = createApp({ dataDir, port: 0 }); | |
| 195 | servers = await serve(app); | |
| 196 | base = servers.url; | |
| 197 | const a = await createUser(app.db, { | |
| 198 | username: "alice", | |
| 199 | email: "[email protected]", | |
| 200 | password: "a-good-passphrase", | |
| 201 | adminIfFirst: true, | |
| 202 | }); | |
| 203 | if (!a.ok) throw new Error(a.error); | |
| 204 | alice = a.value; | |
| 205 | cookie = ( | |
| 206 | ( | |
| 207 | await fetch(`${base}/signin`, { | |
| 208 | method: "POST", | |
| 209 | body: new URLSearchParams({ | |
| 210 | username: "alice", | |
| 211 | password: "a-good-passphrase", | |
| 212 | }), | |
| 213 | redirect: "manual", | |
| 214 | }) | |
| 215 | ).headers.get("set-cookie") ?? "" | |
| 216 | ).split(";")[0]!; | |
| 217 | notes = await makeApp("notes"); | |
| 218 | shop = await makeApp("shop"); | |
| 219 | const scout = await createAgent(app, alice, { username: "scout" }); | |
| 220 | if (!scout.ok) throw new Error(String(scout.error)); | |
| 221 | expect(addToCrew(app, alice, notes, "scout").ok).toBe(true); | |
| 222 | expect(addToCrew(app, alice, shop, "scout").ok).toBe(true); | |
| 223 | notes = updateRepoSettings(app.db, notes, { | |
| 224 | autonomy: "autonomous", | |
| 225 | autonomyDirection: "Notes that load fast.\nAnd stay small.", | |
| 226 | autonomyCeilingUsd: 20, | |
| 227 | autonomySlots: 2, | |
| 228 | autonomyBy: alice.id, | |
| 229 | }); | |
| 230 | shop = updateRepoSettings(app.db, shop, { | |
| 231 | autonomy: "assisted", | |
| 232 | autonomyDirection: "Sell one real thing.", | |
| 233 | autonomyCeilingUsd: 10, | |
| 234 | autonomySlots: 1, | |
| 235 | autonomyBy: alice.id, | |
| 236 | }); | |
| 237 | }, 30_000); | |
| 238 | ||
| 239 | afterAll(async () => { | |
| 240 | servers.stop(); | |
| 241 | app.close(); | |
| 242 | await rm(dataDir, { recursive: true, force: true }); | |
| 243 | }); | |
| 244 | ||
| 245 | test("anonymous is sent to sign in, and comes back here", async () => { | |
| 246 | const res = await fetch(`${base}/big?app=alice%2Fnotes`, { | |
| 247 | redirect: "manual", | |
| 248 | }); | |
| 249 | expect(res.status).toBe(303); | |
| 250 | expect(res.headers.get("location")).toBe( | |
| 251 | "/signin?next=%2Fbig%3Fapp%3Dalice%252Fnotes", | |
| 252 | ); | |
| 253 | }); | |
| 254 | ||
| 255 | test("home: the shell, two live panes, every app as a row, the focused one at a glance", async () => { | |
| 256 | const res = await get("/big"); | |
| 257 | expect(res.status).toBe(200); | |
| 258 | const html = await res.text(); | |
| 259 | expect(html).toContain('<html lang="en" data-theme="dark">'); | |
| 260 | expect(html).not.toContain('class="topbar"'); | |
| 261 | expect(html).toMatch(/<main\s+class="bare"\s+id="main"\s*>/); | |
| 262 | expect(html).toContain('id="big"'); | |
| 263 | expect(html).toContain('data-view="home"'); | |
| 264 | expect(html).toContain('data-apps="alice/shop alice/notes"'); | |
| 265 | expect(html).toContain('<header class="big-top">'); | |
| 266 | expect(html).toContain('id="big-clock"'); | |
| 267 | expect(html).toContain('id="big-live"'); | |
| 268 | expect(html).toContain('<footer class="big-foot" id="big-foot">'); | |
| 269 | expect(html).toContain('id="big-line"'); | |
| 270 | expect(html).toContain('id="big-help"'); | |
| 271 | expect(html).toMatch( | |
| 272 | /id="big-apps"\s+data-live="run\.\*,pull\.\*,issue\.\*,check\.\*"/, | |
| 273 | ); | |
| 274 | expect(html).toMatch( | |
| 275 | /id="big-glance"\s+data-live="run\.\*,pull\.\*,issue\.\*,check\.\*"/, | |
| 276 | ); | |
| 277 | // The rows: name, autonomy word, working, asks, spend against the ceiling. | |
| 278 | expect(html).toContain('data-key="app:alice/notes"'); | |
| 279 | expect(html).toContain('data-href="/big?app=alice%2Fnotes"'); | |
| 280 | expect(html).toContain('data-say="/alice/notes/tell"'); | |
| 281 | expect(html).toContain('data-word="autonomous"'); | |
| 282 | expect(html).toContain('data-word="assisted"'); | |
| 283 | expect(html).toMatch(/\$0\.00\s*\/\s*\$20/); | |
| 284 | // The glance: the direction's first line only, and its own lists. | |
| 285 | expect(html).toContain('data-for="app:alice/notes"'); | |
| 286 | expect(html).toContain("Notes that load fast."); | |
| 287 | expect(html).not.toContain("And stay small."); | |
| 288 | expect(html).toContain("Nobody is working here right now."); | |
| 289 | expect(html).toContain("new EventSource('/events')"); | |
| 290 | // A viewer who chose a theme keeps it. | |
| 291 | const paper = await ( | |
| 292 | await get("/big", { cookie: `${cookie}; forge_theme=paper` }) | |
| 293 | ).text(); | |
| 294 | expect(paper).toContain('data-theme="paper"'); | |
| 295 | }); | |
| 296 | ||
| 297 | test("app: a run in flight with its clock and stop key, the map, landed", async () => { | |
| 298 | const scout = getUserByName(app.db, "scout")!; | |
| 299 | const told = openIssue(app, alice, shop, { | |
| 300 | title: "Checkout keeps the total steady", | |
| 301 | }); | |
| 302 | if (!told.ok) throw new Error(String(told.error)); | |
| 303 | const running = createRun(app.db, { | |
| 304 | repoId: shop.id, | |
| 305 | kind: "agent", | |
| 306 | issueId: told.value.id, | |
| 307 | actorId: scout.id, | |
| 308 | reason: "assigned", | |
| 309 | }); | |
| 310 | claimRun(app.db, running.id); | |
| 311 | // A green pull the crew opened: the shop is assisted, so it asks. | |
| 312 | const branch = await commitToBranch(app, alice, shop, { | |
| 313 | branch: "crew/wrap", | |
| 314 | base: "main", | |
| 315 | message: "Gift wrap", | |
| 316 | files: [{ path: "WRAP.md", content: "wrap\n" }], | |
| 317 | }); | |
| 318 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 319 | const opened = await openPull(app, scout, shop, { | |
| 320 | title: "Gift wrap as a line item", | |
| 321 | body: "", | |
| 322 | baseBranch: "main", | |
| 323 | headBranch: "crew/wrap", | |
| 324 | }); | |
| 325 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 326 | const pull = opened.value; | |
| 327 | const check = createRun(app.db, { | |
| 328 | repoId: shop.id, | |
| 329 | kind: "check", | |
| 330 | issueId: pull.id, | |
| 331 | env: `pull-${pull.number}`, | |
| 332 | commitOid: pull.headOid, | |
| 333 | reason: "pull", | |
| 334 | }); | |
| 335 | claimRun(app.db, check.id); | |
| 336 | finishRun(app.db, check.id, { state: "ok", summary: "check passed" }); | |
| 337 | ||
| 338 | const html = await (await get("/big?app=alice%2Fshop")).text(); | |
| 339 | expect(html).toContain('data-view="app"'); | |
| 340 | expect(html).toContain('data-here="alice/shop"'); | |
| 341 | expect(html).toContain('data-say="/alice/shop/tell"'); | |
| 342 | expect(html).toMatch( | |
| 343 | /id="big-working"\s+data-live="run\.\*"\s+data-live-repo="alice\/shop"/, | |
| 344 | ); | |
| 345 | expect(html).toMatch( | |
| 346 | /id="big-map"\s+data-live="run\.\*,issue\.\*,pull\.\*"/, | |
| 347 | ); | |
| 348 | expect(html).toMatch( | |
| 349 | /id="big-landed"\s+data-live="pull\.merged,run\.finished"/, | |
| 350 | ); | |
| 351 | // The run: who, what, the clock from when it started, and x to stop. | |
| 352 | const working = html.slice( | |
| 353 | html.indexOf('id="big-working"'), | |
| 354 | html.indexOf('id="big-map"'), | |
| 355 | ); | |
| 356 | expect(working).toContain(`data-key="run:${running.id}"`); | |
| 357 | expect(working).toContain(`data-href="/big?run=${running.id}"`); | |
| 358 | expect(working).toContain('data-state="working"'); | |
| 359 | expect(working).toContain("Checkout keeps the total steady"); | |
| 360 | expect(working).toContain("building it"); | |
| 361 | expect(working).toMatch(/data-started="[^"]+"/); | |
| 362 | expect(working).toContain(`data-act="x"`); | |
| 363 | expect(working).toContain(`form="stop-${running.id}"`); | |
| 364 | expect(working).toContain(`action="/alice/shop/runs/${running.id}/cancel"`); | |
| 365 | expect(working).toContain("<b>1</b> of 1 slots"); | |
| 366 | // The map drew the issue the crew is on, ringed, with a key. | |
| 367 | const map = html.slice( | |
| 368 | html.indexOf('id="big-map"'), | |
| 369 | html.indexOf('id="big-landed"'), | |
| 370 | ); | |
| 371 | expect(map).toContain('class="map-node"'); | |
| 372 | expect(map).toContain('data-state="working"'); | |
| 373 | expect(map).toContain(`data-key="issue:${told.value.id}"`); | |
| 374 | expect(map).toContain("<b>1</b> working"); | |
| 375 | // The footer offers the lists, each with where it goes. | |
| 376 | expect(html).toMatch( | |
| 377 | /data-k="r"\s+data-go="\/big\?app=alice%2Fshop&list=runs"/, | |
| 378 | ); | |
| 379 | expect(html).toMatch( | |
| 380 | /data-k="p"\s+data-go="\/big\?app=alice%2Fshop&list=pulls"/, | |
| 381 | ); | |
| 382 | expect(html).toMatch( | |
| 383 | /data-k="m"\s+data-go="\/big\?app=alice%2Fshop&list=map"/, | |
| 384 | ); | |
| 385 | ||
| 386 | // The run's own view: the transcript, following, and the steer line. | |
| 387 | const run = await (await get(`/big?run=${running.id}`)).text(); | |
| 388 | expect(run).toContain('data-view="run"'); | |
| 389 | expect(run).toContain('data-back="/big?app=alice%2Fshop"'); | |
| 390 | expect(run).toContain('id="run-log"'); | |
| 391 | expect(run).toContain( | |
| 392 | `data-stream="/api/v1/repos/alice/shop/runs/${running.id}/log?follow=1"`, | |
| 393 | ); | |
| 394 | expect(run).toContain('id="run-steer"'); | |
| 395 | expect(run).toContain(`action="/alice/shop/runs/${running.id}/steer"`); | |
| 396 | expect(run).toContain('data-running="1"'); | |
| 397 | expect(run).toMatch(/id="run-stop"\s+data-act="x"/); | |
| 398 | ||
| 399 | // The lists. | |
| 400 | const runs = await (await get("/big?app=alice%2Fshop&list=runs")).text(); | |
| 401 | expect(runs).toContain('data-view="list"'); | |
| 402 | expect(runs).toContain(`data-key="run:${running.id}"`); | |
| 403 | expect(runs).toContain(`data-key="run:${check.id}"`); | |
| 404 | const pulls = await (await get("/big?app=alice%2Fshop&list=pulls")).text(); | |
| 405 | expect(pulls).toContain(`data-key="pull:${pull.id}"`); | |
| 406 | expect(pulls).toContain('data-word="green"'); | |
| 407 | expect(pulls).toContain(`form="ship-${pull.id}"`); | |
| 408 | const issues = await ( | |
| 409 | await get("/big?app=alice%2Fshop&list=issues") | |
| 410 | ).text(); | |
| 411 | expect(issues).toContain(`data-key="issue:${told.value.id}"`); | |
| 412 | expect(issues).toContain('data-word="working"'); | |
| 413 | // Home now counts them. | |
| 414 | const home = await (await get("/big")).text(); | |
| 415 | expect(home).toMatch(/1\s+working/); | |
| 416 | // Somebody else's app, or none: not found, never a hint of it. | |
| 417 | expect((await get("/big?app=alice%2Fnowhere")).status).toBe(404); | |
| 418 | expect((await get("/big?run=nothing")).status).toBe(404); | |
| 419 | }); | |
| 420 | ||
| 421 | test("board and map inside the shell, focusable", async () => { | |
| 422 | const board = await (await get("/big?view=board")).text(); | |
| 423 | expect(board).toContain('data-view="board"'); | |
| 424 | expect(board).toContain('data-layout="wall"'); | |
| 425 | expect(board).toMatch(/id="working"\s+data-live="run\.\*"/); | |
| 426 | expect(board).toMatch(/id="today"\s+data-live="run\.\*,pull\.merged"/); | |
| 427 | expect(board).toContain('class="board-card board-run"'); | |
| 428 | expect(board).toMatch(/data-key="run:[^"]+"/); | |
| 429 | expect(board).toMatch(/data-key="app:alice\/notes"/); | |
| 430 | const map = await (await get("/big?view=map")).text(); | |
| 431 | expect(map).toContain('data-view="map"'); | |
| 432 | expect(map).toMatch( | |
| 433 | /id="map-lanes"\s+data-live="run\.\*,issue\.\*,pull\.\*"/, | |
| 434 | ); | |
| 435 | expect(map).toContain('data-key="lane:alice/shop"'); | |
| 436 | expect(map).toContain('data-href="/big?app=alice%2Fshop"'); | |
| 437 | expect(map).toContain('class="map-lane"'); | |
| 438 | }); | |
| 439 | ||
| 440 | test("the command line's targets are pages, and the sheet lists every key the footer shows", async () => { | |
| 441 | const home = await (await get("/big")).text(); | |
| 442 | const apps = /data-apps="([^"]*)"/.exec(home)![1]!.split(" "); | |
| 443 | for (const c of COMMANDS) { | |
| 444 | if (c.name === "help") continue; | |
| 445 | const parsed = parseCommand( | |
| 446 | c.arg === undefined ? c.name : `${c.name} shop`, | |
| 447 | { app: "alice/shop", apps }, | |
| 448 | ); | |
| 449 | expect(parsed.kind).toBe("go"); | |
| 450 | if (parsed.kind !== "go") continue; | |
| 451 | const res = await get(parsed.url); | |
| 452 | expect([200, 303]).toContain(res.status); | |
| 453 | if (res.status === 303) { | |
| 454 | // :q leaves for the web UI, which sends a studio reader to its home. | |
| 455 | expect(res.headers.get("location")).not.toContain("/signin"); | |
| 456 | } | |
| 457 | } | |
| 458 | for (const path of [ | |
| 459 | "/big", | |
| 460 | "/big?app=alice%2Fshop", | |
| 461 | "/big?app=alice%2Fshop&list=runs", | |
| 462 | "/big?view=board", | |
| 463 | "/big?view=map", | |
| 464 | ]) { | |
| 465 | const html = await (await get(path)).text(); | |
| 466 | const foot = kbdsIn(html, "big-foot"); | |
| 467 | const sheet = kbdsIn(html, "big-help"); | |
| 468 | expect(foot.size).toBeGreaterThan(5); | |
| 469 | for (const k of foot) expect(sheet.has(k)).toBe(true); | |
| 470 | // Every command has a line on the sheet. | |
| 471 | for (const c of COMMANDS) expect(html).toContain(`:${c.name}`); | |
| 472 | } | |
| 473 | }); | |
| 474 | }); |
test/board.test.ts +47
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The board: a live wall of the crews at work. Signed in, four live |
| 3 | 3 | * regions; a run in flight and a merged pull on it; `?apps=` narrows it; |
| 4 | * `?wall` is its wall mode, where every old `/big` address lands; | |
| 4 | 5 | * anonymous is sent to sign in. |
| 5 | 6 | */ |
| 6 | 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| @@ -279,6 +280,52 @@ describe("the board", () => { | ||
| 279 | 280 | expect(home).toContain('href="/board"'); |
| 280 | 281 | expect(home).toContain("Board"); |
| 281 | 282 | }); |
| 283 | ||
| 284 | test("?wall is the wall mode: the same live regions, marked for the far screen", async () => { | |
| 285 | const plain = await (await get("/board")).text(); | |
| 286 | expect(plain).not.toContain("data-wall"); | |
| 287 | const res = await get("/board?apps=alice/notes&wall"); | |
| 288 | expect(res.status).toBe(200); | |
| 289 | const html = await res.text(); | |
| 290 | expect(html).toMatch(/<div class="board"\s+data-wall="">/); | |
| 291 | expect(html).toMatch(/id="working"\s+data-live="run\.\*"/); | |
| 292 | expect(html).toMatch(/class="board-scope"\s*>notes</); | |
| 293 | }); | |
| 294 | ||
| 295 | // The big view's every address, each its own test: all land on the wall mode. | |
| 296 | const oldRoutes: [string, string][] = [ | |
| 297 | ["/big", "/board?wall"], | |
| 298 | ["/big?view=board", "/board?wall"], | |
| 299 | ["/big?view=map", "/board?wall"], | |
| 300 | ["/big?app=alice/notes", "/board?apps=alice%2Fnotes&wall"], | |
| 301 | ["/big?app=alice%2Fnotes&list=runs", "/board?apps=alice%2Fnotes&wall"], | |
| 302 | ["/big?app=alice/notes&list=issues", "/board?apps=alice%2Fnotes&wall"], | |
| 303 | ["/big?app=alice/notes&list=pulls", "/board?apps=alice%2Fnotes&wall"], | |
| 304 | ["/big?app=alice/notes&list=map", "/board?apps=alice%2Fnotes&wall"], | |
| 305 | ["/big?run=0123456789abcdef", "/board?wall"], | |
| 306 | // An address that was never an app keeps no scope. | |
| 307 | ["/big?app=../../etc", "/board?wall"], | |
| 308 | ["/big?app=%22%3E%3Cscript%3E", "/board?wall"], | |
| 309 | ]; | |
| 310 | for (const [from, to] of oldRoutes) | |
| 311 | test(`${from} lands on ${to}`, async () => { | |
| 312 | const res = await get(from); | |
| 313 | expect(res.status).toBe(303); | |
| 314 | expect(res.headers.get("location")).toBe(to); | |
| 315 | const landed = await get(to); | |
| 316 | expect(landed.status).toBe(200); | |
| 317 | expect(await landed.text()).toContain("data-wall"); | |
| 318 | }); | |
| 319 | ||
| 320 | test("anonymous at /big reaches the sign-in by way of the wall", async () => { | |
| 321 | const res = await fetch(`${base}/big?app=alice/notes`, { | |
| 322 | redirect: "manual", | |
| 323 | }); | |
| 324 | expect(res.status).toBe(303); | |
| 325 | expect(res.headers.get("location")).toBe("/board?apps=alice%2Fnotes&wall"); | |
| 326 | const next = await fetch(`${base}/board?wall`, { redirect: "manual" }); | |
| 327 | expect(next.headers.get("location")).toMatch(/^\/signin\?next=/); | |
| 328 | }); | |
| 282 | 329 | }); |
| 283 | 330 | |
| 284 | 331 | describe("the board's forge strip", () => { |
new above
offline