Public: anyone can read this; the crew can change it.
Files render in the browser: CSV/TSV as a real table — sticky header, sort, filter, row count #7388
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7334-files-render-in-the-browser-csv-tsv-as-a → main 11 commits fork 5 threads · 5 unresolved
README.md +1
| @@ -426,6 +426,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 426 | 426 | - [](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there. The home composer keeps your draft across a reload. |
| 427 | 427 | - [](docs/PULLS.md) — resolving a conflicted pull without a clone. |
| 428 | 428 | - [](docs/GIT.md) — cloning over HTTPS and SSH, and partial clones of big repositories. |
| 429 | - [](docs/features/csv-view.md) — open a `.csv` or `.tsv` in the code view to read it as a table; `?all=1` draws more rows, `?view=source` shows the text. The terminal has its own table view for them (see the `.csv` paragraph above). | |
| 429 | 430 | - [](docs/features/file-media.md) — a file in the browser: every file page has **Raw** and **Download** (any size), and `/:owner/:name/media/:ref/*path` serves images, audio, video and PDFs under their real type, sandboxed, with byte ranges. |
| 430 | 431 | - [](docs/ROLES.md) — guest, member and admin, and what each may do. |
| 431 | 432 | - [](docs/MCP.md) — the forge as a connector; [](docs/STANDARDS.md) is what it answers by default: `.well-known`, feeds, cards, the manifest. |
docs/design/areas/csv.md added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | # CSV view | |
| 2 | ||
| 3 | New classes, all in `src/web/style/content.ts`, drawn from existing tokens: | |
| 4 | `.csv-view` (the card around a table), `.csv-tools` (filter row, shown only when script runs), | |
| 5 | `.csv-scroll` (the scrolling box that holds the sticky header), `.csv-sort` (the real button inside each sortable header), `.csv-more` (the "Showing N of M rows" line). | |
| 6 | The table itself is the existing `.table` with `data-dense` and `data-sticky-lead`. |
docs/features/csv-view.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # CSV and TSV as a table | |
| 2 | ||
| 3 | `.csv`/`.tsv` files open as a table on the blob page (`src/web/render/csv.ts`, parser in `csv-parse.ts`). | |
| 4 | RFC 4180 with quotes, embedded newlines, doubled quotes and a BOM; the delimiter (`,` `;` tab `|`) is detected from the first record. | |
| 5 | The first 1,000 rows are drawn on the server with a sticky header and (on phones) a sticky first column; numeric columns are right-aligned. | |
| 6 | `?all=1` ("Show all N rows", no JavaScript needed) draws up to 20,000. The header line shows rows × columns and the delimiter. | |
| 7 | A small script adds sort (header click or Enter) and a filter box over the rows drawn; no library. | |
| 8 | A file that is not valid CSV shows the source with a one-line reason. `?view=source`, Raw and Download work as for any file. |
src/web/pages/repo.ts +19 −4
| @@ -71,6 +71,7 @@ import { field, readForm } from "../form.ts"; | ||
| 71 | 71 | import { html, when, type Html } from "../html.ts"; |
| 72 | 72 | import { flashTo, fragment, page, redirect } from "../layout.ts"; |
| 73 | 73 | import { renderMarkdown } from "../markdown.ts"; |
| 74 | import { csvJs, isCsvPath } from "../render/csv.ts"; | |
| 74 | 75 | import { HEAD_BYTES } from "../render/kinds.ts"; |
| 75 | 76 | import { rendererFor } from "../render/renderers.ts"; |
| 76 | 77 | import { mediaHref } from "./media.ts"; |
| @@ -1027,14 +1028,21 @@ export function renderBlob( | ||
| 1027 | 1028 | // a kind with no renderer yet, or one over its cap, keeps the source view. |
| 1028 | 1029 | const renderer = rendererFor(o.path, blob); |
| 1029 | 1030 | const view: BlobView = renderer ? (o.view ?? "rendered") : "source"; |
| 1030 | const { content, lines, summary } = | |
| 1031 | const drawn = | |
| 1031 | 1032 | view === "rendered" && renderer |
| 1032 | 1033 | ? renderer.render({ |
| 1033 | 1034 | path: o.path, |
| 1034 | 1035 | size: blob.size, |
| 1035 | 1036 | body: renderer.body, |
| 1036 | 1037 | media: mediaHref(base, o.ref, o.path), |
| 1038 | pathHref: `${base}/blob/${encoded}/${o.path}`, | |
| 1039 | all: o.all === true, | |
| 1037 | 1040 | }) |
| 1041 | : undefined; | |
| 1042 | const fellBack = drawn?.fallback; | |
| 1043 | const { content, lines, summary } = | |
| 1044 | drawn !== undefined && fellBack === undefined | |
| 1045 | ? drawn | |
| 1038 | 1046 | : { ...blobBody(base, o.ref, o.path, blob, o.highlighted, o.all), summary: undefined }; |
| 1039 | 1047 | const sizeText = |
| 1040 | 1048 | blob.kind === "too_large" ? "" : sizeWithTokens(blob.size, o.path); |
| @@ -1066,12 +1074,12 @@ export function renderBlob( | ||
| 1066 | 1074 | { |
| 1067 | 1075 | label: "Rendered", |
| 1068 | 1076 | href: fileHref, |
| 1069 | current: view === "rendered", | |
| 1077 | current: view === "rendered" && fellBack === undefined, | |
| 1070 | 1078 | }, |
| 1071 | 1079 | { |
| 1072 | 1080 | label: "Source", |
| 1073 | 1081 | href: `${fileHref}?view=source`, |
| 1074 | current: view === "source", | |
| 1082 | current: view === "source" || fellBack !== undefined, | |
| 1075 | 1083 | }, |
| 1076 | 1084 | ])}</span |
| 1077 | 1085 | >`, |
| @@ -1095,6 +1103,10 @@ export function renderBlob( | ||
| 1095 | 1103 | })} |
| 1096 | 1104 | ${editAction(scope, o.ref, o.path)} |
| 1097 | 1105 | </div> |
| 1106 | ${when( | |
| 1107 | fellBack !== undefined, | |
| 1108 | () => html`<p class="muted small mb">Showing the source: ${fellBack}</p>`, | |
| 1109 | )} | |
| 1098 | 1110 | ${content} |
| 1099 | 1111 | </div>`; |
| 1100 | 1112 | } |
| @@ -1350,6 +1362,8 @@ export function codePage( | ||
| 1350 | 1362 | content: Html; |
| 1351 | 1363 | header?: Html; |
| 1352 | 1364 | before?: Html; |
| 1365 | /** Script only some files need (the CSV table's sort and filter). */ | |
| 1366 | extraJs?: string; | |
| 1353 | 1367 | }, |
| 1354 | 1368 | ): Response { |
| 1355 | 1369 | return page({ |
| @@ -1360,7 +1374,7 @@ export function codePage( | ||
| 1360 | 1374 | body: html`${o.header ?? scopeHeader(ctx, scope, "code", { compact: true })} |
| 1361 | 1375 | ${o.before ?? null} ${codeLayout(o.explorer, o.content)} |
| 1362 | 1376 | ${codeMount(scope, o.ref)} ${finderDialog()}`, |
| 1363 | scriptJs: codeJs, | |
| 1377 | scriptJs: o.extraJs === undefined ? codeJs : `${codeJs}\n${o.extraJs}`, | |
| 1364 | 1378 | wide: true, |
| 1365 | 1379 | }); |
| 1366 | 1380 | } |
| @@ -1487,6 +1501,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1487 | 1501 | ), |
| 1488 | 1502 | }), |
| 1489 | 1503 | content: pane, |
| 1504 | ...(isCsvPath(path) ? { extraJs: csvJs } : {}), | |
| 1490 | 1505 | }); |
| 1491 | 1506 | notePhase(ctx.request, "render", paneMs + performance.now() - pageStart); |
| 1492 | 1507 | return response; |
src/web/render/csv-parse.ts added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | /** | |
| 2 | * RFC 4180 as plain data: text in, rows out. Quoted fields, embedded | |
| 3 | * newlines, doubled quotes, a leading BOM, CRLF or LF. The delimiter is | |
| 4 | * detected from the first record. A file that cannot be read as a table says | |
| 5 | * why in one line instead of guessing. | |
| 6 | */ | |
| 7 | ||
| 8 | export const DELIMITERS = [",", ";", "\t", "|"] as const; | |
| 9 | export type Delimiter = (typeof DELIMITERS)[number]; | |
| 10 | ||
| 11 | export interface Table { | |
| 12 | readonly delimiter: Delimiter; | |
| 13 | /** The first `rows` records, each cut to `columns` fields. */ | |
| 14 | readonly rows: string[][]; | |
| 15 | /** The widest record in the whole file, kept or not. */ | |
| 16 | readonly columns: number; | |
| 17 | /** Every record in the file, the header included. */ | |
| 18 | readonly records: number; | |
| 19 | } | |
| 20 | ||
| 21 | export type Parsed = Table | { readonly error: string }; | |
| 22 | ||
| 23 | /** What to keep. The rest of the file is still scanned, only counted. */ | |
| 24 | export interface Limits { | |
| 25 | /** Records kept whole (default: all). */ | |
| 26 | readonly rows?: number; | |
| 27 | /** Fields kept per record (default: all). */ | |
| 28 | readonly columns?: number; | |
| 29 | readonly delimiter?: Delimiter; | |
| 30 | } | |
| 31 | ||
| 32 | /** Each candidate delimiter's count in the first record, quotes respected. */ | |
| 33 | export function detectDelimiter(text: string): Delimiter { | |
| 34 | const counts = new Map<string, number>(); | |
| 35 | let quoted = false; | |
| 36 | for (let i = 0; i < text.length; i++) { | |
| 37 | const c = text[i]!; | |
| 38 | if (c === '"') quoted = !quoted; | |
| 39 | else if (!quoted && (c === "\n" || c === "\r")) break; | |
| 40 | else if (!quoted) counts.set(c, (counts.get(c) ?? 0) + 1); | |
| 41 | } | |
| 42 | let best: Delimiter = ","; | |
outdated
MEDIUM: this builds every field of a 10 MB file on every page view, about 400 ms for 400k rows × 5 columns. Accept a row limit: keep fields for the first N records and only count the rest. |
||
| 43 | let most = 0; | |
| 44 | for (const d of DELIMITERS) { | |
| 45 | const n = counts.get(d) ?? 0; | |
| 46 | if (n > most) { | |
| 47 | best = d; | |
| 48 | most = n; | |
| 49 | } | |
| 50 | } | |
| 51 | return best; | |
| 52 | } | |
| 53 | ||
| 54 | export function parseCsv(input: string, limits: Limits = {}): Parsed { | |
| 55 | const text = input.charCodeAt(0) === 0xfeff ? input.slice(1) : input; | |
| 56 | const delimiter = limits.delimiter ?? detectDelimiter(text); | |
| 57 | const keepRows = limits.rows ?? Infinity; | |
| 58 | const keepCols = limits.columns ?? Infinity; | |
| 59 | const rows: string[][] = []; | |
| 60 | let row: string[] = []; | |
| 61 | let fields = 0; | |
| 62 | let records = 0; | |
| 63 | let columns = 0; | |
| 64 | let i = 0; | |
| 65 | const n = text.length; | |
| 66 | const keeping = (): boolean => records < keepRows; | |
| 67 | const addField = (value: string): void => { | |
| 68 | if (keeping() && fields < keepCols) row.push(value); | |
| 69 | fields++; | |
| 70 | }; | |
| 71 | const endRow = (): void => { | |
| 72 | if (fields > columns) columns = fields; | |
| 73 | if (keeping()) rows.push(row); | |
| 74 | records++; | |
| 75 | row = []; | |
| 76 | fields = 0; | |
| 77 | }; | |
| 78 | let field = ""; | |
| 79 | while (i < n) { | |
| 80 | const c = text[i]!; | |
| 81 | if (c === '"' && field === "") { | |
| 82 | // A quoted field runs to its closing quote; "" inside is one quote. | |
| 83 | const wanted = keeping() && fields < keepCols; | |
| 84 | i++; | |
| 85 | for (;;) { | |
| 86 | const close = text.indexOf('"', i); | |
| 87 | if (close === -1) return { error: "A quoted field is never closed." }; | |
| 88 | if (wanted) field += text.slice(i, close); | |
| 89 | if (text[close + 1] === '"') { | |
| 90 | if (wanted) field += '"'; | |
| 91 | i = close + 2; | |
| 92 | continue; | |
| 93 | } | |
| 94 | i = close + 1; | |
| 95 | break; | |
| 96 | } | |
| 97 | const after = text[i]; | |
| 98 | if (after !== undefined && after !== delimiter && after !== "\n" && after !== "\r") | |
| 99 | return { error: `Text follows a closing quote on row ${records + 1}.` }; | |
| 100 | } else if (c === delimiter) { | |
| 101 | addField(field); | |
| 102 | field = ""; | |
| 103 | i++; | |
| 104 | } else if (c === "\n" || c === "\r") { | |
| 105 | addField(field); | |
| 106 | field = ""; | |
| 107 | endRow(); | |
| 108 | i += c === "\r" && text[i + 1] === "\n" ? 2 : 1; | |
| 109 | } else { | |
| 110 | // Unquoted run up to the next special character. | |
| 111 | let j = i + 1; | |
| 112 | while (j < n) { | |
| 113 | const d = text[j]!; | |
| 114 | if (d === delimiter || d === "\n" || d === "\r") break; | |
| 115 | j++; | |
| 116 | } | |
| 117 | if (keeping() && fields < keepCols) field += text.slice(i, j); | |
| 118 | i = j; | |
| 119 | } | |
| 120 | } | |
| 121 | // A final record with no newline; a trailing newline adds none. | |
| 122 | if (n > 0 && text[n - 1] !== "\n" && text[n - 1] !== "\r") { | |
| 123 | addField(field); | |
| 124 | endRow(); | |
| 125 | } | |
| 126 | if (records === 0 || columns === 0) return { error: "The file has no rows." }; | |
| 127 | return { delimiter, rows, columns, records }; | |
| 128 | } | |
| 129 | ||
| 130 | const NUMBER = /^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i; | |
| 131 | ||
| 132 | /** True for each column whose every non-empty cell, header aside, is a number. */ | |
| 133 | export function numericColumns(table: Table, columns = table.columns): boolean[] { | |
| 134 | const out: boolean[] = []; | |
| 135 | const end = table.rows.length; | |
| 136 | for (let c = 0; c < Math.min(columns, table.columns); c++) { | |
| 137 | let seen = 0; | |
| 138 | let ok = true; | |
| 139 | for (let r = 1; r < end && ok; r++) { | |
| 140 | const cell = (table.rows[r]![c] ?? "").trim(); | |
| 141 | if (cell === "") continue; | |
| 142 | seen++; | |
| 143 | ok = NUMBER.test(cell); | |
| 144 | } | |
| 145 | out.push(ok && seen > 0); | |
| 146 | } | |
| 147 | return out; | |
| 148 | } |
src/web/render/csv.ts added +165
| @@ -0,0 +1,165 @@ | ||
| 1 | /** | |
| 2 | * A CSV or TSV file as a table: the first thousand rows drawn on the server, | |
| 3 | * a sticky header, numbers right-aligned. Without JavaScript `?all=1` draws | |
| 4 | * more, up to a cap; the script below adds sort and filter and nothing else. | |
| 5 | */ | |
| 6 | ||
| 7 | import { escapeHtml, html, raw } from "../html.ts"; | |
| 8 | import { fileSize } from "../ui.ts"; | |
| 9 | import { numericColumns, parseCsv, type Delimiter } from "./csv-parse.ts"; | |
| 10 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 11 | ||
| 12 | /** Rows drawn by default, and by `?all=1` at most. */ | |
| 13 | export const ROW_WINDOW = 1_000; | |
| 14 | export const ROW_CAP = 20_000; | |
| 15 | /** Columns drawn, and cells (rows × columns) drawn, whatever the file holds. */ | |
| 16 | export const COLUMN_CAP = 200; | |
| 17 | export const CELL_CAP = 200_000; | |
| 18 | ||
| 19 | const NAMES: Record<Delimiter, string> = { | |
| 20 | ",": "comma", | |
| 21 | ";": "semicolon", | |
| 22 | "\t": "tab", | |
| 23 | "|": "pipe", | |
| 24 | }; | |
| 25 | ||
| 26 | const cells = (tag: "th" | "td", row: string[], width: number, numeric: boolean[]): string => { | |
| 27 | let out = ""; | |
| 28 | for (let c = 0; c < width; c++) { | |
| 29 | const v = row[c] ?? ""; | |
| 30 | const cls = numeric[c] ? ' class="num"' : ""; | |
| 31 | out += `<${tag}${cls}>${escapeHtml(v)}</${tag}>`; | |
| 32 | } | |
| 33 | return out; | |
| 34 | }; | |
| 35 | ||
| 36 | export function csv(input: RenderInput): Rendered { | |
| 37 | const want = input.all ? ROW_CAP : ROW_WINDOW; | |
outdated
HIGH (same cause): |
||
| 38 | const parsed = parseCsv(new TextDecoder().decode(input.body), { | |
| 39 | rows: want + 1, | |
Non-blocking: for a wide file with |
||
| 40 | columns: COLUMN_CAP, | |
| 41 | }); | |
| 42 | if ("error" in parsed) return { content: html``, lines: 0, fallback: parsed.error }; | |
| 43 | const total = parsed.records - 1; | |
outdated
HIGH: every drawn row is padded to |
||
| 44 | const width = Math.min(parsed.columns, COLUMN_CAP); | |
| 45 | const numeric = numericColumns(parsed, width); | |
| 46 | // A wide table shrinks its row window so the page stays bounded. | |
| 47 | const shown = Math.min(total, want, Math.floor(CELL_CAP / width)); | |
| 48 | const [head, ...rest] = parsed.rows; | |
| 49 | let body = ""; | |
| 50 | for (let r = 0; r < shown; r++) body += `<tr>${cells("td", rest[r]!, width, numeric)}</tr>`; | |
| 51 | const href = input.pathHref ?? ""; | |
| 52 | const wide = | |
| 53 | parsed.columns > width | |
| 54 | ? `Showing ${width.toLocaleString()} of ${parsed.columns.toLocaleString()} columns. ` | |
| 55 | : ""; | |
| 56 | const more = | |
| 57 | shown >= total && wide === "" | |
| 58 | ? "" | |
| 59 | : `<p class="muted small csv-more">${wide}${ | |
| 60 | shown >= total ? "" : `Showing ${shown.toLocaleString()} of ${total.toLocaleString()} rows. ` | |
| 61 | }${ | |
| 62 | shown >= total | |
| 63 | ? "" | |
| 64 | : !input.all && shown === Math.min(total, want) | |
| 65 | ? `<a id="csv-all" href="${escapeHtml(href)}?all=1">Show all ${total.toLocaleString()} rows</a>${ | |
| 66 | total > ROW_CAP ? ` (the page draws at most ${ROW_CAP.toLocaleString()})` : "" | |
| 67 | }` | |
| 68 | : "The rest is in the raw file." | |
| 69 | }</p>`; | |
| 70 | const content = html`<div class="card csv-view" data-csv> | |
| 71 | <div class="csv-tools" hidden> | |
| 72 | <input class="input" type="search" data-csv-filter placeholder="Filter rows" aria-label="Filter rows" autocomplete="off" /> | |
| 73 | <span class="muted small num" data-csv-count aria-live="polite"></span> | |
| 74 | </div> | |
| 75 | <div class="csv-scroll"> | |
| 76 | <table class="table" data-dense data-sticky-lead aria-label="${input.path.slice(input.path.lastIndexOf("/") + 1)} data"> | |
| 77 | <thead><tr>${raw(cells("th", head!, width, numeric))}</tr></thead> | |
| 78 | <tbody>${raw(body)}</tbody> | |
| 79 | </table> | |
| 80 | </div> | |
| 81 | ${raw(more)} | |
| 82 | </div>`; | |
| 83 | return { | |
| 84 | content, | |
| 85 | lines: 0, | |
| 86 | summary: `${total.toLocaleString()} rows × ${parsed.columns.toLocaleString()} columns · ${NAMES[parsed.delimiter]}-separated · ${fileSize(input.size)}`, | |
| 87 | }; | |
| 88 | } | |
| 89 | ||
| 90 | /** Whether a path can hold a table, so only those pages carry the script below. */ | |
| 91 | export const isCsvPath = (path: string): boolean => /\.(csv|tsv)$/i.test(path); | |
| 92 | ||
| 93 | /** Sort on a header click, filter on typing. Trusted static code. */ | |
| 94 | export const csvJs = /* js */ ` | |
| 95 | (function () { | |
| 96 | var doc = document; | |
| 97 | function setup(box) { | |
| 98 | if (box.__csv) return; | |
| 99 | box.__csv = 1; | |
| 100 | var tools = box.querySelector('.csv-tools'); | |
| 101 | if (tools) tools.hidden = false; | |
| 102 | var heads = box.querySelectorAll('thead th'); | |
| 103 | for (var i = 0; i < heads.length; i++) { | |
| 104 | var th = heads[i]; | |
| 105 | var btn = doc.createElement('button'); | |
| 106 | btn.type = 'button'; | |
| 107 | btn.className = 'csv-sort'; | |
| 108 | while (th.firstChild) btn.appendChild(th.firstChild); | |
| 109 | th.appendChild(btn); | |
| 110 | th.setAttribute('aria-sort', 'none'); | |
| 111 | } | |
| 112 | } | |
| 113 | function sort(th) { | |
| 114 | var box = th.closest('[data-csv]'); | |
| 115 | var tbody = box.querySelector('tbody'); | |
| 116 | var idx = Array.prototype.indexOf.call(th.parentNode.children, th); | |
| 117 | var num = th.classList.contains('num'); | |
| 118 | var dir = th.getAttribute('aria-sort') === 'ascending' ? -1 : 1; | |
| 119 | var all = th.parentNode.children; | |
| 120 | for (var k = 0; k < all.length; k++) all[k].setAttribute('aria-sort', 'none'); | |
| 121 | th.setAttribute('aria-sort', dir === 1 ? 'ascending' : 'descending'); | |
| 122 | var rows = Array.prototype.slice.call(tbody.rows); | |
| 123 | var keys = rows.map(function (r) { | |
| 124 | var t = r.cells[idx] ? r.cells[idx].textContent : ''; | |
| 125 | return num ? (t.trim() === '' ? null : parseFloat(t)) : t.toLowerCase(); | |
| 126 | }); | |
| 127 | var order = rows.map(function (_, j) { return j; }); | |
| 128 | order.sort(function (a, b) { | |
| 129 | var x = keys[a], y = keys[b]; | |
| 130 | if (x === null || y === null) return x === y ? a - b : x === null ? 1 : -1; | |
| 131 | return (x < y ? -1 : x > y ? 1 : a - b) * dir; | |
| 132 | }); | |
| 133 | var frag = doc.createDocumentFragment(); | |
| 134 | for (var m = 0; m < order.length; m++) frag.appendChild(rows[order[m]]); | |
| 135 | tbody.appendChild(frag); | |
| 136 | } | |
| 137 | function filter(input) { | |
| 138 | var box = input.closest('[data-csv]'); | |
| 139 | var q = input.value.toLowerCase(); | |
| 140 | var rows = box.querySelector('tbody').rows; | |
| 141 | var shown = 0; | |
| 142 | for (var i = 0; i < rows.length; i++) { | |
| 143 | var hit = q === '' || rows[i].textContent.toLowerCase().indexOf(q) !== -1; | |
| 144 | rows[i].hidden = !hit; | |
| 145 | if (hit) shown++; | |
| 146 | } | |
| 147 | var out = box.querySelector('[data-csv-count]'); | |
| 148 | if (out) out.textContent = q === '' ? '' : shown + ' of ' + rows.length + ' rows'; | |
| 149 | } | |
| 150 | function scan() { | |
| 151 | var boxes = doc.querySelectorAll('[data-csv]'); | |
| 152 | for (var i = 0; i < boxes.length; i++) setup(boxes[i]); | |
| 153 | } | |
| 154 | doc.addEventListener('click', function (e) { | |
| 155 | var b = e.target.closest && e.target.closest('[data-csv] .csv-sort'); | |
| 156 | if (b) sort(b.parentNode); | |
| 157 | }); | |
| 158 | doc.addEventListener('input', function (e) { | |
| 159 | if (e.target.matches && e.target.matches('[data-csv-filter]')) filter(e.target); | |
| 160 | }); | |
| 161 | scan(); | |
| 162 | // The code view swaps its pane in place and says so. | |
| 163 | doc.addEventListener('code:swap', scan); | |
| 164 | })(); | |
| 165 | `; |
src/web/render/renderers.ts +8
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import type { Html } from "../html.ts"; |
| 8 | 8 | import type { Blob } from "../../view/source.ts"; |
| 9 | 9 | import { audio } from "./audio.ts"; |
| 10 | import { csv } from "./csv.ts"; | |
| 10 | 11 | import { image } from "./image.ts"; |
| 11 | 12 | import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts"; |
| 12 | 13 | import { markdown } from "./markdown.ts"; |
| @@ -23,6 +24,10 @@ export interface RenderInput { | ||
| 23 | 24 | readonly body: Uint8Array; |
| 24 | 25 | /** `/:owner/:name/media/:ref/*path`, for `<img>`, `<video>` and `fetch()`. */ |
| 25 | 26 | readonly media: string; |
| 27 | /** The file page's own address, for a link back to it (`?all=1`). */ | |
| 28 | readonly pathHref?: string; | |
| 29 | /** `?all=1`: draw past the default window, up to the renderer's cap. */ | |
| 30 | readonly all?: boolean; | |
| 26 | 31 | } |
| 27 | 32 | |
| 28 | 33 | export interface Rendered { |
| @@ -31,6 +36,8 @@ export interface Rendered { | ||
| 31 | 36 | readonly lines: number; |
| 32 | 37 | /** The header's facts in place of the line count, as `1.2 MB · WebM`. */ |
| 33 | 38 | readonly summary?: string; |
| 39 | /** Set when the file cannot be drawn: the source view shows, with this reason. */ | |
| 40 | readonly fallback?: string; | |
| 34 | 41 | } |
| 35 | 42 | |
| 36 | 43 | export type Renderer = (input: RenderInput) => Rendered; |
| @@ -39,6 +46,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 39 | 46 | image, |
| 40 | 47 | svg: image, |
| 41 | 48 | markdown, |
| 49 | csv, | |
| 42 | 50 | audio, |
| 43 | 51 | video, |
| 44 | 52 | }; |
src/web/style/content.ts +18
| @@ -247,6 +247,24 @@ form.inline { display: inline; margin-left: auto; } | ||
| 247 | 247 | } |
| 248 | 248 | .code tr:target .ln, .code tr[data-hl] .ln { color: var(--attn); } |
| 249 | 249 | |
| 250 | /* ---- csv view: the header stays under the bar, the first column on phones ---- */ | |
| 251 | .csv-tools { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-bottom: 1px solid var(--border); } | |
| 252 | .csv-tools[hidden], .csv-view tr[hidden] { display: none; } | |
| 253 | .csv-tools .input { max-width: 260px; } | |
| 254 | .csv-scroll { overflow: auto; max-height: 75vh; scrollbar-width: thin; } | |
| 255 | .csv-view th { position: sticky; top: 0; z-index: 2; background: var(--card); user-select: none; } | |
| 256 | .csv-sort { | |
| 257 | font: inherit; color: inherit; background: none; border: 0; padding: 0; | |
| 258 | text-align: inherit; cursor: pointer; | |
| 259 | } | |
| 260 | .csv-sort:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; } | |
| 261 | .csv-view th:first-child { z-index: 3; } | |
| 262 | .csv-view td { max-width: 40ch; overflow-wrap: anywhere; } | |
| 263 | .csv-view th[aria-sort="ascending"]::after { content: " \\25B2"; font-size: 12.5px; } | |
| 264 | .csv-view th[aria-sort="descending"]::after { content: " \\25BC"; font-size: 12.5px; } | |
| 265 | .csv-view .num { text-align: right; } | |
| 266 | .csv-more { padding: 10px 12px; margin: 0; } | |
| 267 | ||
| 250 | 268 | /* ---- syntax tokens (colors come from the active theme) ---- |
| 251 | 269 | Two layers, exactly as the tokenizer emits them: a colour class every |
| 252 | 270 | consumer understands, and — where the web can say something finer — a kind |
test/render-csv.test.ts added +150
| @@ -0,0 +1,150 @@ | ||
| 1 | /** CSV/TSV as a table: the parser, the shape of the page, and the budget. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { parseCsv, numericColumns, type Table } from "../src/web/render/csv-parse.ts"; | |
| 4 | import { COLUMN_CAP, csv, csvJs, isCsvPath, ROW_CAP } from "../src/web/render/csv.ts"; | |
| 5 | import { fileKind } from "../src/web/render/kinds.ts"; | |
| 6 | import { renderBlob } from "../src/web/pages/repo.ts"; | |
| 7 | import type { RepoScope } from "../src/web/scope.ts"; | |
| 8 | import { RENDERERS } from "../src/web/render/renderers.ts"; | |
| 9 | ||
| 10 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 11 | const ok = (s: string) => { | |
| 12 | const t = parseCsv(s); | |
| 13 | if ("error" in t) throw new Error(t.error); | |
| 14 | return t; | |
| 15 | }; | |
| 16 | const draw = (text: string, all = false) => | |
| 17 | csv({ path: "d.csv", size: text.length, body: enc(text), media: "/m", pathHref: "/o/r/blob/main/d.csv", all }); | |
| 18 | ||
| 19 | describe("csv parser", () => { | |
| 20 | test("quotes, escaped quotes and newline inside a field", () => { | |
| 21 | const t = ok('a,b\r\n"x, y","say ""hi"""\r\n"line1\nline2",z\r\n'); | |
| 22 | expect(t.rows).toEqual([["a", "b"], ["x, y", 'say "hi"'], ["line1\nline2", "z"]]); | |
| 23 | }); | |
| 24 | test("a BOM is dropped and the last row needs no newline", () => { | |
| 25 | expect(ok("a,b\n1,2").rows).toEqual([["a", "b"], ["1", "2"]]); | |
| 26 | }); | |
| 27 | test("delimiters: semicolon, tab, pipe, and a comma inside quotes", () => { | |
| 28 | expect(ok("a;b\n1;2\n").delimiter).toBe(";"); | |
| 29 | expect(ok("a\tb\n1\t2\n").delimiter).toBe("\t"); | |
| 30 | expect(ok("a|b|c\n1|2|3\n").delimiter).toBe("|"); | |
| 31 | expect(ok('"a,b,c";d\n1;2\n').delimiter).toBe(";"); | |
| 32 | }); | |
| 33 | test("malformed input says why", () => { | |
| 34 | expect(parseCsv('a,b\n"open,1\n')).toEqual({ error: "A quoted field is never closed." }); | |
| 35 | expect("error" in parseCsv('a,"b"x\n')).toBe(true); | |
| 36 | expect("error" in parseCsv("")).toBe(true); | |
| 37 | }); | |
| 38 | test("a numeric column is detected, a mixed one is not", () => { | |
| 39 | const t = ok("n,s,m\n1,a,1\n2.5,b,x\n-3e2,c,\n") as Table; | |
| 40 | expect(numericColumns(t)).toEqual([true, false, false]); | |
| 41 | }); | |
| 42 | }); | |
| 43 | ||
| 44 | describe("csv bounds", () => { | |
| 45 | test("one very wide row cannot multiply the page", () => { | |
| 46 | const text = "h\n" + "x\n".repeat(1_000) + ",".repeat(100_000) + "\n"; | |
| 47 | const r = csv({ path: "w.csv", size: text.length, body: enc(text), media: "/m", pathHref: "/p", all: false }); | |
| 48 | const h = String(r.content); | |
| 49 | expect(h.length).toBeLessThan(2_000_000); | |
| 50 | expect(h).toContain(`Showing ${COLUMN_CAP} of 100,001 columns`); | |
| 51 | expect(r.summary).toContain("100,001 columns"); | |
| 52 | }); | |
| 53 | test("the parser keeps only the window's rows but counts them all", () => { | |
| 54 | const text = "a,b\n" + "1,2\n".repeat(5_000); | |
| 55 | const t = ok(text); | |
| 56 | expect(t.records).toBe(5_001); | |
| 57 | const cut = parseCsv(text, { rows: 11, columns: 1 }); | |
| 58 | if ("error" in cut) throw new Error(cut.error); | |
| 59 | expect(cut.rows.length).toBe(11); | |
| 60 | expect(cut.rows[3]).toEqual(["1"]); | |
| 61 | expect(cut.records).toBe(5_001); | |
| 62 | expect(cut.columns).toBe(2); | |
| 63 | }); | |
| 64 | test("the tail is still checked for an unclosed quote", () => { | |
| 65 | expect("error" in parseCsv("a\n1\n2\n\"x", { rows: 1 })).toBe(true); | |
| 66 | }); | |
| 67 | }); | |
| 68 | ||
| 69 | describe("csv page", () => { | |
| 70 | test("shape: header, counts, delimiter, right-aligned numbers, escaping", () => { | |
| 71 | const r = draw("name,n\n<b>,1\nc,22\n"); | |
| 72 | const h = String(r.content); | |
| 73 | expect(r.summary).toBe("2 rows × 2 columns · comma-separated · 18 B"); | |
| 74 | expect(h).toContain("<thead><tr><th>name</th><th class=\"num\">n</th>"); | |
| 75 | expect(h).toContain("<td><b></td>"); | |
| 76 | expect(h).toContain('<td class="num">22</td>'); | |
| 77 | expect(h).toContain("data-csv-filter"); | |
| 78 | expect(h).not.toContain("csv-all"); | |
| 79 | }); | |
| 80 | test("50k rows: the first 1,000 drawn, quickly, with a no-JS link", () => { | |
| 81 | const text = "id,v\n" + Array.from({ length: 50_000 }, (_, i) => `${i},row ${i}`).join("\n"); | |
| 82 | const h = String(draw(text).content); | |
| 83 | expect(h.split("<tr>").length - 1).toBe(1_001); | |
| 84 | expect(h).toContain("Show all 50,000 rows"); | |
| 85 | expect(h).toContain('href="/o/r/blob/main/d.csv?all=1"'); | |
| 86 | const all = String(draw(text, true).content); | |
| 87 | expect(all.split("<tr>").length - 1).toBe(ROW_CAP + 1); | |
| 88 | expect(all).not.toContain("Show all"); | |
| 89 | }); | |
| 90 | test("a malformed file falls back with a reason", () => { | |
| 91 | expect(draw('a,b\n"x').fallback).toBe("A quoted field is never closed."); | |
| 92 | }); | |
| 93 | test("registered, and the script sorts and filters", () => { | |
| 94 | expect(fileKind("x.tsv", enc("a\tb\n"))).toBe("csv"); | |
| 95 | expect(RENDERERS.csv).toBe(csv); | |
| 96 | expect(csvJs).toContain("aria-sort"); | |
| 97 | expect(csvJs).toContain("csv-sort"); | |
| 98 | expect(csvJs).not.toContain("MutationObserver"); | |
| 99 | expect(csvJs).not.toContain("style.cursor"); | |
| 100 | expect(csvJs).toContain("data-csv-filter"); | |
| 101 | }); | |
| 102 | }); | |
| 103 | ||
| 104 | describe("csv on the blob page", () => { | |
| 105 | const scope = { base: "/o/r", facts: { name: "r" }, caps: { write: false }, kind: "local" } as unknown as RepoScope; | |
| 106 | const page = (name: string, text: string, extra: { view?: "source"; all?: boolean } = {}) => { | |
| 107 | const body = enc(text); | |
| 108 | return String( | |
| 109 | renderBlob(scope, { | |
| 110 | refs: { branches: ["main"], tags: [] }, | |
| 111 | ref: "main", | |
| 112 | path: name, | |
| 113 | blob: { kind: "text", body, size: body.length }, | |
| 114 | ...extra, | |
| 115 | }), | |
| 116 | ); | |
| 117 | }; | |
| 118 | ||
| 119 | test("a CSV shows the summary and the table, Rendered current", () => { | |
| 120 | const h = page("data.csv", "a,b\n1,2\n3,4\n"); | |
| 121 | expect(h).toContain("2 rows × 2 columns · comma-separated"); | |
| 122 | expect(h).toContain("data-csv"); | |
| 123 | expect(h).toContain('aria-label="data.csv data"'); | |
| 124 | expect(h).toMatch(/<[^>]*aria-current[^>]*>\s*Rendered/); | |
| 125 | expect(h).not.toMatch(/<[^>]*aria-current[^>]*>\s*Source/); | |
| 126 | }); | |
| 127 | test("a malformed CSV shows the reason and the source, Source current", () => { | |
| 128 | const h = page("bad.csv", 'a,b\n"x'); | |
| 129 | expect(h).toContain("Showing the source: A quoted field is never closed."); | |
| 130 | expect(h).not.toContain("data-csv"); | |
| 131 | expect(h).toMatch(/<[^>]*aria-current[^>]*>\s*Source/); | |
| 132 | expect(h).toContain('id="L1"'); | |
| 133 | }); | |
| 134 | test("?all=1 reaches the renderer", () => { | |
| 135 | const text = "id\n" + Array.from({ length: 1_500 }, (_, i) => String(i)).join("\n"); | |
| 136 | expect(page("n.csv", text)).toContain("Show all 1,500 rows"); | |
| 137 | const all = page("n.csv", text, { all: true }); | |
| 138 | expect(all).not.toContain("Show all"); | |
| 139 | expect(all.split("<tr>").length - 1).toBe(1_501); | |
| 140 | }); | |
| 141 | test("?view=source shows the lines", () => { | |
| 142 | const h = page("data.csv", "a,b\n1,2\n", { view: "source" }); | |
| 143 | expect(h).not.toContain("data-csv"); | |
| 144 | expect(h).toContain('id="L1"'); | |
| 145 | }); | |
| 146 | test("only a CSV path carries the script", () => { | |
| 147 | expect(isCsvPath("a/b.TSV")).toBe(true); | |
| 148 | expect(isCsvPath("a/b.ts")).toBe(false); | |
| 149 | }); | |
| 150 | }); |
The terminal now shows CSVs as a table too (#7401, README line 80). Remove "The terminal still shows CSVs as source." and point at that section.