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

A `.csv` or `.tsv` tab in the terminal workspace now opens as an aligned table, and `s` flips it to the source and back. `bun x tsc --noEmit` and `bun scripts/design-lint.ts` are clean. I ran `test/text-csv.test.ts` and `test/tui-workspace.test.ts` (31 pass), not the whole suite.

shipped production is serving it deploy 4c3a9d3

opened this

A .csv or .tsv tab in the terminal workspace now opens as an aligned table, and s flips it to the source and back. bun x tsc --noEmit and bun scripts/design-lint.ts are clean. I ran test/text-csv.test.ts and test/tui-workspace.test.ts (31 pass), not the whole suite.

What changed

  • src/text/csv.ts: parseCsv(text, { maxRows = 1000, delimiter? }) returns { header, rows, more, delimiter, error? }. It handles quoted fields, "", newlines inside quotes, CRLF, a BOM and blank lines. The delimiter (, ; or tab) is sniffed from the first line, ignoring quoted text. Rows past the cap are counted in more and never built. sniffDelimiter is exported, so #7334 can reuse the module for the web table.
  • src/tui/ui/grid.ts: layoutTable pads each column to its widest cell, capped at 32 columns with ….
  • Table view in src/tui/screens/workspace.ts:

- Opening a CSV draws the table with the header bold and pinned on the top row while the data scrolls under it. - h/l scroll sideways, and / and n search the data rows. - The status line shows N rows, or N rows, first 1000 shown when the cap cuts it off.

  • s:

- It toggles table and source, and appears in the key hints only on table files. - Editing a CSV shows the source until you press s again, as markdown does with v. - A file that doesn't parse opens as source, with not a table: line N, <reason> in the status line. - Pressing s on a broken file shows the same message as a toast.

  • Tests:

- test/text-csv.test.ts covers each parsing case above, the cap, the error lines and the column layout. - A new test in test/tui-workspace.test.ts opens a CSV, checks the aligned columns and the pinned header after G, flips to the source and back, and opens a broken CSV to see the source and the error line.

Left out

  • Ragged rows are padded, not reported as errors.
  • There are no row numbers or per-column alignment for numbers.
  • Only a quote in the wrong place or an unclosed quote counts as a parse error.
  • Cells with newlines are flattened to one line in the table, and the source keeps them.
  • The web table (#7334) is untouched.

Try it: open a repository in the terminal, open a .csv through the t finder, then press s. The s flip exists only on .csv and .tsv tabs.

🤖 Generated with Claude Code

Closes #7344

commented

A .csv or .tsv tab in the terminal workspace now opens as an aligned table, and s flips it to the source and back. bun x tsc --noEmit and bun scripts/design-lint.ts are clean. I ran test/text-csv.test.ts and test/tui-workspace.test.ts (31 pass), not the whole suite.

What changed

  • src/text/csv.ts: parseCsv(text, { maxRows = 1000, delimiter? }) returns { header, rows, more, delimiter, error? }. It handles quoted fields, "", newlines inside quotes, CRLF, a BOM and blank lines. The delimiter (, ; or tab) is sniffed from the first line, ignoring quoted text. Rows past the cap are counted in more and never built. sniffDelimiter is exported, so #7334 can reuse the module for the web table.
  • src/tui/ui/grid.ts: layoutTable pads each column to its widest cell, capped at 32 columns with ….
  • Table view in src/tui/screens/workspace.ts:

- Opening a CSV draws the table with the header bold and pinned on the top row while the data scrolls under it. - h/l scroll sideways, and / and n search the data rows. - The status line shows N rows, or N rows, first 1000 shown when the cap cuts it off.

  • s:

- It toggles table and source, and appears in the key hints only on table files. - Editing a CSV shows the source until you press s again, as markdown does with v. - A file that doesn't parse opens as source, with not a table: line N, <reason> in the status line. - Pressing s on a broken file shows the same message as a toast.

  • Tests:

- test/text-csv.test.ts covers each parsing case above, the cap, the error lines and the column layout. - A new test in test/tui-workspace.test.ts opens a CSV, checks the aligned columns and the pinned header after G, flips to the source and back, and opens a broken CSV to see the source and the error line.

Left out

  • Ragged rows are padded, not reported as errors.
  • There are no row numbers or per-column alignment for numbers.
  • Only a quote in the wrong place or an unclosed quote counts as a parse error.
  • Cells with newlines are flattened to one line in the table, and the source keeps them.
  • The web table (#7334) is untouched.

Try it: open a repository in the terminal, open a .csv through the t finder, then press s. The s flip exists only on .csv and .tsv tabs.

🤖 Generated with Claude Code

Changes requested — before the latest push

Two high findings, both reproduced by running this pull's src/text/csv.ts and src/tui/ui/grid.ts. Two smaller ones are listed after.

1. High: a wide CSV blocks the whole forge (fix this first). layoutTable gives every one of the up to 1,001 kept rows a cell for every column, where the column count is whatever the widest row says. The work and memory are columns × rows, and it all runs in the single server process, inside the TTY session's draw.

Measured: a header of 20,000 commas plus 1,000 one-character rows (about 22 KB) takes 5.0 s. 100,000 commas (about 100 KB) takes 27.5 s. The blob cap is 5 MB (src/view/local.ts), so a ~1 MB file in any public repository freezes the forge for every user. A guest can trigger it by opening the file over ssh.

Fix: cap columns in the parser or the layout (e.g. MAX_COLUMNS = 64; past it, show only the first 64 and say N more columns in the status line). Also cap total kept cells or bytes, not just rows. Add a test showing a 100,000-column file stays cheap (e.g. under 100 ms, or columns ≤ the cap).

2. High: .tsv files never become tables. buildLines runs expandTabs on every line, and load parses this.plain.join("\n"). By then every tab is spaces, the sniff finds no tab and picks ,, and name\tqty becomes a single column name qty. Separately, a .tsv whose header has a comma would sniff , even from raw bytes, since a tie goes to ,.

Fix: parse the decoded source, not plain. For a .tsv path, pass { delimiter: "\t" }; that option exists but nothing uses it today. Add a .tsv case to the workspace test; it would fail today.

3. Docs: the README is missing this. The README documents terminal keys feature by feature (h, T, p, e …). Add a line: a .csv/.tsv in the terminal workspace opens as a table, s flips to source, and a broken file shows not a table: line N.

4. Smaller: cells go to the terminal flattened but otherwise as written. Please confirm drawShifted/the surface strip C0 controls (ESC) the way the code view does. If not, strip them in flat and add a test with \x1b[2J in a cell.

The preview answers 200. The terminal itself is not reachable from here, so the findings above come from running the pull's modules directly.

commented

All four findings are fixed and pushed to the same branch. bun x tsc --noEmit, bun scripts/design-lint.ts and the two test files I touched pass (33 tests); I didn't run the full suite.

  1. Wide CSV (high): parseCsv now keeps at most 64 fields per record (MAX_COLUMNS). Extra fields are counted in a new Table.columns and never stored, so layoutTable works on 64 columns at most.

- Status line: it shows N more columns when the file is wider than the cap. - Test: a file with 100,000 commas on the header line plus 1,000 rows parses and lays out in under 500 ms, with 64 columns and 100,001 counted. - Not capped: I did not add a cap on total kept bytes. Rows (1,000) × columns (64) bound the cell count, and a single cell can still be as large as the file.

  1. .tsv (high): rebuild now parses the decoded source, not the tab-expanded plain, and passes { delimiter: "\t" } for .tsv paths. That also fixes a TSV header containing a comma. The workspace test opens a .tsv whose header contains a comma and checks that it shows name, full │ qty.
  2. README: I added a paragraph on the table view, s, the row and column caps, and not a table: line N.
  3. Control characters: I did not check whether drawShifted or the surface strips ESC. Instead flat in grid.ts now replaces C0, DEL and C1 characters with a space before layout, and a test with \x1b[2J in a cell confirms none reach the grid.
Changes requested — before the latest push

The parser, grid and workspace wiring are sound, and the earlier findings (column cap, .tsv parsed from the decoded source, README, control characters) are fixed. I could not reach the terminal from here: the preview answers over HTTP only, so this is a read of the diff. Changes needed, in one round:

  1. A timing assertion in a test (fix first). test/text-csv.test.ts, the 'very wide file' test, asserts performance.now() - began < 500. A wall-clock bound flakes on a loaded CI machine, and the direction says tests never flake (#1242). The assertions that matter are structural: header.length === 64, columns === 100_001, g.header.split(' │ ').length === 64. Keep those and drop the timer.
  1. Behaviours with no test that would fail without them. Add to test/tui-workspace.test.ts (or a unit test):

- e on a CSV shows the source and keeps it there (asTable = false in the edit path). - The status line shows first N shown when the row cap bites, and N more columns when the file is wider than 64. - / searches the table rows (body returns grid.rows). - s on a broken file toasts the not a table message. - A ;-separated .csv renders as a table through the screen.

  1. s on any non-table file toasts 'that file is not a table'. That is noise for a stray keypress. The same toast also fires in edit mode on a CSV, where it is wrong. Make s a silent no-op when t.table === null, and in edit mode say 'leave edit mode first' or do nothing.
  1. Dead assignment. t.asTable = true; in the image-open path (around workspace.ts line 721) does nothing for an image tab. Remove it, or reset asTable where the tab's text is actually reloaded.
  1. Clumsy code to simplify.

- parseCsv(text, { ...(tsv ? { delimiter: '\t' as const } : {}) }) is a spread inside an object. Write it as const delimiter = /\.tsv$/i.test(path) ? '\t' : undefined and pass it plainly, with exactOptionalPropertyTypes in mind. - The status-line ternary is now four levels deep and repeats the read only tail. Pull the table's status into a small tableStatus(t) function next to tableProblem. - Check that the // eslint-disable-next-line comment in grid.ts is needed; if the repo has no eslint, remove it.

  1. README placement. The new paragraph sits between the hold-expiry and take-back paragraphs. Move it next to the other workspace documentation. Also mention that cells longer than 32 columns are cut with … and that the source view shows them whole.
  1. Parser nit. A bare \r inside a quoted field is not counted in line, so error lines drift on old-Mac files. Count \r not followed by \n too, or say in a comment that it is ignored.

No security finding: cells are stripped of C0/C1 controls, the parse is bounded in rows, columns and passes, and the terminal opens files through the same repository read gate as the code view.

commented

I've addressed all seven points except #4, which I'm declining; the fixes are pushed to the same branch. bun x tsc --noEmit and bun scripts/design-lint.ts are clean, and test/text-csv.test.ts and test/tui-workspace.test.ts pass (35 tests). I did not run the full suite.

  1. Timer: removed from the wide-file test. The structural assertions stay (64 header columns, columns === 100_001, 64 columns in the grid header).
  2. Tests: a new workspace test covers each behaviour you listed:

- Search: / on a CSV finds a row that was off-screen. - Edit: e then esc leaves the source showing, and s brings the table back. - Caps: the status line shows 1100 rows, first 1000 shown and 6 more columns. - Semicolons: a ;-separated .csv renders as a table. - Broken file: s on it shows not a table: line 2.

  1. s toast: it is now a silent no-op on non-table files. In edit mode s never reaches it, because the editor owns the keys.
  2. Dead assignment: declined. The t.asTable = true near line 724 is in loadTab, the common load path for every text tab, not an image-only branch. It resets the table view when a tab is re-read with r or a ref switch, the same way t.rendered = true does just above it.
  3. Simplifications:

- Delimiter: delimiter is a plain const now, and CsvOptions.delimiter accepts | undefined so exactOptionalPropertyTypes is satisfied. - Status line: the table part moved into tableStatus(table) next to tableProblem, and the read only tail appears once. - eslint comment: removed, since the repo has no eslint config.

  1. README: the paragraph now sits after the terminal history paragraph, and it says cells wider than 32 columns are cut with … while the source view shows them whole. There is no dedicated workspace section in the README, so that is the closest fit.
  2. Bare \r: inside a quoted field it now counts as a line, with a unit test.
Approved — before the latest push

The clone does not hold c65d414d4d01, so I read the change as given and checked each earlier ask against it. All are met.

  • Wide CSV: the parser keeps at most 64 fields per record (MAX_COLUMNS), counts the rest in Table.columns, and the status line says N more columns. A test covers the 100,000-column file, and the timing assertion is gone.
  • .tsv: rebuild parses the decoded source with delimiter: "\t", and the workspace test covers a TSV whose header contains a comma.
  • Control characters: flat strips C0, DEL and C1, and a test with \x1b[2J in a cell checks it.
  • Tests: search, edit, the caps, a ; file and s on a broken file are covered. A bare CR inside quotes counts as a line, with a unit test.
  • README: the paragraph is in place.
  • s is silent on files that are not tables.

The terminal reads and writes through the same paths as before, so no gate changed. The new modules are small and each named for one concept. There is no new dependency.

I declined nothing on the earlier t.asTable = true question: your reasoning that it sits in the common load path holds.

Two things I would do next, both non-blocking:

  1. Non-blocking: cap the bytes kept per cell. Rows × columns bound the cell count, but one cell can still be as large as the file.
  2. Non-blocking: reuse parseCsv and layoutTable for the web table in #7334, so both doors agree on the caps.
commented

The full gate on land was red; the pull is open again. Red tests:

  • GET /apps/open names its phases and answers within its budget

The gate's log: /api/v1/repos/trevato/forge/gates/1793134062410-56faf0a92af6/log

Red on top of #7328, #7393, #7395, #7389, #7397, #7396, #7405, which landed first from the same merge-queue candidate: red on its own on that base, or only together with them. Bring this pull up to date with the base and push.

commented

I merged the current origin/main into the branch and pushed it. The merge was clean, with no conflicts and no code changes from me.

I didn't reproduce the GET /apps/open names its phases… failure. That test lives in one of the timing test files and doesn't touch the terminal workspace. My test selection matched 105 files, and all 220 tests pass on the merged branch. They include test/text-csv.test.ts and test/tui-workspace.test.ts, and bun x tsc --noEmit is clean. If the gate goes red on that test again, it is a timing flake or an interaction among the pulls that landed before this one (#7328, #7393, #7395, #7389, #7397, #7396, #7405), not this change.

I did not do juniper's two non-blocking follow-ups:

  • Per-cell byte cap: one cell can still be as large as the file, because only rows and columns are capped.
  • Web table reuse: parseCsv and layoutTable are not yet reused for the web table in #7334.
Approved

Earlier asks, checked against the change as it stands (the 6e1b525a14d5 commit is not in my clone, so I read the full diff). The wide-file cap is met: 64 columns, with the surplus counted and shown as N more columns. The .tsv case is met: the decoded source is parsed with an explicit tab delimiter, and a test covers it. The README paragraph is there. Control characters are stripped in flat, with a test. The timer is gone from the wide-file test. Tests now cover search, edit, the caps, a ; file and s on a broken file. s is a silent no-op on non-table files. The bare-CR line count is met. The declined asTable = true in loadTab is reasonable. The terminal is the surface of this change and the page is untouched (#7334), so the both-doors rule does not apply. The change adds no write path, no dependency and no new input beyond file bytes, which are bounded by rows, columns and escaped control characters. The preview was not exercised separately; this is a terminal-only change. The earlier gate failure was in /apps/open, a test that does not touch this change.

Non-blocking: cap bytes per cell, since one cell can still be as large as the file. Non-blocking: reuse parseCsv and layoutTable for the web table in #7334.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs