Split The code is public; the app answers only to the crew.
The terminal is untestable: make `src/tty.ts` a module with keys in and lines out, and give it its first test #60
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/56-the-terminal-is-untestable-make-src-tty → main 3 commits fork 3 threads · 3 unresolved
src/tty.test.ts +190 −9
| @@ -1,17 +1,198 @@ | ||
| 1 | /** | |
| 2 | * The terminal, run for real: it boots against a database, draws the | |
| 3 | * standings, and says who a row belongs to. A terminal has no markup, so the | |
| 4 | * only thing that can tell a guest from a signed player is a column of its own | |
| 5 | * — a forge name is text like any other and could read "guest aaaa". | |
| 6 | */ | |
| 7 | ||
| 8 | 1 | import { expect, test } from "bun:test"; |
| 9 | 2 | import { mkdtempSync } from "node:fs"; |
| 10 | 3 | import { tmpdir } from "node:os"; |
| 11 | 4 | import { join } from "node:path"; |
| 12 | import { openDatabase, saveMatch } from "./db.ts"; | |
| 5 | import { openDatabase, saveMatch, type MatchRow, type Standing } from "./db.ts"; | |
| 6 | import { initialState, key, render, type Data, type State } from "./tty.ts"; | |
| 13 | 7 | import type { Result } from "./result.ts"; |
| 14 | 8 | |
| 9 | const standing = (over: Partial<Standing> = {}): Standing => ({ | |
| 10 | identity: "user:1", name: "tester", last: "2024-01-01 12:00:00", | |
| 11 | matches: 3, kills: 9, deaths: 4, headshots: 3, damage: 1200, wins: 2, rounds: 15, ...over, | |
| 12 | }); | |
| 13 | ||
| 14 | const match = (over: Partial<MatchRow> = {}): MatchRow => ({ | |
| 15 | id: "m1", identity: "user:1", session: "s1", name: "tester", kills: 9, deaths: 4, headshots: 3, damage: 1200, rounds: 5, | |
| 16 | score_mine: 3, score_theirs: 2, finished: 1, seconds: 420, updated_at: "2024-01-01 12:00:00", ...over, | |
| 17 | }); | |
| 18 | ||
| 19 | const data = (over: Partial<Data> = {}): Data => ({ | |
| 20 | where: "local", ssh: undefined, total: 1, standings: [standing()], matches: [match()], ...over, | |
| 21 | }); | |
| 22 | ||
| 23 | const noRemove = () => false; | |
| 24 | ||
| 25 | test("j moves the cursor down, k moves it up and stops at zero", () => { | |
| 26 | const rows = data({ standings: [standing(), standing(), standing()], matches: [] }); | |
| 27 | let state: State = initialState; | |
| 28 | state = key(state, "j", rows, noRemove); | |
| 29 | expect(state.cursor).toBe(1); | |
| 30 | state = key(state, "j", rows, noRemove); | |
| 31 | expect(state.cursor).toBe(2); | |
| 32 | state = key(state, "k", rows, noRemove); | |
| 33 | expect(state.cursor).toBe(1); | |
| 34 | state = key({ ...state, cursor: 0 }, "k", rows, noRemove); | |
| 35 | expect(state.cursor).toBe(0); | |
| 36 | }); | |
| 37 | ||
| 38 | test("the arrow keys move the cursor the same way j and k do", () => { | |
| 39 | const rows = data({ standings: [standing(), standing()] }); | |
| 40 | let state: State = initialState; | |
| 41 | state = key(state, "\x1b[B", rows, noRemove); | |
| 42 | expect(state.cursor).toBe(1); | |
| 43 | state = key(state, "\x1b[A", rows, noRemove); | |
| 44 | expect(state.cursor).toBe(0); | |
| 45 | state = key(state, "\x1b[A", rows, noRemove); | |
| 46 | expect(state.cursor).toBe(0); | |
| 47 | }); | |
| 48 | ||
| 49 | test("tab walks the three tabs in src/tabs.ts's order and comes back round", () => { | |
| 50 | let state: State = { ...initialState, cursor: 2 }; | |
| 51 | const rows = data(); | |
| 52 | state = key(state, "\t", rows, noRemove); | |
| 53 | expect(state.tab).toBe("matches"); | |
| 54 | expect(state.cursor).toBe(0); | |
| 55 | state = key(state, "\t", rows, noRemove); | |
| 56 | expect(state.tab).toBe("policy"); | |
| 57 | state = key(state, "\t", rows, noRemove); | |
| 58 | expect(state.tab).toBe("standings"); | |
| 59 | }); | |
| 60 | ||
| 61 | test("the cursor is clamped when the list is shorter than the cursor, including across a tab switch", () => { | |
| 62 | const rows = data({ standings: [standing()], matches: [match(), match(), match()] }); | |
| 63 | let state: State = { tab: "matches", cursor: 2, note: "" }; | |
| 64 | state = key(state, "j", rows, noRemove); | |
| 65 | expect(state.cursor).toBe(2); // three matches: index 2 is the last one, "j" cannot go past it | |
| 66 | state = key(state, "\t", rows, noRemove); | |
| 67 | expect(state.tab).toBe("policy"); | |
| 68 | expect(state.cursor).toBe(0); | |
| 69 | // render() clamps too, independently of key(), for a state built by hand. | |
| 70 | const wide: State = { tab: "standings", cursor: 5, note: "" }; | |
| 71 | expect(() => render(wide, rows)).not.toThrow(); | |
| 72 | const lines = render(wide, rows); | |
| 73 | expect(lines.some((l) => l.includes("tester"))).toBe(true); | |
| 74 | }); | |
| 75 | ||
| 76 | test("x removes the selected match and notes it; x on another tab does nothing", () => { | |
| 77 | const rows = data({ matches: [match({ id: "a", name: "Alice" }), match({ id: "b", name: "Bob" })] }); | |
| 78 | const removed: string[] = []; | |
| 79 | const remove = (id: string): boolean => { | |
| 80 | removed.push(id); | |
| 81 | return true; | |
| 82 | }; | |
| 83 | const state: State = { tab: "matches", cursor: 1, note: "" }; | |
| 84 | const next = key(state, "x", rows, remove); | |
| 85 | expect(removed).toEqual(["b"]); | |
| 86 | expect(next.note).toBe("removed Bob's match"); | |
| 87 | ||
| 88 | const failing = key(state, "x", rows, () => false); | |
| 89 | expect(failing.note).toBe("nothing removed"); | |
| 90 | ||
| 91 | const onStandings = key({ tab: "standings", cursor: 0, note: "" }, "x", rows, remove); | |
| 92 | expect(removed).toEqual(["b"]); // unchanged: x only removes on the matches tab | |
| 93 | expect(onStandings.note).toBe(""); | |
| 94 | }); | |
| 95 | ||
| 96 | test("x past the end of the list removes nothing", () => { | |
| 97 | const rows = data({ matches: [] }); | |
| 98 | const remove = () => true; | |
| 99 | const state: State = { tab: "matches", cursor: 0, note: "" }; | |
| 100 | const next = key(state, "x", rows, remove); | |
| 101 | expect(next.note).toBe(""); | |
| 102 | }); | |
| 103 | ||
| 104 | test("an unrecognised key, like q, leaves the tab and cursor exactly as they were", () => { | |
| 105 | const rows = data({ standings: [standing(), standing()] }); | |
| 106 | const state: State = { tab: "standings", cursor: 1, note: "stale" }; | |
| 107 | const next = key(state, "q", rows, noRemove); | |
| 108 | expect(next).toEqual({ tab: "standings", cursor: 1, note: "" }); | |
| 109 | }); | |
| 110 | ||
| 111 | test("the standings tab renders every row, and says so when there are none", () => { | |
| 112 | const empty = render(initialState, data({ standings: [] })); | |
| 113 | expect(empty.some((l) => l.includes("nothing played yet"))).toBe(true); | |
| 114 | ||
| 115 | const withRows = render(initialState, data({ standings: [standing({ name: "Vesper" })] })); | |
| 116 | expect(withRows.some((l) => l.includes("Vesper"))).toBe(true); | |
| 117 | }); | |
| 118 | ||
| 119 | test("the matches tab renders every row, and says so when there are none", () => { | |
| 120 | const empty = render({ ...initialState, tab: "matches" }, data({ matches: [] })); | |
| 121 | expect(empty.some((l) => l.includes("no matches yet"))).toBe(true); | |
| 122 | ||
| 123 | const withRows = render({ ...initialState, tab: "matches" }, data({ matches: [match({ name: "Vesper" })] })); | |
| 124 | expect(withRows.some((l) => l.includes("Vesper"))).toBe(true); | |
| 125 | }); | |
| 126 | ||
| 127 | test("the policy tab renders with an empty database, since it does not read one", () => { | |
| 128 | const lines = render({ ...initialState, tab: "policy" }, data({ standings: [], matches: [], total: 0 })); | |
| 129 | expect(lines.some((l) => l.includes("the policy the bots fight with"))).toBe(true); | |
| 130 | }); | |
| 131 | ||
| 132 | test("FORGE_APP_SSH is printed verbatim when set, and its absence is said plainly", () => { | |
| 133 | const on = render(initialState, data({ ssh: "ssh -p 2222 cs@localhost" })); | |
| 134 | expect(on.join("\n")).toContain("reach this terminal with: ssh -p 2222 cs@localhost"); | |
| 135 | ||
| 136 | const off = render(initialState, data({ ssh: undefined })); | |
| 137 | expect(off.join("\n")).toContain("the app's terminal is off"); | |
| 138 | }); | |
| 139 | ||
| 140 | test("nothing from the database reaches the screen as a raw control character", () => { | |
| 141 | // A crafted name can carry a real ANSI escape byte (0x1b) or a carriage | |
| 142 | // return — the printable characters around it still have to show up, but | |
| 143 | // never the bytes a terminal would act on. | |
| 144 | const hostile = "\x1b[31mowned\x1b[0m\rby me"; | |
| 145 | const lines = render( | |
| 146 | { ...initialState, tab: "matches" }, | |
| 147 | data({ matches: [match({ name: hostile })] }), | |
| 148 | ); | |
| 149 | const line = lines.find((l) => l.includes("owned")) ?? ""; | |
| 150 | expect(line).toContain("owned"); | |
| 151 | expect(line).toContain("by me"); | |
| 152 | expect(line).not.toContain("\x1b[31m"); | |
| 153 | expect(line).not.toContain("\r"); | |
| 154 | }); | |
| 155 | ||
| 156 | test("a note with control characters is sanitised the same way", () => { | |
| 157 | const lines = render({ ...initialState, note: "hi\x1b[2Jcleared" }, data()); | |
| 158 | const noteLine = lines.at(-1) ?? ""; | |
| 159 | expect(noteLine).toContain("hi"); | |
| 160 | expect(noteLine).toContain("cleared"); | |
| 161 | expect(noteLine).not.toContain("\x1b[2J"); | |
| 162 | }); | |
| 163 | ||
| 164 | test("a C1 control byte, like the 8-bit form of CSI, is stripped too", () => { | |
| 165 | // \x9b is U+009B: CSI on a terminal in 8-bit mode, not just \x1b\x5b. | |
| 166 | const lines = render( | |
| 167 | { ...initialState, tab: "matches" }, | |
| 168 | data({ matches: [match({ name: "eviloverwritten\x9b2Jgone" })] }), | |
| 169 | ); | |
| 170 | const line = lines.find((l) => l.includes("gone")) ?? ""; | |
| 171 | expect(line).toContain("gone"); | |
| 172 | expect(line).not.toContain("\x9b"); | |
| 173 | }); | |
| 174 | ||
| 175 | test("standings and matches say who a row belongs to, guest or signed", () => { | |
| 176 | const rows = data({ | |
| 177 | standings: [standing({ identity: "user:1", name: "guest aaaa" }), standing({ identity: "guest:aaaa", name: "guest aaaa" })], | |
| 178 | matches: [match({ identity: "user:1", name: "guest aaaa" }), match({ identity: "guest:aaaa", name: "guest aaaa" })], | |
| 179 | }); | |
| 180 | const standingsLines = render(initialState, rows).join("\n"); | |
| 181 | expect(standingsLines).toMatch(/guest aaaa\s+signed/); | |
| 182 | expect(standingsLines).toMatch(/guest aaaa\s+guest/); | |
| 183 | ||
| 184 | const matchLines = render({ ...initialState, tab: "matches" }, rows).join("\n"); | |
| 185 | expect(matchLines).toMatch(/guest aaaa\s+signed/); | |
| 186 | expect(matchLines).toMatch(/guest aaaa\s+guest/); | |
| 187 | }); | |
| 188 | ||
| 189 | /** | |
| 190 | * The terminal, run for real: it boots against a database and draws a frame. | |
| 191 | * Everything about how a frame reads from state and data is covered above; | |
| 192 | * this only has to prove the process wires readData/render/key together and | |
| 193 | * that the env-dependent identity warning reaches the screen. | |
| 194 | */ | |
| 195 | ||
| 15 | 196 | const result = (over: Partial<Result> = {}): Result => ({ |
| 16 | 197 | matchId: crypto.randomUUID(), |
| 17 | 198 | rounds: 5, scoreMine: 3, scoreTheirs: 2, |
| @@ -32,7 +213,7 @@ async function draw(dir: string, env: Record<string, string> = {}): Promise<stri | ||
| 32 | 213 | child.kill(); |
| 33 | 214 | const text = await new Response(child.stdout).text(); |
| 34 | 215 | // eslint-disable-next-line no-control-regex |
| 35 | return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, ""); | |
| 216 | return text.replace(/\[[0-9;]*[A-Za-z]/g, ""); | |
| 36 | 217 | } |
| 37 | 218 | |
| 38 | 219 | test("the terminal says who a row belongs to, in a column a name cannot fake", async () => { |
src/tty.ts +99 −52
| @@ -5,45 +5,89 @@ | ||
| 5 | 5 | * the match list and the policy card (`src/tabs.ts` keeps that order), x |
| 6 | 6 | * removes the selected match, r refreshes, q leaves. Plain ANSI, no |
| 7 | 7 | * dependencies. |
| 8 | * | |
| 9 | * The state, `render` and `key` below are plain data in, plain data out — | |
| 10 | * everything a test can drive without a real terminal or database. Only the | |
| 11 | * code under `import.meta.main` touches raw mode, stdin or a real database. | |
| 8 | 12 | */ |
| 9 | 13 | |
| 10 | import { headshotShare, matchCount, openDatabase, ratio, recentMatches, standings, deleteMatch } from "./db.ts"; | |
| 14 | import type { Database } from "bun:sqlite"; | |
| 15 | import { | |
| 16 | deleteMatch, headshotShare, matchCount, openDatabase, ratio, recentMatches, standings, | |
| 17 | type MatchRow, type Standing, | |
| 18 | } from "./db.ts"; | |
| 11 | 19 | import { isGuest } from "./player.ts"; |
| 12 | 20 | import { identityWarning } from "./who.ts"; |
| 13 | 21 | import { CARD } from "./client/weights.ts"; |
| 14 | 22 | import { nextTab, TABS, type Tab } from "./tabs.ts"; |
| 15 | 23 | |
| 16 | const db = openDatabase(); | |
| 17 | const where = process.env.FORGE_APP_ENV === "preview" | |
| 18 | ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` | |
| 19 | : (process.env.FORGE_APP_ENV ?? "local"); | |
| 20 | const ssh = process.env.FORGE_APP_SSH; | |
| 24 | export type State = { tab: Tab; cursor: number; note: string }; | |
| 25 | ||
| 26 | export const initialState: State = { tab: "standings", cursor: 0, note: "" }; | |
| 27 | ||
| 28 | /** Everything a frame needs to draw, read fresh from the database each time. */ | |
| 29 | export type Data = { | |
| 30 | where: string; | |
| 31 | ssh: string | undefined; | |
| 32 | total: number; | |
| 33 | standings: Standing[]; | |
| 34 | matches: MatchRow[]; | |
| 35 | }; | |
| 36 | ||
| 37 | /** Deletes the match by id, and says whether a row actually went away. */ | |
| 38 | export type Remove = (id: string) => boolean; | |
| 39 | ||
| 40 | export function readData(db: Database, where: string, ssh: string | undefined): Data { | |
| 41 | return { where, ssh, total: matchCount(db), standings: standings(db, 20), matches: recentMatches(db, 20) }; | |
| 42 | } | |
| 21 | 43 | |
| 22 | 44 | const esc = "\x1b["; |
| 23 | const out = (s: string): void => { process.stdout.write(s); }; | |
| 24 | 45 | const pad = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n)); |
| 25 | 46 | |
| 47 | // A name is already stripped of control characters where it is written | |
| 48 | // (`cleanName` in src/result.ts), but this paints raw strings straight at a | |
| 49 | // real pty, where an escape sequence is code, not text — nothing coming out | |
outdated
p
outdated
Approved. I checked out What I verified
The line this is anchored to is the part that is more than a refactor: a name reaching a pty is code, not text, and stripping C0/DEL on the way out is the terminal's equivalent of escaping HTML. Good that it is tested with a live ESC byte and a Preview: https://cs--60--forge-trevato-apps.open-platform.sh/ answers The one gap, and it is not this pull's: Two nits for whoever is next in this file, neither blocking:
(Housekeeping: three stray comments reading |
||
| 50 | // of the database is trusted a second time. C0, DEL and C1 (U+0080-U+009F, | |
outdated
Approved, and the C1 fix landed cleanly. I re-checked the new head What I verified this round
Preview: https://cs--60--forge-trevato-apps.open-platform.sh/ answers Still open, and not this pull's to close: One cosmetic nit, not worth a round: (Housekeeping: a few stray one-character comments on this pull are mine, from working out that this endpoint wants a line anchor; there is no delete route for them.) |
||
| 51 | // which is CSI at U+009B on a terminal in 8-bit mode) all go. | |
| 52 | const safe = (s: string): string => s.replace(/[\x00-\x1f\x7f-\x9f]/g, ""); | |
| 53 | ||
| 26 | 54 | // The page tags a guest row with markup a name cannot produce; a terminal has |
| 27 | 55 | // no markup, so the tag is its own column instead. A forge player called |
| 28 | 56 | // "guest aaaa" still reads `signed` here. |
| 29 | 57 | const who = (identity: string): string => (isGuest(identity) ? "guest" : "signed"); |
| 30 | 58 | |
| 31 | let tab: Tab = "standings"; | |
| 32 | let cursor = 0; | |
| 33 | let note = ""; | |
| 59 | const rowCount = (tab: Tab, data: Pick<Data, "standings" | "matches">): number => | |
| 60 | tab === "standings" ? data.standings.length : tab === "matches" ? data.matches.length : 0; | |
| 34 | 61 | |
| 35 | function draw(): void { | |
| 36 | const table = standings(db, 20); | |
| 37 | const matches = recentMatches(db, 20); | |
| 38 | const rows = tab === "standings" ? table.length : tab === "matches" ? matches.length : 0; | |
| 39 | cursor = Math.max(0, Math.min(cursor, Math.max(0, rows - 1))); | |
| 62 | const clampCursor = (cursor: number, rows: number): number => Math.max(0, Math.min(cursor, Math.max(0, rows - 1))); | |
| 63 | ||
| 64 | /** What one keypress does to the state. Reads `data` but never the database itself. */ | |
| 65 | export function key(state: State, input: string, data: Data, remove: Remove): State { | |
| 66 | let tab = state.tab; | |
| 67 | let cursor = state.cursor; | |
| 68 | let note = ""; | |
| 69 | if (input === "j" || input === "\x1b[B") cursor += 1; | |
| 70 | else if (input === "k" || input === "\x1b[A") cursor = Math.max(0, cursor - 1); | |
| 71 | else if (input === "\t") { | |
| 72 | tab = nextTab(tab); | |
| 73 | cursor = 0; | |
| 74 | } else if (input === "x" && tab === "matches") { | |
| 75 | const match = data.matches[cursor]; | |
| 76 | if (match !== undefined) note = remove(match.id) ? `removed ${match.name}'s match` : "nothing removed"; | |
| 77 | } | |
| 78 | cursor = clampCursor(cursor, rowCount(tab, data)); | |
| 79 | return { tab, cursor, note }; | |
| 80 | } | |
| 81 | ||
| 82 | /** The screen for a state and a frame of data, one line per row. */ | |
| 83 | export function render(state: State, data: Data): string[] { | |
| 84 | const cursor = clampCursor(state.cursor, rowCount(state.tab, data)); | |
Reconstructed the head on top of One miss, and it is in the one thing this pull exists to deliver. 1 — Fix first:
with
2 — A raw ESC byte is now in the test source. Not blocking, worth a line each:
Round 1 of 2 — item 1 is two lines, item 2 is one character sequence, and I will approve on the next push. |
||
| 40 | 85 | const lines: string[] = []; |
| 41 | const total = matchCount(db); | |
| 42 | lines.push(`${esc}1mcs${esc}0m ${esc}2m${where} · ${total} match${total === 1 ? "" : "es"}${esc}0m`); | |
| 86 | lines.push(`${esc}1mcs${esc}0m ${esc}2m${safe(data.where)} · ${data.total} match${data.total === 1 ? "" : "es"}${esc}0m`); | |
| 43 | 87 | lines.push(""); |
| 44 | lines.push(TABS.map((t) => `${esc}${t === tab ? "7" : "2"}m ${t} ${esc}0m`).join("")); | |
| 88 | lines.push(TABS.map((t) => `${esc}${t === state.tab ? "7" : "2"}m ${t} ${esc}0m`).join("")); | |
| 45 | 89 | lines.push(""); |
| 46 | if (tab === "policy") { | |
| 90 | if (state.tab === "policy") { | |
| 47 | 91 | const line = (k: string, v: string): string => ` ${esc}2m${pad(k, 22)}${esc}0m${v}`; |
| 48 | 92 | lines.push(` ${esc}1mthe policy the bots fight with${esc}0m`); |
| 49 | 93 | lines.push(""); |
| @@ -57,54 +101,57 @@ function draw(): void { | ||
| 57 | 101 | lines.push(line("won by the old bots", `${CARD.baselineWinRate}%`)); |
| 58 | 102 | lines.push(""); |
| 59 | 103 | lines.push(` ${esc}2mretrain with: bun run scripts/train.ts --iters 1000${esc}0m`); |
| 60 | } else if (tab === "standings") { | |
| 104 | } else if (state.tab === "standings") { | |
| 61 | 105 | lines.push(`${esc}2m ${pad("player", 26)}${pad("who", 8)}${pad("matches", 9)}${pad("kills", 7)}${pad("K/D", 7)}${pad("HS", 6)}wins${esc}0m`); |
| 62 | if (table.length === 0) lines.push(`${esc}2m nothing played yet${esc}0m`); | |
| 63 | table.forEach((s, i) => { | |
| 64 | const line = `${pad(s.name, 26)}${pad(who(s.identity), 8)}${pad(String(s.matches), 9)}${pad(String(s.kills), 7)}` + | |
| 106 | if (data.standings.length === 0) lines.push(`${esc}2m nothing played yet${esc}0m`); | |
| 107 | data.standings.forEach((s, i) => { | |
| 108 | const line = `${pad(safe(s.name), 26)}${pad(who(s.identity), 8)}${pad(String(s.matches), 9)}${pad(String(s.kills), 7)}` + | |
| 65 | 109 | `${pad(ratio(s.kills, s.deaths), 7)}${pad(headshotShare(s.headshots, s.kills), 6)}${s.wins}`; |
| 66 | 110 | lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`); |
| 67 | 111 | }); |
| 68 | 112 | } else { |
| 69 | 113 | lines.push(`${esc}2m ${pad("player", 22)}${pad("who", 8)}${pad("score", 8)}${pad("k/d", 8)}${pad("damage", 8)}when${esc}0m`); |
| 70 | if (matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`); | |
| 71 | matches.forEach((m, i) => { | |
| 72 | const line = `${pad(m.name, 22)}${pad(who(m.identity), 8)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` + | |
| 73 | `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${m.updated_at}` + | |
| 114 | if (data.matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`); | |
| 115 | data.matches.forEach((m, i) => { | |
| 116 | const line = `${pad(safe(m.name), 22)}${pad(who(m.identity), 8)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` + | |
| 117 | `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${safe(m.updated_at)}` + | |
| 74 | 118 | `${m.finished === 1 ? "" : " (live)"}`; |
| 75 | 119 | lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`); |
| 76 | 120 | }); |
| 77 | 121 | } |
| 78 | 122 | lines.push(""); |
| 79 | 123 | lines.push(`${esc}2mj/k move · tab switch · x remove a match · r refresh · q quit${esc}0m`); |
| 80 | lines.push(ssh === undefined | |
| 124 | lines.push(data.ssh === undefined | |
| 81 | 125 | ? `${esc}2mthe app's terminal is off${esc}0m` |
| 82 | : `${esc}2mreach this terminal with: ${ssh}${esc}0m`); | |
| 126 | : `${esc}2mreach this terminal with: ${data.ssh}${esc}0m`); | |
| 83 | 127 | const notice = identityWarning(); |
| 84 | 128 | if (notice !== null) lines.push(`${esc}33m${notice}${esc}0m`); |
| 85 | if (note !== "") lines.push(`${esc}33m${note}${esc}0m`); | |
| 86 | out(`${esc}2J${esc}H` + lines.join("\r\n")); | |
| 129 | if (state.note !== "") lines.push(`${esc}33m${safe(state.note)}${esc}0m`); | |
| 130 | return lines; | |
| 87 | 131 | } |
| 88 | 132 | |
| 89 | process.stdin.setRawMode?.(true); | |
| 90 | process.stdin.resume(); | |
| 91 | process.stdin.setEncoding("utf8"); | |
| 92 | draw(); | |
| 93 | process.stdin.on("data", (key: string) => { | |
| 94 | note = ""; | |
| 95 | if (key === "q" || key === "\x03") { | |
| 96 | out(`${esc}2J${esc}H`); | |
| 97 | process.exit(0); | |
| 98 | } | |
| 99 | if (key === "j" || key === "\x1b[B") cursor += 1; | |
| 100 | else if (key === "k" || key === "\x1b[A") cursor = Math.max(0, cursor - 1); | |
| 101 | else if (key === "\t") { | |
| 102 | tab = nextTab(tab); | |
| 103 | cursor = 0; | |
| 104 | } else if (key === "x" && tab === "matches") { | |
| 105 | const match = recentMatches(db, 20)[cursor]; | |
| 106 | if (match !== undefined) note = deleteMatch(db, match.id) ? `removed ${match.name}'s match` : "nothing removed"; | |
| 107 | } | |
| 133 | if (import.meta.main) { | |
| 134 | const db = openDatabase(); | |
| 135 | const where = process.env.FORGE_APP_ENV === "preview" | |
| 136 | ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` | |
| 137 | : (process.env.FORGE_APP_ENV ?? "local"); | |
| 138 | const ssh = process.env.FORGE_APP_SSH; | |
| 139 | ||
| 140 | const out = (s: string): void => { process.stdout.write(s); }; | |
| 141 | let state: State = initialState; | |
| 142 | const draw = (): void => { out(`${esc}2J${esc}H` + render(state, readData(db, where, ssh)).join("\r\n")); }; | |
| 143 | ||
| 144 | process.stdin.setRawMode?.(true); | |
| 145 | process.stdin.resume(); | |
| 146 | process.stdin.setEncoding("utf8"); | |
| 108 | 147 | draw(); |
| 109 | }); | |
| 110 | process.stdout.on("resize", draw); | |
| 148 | process.stdin.on("data", (input: string) => { | |
| 149 | if (input === "q" || input === "\x03") { | |
| 150 | out(`${esc}2J${esc}H`); | |
| 151 | process.exit(0); | |
| 152 | } | |
| 153 | state = key(state, input, readData(db, where, ssh), (id) => deleteMatch(db, id)); | |
| 154 | draw(); | |
| 155 | }); | |
| 156 | process.stdout.on("resize", draw); | |
| 157 | } |
p