Public: anyone can read this; the crew can change it.
The terminal workspace opens .ipynb, .jsonl and .log files the way the browser does, with `s` for source #11035
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10995-the-terminal-workspace-opens-ipynb-jsonl → main 3 commits fork 2 threads · 2 unresolved
6 files
changed +560
−40
docs/features/terminal-workspace-kinds.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # Notebooks, JSON Lines and logs in the terminal | |
| 2 | ||
| 3 | The SSH workspace opens `.ipynb`, `.jsonl`/`.ndjson` and `.log`/`.ansi` files the way the blob page does, from the same parsers (`parseNotebook` in `src/web/render/notebook.ts`, `parseJsonl`, `ansiLines`, `logLevel` in `src/web/render/log.ts`). Drawing lives in `src/tui/screens/workspace_kinds.ts`. | |
| 4 | ||
| 5 | - **Notebook**: cells in order. Markdown keeps its source lines (headings bold), code is dimmed as `In [n]: …`, outputs are text, errors are red, images say `[image output not shown]`. Nothing is run. | |
| 6 | - **JSON Lines**: a table, one row per object; a bad line is a row that names its line number. | |
| 7 | - **Log**: SGR colours from the file are kept; a line with none is coloured by the level it states first (after an optional timestamp): `ERROR`, `[warn]`, `level=error`. Prose such as "no errors found" is not coloured. Escapes and control characters are never passed to the terminal. | |
| 8 | ||
| 9 | Press `s` to flip between the view and the source text; the footer names the other view. In edit mode the source is always shown. | |
| 10 | ||
| 11 | Limits, always said on screen: a JSON Lines table shows the first 500 rows and ends with "N more rows not shown"; a notebook or log shows 10,000 lines and ends with "N more lines not shown". A notebook is cut at 2,000 cells with "N more cells not shown". | |
| 12 | ||
| 13 | Fallback: a file that is not valid JSON (notebook), has no cell list, or has no JSON object lines (jsonl) has no view and opens as source, as it always did. An empty log is empty. |
src/tui/screens/workspace.ts +46 −7
| @@ -62,6 +62,7 @@ import { gpxFacts, parseGpx } from "../../web/render/gpx.ts"; | ||
| 62 | 62 | import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts"; |
| 63 | 63 | import { CARD_CAP, parseVcf } from "../../web/render/vcf-parse.ts"; |
| 64 | 64 | import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts"; |
| 65 | import { isJsonlPath, kindView, type KindLines } from "./workspace_kinds.ts"; | |
| 65 | 66 | import { layoutTable, type Grid } from "../ui/grid.ts"; |
| 66 | 67 | import type { Ctx, Screen } from "../app/app.ts"; |
| 67 | 68 | import { stripAnsi } from "../term/ansi.ts"; |
| @@ -462,6 +463,9 @@ class Tab { | ||
| 462 | 463 | grid: Grid = { header: "", rows: [] }; |
| 463 | 464 | /** Cues of a subtitle file that could not be read. */ |
| 464 | 465 | unread = 0; |
| 466 | /** The .ipynb / .log view, and the source it replaces while `s` has it up. */ | |
| 467 | kindLines: KindLines | null = null; | |
| 468 | sourceLines: KindLines | null = null; | |
| 465 | 469 | |
| 466 | 470 | constructor(readonly path: string) {} |
| 467 | 471 | |
| @@ -511,6 +515,14 @@ class Tab { | ||
| 511 | 515 | ); |
| 512 | 516 | } |
| 513 | 517 | |
| 518 | /** Put up the notebook/log view or its source, whichever `asTable` says. */ | |
| 519 | showKind(): void { | |
| 520 | if (this.kindLines === null || this.sourceLines === null) return; | |
| 521 | const shown = this.asTable ? this.kindLines : this.sourceLines; | |
| 522 | this.plain = shown.plain; | |
| 523 | this.lines = shown.lines; | |
| 524 | } | |
| 525 | ||
| 514 | 526 | get modified(): boolean { |
| 515 | 527 | return this.creating || (this.editor !== null && this.editor.modified); |
| 516 | 528 | } |
| @@ -551,6 +563,19 @@ class Tab { | ||
| 551 | 563 | this.plain = built.plain; |
| 552 | 564 | this.lines = built.lines; |
| 553 | 565 | this.unread = 0; |
| 566 | this.kindLines = null; | |
| 567 | this.sourceLines = built; | |
| 568 | const kind = kindView(this.path, text, c); | |
| 569 | if (kind?.table !== undefined) { | |
| 570 | this.table = kind.table; | |
| 571 | this.grid = layoutTable(kind.table); | |
| 572 | } else if (kind !== null) { | |
| 573 | this.kindLines = kind; | |
| 574 | if (this.asTable && this.mode === "view") { | |
| 575 | this.plain = kind.plain; | |
| 576 | this.lines = kind.lines; | |
| 577 | } | |
| 578 | } | |
| 554 | 579 | if (isSubtitlePath(this.path)) { |
| 555 | 580 | const subs = subtitleTable(new TextDecoder().decode(source)); |
| 556 | 581 | this.table = subs.table; |
| @@ -576,7 +601,7 @@ class Tab { | ||
| 576 | 601 | const delimiter = /\.tsv$/i.test(this.path) ? "\t" : undefined; |
| 577 | 602 | this.table = parseCsv(new TextDecoder().decode(source), { delimiter }); |
| 578 | 603 | this.grid = layoutTable(this.table); |
| 579 | } else this.table = null; | |
| 604 | } else if (kind?.table === undefined) this.table = null; | |
| 580 | 605 | // The rendered document is derived from the same text, so an edit, a |
| 581 | 606 | // theme switch or a re-read has to throw it away with everything else. |
| 582 | 607 | this.doc = []; |
| @@ -1550,6 +1575,7 @@ export function workspaceScreen( | ||
| 1550 | 1575 | // set would put the document back on `esc` with the edits invisible. |
| 1551 | 1576 | t.rendered = false; |
| 1552 | 1577 | t.asTable = false; |
| 1578 | t.showKind(); | |
| 1553 | 1579 | t.mode = "edit"; |
| 1554 | 1580 | focus = "file"; |
| 1555 | 1581 | ctx.invalidate(); |
| @@ -1618,11 +1644,11 @@ export function workspaceScreen( | ||
| 1618 | 1644 | t !== undefined && |
| 1619 | 1645 | (t.image !== null || isMarkdownPath(t.path) || t.patch !== null); |
| 1620 | 1646 | return [ |
| 1621 | ...(t?.table == null | |
| 1647 | ...(t?.table == null && t?.kindLines == null | |
| 1622 | 1648 | ? [] |
| 1623 | 1649 | : [ |
| 1624 | 1650 | binding(["s"], t.asTable ? "show the source" : "show the table", { |
| 1625 | disabled: t.table.error !== undefined || t.table.header.length === 0, | |
| 1651 | disabled: t.table != null && (t.table.error !== undefined || t.table.header.length === 0), | |
| 1626 | 1652 | }), |
| 1627 | 1653 | ]), |
| 1628 | 1654 | binding(["tab"], "focus explorer"), |
| @@ -2432,7 +2458,16 @@ export function workspaceScreen( | ||
| 2432 | 2458 | /** `s` — a table file's two views: aligned columns, or the text itself. */ |
| 2433 | 2459 | function toggleTable(t: Tab): void { |
| 2434 | 2460 | // A stray `s` on anything else is not worth a toast. |
| 2435 | if (t.table === null || t.mode === "edit") return; | |
| 2461 | if (t.mode === "edit") return; | |
| 2462 | if (t.kindLines !== null && t.sourceLines !== null) { | |
| 2463 | t.asTable = !t.asTable; | |
| 2464 | t.showKind(); | |
| 2465 | t.scroll.top = 0; | |
| 2466 | t.left = 0; | |
| 2467 | t.findHits(); | |
| 2468 | return; | |
| 2469 | } | |
| 2470 | if (t.table === null) return; | |
| 2436 | 2471 | if (t.table.error !== undefined) { |
| 2437 | 2472 | ctx.toast(tableProblem(t.table), "attn"); |
| 2438 | 2473 | return; |
| @@ -2668,7 +2703,7 @@ export function workspaceScreen( | ||
| 2668 | 2703 | ? "" |
| 2669 | 2704 | : `${t.path} · ${ |
| 2670 | 2705 | t.tabular && t.table !== null |
| 2671 | ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path), isVcfPath(t.path), isGeoJsonPath(t.path)) | |
| 2706 | ? tableStatus(t.table, t.unread, isSubtitlePath(t.path), isIcsPath(t.path), isGpxPath(t.path), isVcfPath(t.path), isGeoJsonPath(t.path), isJsonlPath(t.path)) | |
| 2672 | 2707 | : `${plural( |
| 2673 | 2708 | t.reading ? t.doc.length : t.plain.length, |
| 2674 | 2709 | "line", |
| @@ -3002,12 +3037,16 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void { | ||
| 3002 | 3037 | } |
| 3003 | 3038 | |
| 3004 | 3039 | /** `80 rows, first 50 shown · 3 more columns`: what the table leaves out. */ |
| 3005 | function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false, people = false, features = false): string { | |
| 3040 | function tableStatus(table: Table, unread = 0, subs = false, events = false, facts = false, people = false, features = false, jsonl = false): string { | |
| 3006 | 3041 | const rows = table.rows.length + table.more; |
| 3007 | 3042 | return `${plural(rows, subs ? "cue" : events ? "event" : people ? "contact" : facts ? "fact" : features ? "feature" : "row")}${ |
| 3008 | 3043 | unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : "" |
| 3009 | 3044 | }${ |
| 3010 | table.more > 0 ? `, first ${table.rows.length} shown` : "" | |
| 3045 | table.more > 0 | |
| 3046 | ? jsonl | |
| 3047 | ? `, ${plural(table.more, "more row")} not shown` | |
| 3048 | : `, first ${table.rows.length} shown` | |
| 3049 | : "" | |
| 3011 | 3050 | }${ |
| 3012 | 3051 | table.columns > MAX_COLUMNS |
| 3013 | 3052 | ? ` · ${table.columns - MAX_COLUMNS} more columns` |
src/tui/screens/workspace_kinds.ts added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | /** | |
| 2 | * Notebooks, JSON Lines and logs in the terminal workspace, read the way the | |
| 3 | * web reads them: the same parsers, drawn as text. | |
| 4 | * | |
| 5 | * `kindView(path, text, c)` is the one call the workspace makes. It answers a | |
| 6 | * table (`.jsonl`), styled lines (`.ipynb`, `.log`), or null, and null means | |
| 7 | * "not this kind, or malformed": the workspace then shows the source, as it | |
| 8 | * always did. `s` toggles between this view and the source. | |
| 9 | */ | |
| 10 | ||
| 11 | import { parseNotebook } from "../../web/render/notebook.ts"; | |
| 12 | import { parseJsonl } from "../../web/render/jsonl-parse.ts"; | |
| 13 | import { logLevel } from "../../web/render/log.ts"; | |
| 14 | import { ansiLines } from "../../web/render/ansi.ts"; | |
| 15 | import type { Table } from "../../text/csv.ts"; | |
| 16 | import type { Span } from "../term/screen.ts"; | |
| 17 | import { BOLD, DIM, UNDERLINE, style, type Color } from "../term/style.ts"; | |
| 18 | import { expandTabs } from "../term/width.ts"; | |
| 19 | import type { Tokens } from "../ui/theme.ts"; | |
| 20 | ||
| 21 | /** Rows of a JSON Lines file drawn; the rest are counted ("N more rows not shown"). */ | |
| 22 | export const ROW_CAP = 500; | |
| 23 | /** Lines of a log or notebook drawn. */ | |
| 24 | export const LINE_CAP = 10_000; | |
| 25 | ||
| 26 | export interface KindLines { | |
| 27 | readonly plain: string[]; | |
| 28 | readonly lines: Span[][]; | |
| 29 | } | |
| 30 | ||
| 31 | export type KindView = | |
| 32 | | { readonly table: Table; readonly lines?: undefined } | |
| 33 | | ({ readonly table?: undefined } & KindLines); | |
| 34 | ||
| 35 | export const isNotebookPath = (path: string): boolean => /\.ipynb$/i.test(path); | |
| 36 | export const isJsonlPath = (path: string): boolean => /\.(jsonl|ndjson)$/i.test(path); | |
| 37 | export const isLogPath = (path: string): boolean => /\.(log|ansi)$/i.test(path); | |
| 38 | ||
| 39 | /** The view for this file, or null when its kind has none or the file is malformed. */ | |
| 40 | export function kindView(path: string, text: string, c: Tokens): KindView | null { | |
| 41 | if (isNotebookPath(path)) return wrap(notebookLines(text, c)); | |
| 42 | if (isJsonlPath(path)) { | |
| 43 | const table = jsonlTable(text); | |
| 44 | return table === null ? null : { table }; | |
| 45 | } | |
| 46 | if (isLogPath(path)) return wrap(logLines(text, c)); | |
| 47 | return null; | |
| 48 | } | |
| 49 | ||
| 50 | const wrap = (l: KindLines | null): KindView | null => (l === null ? null : l); | |
| 51 | ||
| 52 | // ---- .jsonl ---- | |
| 53 | ||
| 54 | /** The rows as a table; null when no line is a JSON object. */ | |
| 55 | export function jsonlTable(text: string): Table | null { | |
| 56 | const parsed = parseJsonl(text, ROW_CAP + 1); | |
| 57 | if ("error" in parsed) return null; | |
| 58 | const [header = [], ...rows] = parsed.rows; | |
| 59 | // Only unparseable lines: there are no columns, so the source says more. | |
| 60 | if (header.length === 1 && header[0] === "line") return null; | |
| 61 | const shown = rows.slice(0, ROW_CAP); | |
| 62 | return { | |
| 63 | header, | |
| 64 | rows: shown, | |
| 65 | more: Math.max(0, parsed.records - 1 - shown.length), | |
| 66 | delimiter: ",", | |
| 67 | columns: parsed.columns, | |
| 68 | }; | |
| 69 | } | |
| 70 | ||
| 71 | // ---- .ipynb ---- | |
| 72 | ||
| 73 | const ENTITIES: Readonly<Record<string, string>> = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'" }; | |
| 74 | const unescape = (s: string): string => s.replace(/&(amp|lt|gt|quot|#39);/g, (_, k: string) => ENTITIES[k]!); | |
| 75 | ||
| 76 | const clean = (s: string): string => expandTabs(s.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "")); | |
| 77 | ||
| 78 | interface Row { | |
| 79 | text: string; | |
| 80 | color: Color; | |
| 81 | attr: number; | |
| 82 | } | |
| 83 | ||
| 84 | /** The text of `rows`, capped at LINE_CAP with the rest counted, as styled lines. */ | |
| 85 | function finish(rows: Row[], c: Tokens): KindLines { | |
| 86 | const cut = rows.length - LINE_CAP; | |
| 87 | const shown = rows.slice(0, LINE_CAP).map((r) => ({ ...r, text: clean(r.text) })); | |
| 88 | if (cut > 0) shown.push({ text: `${plural(cut, "more line")} not shown`, color: c.faint, attr: DIM }); | |
| 89 | return { | |
| 90 | plain: shown.map((l) => l.text), | |
| 91 | lines: shown.map((l) => [{ text: l.text, style: style(l.color, c.bg, l.attr) }]), | |
| 92 | }; | |
| 93 | } | |
| 94 | ||
| 95 | const plural = (n: number, noun: string): string => `${n.toLocaleString("en-US")} ${noun}${n === 1 ? "" : "s"}`; | |
| 96 | ||
| 97 | /** Cells in order: markdown as its source, code dimmed, outputs as text, errors in the danger colour. */ | |
| 98 | export function notebookLines(text: string, c: Tokens): KindLines | null { | |
| 99 | const parsed = parseNotebook(text); | |
| 100 | if ("error" in parsed) return null; | |
| 101 | const rows: Row[] = []; | |
| 102 | const push = (block: string, color: Color, attr: number): void => { | |
| 103 | for (const line of block.replace(/\n$/, "").split("\n")) rows.push({ text: line, color, attr }); | |
| 104 | }; | |
| 105 | const gap = (): void => void rows.push({ text: "", color: c.fg, attr: 0 }); | |
| 106 | for (const cell of parsed.cells) { | |
| 107 | if (cell.kind === "markdown") { | |
| 108 | for (const line of cell.source.replace(/\n$/, "").split("\n")) | |
| 109 | rows.push({ text: line, color: c.fg, attr: /^#{1,6}\s/.test(line) ? BOLD : 0 }); | |
| 110 | } else if (cell.kind === "code") { | |
| 111 | push(`In [${cell.count}]: ${cell.source}`, c.faint, DIM); | |
| 112 | for (const o of cell.outputs) { | |
| 113 | if (o.kind === "image") push("[image output not shown]", c.faint, DIM); | |
| 114 | else if (o.kind === "caption") push(o.text, c.faint, DIM); | |
| 115 | else push(o.text, o.kind === "error" ? c.danger : c.fg, 0); | |
| 116 | } | |
| 117 | } else push(cell.source, c.fg, 0); | |
| 118 | gap(); | |
| 119 | } | |
| 120 | if (parsed.total > parsed.cells.length) | |
| 121 | push(`${plural(parsed.total - parsed.cells.length, "more cell")} not shown`, c.faint, DIM); | |
| 122 | return finish(rows, c); | |
| 123 | } | |
| 124 | ||
| 125 | // ---- .log ---- | |
| 126 | ||
| 127 | const HUES = (c: Tokens): Readonly<Record<string, Color>> => ({ | |
| 128 | red: c.danger, | |
| 129 | green: c.success, | |
| 130 | yellow: c.attn, | |
| 131 | blue: c.primary, | |
| 132 | magenta: c.merged, | |
| 133 | cyan: c.accent, | |
| 134 | gray: c.faint, | |
| 135 | }); | |
| 136 | ||
| 137 | /** | |
| 138 | * Colours the log carries (SGR, read by the web's `ansiLines`) win; a line | |
| 139 | * with none is coloured by the level it states. | |
| 140 | */ | |
| 141 | export function logLines(text: string, c: Tokens): KindLines { | |
| 142 | const hues = HUES(c); | |
| 143 | const levelColor = { error: c.danger, warn: c.attn, debug: c.faint } as const; | |
| 144 | const { lines: marked, total } = ansiLines(text, LINE_CAP); | |
| 145 | const plain: string[] = []; | |
| 146 | const lines: Span[][] = []; | |
| 147 | for (const m of marked) { | |
| 148 | const spans: Span[] = []; | |
| 149 | let flat = ""; | |
| 150 | for (const part of m.matchAll(/<span class="([^"]*)">([^<]*)<\/span>|([^<]+)/g)) { | |
| 151 | const t = expandTabs(unescape(part[2] ?? part[3] ?? "")); | |
| 152 | flat += t; | |
| 153 | const classes = (part[1] ?? "").split(" "); | |
| 154 | const hue = classes.map((k) => hues[k.slice(5)]).find((x) => x !== undefined); | |
| 155 | const attr = (classes.includes("ansi-b") ? BOLD : 0) | (classes.includes("ansi-u") ? UNDERLINE : 0); | |
| 156 | spans.push({ text: t, style: style(hue ?? c.fg, c.bg, attr) }); | |
| 157 | } | |
| 158 | const level = spans.every((s) => s.style.fg === c.fg) ? logLevel(flat) : undefined; | |
| 159 | plain.push(flat); | |
| 160 | lines.push(level === undefined ? spans : [{ text: flat, style: style(levelColor[level], c.bg) }]); | |
| 161 | } | |
| 162 | if (total > marked.length) { | |
| 163 | const note = `${plural(total - marked.length, "more line")} not shown`; | |
| 164 | plain.push(note); | |
| 165 | lines.push([{ text: note, style: style(c.faint, c.bg, DIM) }]); | |
| 166 | } | |
| 167 | return { plain, lines }; | |
| 168 | } |
src/web/render/log.ts +21
| @@ -34,3 +34,24 @@ export function log(input: RenderInput): Rendered { | ||
| 34 | 34 | : html``}`, |
| 35 | 35 | }; |
| 36 | 36 | } |
| 37 | ||
| 38 | /** A timestamp a log line may open with: `2026-01-02T03:04:05Z`, `[03:04:05.123]`. */ | |
| 39 | const STAMP = /^\s*\[?(?:\d{4}-\d\d-\d\d[T ])?\d\d:\d\d:\d\d(?:[.,]\d+)?(?:Z|[+-]\d\d:?\d\d)?\]?[\s:|-]*/; | |
| 40 | const BARE = /^(ERROR|ERR|FATAL|CRITICAL|CRIT|PANIC|WARN|WARNING|DEBUG|TRACE)(?![A-Za-z0-9_])/; | |
| 41 | const BRACKET = /^[[(<](error|err|fatal|critical|crit|panic|warn|warning|debug|trace)[\])>]/i; | |
| 42 | const LOGFMT = /^(?:\S+=\S+\s+){0,4}level=(error|err|fatal|warn|warning|debug|trace)\b/i; | |
| 43 | ||
| 44 | /** | |
| 45 | * The level a log line states in its first token (after an optional timestamp): | |
| 46 | * `ERROR x`, `[warn] x`, `level=error x`. Prose that merely mentions a word | |
| 47 | * ("no errors found", "0 failed") has none. | |
| 48 | */ | |
| 49 | export function logLevel(line: string): "error" | "warn" | "debug" | undefined { | |
| 50 | const rest = line.replace(STAMP, ""); | |
| 51 | const word = BARE.exec(rest)?.[1] ?? BRACKET.exec(rest)?.[1] ?? LOGFMT.exec(line)?.[1]; | |
| 52 | if (word === undefined) return undefined; | |
| 53 | const w = word.toLowerCase(); | |
| 54 | if (w.startsWith("warn")) return "warn"; | |
| 55 | if (w === "debug" || w === "trace") return "debug"; | |
| 56 | return "error"; | |
| 57 | } |
src/web/render/notebook.ts +63 −33
| @@ -29,60 +29,90 @@ const MAX_CELLS = 2_000; | ||
| 29 | 29 | |
| 30 | 30 | const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); |
| 31 | 31 | |
| 32 | function image(mime: string, data: unknown): Html | undefined { | |
| 33 | const b64 = text(data).replace(/\s+/g, ""); | |
| 34 | if (!BASE64.test(b64)) return undefined; | |
| 35 | return html`<img class="nb-img" alt="Notebook output" src="data:${mime};base64,${b64}" />`; | |
| 32 | /** One saved output, as data: the HTML page and the terminal both draw from this. */ | |
| 33 | export type NbOutput = | |
| 34 | | { readonly kind: "text" | "error" | "caption"; readonly text: string } | |
| 35 | | { readonly kind: "image"; readonly mime: string; readonly data: string }; | |
| 36 | ||
| 37 | /** One cell: its source, the count a code cell ran with, and its outputs. */ | |
| 38 | export interface NbCell { | |
| 39 | readonly kind: "markdown" | "code" | "raw"; | |
| 40 | readonly source: string; | |
| 41 | readonly count: string; | |
| 42 | readonly outputs: readonly NbOutput[]; | |
| 36 | 43 | } |
| 37 | 44 | |
| 38 | function output(out: unknown): Html { | |
| 39 | if (!isObj(out)) return html``; | |
| 40 | if (out.output_type === "stream") | |
| 41 | return html`<pre class="nb-out">${text(out.text)}</pre>`; | |
| 45 | function image(mime: string, data: unknown): NbOutput | undefined { | |
| 46 | const b64 = text(data).replace(/\s+/g, ""); | |
| 47 | return BASE64.test(b64) ? { kind: "image", mime, data: b64 } : undefined; | |
| 48 | } | |
| 49 | ||
| 50 | function outputs(out: unknown): NbOutput[] { | |
| 51 | if (!isObj(out)) return []; | |
| 52 | if (out.output_type === "stream") return [{ kind: "text", text: text(out.text) }]; | |
| 42 | 53 | if (out.output_type === "error") { |
| 43 | 54 | const trace = Array.isArray(out.traceback) ? text(out.traceback) : `${text(out.ename)}: ${text(out.evalue)}`; |
| 44 | return html`<pre class="nb-out nb-err">${stripAnsi(trace)}</pre>`; | |
| 55 | return [{ kind: "error", text: stripAnsi(trace) }]; | |
| 45 | 56 | } |
| 46 | 57 | const data = isObj(out.data) ? out.data : {}; |
| 47 | const parts: Html[] = []; | |
| 58 | const parts: NbOutput[] = []; | |
| 48 | 59 | for (const mime of IMAGES) { |
| 49 | 60 | if (data[mime] === undefined) continue; |
| 50 | 61 | const img = image(mime, data[mime]); |
| 51 | 62 | if (img !== undefined) parts.push(img); |
| 52 | 63 | } |
| 53 | if (data["text/plain"] !== undefined) parts.push(html`<pre class="nb-out">${text(data["text/plain"])}</pre>`); | |
| 64 | if (data["text/plain"] !== undefined) parts.push({ kind: "text", text: text(data["text/plain"]) }); | |
| 54 | 65 | for (const [mime, label] of SOURCES) |
| 55 | if (data[mime] !== undefined) | |
| 56 | parts.push(html`<div class="nb-cap">${label}</div><pre class="nb-out">${text(data[mime])}</pre>`); | |
| 57 | return html`${parts}`; | |
| 66 | if (data[mime] !== undefined) parts.push({ kind: "caption", text: label }, { kind: "text", text: text(data[mime]) }); | |
| 67 | return parts; | |
| 58 | 68 | } |
| 59 | 69 | |
| 60 | function cell(c: unknown): Html { | |
| 61 | if (!isObj(c)) return html``; | |
| 70 | function parseCell(c: unknown): NbCell | undefined { | |
| 71 | if (!isObj(c)) return undefined; | |
| 62 | 72 | const source = text(c.source); |
| 63 | if (c.cell_type === "markdown") return html`<div class="nb-cell">${renderMarkdown(source)}</div>`; | |
| 73 | if (c.cell_type === "markdown") return { kind: "markdown", source, count: "", outputs: [] }; | |
| 64 | 74 | if (c.cell_type === "code") { |
| 65 | const n = typeof c.execution_count === "number" && Number.isFinite(c.execution_count) ? String(c.execution_count) : " "; | |
| 66 | const outs = Array.isArray(c.outputs) ? c.outputs.slice(0, MAX_CELLS).map(output) : []; | |
| 67 | return html`<div class="nb-cell"><pre class="nb-in"><span class="muted">In [${n}]:</span> ${source}</pre>${outs}</div>`; | |
| 75 | const count = typeof c.execution_count === "number" && Number.isFinite(c.execution_count) ? String(c.execution_count) : " "; | |
| 76 | const outs = Array.isArray(c.outputs) ? c.outputs.slice(0, MAX_CELLS).flatMap(outputs) : []; | |
| 77 | return { kind: "code", source, count, outputs: outs }; | |
| 68 | 78 | } |
| 69 | return html`<div class="nb-cell"><pre>${source}</pre></div>`; | |
| 79 | return { kind: "raw", source, count: "", outputs: [] }; | |
| 80 | } | |
| 81 | ||
| 82 | /** The notebook's cells (at most MAX_CELLS) and how many it has, or why it cannot be read. */ | |
| 83 | export function parseNotebook(body: Uint8Array | string): { cells: NbCell[]; total: number } | { error: string } { | |
| 84 | let doc: unknown; | |
| 85 | try { | |
| 86 | doc = JSON.parse(typeof body === "string" ? body : new TextDecoder().decode(body)); | |
| 87 | } catch { | |
| 88 | return { error: "This notebook is not valid JSON, so it is shown as plain text." }; | |
| 89 | } | |
| 90 | if (!isObj(doc) || !Array.isArray(doc.cells)) | |
| 91 | return { error: "This notebook has no cells list, so it is shown as plain text." }; | |
| 92 | const cells = doc.cells.slice(0, MAX_CELLS).flatMap((c) => parseCell(c) ?? []); | |
| 93 | return { cells, total: doc.cells.length }; | |
| 94 | } | |
| 95 | ||
| 96 | function outputHtml(o: NbOutput): Html { | |
| 97 | if (o.kind === "image") | |
| 98 | return html`<img class="nb-img" alt="Notebook output" src="data:${o.mime};base64,${o.data}" />`; | |
| 99 | if (o.kind === "caption") return html`<div class="nb-cap">${o.text}</div>`; | |
| 100 | return html`<pre class="${o.kind === "error" ? "nb-out nb-err" : "nb-out"}">${o.text}</pre>`; | |
| 101 | } | |
| 102 | ||
| 103 | function cellHtml(c: NbCell): Html { | |
| 104 | if (c.kind === "markdown") return html`<div class="nb-cell">${renderMarkdown(c.source)}</div>`; | |
| 105 | if (c.kind === "code") | |
| 106 | return html`<div class="nb-cell"><pre class="nb-in"><span class="muted">In [${c.count}]:</span> ${c.source}</pre>${c.outputs.map(outputHtml)}</div>`; | |
| 107 | return html`<div class="nb-cell"><pre>${c.source}</pre></div>`; | |
| 70 | 108 | } |
| 71 | 109 | |
| 72 | 110 | export function notebook(input: RenderInput): Rendered { |
| 73 | const fail = (why: string): Rendered => ({ content: html``, lines: 0, fallback: why }); | |
| 74 | let doc: unknown; | |
| 75 | try { | |
| 76 | doc = JSON.parse(new TextDecoder().decode(input.body)); | |
| 77 | } catch { | |
| 78 | return fail("This notebook is not valid JSON, so it is shown as plain text."); | |
| 79 | } | |
| 80 | if (!isObj(doc) || !Array.isArray(doc.cells)) | |
| 81 | return fail("This notebook has no cells list, so it is shown as plain text."); | |
| 82 | const cells = doc.cells.slice(0, MAX_CELLS); | |
| 111 | const parsed = parseNotebook(input.body); | |
| 112 | if ("error" in parsed) return { content: html``, lines: 0, fallback: parsed.error }; | |
| 83 | 113 | return { |
| 84 | 114 | lines: 0, |
| 85 | summary: `${doc.cells.length} cells`, | |
| 86 | content: html`<div class="nb prose">${cells.map(cell)}</div>`, | |
| 115 | summary: `${parsed.total} cells`, | |
| 116 | content: html`<div class="nb prose">${parsed.cells.map(cellHtml)}</div>`, | |
| 87 | 117 | }; |
| 88 | 118 | } |
test/tui-workspace-kinds.test.ts added +249
| @@ -0,0 +1,249 @@ | ||
| 1 | /** | |
Add a malformed .log case and make the .log |
||
| 2 | * Notebooks, JSON Lines and logs in the terminal workspace: pure views first, | |
| 3 | * then each kind opened in a real headless workspace, `s` flipping to source, | |
| 4 | * and a malformed file of each kind falling back to the source. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 13 | import type { User } from "../src/core/users.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 17 | import { headlessTerminal, type HeadlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 19 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 20 | import { theme } from "../src/tui/ui/theme.ts"; | |
| 21 | import { logLevel } from "../src/web/render/log.ts"; | |
| 22 | import { jsonlTable, kindView, logLines, LINE_CAP, notebookLines, ROW_CAP } from "../src/tui/screens/workspace_kinds.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | const c = theme("dark").c; | |
| 26 | ||
| 27 | const NB = JSON.stringify({ | |
| 28 | cells: [ | |
| 29 | { cell_type: "markdown", source: ["# Title\n", "prose"] }, | |
| 30 | { cell_type: "code", execution_count: 3, source: "print(1 < 2)", outputs: [{ output_type: "stream", text: "True & done\n" }] }, | |
| 31 | { cell_type: "code", source: "boom()", outputs: [{ output_type: "error", ename: "E", evalue: "bad", traceback: ["Trace line"] }] }, | |
| 32 | ], | |
| 33 | }); | |
| 34 | ||
| 35 | describe("kind views, pure", () => { | |
| 36 | test("a notebook's cells in order, code dimmed, outputs as text", () => { | |
| 37 | const v = notebookLines(NB, c)!; | |
| 38 | const at = (s: string): number => v.plain.findIndex((l) => l.includes(s)); | |
| 39 | expect(at("Title")).toBeGreaterThanOrEqual(0); | |
| 40 | expect(at("Title")).toBeLessThan(at("print(1 < 2)")); | |
| 41 | expect(at("print(1 < 2)")).toBeLessThan(at("True & done")); | |
| 42 | expect(at("True & done")).toBeLessThan(at("boom()")); | |
| 43 | expect(v.lines[at("print(1 < 2)")]![0]!.style.fg).toBe(c.faint); | |
| 44 | expect(v.lines[at("Trace line")]![0]!.style.fg).toBe(c.danger); | |
| 45 | expect(v.plain.join("\n")).not.toContain("<pre"); | |
| 46 | expect(v.plain.join("\n")).not.toContain("&"); | |
| 47 | }); | |
| 48 | test("a malformed notebook has no view", () => { | |
| 49 | expect(kindView("a.ipynb", "{not json", c)).toBeNull(); | |
| 50 | expect(kindView("a.ipynb", '{"nope":1}', c)).toBeNull(); | |
| 51 | }); | |
| 52 | test("jsonl rows become a table, capped with the rest counted", () => { | |
| 53 | const text = Array.from({ length: ROW_CAP + 7 }, (_, i) => JSON.stringify({ id: i, name: `n${i}` })).join("\n"); | |
| 54 | const t = jsonlTable(text)!; | |
| 55 | expect(t.header).toEqual(["id", "name"]); | |
| 56 | expect(t.rows.length).toBe(ROW_CAP); | |
| 57 | expect(t.more).toBe(7); | |
| 58 | }); | |
| 59 | test("jsonl: a bad line is a row, a file of only bad lines has no view", () => { | |
| 60 | const t = jsonlTable('{"a":1}\nnot json\n{"a":2}')!; | |
| 61 | expect(t.rows[1]![0]).toContain("Line 2"); | |
| 62 | expect(jsonlTable("nope\nstill nope")).toBeNull(); | |
| 63 | expect(jsonlTable("")).toBeNull(); | |
| 64 | }); | |
| 65 | test("a log is coloured by its escapes, else by the level it states", () => { | |
| 66 | const v = logLines("\x1b[32mgreen\x1b[0m\nERROR boom\nWARN hmm\nplain\nDEBUG x\n", c); | |
| 67 | const l = v.lines; | |
| 68 | expect(v.plain).toEqual(["green", "ERROR boom", "WARN hmm", "plain", "DEBUG x"]); | |
| 69 | expect(l[0]![0]!.style.fg).toBe(c.success); | |
| 70 | expect(l[1]![0]!.style.fg).toBe(c.danger); | |
| 71 | expect(l[2]![0]!.style.fg).toBe(c.attn); | |
| 72 | expect(l[3]![0]!.style.fg).toBe(c.fg); | |
| 73 | expect(l[4]![0]!.style.fg).toBe(c.faint); | |
| 74 | expect(logLevel("all fine")).toBeUndefined(); | |
| 75 | }); | |
| 76 | test("only a leading level token colours a line, never prose", () => { | |
| 77 | for (const prose of ["no errors found", "0 failed", "Error handling is next", "the warning is gone", "retry failed once"]) | |
| 78 | expect(logLevel(prose)).toBeUndefined(); | |
| 79 | expect(logLevel("2026-01-02T03:04:05Z ERROR boom")).toBe("error"); | |
| 80 | expect(logLevel("[03:04:05] WARN hmm")).toBe("warn"); | |
| 81 | expect(logLevel("[error] x")).toBe("error"); | |
| 82 | expect(logLevel("ts=1 level=error msg=x")).toBe("error"); | |
| 83 | expect(logLevel("DEBUG x")).toBe("debug"); | |
| 84 | }); | |
| 85 | test("a long log says how many lines it leaves out", () => { | |
| 86 | const v = logLines("x\n".repeat(LINE_CAP + 5), c); | |
| 87 | expect(v.plain[v.plain.length - 1]).toBe("5 more lines not shown"); | |
| 88 | }); | |
| 89 | test("a long notebook says how many lines it leaves out", () => { | |
| 90 | const nb = JSON.stringify({ cells: [{ cell_type: "code", source: "x\n".repeat(LINE_CAP + 5), outputs: [] }] }); | |
| 91 | expect(notebookLines(nb, c)!.plain.at(-1)).toMatch(/more lines? not shown$/); | |
| 92 | }); | |
| 93 | test("markdown cells keep their lines and headings are bold", () => { | |
| 94 | const v = notebookLines(JSON.stringify({ cells: [{ cell_type: "markdown", source: "# Head\n- a\n- b" }] }), c)!; | |
| 95 | expect(v.plain.slice(0, 3)).toEqual(["# Head", "- a", "- b"]); | |
| 96 | }); | |
| 97 | test("malformed logs: empty, NUL bytes, one very long line", () => { | |
| 98 | expect(logLines("", c).plain.join("")).toBe(""); | |
| 99 | const nul = logLines("a\x00b\x01c\n", c); | |
| 100 | expect(nul.plain).toEqual(["abc"]); | |
| 101 | const long = logLines("y".repeat(200_000), c); | |
| 102 | expect(long.plain.length).toBe(1); | |
| 103 | expect(kindView("a.log", "", c)).not.toBeUndefined(); | |
| 104 | }); | |
| 105 | test("a hostile log never carries an escape or markup into the view", () => { | |
| 106 | const v = logLines("\x1b]0;t\x07<b>x</b> \x1b[2J &\n", c); | |
| 107 | expect(v.plain).toEqual(["<b>x</b> &"]); | |
| 108 | }); | |
| 109 | test("other files have no kind view", () => { | |
| 110 | expect(kindView("a.ts", "x", c)).toBeNull(); | |
| 111 | }); | |
| 112 | }); | |
| 113 | ||
| 114 | describe("the workspace opens each kind", () => { | |
| 115 | let dataDir: string; | |
| 116 | let forge: Forge; | |
| 117 | let alice: User; | |
| 118 | ||
| 119 | const identity = (): TuiIdentity => ({ sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: alice }); | |
| 120 | interface Harness { text(): string; press(...k: string[]): Promise<void>; type(t: string): Promise<void>; settle(): Promise<void>; until(w: string): Promise<void>; stop(): void } | |
| 121 | ||
| 122 | function boot(cols = 120, rows = 40): Harness { | |
| 123 | const terminal: HeadlessTerminal = headlessTerminal(cols, rows); | |
| 124 | const { app } = createForgeApp(forge, identity(), { terminal, exitHooks: false }); | |
| 125 | app.start(); | |
| 126 | const settle = (): Promise<void> => settleApp(app); | |
| 127 | const send = (key: string): void => { | |
| 128 | const ctrl = key.startsWith("ctrl+"); | |
| 129 | app.dispatch({ type: "key", key: ctrl ? key.slice(5) : key, ctrl, alt: false, shift: false }); | |
| 130 | }; | |
| 131 | return { | |
| 132 | text: () => app.screen.toText(), | |
| 133 | async press(...keys) { for (const k of keys) send(k); await settle(); }, | |
| 134 | async type(t) { for (const ch of t) send(ch); await settle(); }, | |
| 135 | settle, | |
| 136 | until: (w) => settleUntil(app, w), | |
| 137 | stop: () => app.stop(), | |
| 138 | }; | |
| 139 | } | |
| 140 | async function open(h: Harness, name: string): Promise<void> { | |
| 141 | await h.press("/"); | |
| 142 | await h.type(name); | |
| 143 | await h.press("enter"); | |
| 144 | await h.press("enter"); | |
| 145 | await h.until(`alice/${name}`); | |
| 146 | } | |
| 147 | async function openFile(h: Harness, query: string): Promise<void> { | |
| 148 | await h.press("t"); | |
| 149 | await h.settle(); | |
| 150 | await h.type(query); | |
| 151 | await h.press("enter"); | |
| 152 | await h.settle(); | |
| 153 | } | |
| 154 | ||
| 155 | beforeAll(async () => { | |
| 156 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-kinds-")); | |
| 157 | forge = createApp({ dataDir, port: 0 }); | |
| 158 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 159 | if (!a.ok) throw new Error("signup failed"); | |
| 160 | alice = a.value; | |
| 161 | const made = await createRepo(forge, alice, { name: "kinds", visibility: "public", description: "files" }); | |
| 162 | if (!made.ok) throw new Error("repo failed"); | |
| 163 | const rows = Array.from({ length: ROW_CAP + 3 }, (_, i) => JSON.stringify({ id: i, who: `person${i}` })).join("\n"); | |
| 164 | await commitToBranch(forge, alice, made.value, { | |
| 165 | message: "seed", | |
| 166 | files: [ | |
| 167 | { path: "good.ipynb", content: NB }, | |
| 168 | { path: "bad.ipynb", content: "{not json" }, | |
| 169 | { path: "rows.jsonl", content: rows }, | |
| 170 | { path: "bad.jsonl", content: "nope\nnope" }, | |
| 171 | { path: "run.log", content: "INFO started\n\x1b[32mERROR kaboom\x1b[0m\n" }, | |
| 172 | { path: "empty.log", content: "" }, | |
| 173 | { path: "nul.log", content: "a\x00b\nline two\n" }, | |
| 174 | ], | |
| 175 | }); | |
| 176 | }); | |
| 177 | ||
| 178 | afterAll(async () => { | |
| 179 | forge.close(); | |
| 180 | await rm(dataDir, { recursive: true, force: true }); | |
| 181 | }); | |
| 182 | ||
| 183 | test("an .ipynb reads as cells; s shows the JSON; a malformed one is source already", async () => { | |
| 184 | const h = boot(); | |
| 185 | try { | |
| 186 | await h.settle(); | |
| 187 | await open(h, "kinds"); | |
| 188 | await openFile(h, "good.ipynb"); | |
| 189 | expect(h.text()).toContain("True & done"); | |
| 190 | expect(h.text()).not.toContain("cell_type"); | |
| 191 | await h.press("s"); | |
| 192 | expect(h.text()).toContain("cell_type"); | |
| 193 | await openFile(h, "bad.ipynb"); | |
| 194 | expect(h.text()).toContain("{not json"); | |
| 195 | } finally { | |
| 196 | h.stop(); | |
| 197 | } | |
| 198 | }); | |
| 199 | ||
| 200 | test("a .jsonl reads as a table with the cap said; s shows the source", async () => { | |
| 201 | const h = boot(); | |
| 202 | try { | |
| 203 | await h.settle(); | |
| 204 | await open(h, "kinds"); | |
| 205 | await openFile(h, "rows.jsonl"); | |
| 206 | expect(h.text()).toContain("person0"); | |
| 207 | expect(h.text()).toContain("3 more rows not shown"); | |
| 208 | await h.press("s"); | |
| 209 | expect(h.text()).toContain('{"id":0'); | |
| 210 | await openFile(h, "bad.jsonl"); | |
| 211 | expect(h.text()).toContain("nope"); | |
| 212 | } finally { | |
| 213 | h.stop(); | |
| 214 | } | |
| 215 | }); | |
| 216 | ||
| 217 | test("a .log reads without its escapes; s shows the bytes, so the view changes", async () => { | |
| 218 | const h = boot(); | |
| 219 | try { | |
| 220 | await h.settle(); | |
| 221 | await open(h, "kinds"); | |
| 222 | await openFile(h, "run.log"); | |
| 223 | expect(h.text()).toContain("ERROR kaboom"); | |
| 224 | expect(h.text()).not.toContain("[32m"); | |
| 225 | const before = h.text(); | |
| 226 | await h.press("s"); | |
| 227 | expect(h.text()).not.toBe(before); | |
| 228 | expect(h.text()).toContain("INFO started"); | |
| 229 | await h.press("s"); | |
| 230 | expect(h.text()).toBe(before); | |
| 231 | } finally { | |
| 232 | h.stop(); | |
| 233 | } | |
| 234 | }); | |
| 235 | ||
| 236 | test("an empty .log and a .log with NUL bytes open without breaking", async () => { | |
| 237 | const h = boot(); | |
| 238 | try { | |
| 239 | await h.settle(); | |
| 240 | await open(h, "kinds"); | |
| 241 | await openFile(h, "nul.log"); | |
| 242 | expect(h.text()).toContain("Binary file"); | |
| 243 | await openFile(h, "empty.log"); | |
| 244 | expect(h.text()).toContain("alice/kinds"); | |
| 245 | } finally { | |
| 246 | h.stop(); | |
| 247 | } | |
| 248 | }); | |
| 249 | }); |
Replace logLevel/notebook HTML scraping with shared structured helpers in src/web/render (items 1 and 5), and say when LINE_CAP truncates.