agent-orchestrator/ui-electrobun/src/mainview/CsvTable.svelte
Hibryda 252fca70df feat(electrobun): file management — CodeMirror editor, PDF viewer, CSV table, real file I/O
- CodeEditor: CodeMirror 6 with Catppuccin theme, 15+ languages, Ctrl+S save,
  dirty tracking, save-on-blur
- PdfViewer: pdfjs-dist canvas rendering, zoom 0.5-3x, HiDPI, lazy page load
- CsvTable: RFC 4180 parser, delimiter auto-detect, sortable columns, sticky header
- FileBrowser: real filesystem via files.list/read/write RPC, lazy dir loading,
  file type routing (code→editor, pdf→viewer, csv→table, images→display)
- 10MB size gate, binary detection, base64 encoding for non-text files
2026-03-22 01:36:02 +01:00

243 lines
5.9 KiB
Svelte

<script lang="ts">
interface Props {
content: string;
filename: string;
}
let { content, filename }: Props = $props();
/** RFC 4180 CSV parser with quoted field support. */
function parseCsv(text: string, delimiter: string): string[][] {
const rows: string[][] = [];
let i = 0;
const len = text.length;
while (i < len) {
const row: string[] = [];
while (i < len) {
let field = '';
if (text[i] === '"') {
i++; // skip opening quote
while (i < len) {
if (text[i] === '"') {
if (i + 1 < len && text[i + 1] === '"') {
field += '"';
i += 2;
} else {
i++; // skip closing quote
break;
}
} else {
field += text[i];
i++;
}
}
} else {
while (i < len && text[i] !== delimiter && text[i] !== '\n' && text[i] !== '\r') {
field += text[i];
i++;
}
}
row.push(field);
if (i < len && text[i] === delimiter) {
i++;
} else {
if (i < len && text[i] === '\r') i++;
if (i < len && text[i] === '\n') i++;
break;
}
}
if (row.length > 0 && !(row.length === 1 && row[0] === '')) {
rows.push(row);
}
}
return rows;
}
/** Detect delimiter from first line: comma, semicolon, tab, or pipe. */
function detectDelimiter(text: string): string {
const firstLine = text.split('\n')[0] ?? '';
const counts: [string, number][] = [
[',', (firstLine.match(/,/g) ?? []).length],
[';', (firstLine.match(/;/g) ?? []).length],
['\t', (firstLine.match(/\t/g) ?? []).length],
['|', (firstLine.match(/\|/g) ?? []).length],
];
counts.sort((a, b) => b[1] - a[1]);
return counts[0][1] > 0 ? counts[0][0] : ',';
}
let delimiter = $derived(detectDelimiter(content));
let parsed = $derived(parseCsv(content, delimiter));
let headers = $derived(parsed[0] ?? []);
let dataRows = $derived(parsed.slice(1));
let totalRows = $derived(dataRows.length);
let colCount = $derived(Math.max(...parsed.map(r => r.length), 0));
// Sort state
let sortCol = $state<number | null>(null);
let sortAsc = $state(true);
let sortedRows = $derived.by(() => {
if (sortCol === null) return dataRows;
const col = sortCol;
const asc = sortAsc;
return [...dataRows].sort((a, b) => {
const va = a[col] ?? '';
const vb = b[col] ?? '';
const na = Number(va);
const nb = Number(vb);
if (!isNaN(na) && !isNaN(nb)) {
return asc ? na - nb : nb - na;
}
return asc ? va.localeCompare(vb) : vb.localeCompare(va);
});
});
function toggleSort(col: number) {
if (sortCol === col) {
sortAsc = !sortAsc;
} else {
sortCol = col;
sortAsc = true;
}
}
function sortIndicator(col: number): string {
if (sortCol !== col) return '';
return sortAsc ? ' ▲' : ' ▼';
}
</script>
<div class="csv-table-wrapper">
<div class="csv-toolbar">
<span class="csv-info">
{totalRows} row{totalRows !== 1 ? 's' : ''} x {colCount} col{colCount !== 1 ? 's' : ''}
</span>
<span class="csv-filename">{filename}</span>
</div>
<div class="csv-scroll">
<table class="csv-table">
<thead>
<tr>
<th class="row-num">#</th>
{#each headers as header, i}
<th onclick={() => toggleSort(i)} class="sortable">
{header}{sortIndicator(i)}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sortedRows as row, rowIdx (rowIdx)}
<tr>
<td class="row-num">{rowIdx + 1}</td>
{#each { length: colCount } as _, colIdx}
<td>{row[colIdx] ?? ''}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<style>
.csv-table-wrapper {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--ctp-base);
}
.csv-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.csv-info {
font-size: 0.7rem;
color: var(--ctp-overlay1);
font-variant-numeric: tabular-nums;
}
.csv-filename {
font-size: 0.65rem;
color: var(--ctp-overlay0);
font-family: var(--term-font-family, monospace);
}
.csv-scroll {
flex: 1;
overflow: auto;
}
.csv-table {
width: 100%;
border-collapse: collapse;
font-size: 0.725rem;
font-family: var(--term-font-family, monospace);
white-space: nowrap;
}
.csv-table thead {
position: sticky;
top: 0;
z-index: 1;
}
.csv-table th {
background: var(--ctp-mantle);
color: var(--ctp-subtext1);
font-weight: 600;
text-align: left;
padding: 0.3125rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface1);
user-select: none;
}
.csv-table th.sortable {
cursor: pointer;
transition: background 0.12s;
}
.csv-table th.sortable:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.csv-table td {
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
color: var(--ctp-text);
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
}
.csv-table tbody tr:hover td {
background: color-mix(in srgb, var(--ctp-surface0) 50%, transparent);
}
.row-num {
color: var(--ctp-overlay0);
font-size: 0.625rem;
text-align: right;
width: 2.5rem;
min-width: 2.5rem;
padding-right: 0.625rem;
border-right: 1px solid var(--ctp-surface0);
}
thead .row-num {
border-bottom: 1px solid var(--ctp-surface1);
}
</style>