Public: anyone can read this; the crew can change it.

The terminal workspace shows a .csv or .tsv file as an aligned table with a header row, and `s` flips to its source #7401

Merged opened this

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

Merged

scout/forge:scout/7344-the-terminal-workspace-shows-a-csv-or-ts → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/7344-the-terminal-workspace-shows-a-csv-or-ts was merged into main as c3737f6.

Review

Sign in to review this change.
6 files changed +530 −7
README.md +2
@@ -71,6 +71,8 @@ Reports from outside (sent with no account) are decided in the terminal too: ope
7171
7272 The terminal's history says each commit's check, as MCP `commits` with `oid` does: a ✓ or ✗ mark on a log row where the commit's own check on the default branch passed or failed (blank where it has no verdict yet), and a `check:` line under one commit's title: `green`, `red · <first line of the failing step>`, `running`, `superseded` or `not run`, with `· via <sha>` when a later main commit's check answers for it. Nothing shows to a viewer who may not read the repository's runs (`src/checks/commit_check.ts`, `src/tui/screens/commits.ts`).
7373
74 In the terminal workspace a `.csv` or `.tsv` file opens as a table (columns padded to their widest cell, the header kept on top, `N rows` in the status line; at most 1,000 rows and 64 columns are drawn and the rest is counted; a cell wider than 32 columns is cut with `…`, and the source view shows it whole). `s` flips between the table and the source, and a file that does not parse opens as source with `not a table: line N` in the status line (`src/text/csv.ts`, `src/tui/ui/grid.ts`).
75
7476 Under the agents, each team that has a pull has one pooled row (its members'
7577 counts added, the ratios taken from the sums; an agent on two teams counts in
7678 both): `data-team` rows on the page, "team <name>" rows in that terminal screen.
src/text/csv.ts added +160
@@ -0,0 +1,160 @@
1 /**
2 * CSV and TSV, per RFC 4180: quoted fields, `""` for a quote inside one,
3 * newlines inside quotes, CRLF, and a delimiter sniffed from the first line.
4 *
5 * Plain text in, plain data out. Rows past `maxRows` are counted, never
6 * built, so a huge file costs one pass and a bounded amount of memory.
7 * A file that does not parse comes back with `error` saying which line broke;
8 * `header` and `rows` then hold whatever was read before it.
9 */
10
11 export type Delimiter = "," | ";" | "\t";
12
13 export interface CsvError {
14 /** 1-based line in the source where the problem is. */
15 readonly line: number;
16 readonly message: string;
17 }
18
19 export interface Table {
20 readonly header: string[];
21 readonly rows: string[][];
22 /** Data rows past the cap: counted, not parsed. */
23 readonly more: number;
24 readonly delimiter: Delimiter;
25 /** The widest record's field count, before `maxColumns` cut it. */
26 readonly columns: number;
27 readonly error?: CsvError;
28 }
29
30 export interface CsvOptions {
31 /** Data rows kept, header excluded. @default 1000 */
32 readonly maxRows?: number;
outdated

Nothing passes this option. Use it for .tsv (see workspace.ts), which also fixes the tie-goes-to-comma sniff for TSV headers that contain commas.

33 /** Fields kept per record; the rest are counted, not stored. @default MAX_COLUMNS */
34 readonly maxColumns?: number;
35 /** Skip the sniff. */
36 readonly delimiter?: Delimiter | undefined;
37 }
38
39 /** Fields kept per record unless the caller says otherwise. */
40 export const MAX_COLUMNS = 64;
41
42 const CANDIDATES: readonly Delimiter[] = [",", ";", "\t"];
43
44 /** The delimiter that appears most, outside quotes, on the first line. */
45 export function sniffDelimiter(text: string): Delimiter {
46 const counts = new Map<Delimiter, number>();
47 let quoted = false;
48 for (const ch of text) {
49 if (ch === '"') quoted = !quoted;
50 else if (!quoted && (ch === "\n" || ch === "\r")) break;
51 else if (!quoted && (ch === "," || ch === ";" || ch === "\t"))
52 counts.set(ch, (counts.get(ch) ?? 0) + 1);
53 }
54 let best: Delimiter = ",";
55 let most = 0;
56 for (const d of CANDIDATES) {
57 const n = counts.get(d) ?? 0;
58 if (n > most) {
59 best = d;
60 most = n;
61 }
62 }
63 return best;
64 }
65
66 export function parseCsv(input: string, options: CsvOptions = {}): Table {
67 const maxRows = Math.max(0, Math.floor(options.maxRows ?? 1000));
68 const text = input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
69 const maxColumns = Math.max(1, Math.floor(options.maxColumns ?? MAX_COLUMNS));
70 const delimiter = options.delimiter ?? sniffDelimiter(text);
71
72 const records: string[][] = [];
73 let more = 0;
74 let row: string[] = [];
75 let fields = 0; // fields in this record, kept or not
76 let columns = 0;
77 let field = "";
78 let quoted = false;
79 let touched = false; // the field has had a character, kept or not
80 let closed = false; // just left a quoted field: only a delimiter or newline may follow
81 let line = 1;
82 let quoteLine = 1;
83 let error: CsvError | undefined;
84
85 // The header and `maxRows` data rows are kept; after that fields are not
86 // even accumulated, only the records are counted.
87 const keep = (): boolean =>
88 records.length < maxRows + 1 && row.length < maxColumns;
89 const endField = (): void => {
90 if (row.length < maxColumns) row.push(field);
91 fields++;
92 field = "";
93 closed = false;
94 };
95 const endRecord = (): void => {
96 endField();
97 columns = Math.max(columns, fields);
98 fields = 0;
99 const blank = row.length === 1 && row[0] === "" && !touched;
100 touched = false;
101 if (!blank) {
102 if (records.length < maxRows + 1) records.push(row);
103 else more++;
104 }
105 row = [];
106 };
107
108 for (let i = 0; i < text.length && error === undefined; i++) {
109 const ch = text[i]!;
110 if (quoted) {
111 touched = true;
112 if (ch === '"') {
113 if (text[i + 1] === '"') {
114 if (keep()) field += '"';
115 i++;
116 } else {
117 quoted = false;
118 closed = true;
119 }
120 } else {
121 // A bare `\r` ends a line in old files, so it counts like `\n`.
122 if (ch === "\n" || (ch === "\r" && text[i + 1] !== "\n")) line++;
123 if (keep()) field += ch;
124 }
125 continue;
126 }
127 if (ch === delimiter) endField();
128 else if (ch === "\n" || ch === "\r") {
129 if (ch === "\r" && text[i + 1] === "\n") i++;
130 endRecord();
131 line++;
132 } else if (closed)
133 error = { line, message: "text after a closing quote" };
134 else if (ch === '"') {
135 if (field === "") {
136 quoted = true;
137 touched = true;
138 quoteLine = line;
139 } else error = { line, message: "a quote inside an unquoted field" };
140 } else {
141 touched = true;
142 if (keep()) field += ch;
143 }
144 }
145
146 if (error === undefined && quoted)
147 error = { line: quoteLine, message: "a quoted field is never closed" };
148 if (error === undefined && (fields > 0 || touched))
149 endRecord();
150
151 const [header = [], ...rows] = records;
152 return {
153 header,
154 rows,
155 more,
156 delimiter,
157 columns,
158 ...(error === undefined ? {} : { error }),
159 };
160 }
src/tui/screens/workspace.ts +136 −7
@@ -48,6 +48,8 @@ import type { TreeEntry } from "../../git/read.ts";
4848 import { fileKind, NO_BYTES } from "../../web/render/kinds.ts";
4949 import type { RepoFacts, RepoSource } from "../../view/source.ts";
5050 import { detectLanguage, highlightLines } from "../../web/highlight.ts";
51 import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts";
52 import { layoutTable, type Grid } from "../ui/grid.ts";
5153 import type { Ctx, Screen } from "../app/app.ts";
5254 import { binding, type Binding } from "../app/keys.ts";
5355 import type { KeyEvent } from "../term/input.ts";
@@ -259,6 +261,11 @@ type FindMode = "off" | "find" | "replace";
259261 /** What a path is, as far as the file pane is concerned. */
260262 type TabKind = "image" | "markdown" | "code";
261263
264 /** `.csv` and `.tsv`: files that read as a table. */
265 function isTablePath(path: string): boolean {
266 return /\.[ct]sv$/i.test(path);
267 }
268
262269 /** `README.md`, `NOTES.markdown`, `readme` — the prose a repo opens on. */
263270 function isMarkdownPath(path: string): boolean {
264271 const name = path.slice(path.lastIndexOf("/") + 1);
@@ -305,6 +312,11 @@ class Tab {
305312 doc: Line[] = [];
306313 docPlain: string[] = [];
307314 docWidth = 0;
315 /** `s` on a CSV/TSV: the aligned table, or the source behind it. */
316 asTable = true;
317 /** The parsed file, when its name says table. Parse errors ride along. */
318 table: Table | null = null;
319 grid: Grid = { header: "", rows: [] };
308320
309321 constructor(readonly path: string) {}
310322
@@ -322,9 +334,21 @@ class Tab {
322334 return this.kind === "markdown" && this.rendered && this.mode === "view";
323335 }
324336
337 /** True when the pane is showing a CSV/TSV as columns. */
338 get tabular(): boolean {
339 return (
340 this.table !== null &&
341 this.table.error === undefined &&
342 this.table.header.length > 0 &&
343 this.asTable &&
344 this.mode === "view"
345 );
346 }
347
325348 /** The lines `/` searches and `j` scrolls: whichever view is up. */
326349 get body(): string[] {
327 return this.reading ? this.docPlain : this.plain;
350 if (this.reading) return this.docPlain;
351 return this.tabular ? this.grid.rows : this.plain;
328352 }
329353
330354 /** Wrap the markdown for this width, and only when the width moved. */
@@ -371,6 +395,12 @@ class Tab {
371395 const built = buildLines(new TextDecoder().decode(source), this.path, c);
372396 this.plain = built.plain;
373397 this.lines = built.lines;
398 if (isTablePath(this.path)) {
outdated

High: this.plain has already been through expandTabs, so a .tsv has no tabs left by the time it is parsed and comes out as one column. Parse the decoded text, and pass { delimiter: "\t" } when the path ends in .tsv.

399 // The decoded source, not `plain`: `expandTabs` has eaten every tab there.
400 const delimiter = /\.tsv$/i.test(this.path) ? "\t" : undefined;
401 this.table = parseCsv(new TextDecoder().decode(source), { delimiter });
402 this.grid = layoutTable(this.table);
403 } else this.table = null;
374404 // The rendered document is derived from the same text, so an edit, a
375405 // theme switch or a re-read has to throw it away with everything else.
376406 this.doc = [];
@@ -704,6 +734,7 @@ export function workspaceScreen(
704734 : prepareImage(entry.picture, imageId(t.path), ctx.caps);
705735 t.showImage = true;
706736 t.rendered = true;
737 t.asTable = true;
707738 t.editor = null;
708739 t.mode = "view";
709740 t.find = "off";
@@ -1209,6 +1240,7 @@ export function workspaceScreen(
12091240 // You edit the bytes, never the rendering of them. Leaving `rendered`
12101241 // set would put the document back on `esc` with the edits invisible.
12111242 t.rendered = false;
1243 t.asTable = false;
12121244 t.mode = "edit";
12131245 focus = "file";
12141246 ctx.invalidate();
@@ -1276,6 +1308,13 @@ export function workspaceScreen(
12761308 const two =
12771309 t !== undefined && (t.image !== null || isMarkdownPath(t.path));
12781310 return [
1311 ...(t?.table == null
1312 ? []
1313 : [
1314 binding(["s"], t.asTable ? "show the source" : "show the table", {
1315 disabled: t.table.error !== undefined || t.table.header.length === 0,
1316 }),
1317 ]),
12791318 binding(["tab"], "focus explorer"),
12801319 binding(["e"], "edit", { disabled: !editable() }),
12811320 binding(
@@ -1608,6 +1647,10 @@ export function workspaceScreen(
16081647 toggleView();
16091648 return true;
16101649 }
1650 if (plain && e.key === "s" && focus === "file" && t !== undefined) {
1651 toggleTable(t);
1652 return true;
1653 }
16111654
16121655 // ---- the repository ----
16131656 if (plain && e.key === "c") {
@@ -2030,6 +2073,20 @@ export function workspaceScreen(
20302073 t.findHits();
20312074 }
20322075
2076 /** `s` — a table file's two views: aligned columns, or the text itself. */
2077 function toggleTable(t: Tab): void {
2078 // A stray `s` on anything else is not worth a toast.
2079 if (t.table === null || t.mode === "edit") return;
2080 if (t.table.error !== undefined) {
2081 ctx.toast(tableProblem(t.table), "attn");
2082 return;
2083 }
2084 t.asTable = !t.asTable;
2085 t.scroll.top = 0;
2086 t.left = 0;
2087 t.findHits();
2088 }
2089
20332090 function drawPane(s: Surface, r: Rect, c: Tokens): void {
20342091 const t = tab();
20352092 if (t === undefined) {
@@ -2066,6 +2123,10 @@ export function workspaceScreen(
20662123 drawDoc(s, r, c, t);
20672124 return;
20682125 }
2126 if (t.tabular) {
2127 drawTable(s, r, c, t);
2128 return;
2129 }
20692130 drawCode(s, r, c, t);
20702131 }
20712132
@@ -2244,12 +2305,18 @@ export function workspaceScreen(
22442305 }· ${humanBytes(t.image.byteLength)}`
22452306 : t.note !== ""
22462307 ? ""
2247 : `${t.path} · ${plural(
2248 t.reading ? t.doc.length : t.plain.length,
2249 "line",
2250 )}${t.reading ? " rendered" : ""}${
2251 writable() || target !== undefined ? "" : " · read only"
2252 }`;
2308 : `${t.path} · ${
2309 t.tabular && t.table !== null
2310 ? tableStatus(t.table)
2311 : `${plural(
2312 t.reading ? t.doc.length : t.plain.length,
2313 "line",
2314 )}${t.reading ? " rendered" : ""}${
2315 t.table?.error === undefined || t.mode === "edit"
2316 ? ""
2317 : ` · ${tableProblem(t.table)}`
2318 }`
2319 }${writable() || target !== undefined ? "" : " · read only"}`;
22532320 if (tail === "") return;
22542321 const shown = truncate(tail, Math.max(0, r.w - 4));
22552322 s.text(
@@ -2571,6 +2638,68 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void {
25712638 });
25722639 }
25732640
2641 /** `80 rows, first 50 shown · 3 more columns`: what the table leaves out. */
2642 function tableStatus(table: Table): string {
2643 const rows = table.rows.length + table.more;
2644 return `${plural(rows, "row")}${
2645 table.more > 0 ? `, first ${table.rows.length} shown` : ""
2646 }${
2647 table.columns > MAX_COLUMNS
2648 ? ` · ${table.columns - MAX_COLUMNS} more columns`
2649 : ""
2650 }`;
2651 }
2652
2653 /** The one line a broken table file gets: where, and what. */
2654 function tableProblem(table: Table): string {
2655 return table.error === undefined
2656 ? ""
2657 : `not a table: line ${table.error.line}, ${table.error.message}`;
2658 }
2659
2660 /**
2661 * A table: the header pinned on the top row in bold, the data scrolling under
2662 * it, `t.left` shifting both so a wide table can be read across.
2663 */
2664 function drawTable(s: Surface, r: Rect, c: Tokens, t: Tab): void {
2665 if (r.w <= 1 || r.h <= 1) return;
2666 const textW = r.w - 1;
2667 const rows = t.grid.rows;
2668 const body = r.h - 1;
2669 const from = t.scroll.window(body, rows.length);
2670 const hits = new Set(t.hits);
2671 s.fill({ x: r.x, y: r.y, w: textW, h: 1 }, " ", style(c.fg, c.bg));
2672 drawShifted(
2673 s,
2674 r.x,
2675 r.y,
2676 [{ text: t.grid.header, style: style(c.accent, c.bg, BOLD) }],
2677 t.left,
2678 textW,
2679 c.bg,
2680 );
2681 for (let i = 0; i < body; i++) {
2682 const row = from + i;
2683 if (row >= rows.length) break;
2684 const y = r.y + 1 + i;
2685 const bg = hits.has(row) ? c.muted : c.bg;
2686 s.fill({ x: r.x, y, w: textW, h: 1 }, " ", style(c.fg, bg));
2687 drawShifted(
2688 s,
2689 r.x,
2690 y,
2691 [{ text: rows[row]!, style: style(c.fg, bg) }],
2692 t.left,
2693 textW,
2694 bg,
2695 );
2696 }
2697 scrollbar(s, { x: r.x + r.w - 1, y: r.y + 1, w: 1, h: body }, c, {
2698 total: rows.length,
2699 offset: from,
2700 });
2701 }
2702
25742703 /** Draw spans clipped to a window of display columns. */
25752704 function drawShifted(
25762705 s: Surface,
src/tui/ui/grid.ts added +40
@@ -0,0 +1,40 @@
1 /**
2 * A parsed table as text: every column padded to its widest cell, capped,
3 * with an ellipsis where a cell was cut. Plain strings out, so the pane can
4 * scroll, search and shift them the way it does code.
5 */
6
7 import type { Table } from "../../text/csv.ts";
8 import { displayWidth, pad, truncate } from "../term/width.ts";
9
10 /** The widest a column may grow, in display columns. */
11 export const CELL_MAX = 32;
12 const GAP = " │ ";
13
14 export interface Grid {
15 readonly header: string;
16 readonly rows: string[];
17 }
18
19 /** One line, no control characters: a cell must never reach the terminal as a command. */
20 const flat = (cell: string): string =>
21 cell
22 .replace(/[\x00-\x1f\x7f-\x9f]+/g, " ")
outdated

High (DoS): columns is unbounded, and every kept row is padded to it. 100k commas on line 1 plus 1,000 rows took 27 s of single-process CPU here. Cap the columns (and say how many were cut), and add a test that a very wide file stays cheap.

23 .replace(/\s+/g, " ")
24 .trim();
25
26 export function layoutTable(table: Table, cap = CELL_MAX): Grid {
27 const columns = Math.max(
28 table.header.length,
29 ...table.rows.map((r) => r.length),
30 );
31 const cells = (r: readonly string[]): string[] =>
32 Array.from({ length: columns }, (_, i) => flat(r[i] ?? ""));
33 const all = [cells(table.header), ...table.rows.map(cells)];
34 const widths = Array.from({ length: columns }, (_, i) =>
35 Math.min(cap, Math.max(1, ...all.map((r) => displayWidth(r[i]!)))),
36 );
37 const line = (r: readonly string[]): string =>
38 r.map((cell, i) => pad(truncate(cell, widths[i]!), widths[i]!)).join(GAP);
39 return { header: line(all[0]!), rows: all.slice(1).map(line) };
40 }
test/text-csv.test.ts added +85
@@ -0,0 +1,85 @@
1 import { describe, expect, test } from "bun:test";
2 import { parseCsv, sniffDelimiter } from "../src/text/csv.ts";
3 import { layoutTable } from "../src/tui/ui/grid.ts";
4
5 describe("parseCsv", () => {
6 test("reads a header and rows", () => {
7 const t = parseCsv("a,b\n1,2\n3,4\n");
8 expect(t.header).toEqual(["a", "b"]);
9 expect(t.rows).toEqual([["1", "2"], ["3", "4"]]);
10 expect(t.more).toBe(0);
11 expect(t.error).toBeUndefined();
12 });
13
14 test("quoted fields hold delimiters, doubled quotes and newlines", () => {
15 const t = parseCsv('name,note\n"Smith, J","said ""hi"""\n"two\nlines",x\n');
16 expect(t.rows).toEqual([["Smith, J", 'said "hi"'], ["two\nlines", "x"]]);
17 expect(t.error).toBeUndefined();
18 });
19
20 test("CRLF, a missing final newline, blank lines and a BOM", () => {
21 const t = parseCsv("a,b\r\n1,2\r\n\r\n3,4");
22 expect(t.header).toEqual(["a", "b"]);
23 expect(t.rows).toEqual([["1", "2"], ["3", "4"]]);
24 });
25
26 test("the delimiter is sniffed from the first line", () => {
27 expect(sniffDelimiter("a;b;c\n1,2,3")).toBe(";");
28 expect(sniffDelimiter("a\tb\n1,2")).toBe("\t");
29 expect(sniffDelimiter('"a,b,c";d\n')).toBe(";");
30 expect(sniffDelimiter("single\n")).toBe(",");
31 expect(parseCsv("a;b\n1;2").rows).toEqual([["1", "2"]]);
32 expect(parseCsv("a\tb\n1\t2").delimiter).toBe("\t");
33 });
34
35 test("rows past the cap are counted, not kept", () => {
36 const text = ["h", ...Array.from({ length: 50 }, (_, i) => String(i))].join("\n");
37 const t = parseCsv(text, { maxRows: 10 });
38 expect(t.rows.length).toBe(10);
39 expect(t.more).toBe(40);
40 });
41
42 test("an unclosed quote names the line it opened on", () => {
43 const t = parseCsv('a,b\n1,2\n3,"oops\n4,5\n');
44 expect(t.error).toEqual({ line: 3, message: "a quoted field is never closed" });
45 expect(t.rows).toEqual([["1", "2"]]);
46 });
47
48 test("a bare CR inside quotes still counts as a line", () => {
49 expect(parseCsv('a\r"x\ry"z\n').error?.line).toBe(3);
50 });
51
52 test("stray quotes name their line", () => {
53 expect(parseCsv('a,b\n1,x"y\n').error?.line).toBe(2);
54 expect(parseCsv('a,b\n"x"y,2\n').error?.message).toBe("text after a closing quote");
55 });
56
57 test("a very wide file is cut at the column cap, and stays cheap", () => {
58 const text = `${",".repeat(100_000)}\n${Array.from({ length: 1000 }, () => "x").join("\n")}\n`;
59 const t = parseCsv(text);
60 const g = layoutTable(t);
61 expect(t.header.length).toBe(64);
62 expect(t.columns).toBe(100_001);
63 expect(t.rows.length).toBe(1000);
64 expect(g.header.split(" │ ").length).toBe(64);
65 });
66
67 test("an empty file has no header", () => {
68 expect(parseCsv("").header).toEqual([]);
69 });
70 });
71
72 describe("layoutTable", () => {
73 test("pads columns to their widest cell and cuts long ones with an ellipsis", () => {
74 const g = layoutTable(parseCsv("id,name\n1,Alexander\n22,Bo\n"));
75 expect(g.header).toBe("id │ name ");
76 expect(g.rows).toEqual(["1 │ Alexander", "22 │ Bo "]);
77 const cut = layoutTable(parseCsv("x\nabcdefghij\n"), 5);
78 expect(cut.rows).toEqual(["abcd…"]);
79 });
80
81 test("control characters in a cell never reach the surface", () => {
82 const g = layoutTable(parseCsv('a\n"x\x1b[2Jy"\n'));
83 expect(g.rows[0]).not.toContain("\x1b");
84 });
85 });
test/tui-workspace.test.ts +107
@@ -262,6 +262,113 @@ describe("the terminal workspace", () => {
262262 }
263263 });
264264
265 test("a .csv opens as an aligned table with a pinned header, and s flips to the source", async () => {
266 const made = await createRepo(forge, alice, {
267 name: "ledger",
268 visibility: "public",
269 description: "numbers",
270 });
271 if (!made.ok) throw new Error("repo failed");
272 const rows = Array.from({ length: 80 }, (_, i) => `${i + 1},item ${i + 1}`);
273 await commitToBranch(forge, alice, made.value, {
274 message: "seed",
275 files: [
276 { path: "data.csv", content: ["id,name", ...rows, ""].join("\n") },
277 { path: "qty.tsv", content: "name, full\tqty\nwidget\t4\n" },
outdated

Add a .tsv file here and check it shows │ columns. That test would fail today. Also add a test that a very wide CSV opens quickly or is cut at the column cap.

278 { path: "bad.csv", content: 'a,b\n1,"oops\n' },
279 ],
280 });
281 const h = boot();
282 try {
283 await h.settle();
284 await open(h, "ledger");
285 await openFile(h, "data.csv");
286 expect(h.text()).toContain("id │ name");
287 expect(h.text()).toContain("1 │ item 1");
288 expect(h.text()).toContain("80 rows");
289 await h.press("G");
290 expect(h.text()).toContain("id │ name");
291 expect(h.text()).toContain("80 │ item 80");
292 await h.press("s");
293 expect(h.text()).toContain("id,name");
294 expect(h.text()).not.toContain("│ item");
295 await h.press("s");
296 expect(h.text()).toContain("id │ name");
297
298 await openFile(h, "qty.tsv");
299 expect(h.text()).toContain("name, full │ qty");
300 expect(h.text()).toContain("widget");
301 expect(h.text()).toContain("1 row");
302
303 await openFile(h, "bad.csv");
304 expect(h.text()).toContain("a,b");
305 expect(h.text()).toContain("not a table: line 2");
306 } finally {
307 h.stop();
308 }
309 });
310
311 test("tables: search, edit, the caps in the status line, a ; file, and s on a broken one", async () => {
312 const made = await createRepo(forge, alice, {
313 name: "sheets",
314 visibility: "public",
315 description: "more numbers",
316 });
317 if (!made.ok) throw new Error("repo failed");
318 const rows = Array.from({ length: 80 }, (_, i) => `${i + 1},item ${i + 1}`);
319 await commitToBranch(forge, alice, made.value, {
320 message: "seed",
321 files: [
322 { path: "data.csv", content: ["id,name", ...rows, ""].join("\n") },
323 { path: "semi.csv", content: "city;pop\nOslo;700\n" },
324 {
325 path: "wide.csv",
326 content: `${Array.from({ length: 70 }, (_, i) => `c${i}`).join(",")}\n1\n`,
327 },
328 {
329 path: "long.csv",
330 content: `n\n${Array.from({ length: 1100 }, (_, i) => i).join("\n")}\n`,
331 },
332 { path: "bad.csv", content: 'a,b\n1,"oops\n' },
333 ],
334 });
335 const h = boot();
336 try {
337 await h.settle();
338 await open(h, "sheets");
339
340 await openFile(h, "semi.csv");
341 expect(h.text()).toContain("city │ pop");
342 expect(h.text()).toContain("Oslo");
343
344 await openFile(h, "wide.csv");
345 expect(h.text()).toContain("6 more columns");
346
347 await openFile(h, "long.csv");
348 expect(h.text()).toContain("1100 rows, first 1000 shown");
349
350 await openFile(h, "data.csv");
351 expect(h.text()).not.toContain("item 70");
352 await h.press("/");
353 await h.type("item 70");
354 await h.press("enter");
355 expect(h.text()).toContain("70 │ item 70");
356
357 await h.press("e");
358 await h.press("esc");
359 expect(h.text()).toContain("70,item 70");
360 expect(h.text()).not.toContain("│ item");
361 await h.press("s");
362 expect(h.text()).toContain("id │ name");
363
364 await openFile(h, "bad.csv");
365 await h.press("s");
366 expect(h.text()).toContain("not a table: line 2");
367 } finally {
368 h.stop();
369 }
370 });
371
265372 test("moving in the explorer changes nothing in the file pane", async () => {
266373 const h = boot();
267374 try {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs