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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10995-the-terminal-workspace-opens-ipynb-jsonl → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/10995-the-terminal-workspace-opens-ipynb-jsonl was merged into main as 026ce8f.

Review

Sign in to review this change.
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";
6262 import { EVENT_CAP, parseIcs } from "../../web/render/ics.ts";
6363 import { CARD_CAP, parseVcf } from "../../web/render/vcf-parse.ts";
6464 import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts";
65 import { isJsonlPath, kindView, type KindLines } from "./workspace_kinds.ts";
6566 import { layoutTable, type Grid } from "../ui/grid.ts";
6667 import type { Ctx, Screen } from "../app/app.ts";
6768 import { stripAnsi } from "../term/ansi.ts";
@@ -462,6 +463,9 @@ class Tab {
462463 grid: Grid = { header: "", rows: [] };
463464 /** Cues of a subtitle file that could not be read. */
464465 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;
465469
466470 constructor(readonly path: string) {}
467471
@@ -511,6 +515,14 @@ class Tab {
511515 );
512516 }
513517
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
514526 get modified(): boolean {
515527 return this.creating || (this.editor !== null && this.editor.modified);
516528 }
@@ -551,6 +563,19 @@ class Tab {
551563 this.plain = built.plain;
552564 this.lines = built.lines;
553565 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 }
554579 if (isSubtitlePath(this.path)) {
555580 const subs = subtitleTable(new TextDecoder().decode(source));
556581 this.table = subs.table;
@@ -576,7 +601,7 @@ class Tab {
576601 const delimiter = /\.tsv$/i.test(this.path) ? "\t" : undefined;
577602 this.table = parseCsv(new TextDecoder().decode(source), { delimiter });
578603 this.grid = layoutTable(this.table);
579 } else this.table = null;
604 } else if (kind?.table === undefined) this.table = null;
580605 // The rendered document is derived from the same text, so an edit, a
581606 // theme switch or a re-read has to throw it away with everything else.
582607 this.doc = [];
@@ -1550,6 +1575,7 @@ export function workspaceScreen(
15501575 // set would put the document back on `esc` with the edits invisible.
15511576 t.rendered = false;
15521577 t.asTable = false;
1578 t.showKind();
15531579 t.mode = "edit";
15541580 focus = "file";
15551581 ctx.invalidate();
@@ -1618,11 +1644,11 @@ export function workspaceScreen(
16181644 t !== undefined &&
16191645 (t.image !== null || isMarkdownPath(t.path) || t.patch !== null);
16201646 return [
1621 ...(t?.table == null
1647 ...(t?.table == null && t?.kindLines == null
16221648 ? []
16231649 : [
16241650 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),
16261652 }),
16271653 ]),
16281654 binding(["tab"], "focus explorer"),
@@ -2432,7 +2458,16 @@ export function workspaceScreen(
24322458 /** `s` — a table file's two views: aligned columns, or the text itself. */
24332459 function toggleTable(t: Tab): void {
24342460 // 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;
24362471 if (t.table.error !== undefined) {
24372472 ctx.toast(tableProblem(t.table), "attn");
24382473 return;
@@ -2668,7 +2703,7 @@ export function workspaceScreen(
26682703 ? ""
26692704 : `${t.path} · ${
26702705 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))
26722707 : `${plural(
26732708 t.reading ? t.doc.length : t.plain.length,
26742709 "line",
@@ -3002,12 +3037,16 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void {
30023037 }
30033038
30043039 /** `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 {
30063041 const rows = table.rows.length + table.more;
30073042 return `${plural(rows, subs ? "cue" : events ? "event" : people ? "contact" : facts ? "fact" : features ? "feature" : "row")}${
30083043 unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : ""
30093044 }${
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 : ""
30113050 }${
30123051 table.columns > MAX_COLUMNS
30133052 ? ` · ${table.columns - MAX_COLUMNS} more columns`
src/tui/screens/workspace_kinds.ts added +168
@@ -0,0 +1,168 @@
1 /**

Replace logLevel/notebook HTML scraping with shared structured helpers in src/web/render (items 1 and 5), and say when LINE_CAP truncates.

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 {
3434 : html``}`,
3535 };
3636 }
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;
2929
3030 const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
3131
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[];
3643 }
3744
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) }];
4253 if (out.output_type === "error") {
4354 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) }];
4556 }
4657 const data = isObj(out.data) ? out.data : {};
47 const parts: Html[] = [];
58 const parts: NbOutput[] = [];
4859 for (const mime of IMAGES) {
4960 if (data[mime] === undefined) continue;
5061 const img = image(mime, data[mime]);
5162 if (img !== undefined) parts.push(img);
5263 }
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"]) });
5465 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;
5868 }
5969
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;
6272 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: [] };
6474 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 };
6878 }
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>`;
70108 }
71109
72110 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 };
83113 return {
84114 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>`,
87117 };
88118 }
test/tui-workspace-kinds.test.ts added +249
@@ -0,0 +1,249 @@
1 /**

Add a malformed .log case and make the .log s test fail when s does nothing (item 2).

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("&amp;");
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 &amp;\n", c);
107 expect(v.plain).toEqual(["<b>x</b> &amp;"]);
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs