Skip to content

feat(webapp): render .csv/.tsv as a table instead of a wall of monospace (BEA-74) - #124

Open
ssowonny wants to merge 1 commit into
mainfrom
bea-74-render-and-show-csv-files-in-web-app
Open

feat(webapp): render .csv/.tsv as a table instead of a wall of monospace (BEA-74)#124
ssowonny wants to merge 1 commit into
mainfrom
bea-74-render-and-show-csv-files-in-web-app

Conversation

@ssowonny

@ssowonny ssowonny commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

TL;DR

  • A .csv in the hub used to be a wall of monospaced text. It's a table now.
  • .tsv gets the same treatment — it's new, not a redirect: today a .tsv falls through to SniffView.
  • Anything CSV does badly (unterminated quote, no delimiter at all) falls back to exactly today's plain-text view, so nothing white-screens.
  • Big files stop at 5,000 rows and say so on screen. Wide ones scroll inside their own box, not the page.
  • Known gap: no sorting, filtering or search — deliberately out of scope. Say the word and it gets re-scoped.

Before / after

Before After
desktop before desktop after
mobile before mobile after

Same file in both columns. It exercises every quoting case at once: a quoted
comma ("Ortiz, Ana" stays one cell), a doubled quote (""ship it"" renders
as "ship it"), a newline inside quotes (LATAM's two-line cell is one row),
and a short last row (NA has four fields where the header has five —
the trailing cell renders empty instead of shifting its neighbours).

The row cap, stated on screen rather than silently applied:

row cap

The fallback is the feature

A viewer that white-screens on a malformed CSV is worse than the monospace it
replaces, so the fallback is structural rather than a try/catch someone can
forget. parseDelimited returns null — never throws — and null routes to
the <pre className="plain"> that renders .csv today:

  MD -> HTML -> PDF -> IMG -> [CSV_EXT: new] -> TEXT_EXT -> SniffView
                               |
                               +-- parse ok  -> <table class="csvview">
                               +-- parse null -> <pre class="plain">   (today's view)

That <pre> is the same JSX in the same component, not a second code path
to keep in sync: TextView gained a delim prop instead of getting a sibling
component. It also inherits the two behaviours a new component would have had
to re-implement — the ["text", fileURL] query key a restore invalidates, and
the retry: version ? false : undefined a pinned ?v=<sha> version needs
(so a .csv opened from History is a table too).

null means one of two things, both intentional:

Input Result
unterminated quote at EOF plain text — not something to guess at
first row has one column plain text — a file with no delimiter is prose, not a table

What's in it

  • src/lib/csv.ts — a ~50-line character scanner over RFC 4180. No papaparse: the parser is smaller than the audit of a dependency would be. Pure, so npm test (node's runner) can import it, same reason as lib/sniff.ts.
  • util.tsCSV_EXT = /\.(csv|tsv)$/i, checked before TEXT_EXT (which still lists csv; leaving it there is what makes the fallback path unchanged). Delimiter comes from the extension, never from sniffing the bytes.
  • FileView.tsx — the new dispatch branch, TextView's delim prop, and CsvTable: a plain <table>, since with no sorting in scope @tanstack/react-table would be weight. Every row is padded to the widest one, so a ragged row in either direction renders correctly.
  • style.css.csvbox (overflow-x: auto, width: fit-content, max-width: 100%) and the .csvview treatment.

The one CSS thing worth a look

Every CSV rule is scoped under .csvbox, and that is load-bearing. The file
pane carries .markdown, so the plain .markdown table / th / td rules
out-specify a bare .csvview — including the @media (max-width: 900px) one
that turns any table into its own scroller. First cut had exactly that: two
nested scroll containers, and the outer one never moved. Measured at 390px, the
box now scrolls (scrollWidth 2924 vs clientWidth 352) and the page body
does not.

This also sidesteps the file conflict the plan flagged with BEA-70 — nothing
in the shared @media (max-width: 900px) block was touched.

Architecture changes

architecture/webapp-frontend.md: the lib class gains one member,
csv.ts parseDelimited Csv CSV_ROWS, and the components --> lib edge label
gains parseDelimited. Nothing was removed and no seam moved — csv.ts is a
peer of sniff.ts and diff.ts, not a new layer.

✅ added · unmarked = unchanged

flowchart TB
    components["<div style='text-align:left'><b>components</b><br/>FileView FolderListing FileTree<br/>HistoryView DiffView Insights<br/>...</div>"]
    lib["<div style='text-align:left'><b>lib</b> (pure, node-tested)<br/>+diff.ts splitLines lcsDiff diffText<br/>+runs.ts groupRuns runFileCount<br/>+heat.ts heatFor heatTotal heatLevel hotPathSplit<br/>+sniff.ts sniffBytes BlobText MAX_BYTES<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +csv.ts parseDelimited Csv CSV_ROWS</span><br/>+utils.ts</div>"]
    Note["csv.ts never throws.<br/>null = not a table<br/>(unterminated quote, no delimiter)<br/>and FileView falls back to pre.plain"]
    components -- "diffText groupRuns hotPathSplit placeLabels <span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ parseDelimited</span>" --> lib
    lib -.- Note
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    class Note noteBox
Loading

What was run

Check Result
go build ./..., go vet ./... pass
go test ./... pass (11 packages)
npm test 71 pass, 0 fail — 15 of them new in src/lib/csv.test.ts
npm run e2e 156 pass, 1 skipped (pre-existing) — 6 new specs in browse.spec.ts
UI evaluation drove the real hub at 1280px and 390px; screenshots above
internal/webapp/static rebuilt and committed

New e2e coverage: the quoting cases end to end, .tsv, both fallback paths,
a pinned ?v= version rendering as a table, the row-cap notice, and the 390px
scroll containment.

Deviations from the plan

One, small: the plan said to add .csvbox to the shared @media (max-width: 900px)
rule. Scoping the .csvview rules under .csvbox instead was needed anyway
(see above) and makes that line unnecessary, so the shared block is untouched.

Build session

cd $(git worktree list | grep bea-74-render-and-show-csv | awk '{print $1}') && claude --resume 63d73534-c119-423f-b7f7-f0dc6bf06da7

(only works on the machine this ran on)

…ace (BEA-74)

A .csv already previewed — as raw text in a <pre>, columns lining up only
if the file happened to be padded. It now renders as an HTML table with the
first row as a header.

The parser is a new pure lib/csv.ts (~50 lines of RFC 4180: quoted
delimiters, "" as a literal quote, newlines inside quotes), so no
papaparse. It never throws: null means "not a table" — an unterminated
quote, or a file with no delimiter at all — and the caller falls back to
the very <pre> it renders today. That fallback is structural rather than a
second code path, because TextView gained a `delim` prop instead of a new
component: it also keeps the ["text", fileURL] query key a restore
invalidates and the retry:false a pinned ?v= version needs.

.tsv is new here — it used to fall through to SniffView and render as
text. The delimiter comes from the extension, never from sniffing.

Big files are capped at 5,000 rows with the count stated on screen
(virtualization is out of scope). Wide files scroll inside .csvbox, whose
rules are scoped under that class on purpose: the file pane carries
.markdown, and the plain .markdown table rules — including the ≤900px one
that turns a table into its own scroller — would otherwise out-specify a
bare .csvview and give the page two nested scrollers.

Not doing: sorting, filtering, search, editing, XLSX.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant