Public: anyone can read this; the crew can change it.
The terminal keeps up: the board redraws when someone else changes it #47
Check · Review · Land · Live done — Landed and live in production.
scout/tempo:scout/17-the-terminal-keeps-up-the-board-redraws → main 4 commits fork 2 threads · 2 unresolved
README.md +16 −3
| @@ -128,9 +128,22 @@ background, reverting if it fails. | ||
| 128 | 128 | board's `/b/<id>` address the way the picker does, so a board made with |
| 129 | 129 | `n` — unowned, and so invisible to the picker even for the person who |
| 130 | 130 | just made it — stays reachable afterwards. |
| 131 | - The event stream is this one process's: the terminal is a separate | |
| 132 | process on the same database, so a change made there shows up on the | |
| 133 | next page load, not live. | |
| 131 | - The event bus (`src/bus.ts`) is this one process's in memory, and the | |
| 132 | terminal is a separate process on a pty, so it can't hear it. Instead the | |
| 133 | terminal follows the same board live by polling: `src/db.ts` keeps a | |
| 134 | revision per board (and one for the boards list) bumped in the same | |
| 135 | transaction as every write, and `src/tty.ts` checks it about twice a | |
| 136 | second while idle, redrawing only when it moved — so someone else's | |
| 137 | card move, edit, or new board shows up within half a second whether it | |
| 138 | came from the page or another terminal. A redraw never disturbs what | |
| 139 | you're doing: an open prompt keeps its text, the filter stays, and the | |
| 140 | cursor rides the same card or column (found by id) rather than jumping | |
| 141 | when the list changes under it. It's one-way: only `src/routes.ts` and | |
| 142 | `src/mcp.ts` call `bus.publish`, and the terminal writes straight to | |
| 143 | `src/db.ts`, so a change made *in* the terminal still doesn't reach an | |
| 144 | open web page until its next load — the revision table makes that cheap | |
| 145 | to close (the server could poll it and publish too), but that's outside | |
| 146 | this issue. | |
| 134 | 147 | |
| 135 | 148 | ## Limits |
| 136 | 149 |
src/db.test.ts +77
| @@ -203,6 +203,83 @@ test("every board returned by createBoard has exactly its three default columns" | ||
| 203 | 203 | } |
| 204 | 204 | }); |
| 205 | 205 | |
| 206 | test("boardRevision moves after every kind of change to a board, and after undo, but never on a plain read", () => { | |
| 207 | const { boardId, columnId } = setup(); | |
| 208 | const before = db.boardRevision(boardId); | |
| 209 | expect(db.boardRevision(boardId)).toBe(before); // reading twice does not move it | |
| 210 | ||
| 211 | const card = assertCard(db.createCard(boardId, columnId, "one")); | |
| 212 | const afterCreateCard = db.boardRevision(boardId); | |
| 213 | expect(afterCreateCard).toBeGreaterThan(before); | |
| 214 | ||
| 215 | db.editCard(boardId, card.id, "one!"); | |
| 216 | const afterEdit = db.boardRevision(boardId); | |
| 217 | expect(afterEdit).toBeGreaterThan(afterCreateCard); | |
| 218 | ||
| 219 | const column2 = db.createColumn(boardId, "Next"); | |
| 220 | const afterCreateColumn = db.boardRevision(boardId); | |
| 221 | expect(afterCreateColumn).toBeGreaterThan(afterEdit); | |
| 222 | if (typeof column2 === "string") throw new Error("expected a Column"); | |
| 223 | ||
| 224 | db.moveCard(boardId, card.id, column2.id, 0); | |
| 225 | const afterMoveCard = db.boardRevision(boardId); | |
| 226 | expect(afterMoveCard).toBeGreaterThan(afterCreateColumn); | |
| 227 | ||
| 228 | db.moveColumn(boardId, column2.id, "left"); | |
| 229 | const afterMoveColumn = db.boardRevision(boardId); | |
| 230 | expect(afterMoveColumn).toBeGreaterThan(afterMoveCard); | |
| 231 | ||
| 232 | db.renameColumn(boardId, column2.id, "Renamed"); | |
| 233 | const afterRenameColumn = db.boardRevision(boardId); | |
| 234 | expect(afterRenameColumn).toBeGreaterThan(afterMoveColumn); | |
| 235 | ||
| 236 | db.deleteColumn(boardId, columnId); | |
| 237 | const afterDeleteColumn = db.boardRevision(boardId); | |
| 238 | expect(afterDeleteColumn).toBeGreaterThan(afterRenameColumn); | |
| 239 | ||
| 240 | db.deleteCard(boardId, card.id); | |
| 241 | const afterDeleteCard = db.boardRevision(boardId); | |
| 242 | expect(afterDeleteCard).toBeGreaterThan(afterDeleteColumn); | |
| 243 | ||
| 244 | db.undo(boardId); | |
| 245 | const afterUndo = db.boardRevision(boardId); | |
| 246 | expect(afterUndo).toBeGreaterThan(afterDeleteCard); | |
| 247 | }); | |
| 248 | ||
| 249 | test("boardRevision of a board that does not exist is 0 and stays put", () => { | |
| 250 | expect(db.boardRevision("no-such-board")).toBe(0); | |
| 251 | expect(db.boardRevision("no-such-board")).toBe(0); | |
| 252 | }); | |
| 253 | ||
| 254 | test("boardsRevision moves when a board is created, renamed, or claimed, but a board's own revision does not move for the other two", () => { | |
| 255 | const start = db.boardsRevision(); | |
| 256 | ||
| 257 | const board = db.createBoard("Original"); | |
| 258 | const afterCreate = db.boardsRevision(); | |
| 259 | expect(afterCreate).toBeGreaterThan(start); | |
| 260 | ||
| 261 | const ownRevision = db.boardRevision(board.id); | |
| 262 | ||
| 263 | db.renameBoard(board.id, "Renamed"); | |
| 264 | const afterRename = db.boardsRevision(); | |
| 265 | expect(afterRename).toBeGreaterThan(afterCreate); | |
| 266 | expect(db.boardRevision(board.id)).toBeGreaterThan(ownRevision); // the board's own revision moves too | |
| 267 | ||
| 268 | db.claimBoard(board.id, { id: "owner-1", name: "Owner" }); | |
| 269 | const afterClaim = db.boardsRevision(); | |
| 270 | expect(afterClaim).toBeGreaterThan(afterRename); | |
| 271 | }); | |
| 272 | ||
| 273 | test("undo of a renamed board also moves boardsRevision, since the boards list shows the restored name", () => { | |
| 274 | const board = db.createBoard("Original"); | |
| 275 | db.renameBoard(board.id, "Renamed"); | |
| 276 | const afterRename = db.boardsRevision(); | |
| 277 | ||
| 278 | db.undo(board.id); | |
| 279 | expect(db.getBoard(board.id)!.name).toBe("Original"); | |
| 280 | expect(db.boardsRevision()).toBeGreaterThan(afterRename); | |
| 281 | }); | |
| 282 | ||
| 206 | 283 | test("undoing a rename restores the old name and leaves a card moved before it untouched; a second undo then reverses the move", () => { |
| 207 | 284 | const { boardId, columnId } = setup(); |
| 208 | 285 | const columns = db.listColumns(boardId); |
src/db.ts +49
| @@ -51,6 +51,10 @@ db.exec(` | ||
| 51 | 51 | CREATE INDEX IF NOT EXISTS card_column_idx ON card(column_id); |
| 52 | 52 | CREATE INDEX IF NOT EXISTS column_board_idx ON board_column(board_id); |
| 53 | 53 | CREATE INDEX IF NOT EXISTS action_board_idx ON action(board_id); |
| 54 | CREATE TABLE IF NOT EXISTS revision ( | |
| 55 | scope TEXT PRIMARY KEY, | |
| 56 | value INTEGER NOT NULL DEFAULT 0 | |
| 57 | ); | |
| 54 | 58 | `); |
| 55 | 59 | |
| 56 | 60 | // A board created before ownership existed has no owner_id/owner_name |
| @@ -109,6 +113,36 @@ function logAction(boardId: string, payload: Payload): void { | ||
| 109 | 113 | ).run(boardId, MAX_UNDO_DEPTH); |
| 110 | 114 | } |
| 111 | 115 | |
| 116 | // The terminal (src/tty.ts) is a separate process on a pty; it cannot hear | |
| 117 | // the web server's in-process event bus (src/live.ts), so it polls this | |
| 118 | // instead. A scope's revision is a counter bumped in the same transaction | |
| 119 | // as every write that changes what that scope shows, never derived from | |
| 120 | // the action log — the log is trimmed and undone, so it is not a clock. | |
| 121 | // "__boards__" is not a valid board id (those are 8 lowercase/digit | |
| 122 | // characters from randomId), so it cannot collide with one. | |
| 123 | const BOARDS_SCOPE = "__boards__"; | |
| 124 | ||
| 125 | function bumpRevision(scope: string): void { | |
| 126 | db.query( | |
| 127 | `INSERT INTO revision (scope, value) VALUES (?1, 1) | |
| 128 | ON CONFLICT(scope) DO UPDATE SET value = value + 1`, | |
| 129 | ).run(scope); | |
| 130 | } | |
| 131 | ||
| 132 | function readRevision(scope: string): number { | |
| 133 | return db.query<{ value: number }, [string]>("SELECT value FROM revision WHERE scope = ?1").get(scope)?.value ?? 0; | |
| 134 | } | |
| 135 | ||
| 136 | /** Bumped by every change to this board's columns and cards, and by undo. */ | |
| 137 | export function boardRevision(boardId: string): number { | |
| 138 | return readRevision(boardId); | |
| 139 | } | |
| 140 | ||
| 141 | /** Bumped when a board is created, renamed, or claimed (leaves the unowned list). */ | |
| 142 | export function boardsRevision(): number { | |
| 143 | return readRevision(BOARDS_SCOPE); | |
| 144 | } | |
| 145 | ||
| 112 | 146 | const BOARD_FIELDS = "id, name, owner_id, owner_name, created_at"; |
| 113 | 147 | |
| 114 | 148 | export function createBoard(name: string, owner?: { id: string; name: string }): Board { |
| @@ -122,6 +156,7 @@ export function createBoard(name: string, owner?: { id: string; name: string }): | ||
| 122 | 156 | ); |
| 123 | 157 | const defaults = ["To do", "Doing", "Done"]; |
| 124 | 158 | defaults.forEach((columnName, i) => createColumn(id, columnName, i)); |
| 159 | bumpRevision(BOARDS_SCOPE); | |
| 125 | 160 | return getBoard(id)!; |
| 126 | 161 | }).immediate(); |
| 127 | 162 | } |
| @@ -149,6 +184,7 @@ export function claimBoard(boardId: string, owner: { id: string; name: string }) | ||
| 149 | 184 | if (board.owner_id === owner.id) return "already_yours"; |
| 150 | 185 | if (board.owner_id !== null) return "owned_by_other"; |
| 151 | 186 | db.query("UPDATE board SET owner_id = ?2, owner_name = ?3 WHERE id = ?1").run(boardId, owner.id, owner.name); |
| 187 | bumpRevision(BOARDS_SCOPE); | |
| 152 | 188 | return "claimed"; |
| 153 | 189 | }).immediate(); |
| 154 | 190 | } |
| @@ -159,6 +195,8 @@ export function renameBoard(boardId: string, name: string): boolean { | ||
| 159 | 195 | if (!before) return false; |
| 160 | 196 | db.query("UPDATE board SET name = ?2 WHERE id = ?1").run(boardId, name); |
| 161 | 197 | logAction(boardId, { restoreBoardName: { id: boardId, name: before.name } }); |
| 198 | bumpRevision(boardId); | |
| 199 | bumpRevision(BOARDS_SCOPE); | |
| 162 | 200 | return true; |
| 163 | 201 | }).immediate(); |
| 164 | 202 | } |
| @@ -205,6 +243,7 @@ export function createColumn(boardId: string, name: string, explicitPosition?: n | ||
| 205 | 243 | position, |
| 206 | 244 | ); |
| 207 | 245 | logAction(boardId, { deleteColumnIds: [id] }); |
| 246 | bumpRevision(boardId); | |
| 208 | 247 | return { id, board_id: boardId, name, position }; |
| 209 | 248 | }).immediate(); |
| 210 | 249 | } |
| @@ -221,6 +260,7 @@ export function renameColumn(boardId: string, columnId: string, name: string): b | ||
| 221 | 260 | if (!before) return false; |
| 222 | 261 | db.query("UPDATE board_column SET name = ?2 WHERE id = ?1").run(columnId, name); |
| 223 | 262 | logAction(boardId, { restoreColumns: [before] }); |
| 263 | bumpRevision(boardId); | |
| 224 | 264 | return true; |
| 225 | 265 | }).immediate(); |
| 226 | 266 | } |
| @@ -236,6 +276,7 @@ export function moveColumn(boardId: string, columnId: string, direction: "left" | ||
| 236 | 276 | db.query("UPDATE board_column SET position = ?2 WHERE id = ?1").run(columns[i].id, columns[j].position); |
| 237 | 277 | db.query("UPDATE board_column SET position = ?2 WHERE id = ?1").run(columns[j].id, columns[i].position); |
| 238 | 278 | logAction(boardId, { restoreColumns: before }); |
| 279 | bumpRevision(boardId); | |
| 239 | 280 | return true; |
| 240 | 281 | }).immediate(); |
| 241 | 282 | } |
| @@ -247,6 +288,7 @@ export function deleteColumn(boardId: string, columnId: string): boolean { | ||
| 247 | 288 | const cards = listCards(boardId).filter((c) => c.column_id === columnId); |
| 248 | 289 | db.query("DELETE FROM board_column WHERE id = ?1").run(columnId); |
| 249 | 290 | logAction(boardId, { restoreColumns: [before], restoreCards: cards }); |
| 291 | bumpRevision(boardId); | |
| 250 | 292 | return true; |
| 251 | 293 | }).immediate(); |
| 252 | 294 | } |
| @@ -266,6 +308,7 @@ export function createCard(boardId: string, columnId: string, title: string): Ca | ||
| 266 | 308 | position, |
| 267 | 309 | ); |
| 268 | 310 | logAction(boardId, { deleteCardIds: [id] }); |
| 311 | bumpRevision(boardId); | |
| 269 | 312 | return getCard(boardId, id)!; |
| 270 | 313 | }).immediate(); |
| 271 | 314 | } |
| @@ -285,6 +328,7 @@ export function editCard(boardId: string, cardId: string, title: string): boolea | ||
| 285 | 328 | if (!before) return false; |
| 286 | 329 | db.query("UPDATE card SET title = ?2, updated_at = datetime('now') WHERE id = ?1").run(cardId, title); |
| 287 | 330 | logAction(boardId, { restoreCards: [before] }); |
| 331 | bumpRevision(boardId); | |
| 288 | 332 | return true; |
| 289 | 333 | }).immediate(); |
| 290 | 334 | } |
| @@ -314,6 +358,7 @@ export function moveCard(boardId: string, cardId: string, columnId: string, inde | ||
| 314 | 358 | position, |
| 315 | 359 | ); |
| 316 | 360 | logAction(boardId, { restoreCards: [before] }); |
| 361 | bumpRevision(boardId); | |
| 317 | 362 | return true; |
| 318 | 363 | } |
| 319 | 364 | |
| @@ -331,6 +376,7 @@ export function moveCard(boardId: string, cardId: string, columnId: string, inde | ||
| 331 | 376 | at + 1, |
| 332 | 377 | ); |
| 333 | 378 | logAction(boardId, { restoreCards }); |
| 379 | bumpRevision(boardId); | |
| 334 | 380 | return true; |
| 335 | 381 | }).immediate(); |
| 336 | 382 | } |
| @@ -341,6 +387,7 @@ export function deleteCard(boardId: string, cardId: string): boolean { | ||
| 341 | 387 | if (!before) return false; |
| 342 | 388 | db.query("DELETE FROM card WHERE id = ?1").run(cardId); |
| 343 | 389 | logAction(boardId, { restoreCards: [before] }); |
| 390 | bumpRevision(boardId); | |
| 344 | 391 | return true; |
| 345 | 392 | }).immediate(); |
| 346 | 393 | } |
| @@ -364,8 +411,10 @@ export function undo(boardId: string): boolean { | ||
| 364 | 411 | for (const id of payload.deleteCardIds ?? []) db.query("DELETE FROM card WHERE id = ?1").run(id); |
| 365 | 412 | if (payload.restoreBoardName) { |
| 366 | 413 | db.query("UPDATE board SET name = ?2 WHERE id = ?1").run(payload.restoreBoardName.id, payload.restoreBoardName.name); |
| 414 | bumpRevision(BOARDS_SCOPE); // the name undo restored is what the boards list shows too | |
| 367 | 415 | } |
| 368 | 416 | db.query("DELETE FROM action WHERE id = ?1").run(row.id); |
| 417 | bumpRevision(boardId); | |
| 369 | 418 | return true; |
| 370 | 419 | }).immediate(); |
| 371 | 420 | } |
src/keys.ts +54
| @@ -31,6 +31,8 @@ export type BoardStore = { | ||
| 31 | 31 | deleteCard(boardId: string, cardId: string): boolean; |
| 32 | 32 | moveCard(boardId: string, cardId: string, columnId: string, index: number): boolean; |
| 33 | 33 | undo(boardId: string): boolean; |
| 34 | boardRevision(boardId: string): number; | |
| 35 | boardsRevision(): number; | |
| 34 | 36 | }; |
| 35 | 37 | |
| 36 | 38 | export type Mode = "boards" | "board"; |
| @@ -55,6 +57,8 @@ export type State = { | ||
| 55 | 57 | filter: string; |
| 56 | 58 | message: string; |
| 57 | 59 | prompt?: Prompt; |
| 60 | /** The revision (`src/db.ts`'s `boardRevision`/`boardsRevision`) last drawn, so the tty can poll for a newer one. */ | |
| 61 | revision?: number; | |
| 58 | 62 | }; |
| 59 | 63 | |
| 60 | 64 | export type Outcome = { state: State; quit: boolean }; |
| @@ -85,6 +89,56 @@ export function visibleCards(board: BoardStore, state: State, columnId: string): | ||
| 85 | 89 | .filter((c) => q === "" || c.title.toLowerCase().includes(q)); |
| 86 | 90 | } |
| 87 | 91 | |
| 92 | /** Whether a newly-read revision is worth a redraw: the first read, or one that moved. */ | |
| 93 | export function shouldRedraw(previous: number | undefined, next: number): boolean { | |
| 94 | return previous === undefined || next !== previous; | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Where the cursor lands in a freshly-read list after something else may | |
| 99 | * have changed it: on the same item if it is still there (found by id), | |
| 100 | * otherwise the nearest remaining item at (or before) its old index — never | |
| 101 | * a jump to 0 just because the list moved under it. | |
| 102 | */ | |
| 103 | export function keepCursor<T extends { id: string }>(previous: T[], index: number, next: T[]): number { | |
| 104 | const id = previous[index]?.id; | |
| 105 | if (id !== undefined) { | |
| 106 | const found = next.findIndex((item) => item.id === id); | |
| 107 | if (found >= 0) return found; | |
| 108 | } | |
| 109 | if (next.length === 0) return 0; | |
| 110 | return Math.max(0, Math.min(index, next.length - 1)); | |
| 111 | } | |
| 112 | ||
| 113 | /** What was last drawn, kept only so a later `follow` can carry the cursor onto the same item by id. */ | |
| 114 | export type Drawn = { boards: Board[]; columns: Column[]; cards: Card[] }; | |
| 115 | ||
| 116 | export const emptyDrawn: Drawn = { boards: [], columns: [], cards: [] }; | |
| 117 | ||
| 118 | /** | |
| 119 | * What `src/tty.ts`'s idle poll does: read the current scope's revision | |
| 120 | * and, if it moved since the state was last drawn, re-read the board (or | |
| 121 | * boards list) and carry the cursor onto the same item by id — the same | |
| 122 | * decision a keypress makes, just triggered by a timer instead of a key. | |
| 123 | * `redraw` tells the caller whether to actually draw; `state` is always the | |
| 124 | * next one to draw from, whether or not it changed. | |
| 125 | */ | |
| 126 | export function follow(state: State, board: BoardStore, drawn: Drawn): { state: State; redraw: boolean } { | |
| 127 | const next = state.mode === "boards" ? board.boardsRevision() : board.boardRevision(state.boardId!); | |
| 128 | if (!shouldRedraw(state.revision, next)) return { state, redraw: false }; | |
| 129 | ||
| 130 | if (state.mode === "boards") { | |
| 131 | const boards = visibleBoards(board, state.ownerId); | |
| 132 | return { state: { ...state, boardsCursor: keepCursor(drawn.boards, state.boardsCursor, boards) }, redraw: true }; | |
| 133 | } | |
| 134 | const columns = board.listColumns(state.boardId!); | |
| 135 | const columnCursor = keepCursor(drawn.columns, state.columnCursor, columns); | |
| 136 | const column = columns[columnCursor]; | |
| 137 | const cards = column ? visibleCards(board, { ...state, columnCursor }, column.id) : []; | |
| 138 | const cardCursor = keepCursor(drawn.cards, state.cardCursor, cards); | |
| 139 | return { state: { ...state, columnCursor, cardCursor }, redraw: true }; | |
| 140 | } | |
| 141 | ||
| 88 | 142 | /** |
| 89 | 143 | * Where `gap` — a position among a column's *visible* cards, 0 meaning |
| 90 | 144 | * before all of them — lands among every card in that column, the index |
src/tty.test.ts +129 −1
| @@ -14,7 +14,9 @@ import { MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD } from "./limits.ts"; | ||
| 14 | 14 | |
| 15 | 15 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 16 | 16 | const db = await import("./db.ts"); |
| 17 | const { handleKey, initialState, visibleBoards, visibleCards } = await import("./keys.ts"); | |
| 17 | const { emptyDrawn, follow, handleKey, initialState, keepCursor, shouldRedraw, visibleBoards, visibleCards } = await import( | |
| 18 | "./keys.ts" | |
| 19 | ); | |
| 18 | 20 | |
| 19 | 21 | /** One keypress after another, the way the tty feeds them in. */ |
| 20 | 22 | function press(state: State, ...keys: string[]): State { |
| @@ -473,3 +475,129 @@ test("the cursor cannot point outside a board with no columns at all", () => { | ||
| 473 | 475 | expect([after.columnCursor, after.cardCursor]).toEqual([0, 0]); |
| 474 | 476 | expect(after.prompt).toBeUndefined(); |
| 475 | 477 | }); |
| 478 | ||
| 479 | // shouldRedraw and keepCursor are what src/tty.ts's poll loop uses to decide | |
| 480 | // whether, and where, to redraw when the board changed under someone who | |
| 481 | // isn't typing — driven here as plain functions over ids, no pty involved. | |
| 482 | ||
| 483 | test("shouldRedraw fires on the first read and whenever the revision moved, never when it is unchanged", () => { | |
| 484 | expect(shouldRedraw(undefined, 0)).toBe(true); | |
| 485 | expect(shouldRedraw(undefined, 5)).toBe(true); | |
| 486 | expect(shouldRedraw(5, 5)).toBe(false); | |
| 487 | expect(shouldRedraw(5, 6)).toBe(true); | |
| 488 | expect(shouldRedraw(6, 5)).toBe(true); // moved is moved, even if the caller mixed up direction | |
| 489 | }); | |
| 490 | ||
| 491 | const item = (id: string) => ({ id }); | |
| 492 | ||
| 493 | test("keepCursor stays on the same item by id when it is still in the list, wherever it moved to", () => { | |
| 494 | const before = [item("a"), item("b"), item("c")]; | |
| 495 | const after = [item("c"), item("a"), item("b")]; // reordered elsewhere | |
| 496 | expect(keepCursor(before, 1, after)).toBe(2); // was on "b", "b" is now at index 2 | |
| 497 | }); | |
| 498 | ||
| 499 | test("keepCursor clamps to the nearest remaining item when the focused one is gone", () => { | |
| 500 | const before = [item("a"), item("b"), item("c")]; | |
| 501 | expect(keepCursor(before, 1, [item("a"), item("c")])).toBe(1); // "b" gone, index 1 still valid ("c") | |
| 502 | expect(keepCursor(before, 2, [item("a"), item("b")])).toBe(1); // "c" gone, clamp to the end | |
| 503 | expect(keepCursor(before, 0, [])).toBe(0); // nothing left at all | |
| 504 | }); | |
| 505 | ||
| 506 | test("keepCursor never jumps to 0 just because the list changed under it", () => { | |
| 507 | const before = [item("a"), item("b"), item("c"), item("d")]; | |
| 508 | const after = [item("x"), item("b"), item("c"), item("d")]; // "a" replaced by something new at 0 | |
| 509 | expect(keepCursor(before, 2, after)).toBe(2); // "c" is still findable, stays put | |
| 510 | // an out-of-band index (nothing was focused) clamps rather than resetting | |
| 511 | expect(keepCursor([], 0, after)).toBe(0); | |
| 512 | }); | |
| 513 | ||
| 514 | // follow is the whole decision src/tty.ts's idle poll makes each tick: read | |
| 515 | // the current revision, and if it moved since what's on screen, re-read the | |
| 516 | // board (or boards list) and carry the cursor by id. It is driven here | |
| 517 | // against the real db, the same way `press` drives handleKey, so a change | |
| 518 | // to what triggers a redraw or where the cursor lands fails a test here — | |
| 519 | // no pty, and nothing in src/tty.ts itself needs covering separately. | |
| 520 | ||
| 521 | /** What src/tty.ts's `draw` would have captured last time, for this state. */ | |
| 522 | function drawnFor(state: State) { | |
| 523 | if (state.mode === "boards") return { ...emptyDrawn, boards: visibleBoards(db, state.ownerId) }; | |
| 524 | const columns = db.listColumns(state.boardId!); | |
| 525 | const column = columns[state.columnCursor]; | |
| 526 | const cards = column ? visibleCards(db, state, column.id) : []; | |
| 527 | return { ...emptyDrawn, columns, cards }; | |
| 528 | } | |
| 529 | ||
| 530 | test("follow does not redraw when the revision has not moved", () => { | |
| 531 | const { state, boardId } = openBoard(); | |
| 532 | const [todo] = db.listColumns(boardId); | |
| 533 | db.createCard(boardId, todo.id, "only"); | |
| 534 | const seeded = { ...state, revision: db.boardRevision(boardId) }; | |
| 535 | ||
| 536 | const result = follow(seeded, db, drawnFor(seeded)); | |
| 537 | expect(result.redraw).toBe(false); | |
| 538 | expect(result.state).toBe(seeded); // the same object back, nothing recomputed | |
| 539 | }); | |
| 540 | ||
| 541 | test("follow redraws and carries the cursor onto the same card by id after cards are reordered elsewhere", () => { | |
| 542 | const { state, boardId } = openBoard(); | |
| 543 | const [todo] = db.listColumns(boardId); | |
| 544 | for (const title of ["first", "second", "third"]) db.createCard(boardId, todo.id, title); | |
| 545 | const onSecond = { ...press(state, "j"), revision: db.boardRevision(boardId) }; | |
| 546 | const drawn = drawnFor(onSecond); | |
| 547 | expect(titlesIn(boardId, 0)[onSecond.cardCursor]).toBe("second"); | |
| 548 | ||
| 549 | const [, , third] = db.listCards(boardId).filter((c) => c.column_id === todo.id); | |
| 550 | db.moveCard(boardId, third.id, todo.id, 0); // "third" jumps to the front elsewhere | |
| 551 | ||
| 552 | const result = follow(onSecond, db, drawn); | |
| 553 | expect(result.redraw).toBe(true); | |
| 554 | expect(titlesIn(boardId, 0)).toEqual(["third", "first", "second"]); | |
| 555 | expect(titlesIn(boardId, 0)[result.state.cardCursor]).toBe("second"); // rode "second", not its old index | |
| 556 | }); | |
| 557 | ||
| 558 | test("follow moves the cursor to the nearest remaining card when the focused one is deleted elsewhere", () => { | |
| 559 | const { state, boardId } = openBoard(); | |
| 560 | const [todo] = db.listColumns(boardId); | |
| 561 | for (const title of ["a", "b", "c"]) db.createCard(boardId, todo.id, title); | |
| 562 | const onB = { ...press(state, "j"), revision: db.boardRevision(boardId) }; | |
| 563 | const drawn = drawnFor(onB); | |
| 564 | ||
| 565 | const [, b] = db.listCards(boardId).filter((c) => c.column_id === todo.id); | |
| 566 | db.deleteCard(boardId, b.id); | |
| 567 | ||
| 568 | const result = follow(onB, db, drawn); | |
| 569 | expect(result.redraw).toBe(true); | |
| 570 | expect(titlesIn(boardId, 0)).toEqual(["a", "c"]); | |
| 571 | expect(result.state.cardCursor).toBe(1); // "b" is gone; clamped to the nearest remaining, not reset to 0 | |
| 572 | }); | |
| 573 | ||
| 574 | test("follow keeps a prompt and the filter untouched — it only moves the cursor", () => { | |
| 575 | const { state, boardId } = openBoard(); | |
| 576 | const [todo] = db.listColumns(boardId); | |
| 577 | db.createCard(boardId, todo.id, "editing this"); | |
| 578 | const prompted = { ...press(state, "e"), revision: db.boardRevision(boardId) }; | |
| 579 | expect(prompted.prompt).toMatchObject({ kind: "editCard", value: "editing this" }); | |
| 580 | const drawn = drawnFor(prompted); | |
| 581 | ||
| 582 | db.createCard(boardId, todo.id, "added while typing"); | |
| 583 | ||
| 584 | const result = follow(prompted, db, drawn); | |
| 585 | expect(result.redraw).toBe(true); | |
| 586 | expect(result.state.prompt).toEqual(prompted.prompt); | |
| 587 | expect(result.state.filter).toBe(prompted.filter); | |
| 588 | }); | |
| 589 | ||
| 590 | test("follow redraws the boards list and carries its cursor onto the same board when one is created elsewhere", () => { | |
| 591 | const owner = { id: "follow-owner", name: "Follow" }; | |
| 592 | db.createBoard("Kept", owner); | |
| 593 | const onKept = { ...initialState(owner.id), boardsCursor: 0, revision: db.boardsRevision() }; | |
| 594 | const drawn = drawnFor(onKept); | |
| 595 | const keptId = visibleBoards(db, owner.id)[0].id; | |
| 596 | ||
| 597 | db.createBoard("New, listed first", owner); // ordered by created_at DESC, so it sorts ahead of "Kept" | |
| 598 | ||
| 599 | const result = follow(onKept, db, drawn); | |
| 600 | expect(result.redraw).toBe(true); | |
| 601 | const boards = visibleBoards(db, owner.id); | |
| 602 | expect(boards[result.state.boardsCursor].id).toBe(keptId); | |
| 603 | }); |
src/tty.ts +33 −1
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | import * as db from "./db.ts"; |
| 13 | import { handleKey, initialState, visibleBoards, visibleCards, type State } from "./keys.ts"; | |
| 13 | import { emptyDrawn, follow, handleKey, initialState, visibleBoards, visibleCards, type Drawn, type State } from "./keys.ts"; | |
| 14 | 14 | import { ownerIdFromEnv } from "./session.ts"; |
| 15 | 15 | |
| 16 | 16 | const esc = "\x1b["; |
| @@ -20,11 +20,24 @@ const out = (s: string): void => { | ||
| 20 | 20 | |
| 21 | 21 | let state: State = initialState(ownerIdFromEnv(process.env)); |
| 22 | 22 | |
| 23 | // What was last drawn, kept only so `follow` (src/keys.ts) can carry the | |
| 24 | // cursor onto the same item by id when a redraw is triggered by someone | |
| 25 | // else's change rather than a keypress. | |
| 26 | let drawn: Drawn = emptyDrawn; | |
| 27 | ||
| 23 | 28 | function draw(): void { |
| 29 | // Read before the lists below, not after: a write landing while this | |
| 30 | // function runs would otherwise be recorded as already-seen (the | |
| 31 | // revision read at the end would include it) without its effect ever | |
| 32 | // making it into `lines`, leaving the terminal stale until the *next* | |
| 33 | // write. Reading first costs at most one redundant redraw instead. | |
| 34 | const revision = state.mode === "boards" ? db.boardsRevision() : db.boardRevision(state.boardId!); | |
| 35 | ||
| 24 | 36 | const lines: string[] = []; |
| 25 | 37 | const prompt = state.prompt; |
| 26 | 38 | if (state.mode === "boards") { |
| 27 | 39 | const boards = visibleBoards(db, state.ownerId); |
| 40 | drawn = { ...drawn, boards }; | |
| 28 | 41 | lines.push(`${esc}1mtempo${esc}0m ${esc}2mpick a board${esc}0m`); |
| 29 | 42 | lines.push(""); |
| 30 | 43 | if (boards.length === 0) { |
| @@ -48,12 +61,14 @@ function draw(): void { | ||
| 48 | 61 | } else { |
| 49 | 62 | const board = db.getBoard(state.boardId!)!; |
| 50 | 63 | const columns = db.listColumns(state.boardId!); |
| 64 | drawn = { ...drawn, columns }; | |
| 51 | 65 | lines.push( |
| 52 | 66 | `${esc}1mtempo${esc}0m ${esc}2m${board.name} /b/${board.id}${esc}0m${state.filter ? ` ${esc}2mfilter: ${state.filter}${esc}0m` : ""}`, |
| 53 | 67 | ); |
| 54 | 68 | lines.push(""); |
| 55 | 69 | const columnLines = columns.map((col, ci) => { |
| 56 | 70 | const cards = visibleCards(db, state, col.id); |
| 71 | if (ci === state.columnCursor) drawn = { ...drawn, cards }; | |
| 57 | 72 | const header = ci === state.columnCursor ? `${esc}1m${col.name}${esc}0m` : col.name; |
| 58 | 73 | const body = cards.map((c, i) => { |
| 59 | 74 | const focused = ci === state.columnCursor && i === state.cardCursor; |
| @@ -63,6 +78,7 @@ function draw(): void { | ||
| 63 | 78 | if (body.length === 0) body.push(`${esc}2m(empty)${esc}0m`); |
| 64 | 79 | return [`${header}`, ...body]; |
| 65 | 80 | }); |
| 81 | if (!columns[state.columnCursor]) drawn = { ...drawn, cards: [] }; | |
| 66 | 82 | const height = Math.max(...columnLines.map((c) => c.length)); |
| 67 | 83 | const width = 24; |
| 68 | 84 | for (let row = 0; row < height; row++) { |
| @@ -79,6 +95,7 @@ function draw(): void { | ||
| 79 | 95 | : state.message || `${esc}2mhjkl move · a add · e edit · d delete · H/L/K/J move card · c column · r rename · D delete col · [ ] reorder col · f filter · u undo · b back · q quit${esc}0m`, |
| 80 | 96 | ); |
| 81 | 97 | } |
| 98 | state.revision = revision; | |
| 82 | 99 | out(`${esc}2J${esc}H` + lines.join("\r\n")); |
| 83 | 100 | } |
| 84 | 101 | |
| @@ -87,13 +104,28 @@ function visibleLength(s: string): number { | ||
| 87 | 104 | return s.replace(/\x1b\[[0-9]*m/g, "").length; |
| 88 | 105 | } |
| 89 | 106 | |
| 107 | // Runs about twice a second, whether the board list or a board is open, and | |
| 108 | // costs one indexed read when nothing changed (`follow`, src/keys.ts, decides | |
| 109 | // that — never mid-keypress, so a redraw never eats a keystroke). | |
| 110 | const POLL_MS = 500; | |
| 111 | ||
| 112 | function poll(): void { | |
| 113 | const followed = follow(state, db, drawn); | |
| 114 | if (!followed.redraw) return; | |
| 115 | state = followed.state; | |
| 116 | draw(); | |
| 117 | } | |
| 118 | ||
| 90 | 119 | process.stdin.setRawMode?.(true); |
| 91 | 120 | process.stdin.resume(); |
| 92 | 121 | process.stdin.setEncoding("utf8"); |
| 93 | 122 | draw(); |
| 123 | const timer = setInterval(poll, POLL_MS); | |
I applied the head on top of the tree and ran it. The feature works: with a tty on board Three things, in order. 1. The behaviour this pull is about has no test (fix this first). I replaced 2. 3. The README dropped a limit that is still true ( Approved. Both things I asked for last round are done, and I checked the result rather than the description. Verified against the head (a5af938), run out of tree so this clone stays untouched:
Security: no new request surface, no new input, no new output. The revision SQL is parameterised, Preview (https://tempo--47--forge-trevato-apps.open-platform.sh): One nit, not worth another round: the comment at The remaining direction gap — a terminal write reaching open web pages live — is real but out of this issue's scope and already tracked in #31 and #56, and this revision table is what makes it cheap. Nothing new to open. |
||
| 124 | timer.unref?.(); | |
| 94 | 125 | process.stdin.on("data", (key: string) => { |
| 95 | 126 | const outcome = handleKey(state, key, db); |
| 96 | 127 | if (outcome.quit) { |
| 128 | clearInterval(timer); | |
| 97 | 129 | out(`${esc}2J${esc}H`); |
| 98 | 130 | process.exit(0); |
| 99 | 131 | } |
Approved. All three findings from the last round are fixed, and I re-ran everything against the new head on top of
mainat d322ec8.Verified again, by hand.
bun test: 126 pass, 0 fail. A tty opened on a board witho: idle wrote 0 bytes across five poll ticks (no flicker, no churn); a card created by a second process appeared without a keypress inside the poll interval; with aNew card:prompt open and text typed into it, an external change redrew the board and the prompt still readNew card: typedafterwards;qexited 0 in ~13 ms. The preview is healthy: root 200 in ~210 ms with the CSP / nosniff / referrer-policy /vary: x-forge-identityfloor intact,POST /mcpinitializeanswers, an anonymouslist_boardsreturns[], an unknown board 404s.1. The behaviour is now genuinely covered.
followis insrc/keys.tswhere it can be driven without a pty, andsrc/tty.tsis back to raw mode, drawing and a timer. I mutation-tested the new tests rather than trusting them: makingfollowignore the revision fails 1 test, resettingcardCursorto 0 instead ofkeepCursorfails 2, and dropping a singlebumpRevisionfrom the same-column branch ofmoveCardfails 1 insrc/db.test.ts. That is the real thing being tested, not a restatement of the implementation.2. The read order in
drawis fixed, with the reason in the comment — the revision is read before the lists, so a write that lands mid-draw costs one redundant redraw instead of leaving the terminal stale until the next write.3. The README says the limit out loud: the follow is one-way, only
src/routes.tsandsrc/mcp.tstouchthe bus, and a change made in the terminal still doesn't reach an open page until its next load. Naming the cheap fix and explicitly scoping it out is the right call for this issue.You also closed something I hadn't flagged: undo of a board rename now bumps
boardsRevisiontoo, with a test — that path only appeared when #59 landed under you, and it would have left a stale picker.Nothing else to raise. The revision is bumped in-transaction on every write path in
src/db.tswith none missed, the SQL is parameterised,__boards__can't collide with an 8-character id,followgates the boards list through the samevisibleBoards(board, state.ownerId)the draw does, and there's no new dependency, dead code or TODO.