/ concept-collection / stan-web-ide
Sign in
concept-collection / stan-web-ide
CSV table view: default editor for .csv files
Sticky header, row numbers, right-aligned numeric columns, and click-to-sort headers (asc/desc/file-order, NaN last). Shares the text model, so it follows edits live and 'Reopen as Text Editor' stays available. Caps rendering at 10k rows.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit ad7d2c476ada parent 6948282 Browse files
4 changed files+284−2
scripts/smoke.mjsmodified+13−2View file
@@ -119,11 +119,22 @@ try {
119119 await page.waitForTimeout(400);
120120 check('chain_1.csv written', await explorerItem('chain_1.csv').count() === 1);
121121 check('summary.csv written', await explorerItem('summary.csv').count() === 1);
122+ // summary.csv opens as the CSV table view
122123 await explorerItem('summary.csv').click();
123124 await page.waitForTimeout(800);
124- const summary = normalize(await page.locator('.view-lines').first().innerText());
125- check('summary has beta row', summary.includes('beta'));
125+ const summaryTable = page.locator('.csv-view:visible');
126+ check('summary opens as csv table', await summaryTable.locator('.csv-table').count() === 1);
127+ check('summary table has beta row', await summaryTable.locator('td', { hasText: /^beta$/ }).count() === 1);
128+ check('summary table meta shows rows', (await summaryTable.locator('.csv-view-meta').innerText()).includes('rows × 10 columns'));
129+ // click-to-sort by rhat
130+ await summaryTable.locator('th', { hasText: /^rhat/ }).click();
131+ await page.waitForTimeout(300);
132+ check('sort by rhat', (await summaryTable.locator('.csv-view-meta').innerText()).includes('sorted by rhat'));
126133 await page.screenshot({ path: out + '/s-summary.png' });
134+ // a draws file renders too (1000 rows)
135+ await explorerItem('chain_1.csv').click();
136+ await page.waitForTimeout(800);
137+ check('chain csv shows 1,000 rows', (await page.locator('.csv-view:visible .csv-view-meta').innerText()).includes('1,000 rows'));
127138
128139 // form edit round-trip: bump quick.sample's num_samples, run, check
129140 // the recorded sampling_opts.json (proves form → YAML → runner)
src/csvTable.cssadded+88−0View file
@@ -0,0 +1,88 @@
1+/* The CSV table view, themed with --vscode-* variables. */
2+
3+.csv-view {
4+ height: 100%;
5+ display: flex;
6+ flex-direction: column;
7+ background-color: var(--vscode-editor-background);
8+ color: var(--vscode-foreground);
9+ font-family: system-ui, 'Ubuntu', 'Droid Sans', sans-serif;
10+ font-size: 13px;
11+}
12+
13+.csv-view-meta {
14+ flex: none;
15+ padding: 4px 12px;
16+ color: var(--vscode-descriptionForeground);
17+ font-size: 11px;
18+ border-bottom: 1px solid var(--vscode-panel-border);
19+}
20+
21+.csv-view-scroll {
22+ flex: 1;
23+ overflow: auto;
24+}
25+
26+.csv-table {
27+ border-collapse: collapse;
28+ font-variant-numeric: tabular-nums;
29+}
30+
31+.csv-table th,
32+.csv-table td {
33+ padding: 3px 10px;
34+ border-right: 1px solid var(--vscode-panel-border);
35+ white-space: nowrap;
36+ max-width: 480px;
37+ overflow: hidden;
38+ text-overflow: ellipsis;
39+ text-align: left;
40+}
41+
42+.csv-table th {
43+ position: sticky;
44+ top: 0;
45+ z-index: 1;
46+ background-color: var(--vscode-editorGroupHeader-tabsBackground, #252526);
47+ border-bottom: 1px solid var(--vscode-panel-border);
48+ font-weight: 600;
49+ cursor: pointer;
50+ user-select: none;
51+}
52+
53+.csv-table th:hover {
54+ background-color: var(--vscode-list-hoverBackground);
55+}
56+
57+.csv-table th .csv-sort {
58+ margin-left: 4px;
59+ color: var(--vscode-descriptionForeground);
60+ font-size: 10px;
61+}
62+
63+.csv-table td.num,
64+.csv-table th.num {
65+ text-align: right;
66+}
67+
68+.csv-table td.csv-rownum,
69+.csv-table th.csv-rownum {
70+ color: var(--vscode-editorLineNumber-foreground, #858585);
71+ text-align: right;
72+ background-color: var(--vscode-editor-background);
73+ cursor: default;
74+ font-size: 11px;
75+}
76+
77+.csv-table tbody tr:nth-child(even) {
78+ background-color: var(--vscode-keybindingTable-rowsBackground, rgba(128, 128, 128, 0.04));
79+}
80+
81+.csv-table tbody tr:hover {
82+ background-color: var(--vscode-list-hoverBackground);
83+}
84+
85+.csv-view-empty {
86+ padding: 16px;
87+ color: var(--vscode-descriptionForeground);
88+}
src/csvTable.tsadded+181−0View file
@@ -0,0 +1,181 @@
1+import type { CustomEditorProvider } from 'minwebide';
2+import './csvTable.css';
3+
4+// The default view for .csv files: a table with a sticky header, row
5+// numbers, right-aligned numeric columns, and click-to-sort headers (nice
6+// for ranking summary.csv by ESS or Rhat). Shares the text model with the
7+// built-in editor, so it follows edits live and the tab menu offers
8+// 'Reopen as Text Editor' for the raw file.
9+
10+const MAX_RENDERED_ROWS = 10_000;
11+const DEBOUNCE_MS = 300;
12+
13+export function createCsvTableProvider(): CustomEditorProvider {
14+ return {
15+ viewType: 'stan.csvTable',
16+ displayName: 'CSV Table',
17+ selector: [{ filenamePattern: '*.csv' }],
18+ priority: 'default',
19+ async resolveCustomEditor(doc) {
20+ const model = await doc.getTextModel();
21+
22+ const element = el('div', 'csv-view');
23+ const meta = el('div', 'csv-view-meta');
24+ const scroll = el('div', 'csv-view-scroll');
25+ element.append(meta, scroll);
26+
27+ // sort state: column index, or -1 for file order
28+ let sortColumn = -1;
29+ let sortAscending = true;
30+
31+ const render = () => {
32+ const rows = parseCsv(model.getValue());
33+ scroll.textContent = '';
34+ if (rows.length === 0 || (rows.length === 1 && rows[0].every(cell => cell === ''))) {
35+ meta.textContent = 'empty file';
36+ scroll.appendChild(el('div', 'csv-view-empty', 'This CSV file is empty.'));
37+ return;
38+ }
39+
40+ const header = rows[0];
41+ const body = rows.slice(1).map(row =>
42+ row.length === header.length
43+ ? row
44+ : [...row, ...Array(Math.max(0, header.length - row.length)).fill('')].slice(0, header.length));
45+
46+ const numeric = header.map((_, column) =>
47+ body.length > 0 && body.every(row => row[column] === '' || isNumeric(row[column])));
48+
49+ if (sortColumn >= 0) {
50+ const column = sortColumn;
51+ const direction = sortAscending ? 1 : -1;
52+ body.sort((a, b) => {
53+ if (numeric[column]) {
54+ const left = a[column].trim() === '' ? NaN : Number(a[column]);
55+ const right = b[column].trim() === '' ? NaN : Number(b[column]);
56+ // NaN/empty cells sort last in either direction
57+ if (Number.isNaN(left) !== Number.isNaN(right)) {
58+ return Number.isNaN(left) ? 1 : -1;
59+ }
60+ return direction * (left - right);
61+ }
62+ return direction * a[column].localeCompare(b[column]);
63+ });
64+ }
65+
66+ const truncated = body.length > MAX_RENDERED_ROWS;
67+ const shown = truncated ? body.slice(0, MAX_RENDERED_ROWS) : body;
68+ meta.textContent = `${body.length.toLocaleString()} rows × ${header.length} columns`
69+ + (truncated ? ` — showing the first ${MAX_RENDERED_ROWS.toLocaleString()}` : '')
70+ + (sortColumn >= 0 ? ` — sorted by ${header[sortColumn] || `column ${sortColumn + 1}`}` : '');
71+
72+ const table = el('table', 'csv-table');
73+ const thead = table.createTHead();
74+ const headRow = thead.insertRow();
75+ headRow.appendChild(el('th', 'csv-rownum', ''));
76+ header.forEach((name, column) => {
77+ const th = el('th', numeric[column] ? 'num' : undefined, name);
78+ if (sortColumn === column) {
79+ th.appendChild(el('span', 'csv-sort', sortAscending ? '▲' : '▼'));
80+ }
81+ th.title = 'Click to sort';
82+ th.addEventListener('click', () => {
83+ if (sortColumn !== column) {
84+ sortColumn = column;
85+ sortAscending = true;
86+ } else if (sortAscending) {
87+ sortAscending = false;
88+ } else {
89+ sortColumn = -1; // third click: back to file order
90+ }
91+ render();
92+ });
93+ headRow.appendChild(th);
94+ });
95+
96+ const tbody = table.createTBody();
97+ shown.forEach((row, index) => {
98+ const tr = tbody.insertRow();
99+ tr.appendChild(el('td', 'csv-rownum', String(index + 1)));
100+ row.forEach((cell, column) => {
101+ tr.appendChild(el('td', numeric[column] ? 'num' : undefined, cell));
102+ });
103+ });
104+ scroll.appendChild(table);
105+ };
106+ render();
107+
108+ let timer: ReturnType<typeof setTimeout> | undefined;
109+ const changeListener = model.onDidChangeContent(() => {
110+ clearTimeout(timer);
111+ timer = setTimeout(render, DEBOUNCE_MS);
112+ });
113+
114+ return {
115+ element,
116+ dispose() {
117+ clearTimeout(timer);
118+ changeListener.dispose();
119+ },
120+ };
121+ },
122+ };
123+}
124+
125+function isNumeric(value: string): boolean {
126+ if (value === 'NaN' || value === 'Inf' || value === '-Inf') {
127+ return true; // summary.csv sentinel values
128+ }
129+ return value.trim() !== '' && Number.isFinite(Number(value));
130+}
131+
132+/** RFC 4180-ish CSV: quoted fields, doubled quotes, newlines in quotes. */
133+function parseCsv(text: string): string[][] {
134+ const rows: string[][] = [];
135+ let row: string[] = [];
136+ let field = '';
137+ let inQuotes = false;
138+ for (let i = 0; i < text.length; i++) {
139+ const char = text[i];
140+ if (inQuotes) {
141+ if (char === '"') {
142+ if (text[i + 1] === '"') {
143+ field += '"';
144+ i++;
145+ } else {
146+ inQuotes = false;
147+ }
148+ } else {
149+ field += char;
150+ }
151+ } else if (char === '"') {
152+ inQuotes = true;
153+ } else if (char === ',') {
154+ row.push(field);
155+ field = '';
156+ } else if (char === '\n') {
157+ row.push(field);
158+ rows.push(row);
159+ row = [];
160+ field = '';
161+ } else if (char !== '\r') {
162+ field += char;
163+ }
164+ }
165+ if (field !== '' || row.length > 0) {
166+ row.push(field);
167+ rows.push(row);
168+ }
169+ return rows;
170+}
171+
172+function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
173+ const node = document.createElement(tag);
174+ if (className) {
175+ node.className = className;
176+ }
177+ if (text !== undefined) {
178+ node.textContent = text;
179+ }
180+ return node;
181+}
src/ide.tsmodified+2−0View file
@@ -1,4 +1,5 @@
11 import { createWorkbench, type WorkbenchTheme } from 'minwebide';
2+import { createCsvTableProvider } from './csvTable';
23 import { openProjectFileSystem, touchProject, type ProjectInfo } from './projects';
34 import { createStanRunner } from './stan/runner';
45 import { createSampleEditorProvider } from './stan/sampleEditor';
@@ -20,6 +21,7 @@ export async function openIde(container: HTMLElement, project: ProjectInfo, them
2021 });
2122 workbench.registerRunner(stan.runner);
2223 workbench.registerCustomEditor(createSampleEditorProvider(fs, workbench, { stop: stan.stop }));
24+ workbench.registerCustomEditor(createCsvTableProvider());
2325
2426 // the project indicator: click to go back to the project list
2527 workbench.statusBar.setItem('project', 'left', project.name, {
moveopenescclose