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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7344-the-terminal-workspace-shows-a-csv-or-ts → main 4 commits fork 4 threads · 4 unresolved
README.md +2
| @@ -71,6 +71,8 @@ Reports from outside (sent with no account) are decided in the terminal too: ope | ||
| 71 | 71 | |
| 72 | 72 | 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`). |
| 73 | 73 | |
| 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 | ||
| 74 | 76 | Under the agents, each team that has a pull has one pooled row (its members' |
| 75 | 77 | counts added, the ratios taken from the sums; an agent on two teams counts in |
| 76 | 78 | 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; | |
| 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"; | ||
| 48 | 48 | import { fileKind, NO_BYTES } from "../../web/render/kinds.ts"; |
| 49 | 49 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| 50 | 50 | 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"; | |
| 51 | 53 | import type { Ctx, Screen } from "../app/app.ts"; |
| 52 | 54 | import { binding, type Binding } from "../app/keys.ts"; |
| 53 | 55 | import type { KeyEvent } from "../term/input.ts"; |
| @@ -259,6 +261,11 @@ type FindMode = "off" | "find" | "replace"; | ||
| 259 | 261 | /** What a path is, as far as the file pane is concerned. */ |
| 260 | 262 | type TabKind = "image" | "markdown" | "code"; |
| 261 | 263 | |
| 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 | ||
| 262 | 269 | /** `README.md`, `NOTES.markdown`, `readme` — the prose a repo opens on. */ |
| 263 | 270 | function isMarkdownPath(path: string): boolean { |
| 264 | 271 | const name = path.slice(path.lastIndexOf("/") + 1); |
| @@ -305,6 +312,11 @@ class Tab { | ||
| 305 | 312 | doc: Line[] = []; |
| 306 | 313 | docPlain: string[] = []; |
| 307 | 314 | 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: [] }; | |
| 308 | 320 | |
| 309 | 321 | constructor(readonly path: string) {} |
| 310 | 322 | |
| @@ -322,9 +334,21 @@ class Tab { | ||
| 322 | 334 | return this.kind === "markdown" && this.rendered && this.mode === "view"; |
| 323 | 335 | } |
| 324 | 336 | |
| 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 | ||
| 325 | 348 | /** The lines `/` searches and `j` scrolls: whichever view is up. */ |
| 326 | 349 | 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; | |
| 328 | 352 | } |
| 329 | 353 | |
| 330 | 354 | /** Wrap the markdown for this width, and only when the width moved. */ |
| @@ -371,6 +395,12 @@ class Tab { | ||
| 371 | 395 | const built = buildLines(new TextDecoder().decode(source), this.path, c); |
| 372 | 396 | this.plain = built.plain; |
| 373 | 397 | this.lines = built.lines; |
| 398 | if (isTablePath(this.path)) { | |
outdated
High: |
||
| 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; | |
| 374 | 404 | // The rendered document is derived from the same text, so an edit, a |
| 375 | 405 | // theme switch or a re-read has to throw it away with everything else. |
| 376 | 406 | this.doc = []; |
| @@ -704,6 +734,7 @@ export function workspaceScreen( | ||
| 704 | 734 | : prepareImage(entry.picture, imageId(t.path), ctx.caps); |
| 705 | 735 | t.showImage = true; |
| 706 | 736 | t.rendered = true; |
| 737 | t.asTable = true; | |
| 707 | 738 | t.editor = null; |
| 708 | 739 | t.mode = "view"; |
| 709 | 740 | t.find = "off"; |
| @@ -1209,6 +1240,7 @@ export function workspaceScreen( | ||
| 1209 | 1240 | // You edit the bytes, never the rendering of them. Leaving `rendered` |
| 1210 | 1241 | // set would put the document back on `esc` with the edits invisible. |
| 1211 | 1242 | t.rendered = false; |
| 1243 | t.asTable = false; | |
| 1212 | 1244 | t.mode = "edit"; |
| 1213 | 1245 | focus = "file"; |
| 1214 | 1246 | ctx.invalidate(); |
| @@ -1276,6 +1308,13 @@ export function workspaceScreen( | ||
| 1276 | 1308 | const two = |
| 1277 | 1309 | t !== undefined && (t.image !== null || isMarkdownPath(t.path)); |
| 1278 | 1310 | 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 | ]), | |
| 1279 | 1318 | binding(["tab"], "focus explorer"), |
| 1280 | 1319 | binding(["e"], "edit", { disabled: !editable() }), |
| 1281 | 1320 | binding( |
| @@ -1608,6 +1647,10 @@ export function workspaceScreen( | ||
| 1608 | 1647 | toggleView(); |
| 1609 | 1648 | return true; |
| 1610 | 1649 | } |
| 1650 | if (plain && e.key === "s" && focus === "file" && t !== undefined) { | |
| 1651 | toggleTable(t); | |
| 1652 | return true; | |
| 1653 | } | |
| 1611 | 1654 | |
| 1612 | 1655 | // ---- the repository ---- |
| 1613 | 1656 | if (plain && e.key === "c") { |
| @@ -2030,6 +2073,20 @@ export function workspaceScreen( | ||
| 2030 | 2073 | t.findHits(); |
| 2031 | 2074 | } |
| 2032 | 2075 | |
| 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 | ||
| 2033 | 2090 | function drawPane(s: Surface, r: Rect, c: Tokens): void { |
| 2034 | 2091 | const t = tab(); |
| 2035 | 2092 | if (t === undefined) { |
| @@ -2066,6 +2123,10 @@ export function workspaceScreen( | ||
| 2066 | 2123 | drawDoc(s, r, c, t); |
| 2067 | 2124 | return; |
| 2068 | 2125 | } |
| 2126 | if (t.tabular) { | |
| 2127 | drawTable(s, r, c, t); | |
| 2128 | return; | |
| 2129 | } | |
| 2069 | 2130 | drawCode(s, r, c, t); |
| 2070 | 2131 | } |
| 2071 | 2132 | |
| @@ -2244,12 +2305,18 @@ export function workspaceScreen( | ||
| 2244 | 2305 | }· ${humanBytes(t.image.byteLength)}` |
| 2245 | 2306 | : t.note !== "" |
| 2246 | 2307 | ? "" |
| 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"}`; | |
| 2253 | 2320 | if (tail === "") return; |
| 2254 | 2321 | const shown = truncate(tail, Math.max(0, r.w - 4)); |
| 2255 | 2322 | s.text( |
| @@ -2571,6 +2638,68 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void { | ||
| 2571 | 2638 | }); |
| 2572 | 2639 | } |
| 2573 | 2640 | |
| 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 | ||
| 2574 | 2703 | /** Draw spans clipped to a window of display columns. */ |
| 2575 | 2704 | function drawShifted( |
| 2576 | 2705 | 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): |
||
| 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", () => { | ||
| 262 | 262 | } |
| 263 | 263 | }); |
| 264 | 264 | |
| 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 |
||
| 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 | ||
| 265 | 372 | test("moving in the explorer changes nothing in the file pane", async () => { |
| 266 | 373 | const h = boot(); |
| 267 | 374 | try { |
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.