proteinea / src /app /chat /_components /CSVViewer.tsx
Mahmoud Eljendy
feat: Antibody Studio — AI-native antibody design workspace by Proteinea
30cc31a
// 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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAll, setShowAll] = useState(false);
const delim = useMemo(() => {
// Prefer the explicit filename; extensionless artifact URLs (/api/artifacts/<id>)
// 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<ParsedCsv | null>(() => {
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 (
<div
data-testid="csv-viewer"
className="relative w-full overflow-hidden rounded-lg border border-border-subtle bg-white"
>
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-fg">
{name ? `CSV · ${name}` : "CSV"}
</span>
<a
href={url}
download={name}
className="text-[10px] text-muted-fg hover:text-accent underline underline-offset-2"
>
Download
</a>
</div>
{error ? (
<div className="p-4 text-xs text-red-600">
Failed to load CSV: {error}
</div>
) : raw == null ? (
<div className="p-4 text-xs text-muted-fg">Loading CSV…</div>
) : parsed == null ? (
<div className="p-4 text-xs text-red-600">Could not parse as CSV.</div>
) : (
<>
<div className="max-h-96 overflow-auto">
<table className="w-full border-collapse font-mono text-[11px] leading-tight">
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur">
<tr>
{parsed.header.map((h, idx) => (
<th
key={idx}
title={h}
className={`px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-fg border-b border-border-subtle whitespace-nowrap ${
numericCols[idx] ? "text-right" : "text-left"
}`}
style={{ maxWidth: 140 }}
>
<span className="block overflow-hidden text-ellipsis whitespace-nowrap">
{h}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{shownRows.map((row, rIdx) => (
<tr
key={rIdx}
className={rIdx % 2 === 1 ? "bg-muted/30" : undefined}
>
{parsed.header.map((_, cIdx) => {
const val = row[cIdx] ?? "";
return (
<td
key={cIdx}
title={val}
className={`px-2 py-1 border-b border-border-subtle/60 ${
numericCols[cIdx] ? "text-right tabular-nums" : "text-left"
}`}
style={{ maxWidth: 140 }}
>
<span className="block overflow-hidden text-ellipsis whitespace-nowrap">
{val}
</span>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border-subtle text-[10px] text-muted-fg">
<span>
{totalRows} row{totalRows === 1 ? "" : "s"} × {parsed.header.length} column
{parsed.header.length === 1 ? "" : "s"}
{bytes != null ? ` · ${formatBytes(bytes)}` : ""}
{!showAll && hasToggle ? ` · showing ${visibleRows}` : ""}
</span>
{hasToggle ? (
<button
type="button"
onClick={() => setShowAll((s) => !s)}
className="text-[10px] text-muted-fg hover:text-accent underline underline-offset-2"
>
{showAll ? "Show fewer" : "Show all"}
</button>
) : null}
</div>
</>
)}
</div>
);
}
export { CSVViewer };