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

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.
This commit is contained in:
Snow Lee (Sungwon)
2026-08-11 00:22:18 +09:00
committed by GitHub
parent 70cf9818ce
commit 119d4abf79
10 changed files with 374 additions and 21 deletions
+123
View File
@@ -777,3 +777,126 @@ test("an old version of an extensionless file previews the same way", async ({ p
await page.goto(`/${pid}/sniff/LICENSE?v=${"0".repeat(64)}`);
await expect(page.locator("#content .empty")).toContainText("That version isn't available.");
});
// BEA-74: .csv/.tsv render as a table, and anything the parser can't make a
// table of stays the plain-text view it is today.
const SALES_CSV = [
`region,rep,quarter,"revenue (usd)",notes`,
`EMEA,"Ortiz, Ana",Q1,128400,steady growth in the enterprise segment`,
`APAC,"Chen, Wei",Q2,96250,"he said ""ship it"" on Friday"`,
`LATAM,"Silva, Joao",Q3,74100,"two lines\nin one cell"`,
`NA,"Baker, Sam",Q4,181900`,
``,
].join("\n");
test("csv renders as a table: quoting, embedded newlines, a ragged row", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.request.put(`/api/p/${pid}/upload/content?path=csv/sales.csv`, { data: SALES_CSV });
await page.goto(`/${pid}/csv/sales.csv`);
const table = page.locator("#content table.csvview");
await expect(table).toBeVisible();
await expect(page.locator("#content pre.plain")).toHaveCount(0);
await expect(table.locator("thead th")).toHaveCount(5);
await expect(table.locator("thead th").nth(3)).toHaveText("revenue (usd)");
await expect(table.locator("tbody tr")).toHaveCount(4);
// A quoted comma is one cell, not two.
await expect(table.locator("tbody tr").first().locator("td").nth(1)).toHaveText("Ortiz, Ana");
// "" is one literal quote.
await expect(table.locator("tbody tr").nth(1).locator("td").nth(4)).toHaveText(
'he said "ship it" on Friday',
);
// A newline inside quotes is one cell in one row, not a second row.
// textContent, not toHaveText: the latter normalizes away the very
// newline this case exists to prove survived.
expect(
await table
.locator("tbody tr")
.nth(2)
.locator("td")
.nth(4)
.evaluate((el) => el.textContent),
).toBe("two lines\nin one cell");
// The short last row keeps its columns and pads the missing one.
const last = table.locator("tbody tr").last().locator("td");
await expect(last).toHaveCount(5);
await expect(last.nth(3)).toHaveText("181900");
await expect(last.nth(4)).toHaveText("");
});
test("tsv gets the same table, by extension and not by sniffing", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.request.put(`/api/p/${pid}/upload/content?path=csv/hosts.tsv`, {
data: "host\trole\nalpha\tweb\nbeta\tdb\n",
});
await page.goto(`/${pid}/csv/hosts.tsv`);
await expect(page.locator("#content table.csvview thead th")).toHaveCount(2);
await expect(page.locator("#content table.csvview tbody tr")).toHaveCount(2);
});
test("a csv the parser can't read falls back to today's plain text", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
// Unterminated quote: not something to guess at.
await page.request.put(`/api/p/${pid}/upload/content?path=csv/broken.csv`, {
data: 'a,b\n"never closed,2\n',
});
await page.goto(`/${pid}/csv/broken.csv`);
await expect(page.locator("#content pre.plain")).toContainText("never closed");
await expect(page.locator("#content table.csvview")).toHaveCount(0);
// No delimiter at all is prose, not a one-column table.
await page.request.put(`/api/p/${pid}/upload/content?path=csv/prose.csv`, {
data: "just some prose\nover two lines\n",
});
await page.goto(`/${pid}/csv/prose.csv`);
await expect(page.locator("#content pre.plain")).toContainText("just some prose");
await expect(page.locator("#content table.csvview")).toHaveCount(0);
});
test("a past version of a csv is a table too", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
const url = `/api/p/${pid}/upload/content?path=csv/versioned.csv`;
await page.request.put(url, { data: "a,b\n1,2\n" });
await page.request.put(url, { data: "a,b\n3,4\n" });
await page.goto(`/${pid}/history/csv/versioned.csv`);
const older = page.locator(".hentry.add");
await expect(older).toBeVisible();
await older.getByRole("button", { name: /^Open .* as of/ }).click();
await page.waitForURL(new RegExp(`/${pid}/csv/versioned\\.csv\\?v=[0-9a-f]{64}$`));
await expect(page.locator("#content table.csvview tbody")).toContainText("1");
});
test("a csv past the row cap says how many rows it left out", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
const big = "n,sq\n" + Array.from({ length: 5200 }, (_, i) => `${i},${i * i}`).join("\n") + "\n";
await page.request.put(`/api/p/${pid}/upload/content?path=csv/big.csv`, { data: big });
await page.goto(`/${pid}/csv/big.csv`);
await expect(page.locator("#content table.csvview tbody tr")).toHaveCount(4999); // 5,000 incl. header
await expect(page.locator("#content .csvnote")).toContainText("showing 5,000 of 5,201 rows");
});
test("a wide csv scrolls inside its own box at 390px", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
const cols = Array.from({ length: 14 }, (_, i) => `column_heading_number_${i}`);
const wide = [cols.join(","), cols.map((_, i) => `value-${i}-with-some-length`).join(",")].join(
"\n",
);
await page.request.put(`/api/p/${pid}/upload/content?path=csv/wide.csv`, { data: wide + "\n" });
await page.setViewportSize({ width: 390, height: 780 });
await page.goto(`/${pid}/csv/wide.csv`);
const box = page.locator("#content .csvbox");
await expect(box).toBeVisible();
expect(await box.evaluate((el) => getComputedStyle(el).overflowX)).toBe("auto");
// The box takes the sideways scroll; the page body never does.
expect(await box.evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true);
expect(
await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1),
).toBe(false);
});
@@ -4,7 +4,18 @@ import { getJSON } from "../api/http";
import type { HeatMap, Node, RenderDoc } from "../api/types";
import { heatTotal, heatText } from "../hooks/useBrowse";
import { useTextAt } from "../hooks/useBlob";
import { HTML_EXT, IMG_EXT, MD_EXT, PDF_EXT, TEXT_EXT, humanSize, joinPath, whoChanged } from "../util";
import {
CSV_EXT,
HTML_EXT,
IMG_EXT,
MD_EXT,
PDF_EXT,
TEXT_EXT,
humanSize,
joinPath,
whoChanged,
} from "../util";
import { CSV_ROWS, parseDelimited, type Csv } from "../lib/csv";
export function FileView(props: {
apiBase: string;
@@ -52,6 +63,12 @@ export function FileView(props: {
if (IMG_EXT.test(path)) {
return <ImgView src={fileURL} alt={path} version={version} onRendered={props.onRendered} />;
}
// Same component as plain text on purpose: the fallback for a file the
// parser can't make a table of is then the very JSX it already renders,
// not a second code path to keep in sync.
if (CSV_EXT.test(path)) {
return <TextView {...props} fileURL={fileURL} delim={/\.tsv$/i.test(path) ? "\t" : ","} />;
}
if (TEXT_EXT.test(path)) return <TextView {...props} fileURL={fileURL} />;
// No extension we recognize: decide on the bytes instead of giving up.
return <SniffView {...props} fileURL={fileURL} />;
@@ -239,8 +256,8 @@ function LoadError({ version, err }: { version?: string; err: Error }) {
);
}
function TextView(props: Parameters<typeof FileView>[0] & { fileURL: string }) {
const { path, version, fileURL, onRendered } = props;
function TextView(props: Parameters<typeof FileView>[0] & { fileURL: string; delim?: string }) {
const { path, version, fileURL, delim, onRendered } = props;
const { data, error } = useQuery({
queryKey: ["text", fileURL],
queryFn: async () => {
@@ -253,8 +270,15 @@ function TextView(props: Parameters<typeof FileView>[0] & { fileURL: string }) {
useEffect(() => {
if (data != null) onRendered?.();
}, [data, onRendered]);
// null = not usefully delimited (or no delimiter asked for): fall through
// to the plain-text view below.
const csv = useMemo(
() => (delim && data != null ? parseDelimited(data, delim, CSV_ROWS) : null),
[data, delim],
);
if (error) return <LoadError version={version} err={error as Error} />;
if (data == null) return null;
if (csv) return <CsvTable csv={csv} key={path} />;
return (
<pre className="plain" key={path}>
{data}
@@ -262,6 +286,45 @@ function TextView(props: Parameters<typeof FileView>[0] & { fileURL: string }) {
);
}
/* Plain <table> — no sorting, filtering or search, so @tanstack/react-table
would only be weight. Every row is padded to the widest one so a ragged
row renders empty trailing cells instead of shifting its neighbours. */
function CsvTable({ csv }: { csv: Csv }) {
const [head, ...body] = csv.rows;
const cols = csv.rows.reduce((m, r) => Math.max(m, r.length), 0);
const idx = Array.from({ length: cols }, (_, i) => i);
return (
<>
<div className="csvbox">
<table className="csvview">
<thead>
<tr>
{idx.map((i) => (
<th key={i}>{head[i] ?? ""}</th>
))}
</tr>
</thead>
<tbody>
{body.map((r, i) => (
<tr key={i}>
{idx.map((j) => (
<td key={j}>{r[j] ?? ""}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{csv.truncated > 0 && (
<p className="csvnote">
showing {csv.rows.length.toLocaleString()} of{" "}
{(csv.rows.length + csv.truncated).toLocaleString()} rows Download for the rest
</p>
)}
</>
);
}
function openWikilink(target: string, flatFiles: Node[], openFile: (path: string) => void) {
const want = target.toLowerCase();
const hit =
@@ -0,0 +1,84 @@
// Run with `npm test` (node's built-in runner; node ≥ 23 strips the types).
// Excluded from tsconfig's include — it imports node: builtins, which the
// app's DOM-only lib set does not know about.
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseDelimited, CSV_ROWS } from "./csv.ts";
const rows = (text: string, delim = ",", cap = CSV_ROWS) =>
parseDelimited(text, delim, cap)?.rows;
test("plain file: first row is the header, one cell per field", () => {
assert.deepEqual(rows("a,b,c\n1,2,3\n"), [
["a", "b", "c"],
["1", "2", "3"],
]);
});
test("a trailing newline does not add a blank row", () => {
assert.equal(rows("a,b\n1,2\n")!.length, 2);
assert.equal(rows("a,b\n1,2")!.length, 2); // and neither does its absence
});
test("a quoted comma stays inside its cell", () => {
assert.deepEqual(rows('a,"b,c",d\n')![0], ["a", "b,c", "d"]);
});
test('"" is one literal quote', () => {
assert.deepEqual(rows('x,y\n"he said ""hi"" ok",2\n')![1], ['he said "hi" ok', "2"]);
});
test("a newline inside quotes is content, not a row break", () => {
const r = rows('a,b\n"two lines\nin one cell",2\n')!;
assert.equal(r.length, 2);
assert.deepEqual(r[1], ["two lines\nin one cell", "2"]);
});
test("CRLF ends a row like LF", () => {
assert.deepEqual(rows("a,b\r\n1,2\r\n"), [
["a", "b"],
["1", "2"],
]);
});
test("a short row keeps its cells — no shifting, no throw", () => {
const r = rows("a,b,c\n1,2\n")!;
assert.deepEqual(r[1], ["1", "2"]); // the view pads the missing trailing cell
});
test("a long row is not truncated either", () => {
assert.deepEqual(rows("a,b\n1,2,3\n")![1], ["1", "2", "3"]);
});
test("empty cells survive, including a trailing one", () => {
assert.deepEqual(rows("a,b,c\n1,,\n")![1], ["1", "", ""]);
});
test("rows past the cap are counted, not kept", () => {
const text = "a,b\n" + "1,2\n".repeat(10);
const out = parseDelimited(text, ",", 4)!;
assert.equal(out.rows.length, 4);
assert.equal(out.truncated, 7); // 11 rows total, 4 kept
});
test("an unterminated quote falls back to text", () => {
assert.equal(parseDelimited('a,b\n"never closed,2\n', ",", CSV_ROWS), null);
});
test("a file with no delimiter is text, not a one-column table", () => {
assert.equal(parseDelimited("just some prose\nover two lines\n", ",", CSV_ROWS), null);
assert.equal(parseDelimited("", ",", CSV_ROWS), null);
});
test("tabs: the same file with a tab delimiter", () => {
assert.deepEqual(rows("a\tb\n1\t2\n", "\t"), [
["a", "b"],
["1", "2"],
]);
// and a comma-delimited file read as TSV is not a table
assert.equal(parseDelimited("a,b\n1,2\n", "\t", CSV_ROWS), null);
});
test("a quote that is not at the start of a cell is literal", () => {
assert.deepEqual(rows('a,5" pipe,c\n')![0], ["a", '5" pipe', "c"]);
});
+61
View File
@@ -0,0 +1,61 @@
// RFC 4180-ish delimited text, parsed so the viewer can show a table instead
// of a wall of monospace. Pure so `npm test` (node's runner) can import it
// without React — same reason as lib/sniff.ts.
//
// The contract that matters: NEVER throw. `null` means "this isn't a table",
// and the caller falls back to the plain-text preview. A viewer that
// white-screens on a malformed CSV is worse than the wall it replaced.
export type Csv = {
rows: string[][];
truncated: number; // rows past the cap — counted, not kept
};
// Rows kept in the DOM. Not a final number; it only has to be stated on
// screen. Virtualization is deliberately out of scope — lower this if 5k
// rows measures badly.
export const CSV_ROWS = 5000;
export function parseDelimited(text: string, delim: string, cap = CSV_ROWS): Csv | null {
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let quoted = false;
let truncated = 0;
const endRow = () => {
row.push(cell);
cell = "";
if (rows.length < cap) rows.push(row);
else truncated++;
row = [];
};
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quoted) {
// Inside quotes a doubled quote is a literal one and a newline is
// content, not a row break.
if (c !== '"') cell += c;
else if (text[i + 1] === '"') {
cell += '"';
i++;
} else quoted = false;
continue;
}
if (c === '"' && cell === "") quoted = true;
else if (c === delim) {
row.push(cell);
cell = "";
} else if (c === "\n") endRow();
else if (c === "\r" && text[i + 1] === "\n") continue; // CRLF
else cell += c;
}
if (quoted) return null; // unterminated quote: not something to guess at
if (cell !== "" || row.length) endRow(); // last row, no trailing newline
if (!rows.length) return null;
// A file with no delimiter is not a table, it is text.
if (rows[0].length < 2) return null;
return { rows, truncated };
}
+14
View File
@@ -1059,6 +1059,20 @@ input[type="checkbox"] { accent-color: var(--accent); }
/* plain file / binary views */
pre.plain { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r-card); padding: 14px 16px; overflow-x: auto; font: 12.5px/1.6 var(--mono); color: #c6cbd3; white-space: pre-wrap; overflow-wrap: anywhere; }
/* long unbreakable lines wrap instead of blowing out the column: .page has min-width: 0 */
/* CSV/TSV table view. Cells never wrap (white-space: pre keeps a quoted
newline as a line break without turning a wide file into a tall one), so
the box takes the sideways scroll and the page body never does.
Every rule is scoped under .csvbox: the file pane carries .markdown, so
the plain `.markdown table/th/td` rules would otherwise out-specify a
bare .csvview including the 900px one that turns a table into its own
scroller, which is exactly the job .csvbox is here to do. */
.csvbox { overflow-x: auto; width: fit-content; max-width: 100%; border: 1px solid var(--border); border-radius: var(--r-card); background: var(--code-bg); }
.csvbox .csvview { display: table; max-width: none; overflow: visible; margin: 0; border-collapse: collapse; font: 12.5px/1.5 var(--mono); font-variant-numeric: tabular-nums; }
.csvbox .csvview th, .csvbox .csvview td { border-bottom: 1px solid var(--border); padding: 8px 14px; text-align: left; white-space: pre; vertical-align: top; color: #c6cbd3; font-size: 12.5px; }
.csvbox .csvview th { background: var(--surface); color: var(--text-faint); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; border-bottom-color: var(--border-2); }
.csvbox .csvview tr:last-child td { border-bottom: none; }
.csvbox .csvview tbody tr:hover td { background: rgba(255,255,255,.02); }
.csvnote { color: var(--text-faint); font-size: 12px; margin: 10px 2px 0; }
.filecard { margin-top: var(--hero-top); text-align: center; color: var(--text-dim); }
.filecard .name { font-size: 1.2em; color: var(--text); margin-bottom: .3em; }
.filecard .btn { margin-top: 14px; }
+4
View File
@@ -2,6 +2,10 @@ export const MD_EXT = /\.(md|markdown)$/i;
export const IMG_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|avif)$/i;
export const HTML_EXT = /\.html?$/i;
export const PDF_EXT = /\.pdf$/i;
// Checked BEFORE TEXT_EXT (csv is in both): a parse failure falls back to
// the same plain-text view TEXT_EXT would have given. Delimiter comes from
// the extension, never from sniffing the bytes.
export const CSV_EXT = /\.(csv|tsv)$/i;
export const TEXT_EXT =
/\.(txt|log|json|ya?ml|toml|csv|go|py|js|ts|jsx|tsx|sh|bash|zsh|rb|rs|c|h|cpp|java|kt|swift|sql|css|xml|ini|conf|env|mod|sum|jsonl)$/i;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-ClP6ITud.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C52IQv2y.css">
<script type="module" crossorigin src="/assets/index-Cmjeu7KJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7wX--nX5.css">
</head>
<body>
<div id="root"></div>