// CSVViewer.tsx — Inline preview for CSV / TSV artifacts. Renders a compact // table with sticky header, truncated cells, right-aligned numeric columns, // and a show-all toggle. In-house parser, no deps. "use client"; import { useEffect, useMemo, useState } from "react"; export interface CSVViewerProps { url: string; /** Used to pick the delimiter when the URL is a data: URL (extensionless). */ name?: string; bytes?: number; } interface ParsedCsv { header: string[]; rows: string[][]; } /** Split text into logical lines, respecting double-quoted fields that may * contain embedded newlines. Only breaks on \n (or \r\n) when outside quotes. */ function splitLines(text: string): string[] { const lines: string[] = []; let current = ""; let inQuotes = false; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === '"') { inQuotes = !inQuotes; current += ch; } else if (ch === "\r" && text[i + 1] === "\n" && !inQuotes) { lines.push(current); current = ""; i++; // skip the \n } else if (ch === "\n" && !inQuotes) { lines.push(current); current = ""; } else { current += ch; } } if (current.length > 0) lines.push(current); return lines; } function parseCsv(input: string, delim: string): ParsedCsv { let text = input; if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); const lines = splitLines(text); const parsed: string[][] = []; for (const line of lines) { if (line.length === 0) continue; parsed.push(parseLine(line, delim)); } while (parsed.length > 0) { const last = parsed[parsed.length - 1]; if (last.every((c) => c === "")) parsed.pop(); else break; } if (parsed.length === 0) return { header: [], rows: [] }; const [header, ...rows] = parsed; return { header, rows }; } function parseLine(line: string, delim: string): string[] { const out: string[] = []; let field = ""; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (line[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; } } else { field += ch; } } else { if (ch === '"' && field === "") { inQuotes = true; } else if (ch === delim) { out.push(field); field = ""; } else { field += ch; } } } out.push(field); return out; } function isNumericValue(v: string): boolean { if (v === "" || v == null) return false; const trimmed = v.trim(); if (trimmed === "") return false; if (!/^-?\d*\.?\d+(?:[eE][-+]?\d+)?$/.test(trimmed)) return false; return Number.isFinite(Number(trimmed)); } function detectNumericColumns(header: string[], rows: string[][]): boolean[] { return header.map((_, col) => { let total = 0; let numeric = 0; for (const row of rows) { const v = row[col]; if (v === undefined || v === "") continue; total++; if (isNumericValue(v)) numeric++; } if (total === 0) return false; return numeric / total >= 0.8; }); } function formatBytes(n: number | undefined): string { if (!n && n !== 0) return ""; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } const DEFAULT_ROWS_SHOWN = 25; export default function CSVViewer({ url, name, bytes }: CSVViewerProps) { const [raw, setRaw] = useState(null); const [error, setError] = useState(null); const [showAll, setShowAll] = useState(false); const delim = useMemo(() => { // Prefer the explicit filename; extensionless artifact URLs (/api/artifacts/) // fall through to content-sniffing below. const n = (name || "").toLowerCase(); if (n.endsWith(".tsv")) return "\t"; return ","; // Content-sniffing happens after fetch if needed }, [name]); const MAX_PREVIEW_BYTES = 2 * 1024 * 1024; // 2 MB preview cap useEffect(() => { let cancelled = false; setRaw(null); setError(null); setShowAll(false); fetch(url) .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text(); }) .then((t) => { if (!cancelled) { // Cap preview at 2 MB to avoid freezing the chat tab on large CSVs if (t.length > MAX_PREVIEW_BYTES) { setRaw(t.slice(0, MAX_PREVIEW_BYTES)); } else { setRaw(t); } } }) .catch((e) => { if (!cancelled) setError(e.message || String(e)); }); return () => { cancelled = true; }; }, [url]); const parsed = useMemo(() => { if (raw == null) return null; try { const p = parseCsv(raw, delim); if (p.header.length === 0) return null; return p; } catch { return null; } }, [raw, delim]); const numericCols = useMemo( () => (parsed ? detectNumericColumns(parsed.header, parsed.rows) : []), [parsed], ); const totalRows = parsed?.rows.length ?? 0; const visibleRows = showAll ? totalRows : Math.min(DEFAULT_ROWS_SHOWN, totalRows); const shownRows = parsed ? parsed.rows.slice(0, visibleRows) : []; const hasToggle = totalRows > DEFAULT_ROWS_SHOWN; return (
{name ? `CSV · ${name}` : "CSV"} Download
{error ? (
Failed to load CSV: {error}
) : raw == null ? (
Loading CSV…
) : parsed == null ? (
Could not parse as CSV.
) : ( <>
{parsed.header.map((h, idx) => ( ))} {shownRows.map((row, rIdx) => ( {parsed.header.map((_, cIdx) => { const val = row[cIdx] ?? ""; return ( ); })} ))}
{h}
{val}
{totalRows} row{totalRows === 1 ? "" : "s"} × {parsed.header.length} column {parsed.header.length === 1 ? "" : "s"} {bytes != null ? ` · ${formatBytes(bytes)}` : ""} {!showAll && hasToggle ? ` · showing ${visibleRows}` : ""} {hasToggle ? ( ) : null}
)}
); } export { CSVViewer };