ad7d2c4CSV table view: default editor for .csv filesJeremy Magland 1import type { CustomEditorProvider } from 'minwebide';
2import './csvTable.css';
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.
10const MAX_RENDERED_ROWS = 10_000;
11const DEBOUNCE_MS = 300;
13export 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();
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);
27 // sort state: column index, or -1 for file order
28 let sortColumn = -1;
29 let sortAscending = true;
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 }
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));
46 const numeric = header.map((_, column) =>
47 body.length > 0 && body.every(row => row[column] === '' || isNumeric(row[column])));
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 }
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}`}` : '');
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 });
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();
108 let timer: ReturnType<typeof setTimeout> | undefined;
109 const changeListener = model.onDidChangeContent(() => {
110 clearTimeout(timer);
111 timer = setTimeout(render, DEBOUNCE_MS);
112 });
114 return {
115 element,
116 dispose() {
117 clearTimeout(timer);
118 changeListener.dispose();
119 },
120 };
121 },
122 };
123}
125function 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}
132/** RFC 4180-ish CSV: quoted fields, doubled quotes, newlines in quotes. */
133function 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}
172function 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}