Repository Public
1// Text, CSV and JSON. The file's characters only ever become text nodes: nothing here builds HTML from content.2import { memo, type ReactNode, useMemo, useState } from 'react';3import { Glyph } from '../glyphs.tsx';4import { useT } from '../i18n.ts';5import type { PreviewSource } from '../types.ts';6import { IconButton } from '../ui.tsx';7import { parseCsv } from './csv.ts';8import { Grid } from './Grid.tsx';9import { Busy, Failure, fmtSize, Note, type TextState, TEXT_CAP, Tools, ToolToggle, useText } from './shared.tsx';1011const CHUNK = 200;12const Chunk = memo(function Chunk({ lines, from }: { lines: string[]; from: number }) {13 return <div className="spd-chunk">{lines.map((l, i) => <div key={i} className="spd-line" data-n={from + i + 1}>{l}</div>)}</div>;14});15/** Monospace text with line numbers. Lines are grouped so the browser skips the layout of what is off screen. */16export function TextBlock({ text, wrap }: { text: string; wrap: boolean }) {17 const chunks = useMemo(() => {18 const lines = text.split(/\r\n|\r|\n/);19 if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();20 const out: string[][] = [];21 for (let i = 0; i < lines.length; i += CHUNK) out.push(lines.slice(i, i + CHUNK));22 return { out, count: lines.length };23 }, [text]);24 return (25 <div className="min-h-0 flex-1 overflow-auto bg-surface" tabIndex={0} data-testid="text-scroll">26 <div className="spd-code py-3" data-wrap={wrap ? '1' : '0'} data-testid="text-block" style={{ ['--gutter' as string]: `${String(chunks.count).length}ch`, minWidth: wrap ? undefined : 'max-content' }}>27 {chunks.out.map((c, i) => <Chunk key={i} lines={c} from={i * CHUNK} />)}28 </div>29 </div>30 );31}32export function TruncatedNote({ state }: { state: Extract<TextState, { status: 'ready' }> }) {33 const t = useT();34 return state.truncated ? <Note tone="warn" testId="truncated-note">{t('preview.textTruncated', { shown: fmtSize(TEXT_CAP), total: fmtSize(state.total) })}</Note> : null;35}36function WrapToggle({ wrap, setWrap }: { wrap: boolean; setWrap: (v: boolean) => void }) {37 const t = useT();38 return <IconButton icon="wrap" label={t('preview.wrap')} active={wrap} onClick={() => setWrap(!wrap)} data-testid="wrap-toggle" />;39}40/** Loading and error states common to the text-based views. */41export function TextGate({ source, children }: { source: PreviewSource; children: (s: Extract<TextState, { status: 'ready' }>) => ReactNode }) {42 const t = useT();43 const [state, retry] = useText(source);44 if (state.status === 'loading') return <Busy label={t('common.loading')} />;45 if (state.status === 'error') return <Failure error={state.error} source={source} onRetry={retry} />;46 return <>{children(state)}</>;47}4849export function TextFileView({ source }: { source: PreviewSource }) {50 const t = useT();51 const [wrap, setWrap] = useState(() => window.matchMedia('(max-width: 639px)').matches);52 return (53 <TextGate source={source}>54 {(s) => (55 <>56 <Tools><WrapToggle wrap={wrap} setWrap={setWrap} /></Tools>57 <TruncatedNote state={s} />58 {s.text === '' ? <Note testId="empty-note">{t('preview.emptyFile')}</Note> : null}59 <TextBlock text={s.text} wrap={wrap} />60 </>61 )}62 </TextGate>63 );64}6566// --- CSV ---67const CSV_ROWS = 2000, CSV_COLS = 200;68function CsvBody({ s }: { s: Extract<TextState, { status: 'ready' }> }) {69 const t = useT();70 const [raw, setRaw] = useState(false);71 const [wrap, setWrap] = useState(false);72 const table = useMemo(() => parseCsv(s.text, CSV_ROWS + 1), [s.text]);73 const [header, ...rows] = table.rows;74 const columns = Math.min(table.columns, CSV_COLS);75 const sep = table.delimiter === '\t' ? t('preview.sepTab') : table.delimiter === ';' ? t('preview.sepSemicolon') : t('preview.sepComma');76 return (77 <>78 <Tools>79 <ToolToggle active={!raw} onClick={() => setRaw(false)} testId="csv-table">{t('preview.table')}</ToolToggle>80 <ToolToggle active={raw} onClick={() => setRaw(true)} testId="csv-raw">{t('preview.source')}</ToolToggle>81 {raw ? <WrapToggle wrap={wrap} setWrap={setWrap} /> : null}82 </Tools>83 <TruncatedNote state={s} />84 {!raw && table.more ? <Note tone="warn" testId="rows-note">{t('preview.csvRowCap', { n: CSV_ROWS })}</Note> : null}85 {!raw && table.columns > CSV_COLS ? <Note tone="warn">{t('preview.csvColCap', { n: CSV_COLS, total: table.columns })}</Note> : null}86 {raw ? <TextBlock text={s.text} wrap={wrap} /> : !header ? <Note testId="empty-note">{t('preview.emptyFile')}</Note> : (87 <>88 <Grid header={header} rows={rows} columns={columns} firstRowNumber={2} label={t('preview.table')} />89 <p className="shrink-0 border-t border-line bg-surface px-4 py-1.5 text-xs text-ink-3" data-testid="csv-summary">{t('preview.csvSummary', { n: rows.length, cols: table.columns, sep })}</p>90 </>91 )}92 </>93 );94}95export function CsvView({ source }: { source: PreviewSource }) { return <TextGate source={source}>{(s) => <CsvBody s={s} />}</TextGate>; }9697// --- JSON ---98const PAGE = 100;99type Json = null | boolean | number | string | Json[] | { [k: string]: Json };100function Scalar({ v }: { v: Exclude<Json, Json[] | { [k: string]: Json }> }) {101 if (typeof v === 'string') return <span className="whitespace-pre-wrap break-words text-ok">"{v}"</span>;102 if (typeof v === 'number') return <span className="text-accent">{String(v)}</span>;103 return <span className="text-warn">{String(v)}</span>;104}105function JsonNode({ name, value, depth, open: openAll }: { name?: string; value: Json; depth: number; open: number }) {106 const t = useT();107 const branch = value !== null && typeof value === 'object';108 const [open, setOpen] = useState(depth < openAll);109 const [shown, setShown] = useState(PAGE);110 const label = name !== undefined ? <span className="text-ink">{name}<span className="text-ink-3">: </span></span> : null;111 if (!branch) return <div className="flex min-h-7 items-start gap-1 py-0.5 pl-6 leading-6"><span className="min-w-0">{label}<Scalar v={value} /></span></div>;112 const isArray = Array.isArray(value);113 const entries: [string, Json][] = isArray ? value.map((v, i) => [String(i), v]) : Object.entries(value);114 const summary = isArray ? t('preview.jsonItems', { n: entries.length }) : t('preview.jsonKeys', { n: entries.length });115 return (116 <div>117 <button type="button" aria-expanded={open} onClick={() => setOpen(!open)} className="flex min-h-7 w-full items-center gap-1 rounded py-0.5 text-left leading-6 hover:bg-hover" data-testid="json-branch">118 <Glyph name={open ? 'chevronDown' : 'chevronRight'} size={16} className="w-5 shrink-0 text-ink-3" />119 <span className="min-w-0 truncate">{label}<span className="text-ink-3">{isArray ? '[' : '{'}{open ? '' : ` … ${isArray ? ']' : '}'}`} <span className="font-sans text-xs">{summary}</span></span></span>120 </button>121 {open ? (122 <div className="ml-2.5 border-l border-line pl-2">123 {entries.slice(0, shown).map(([k, v]) => <JsonNode key={k} name={isArray ? undefined : k} value={v} depth={depth + 1} open={openAll} />)}124 {entries.length > shown ? <button type="button" onClick={() => setShown(shown + PAGE * 5)} className="ml-6 min-h-10 rounded px-2 font-sans text-xs font-medium text-accent hover:bg-hover">{t('preview.jsonMore', { n: entries.length - shown })}</button> : null}125 <div className="pl-6 leading-6 text-ink-3">{isArray ? ']' : '}'}</div>126 </div>127 ) : null}128 </div>129 );130}131function JsonBody({ s }: { s: Extract<TextState, { status: 'ready' }> }) {132 const t = useT();133 const parsed = useMemo((): { ok: true; value: Json } | { ok: false } => { try { return { ok: true, value: JSON.parse(s.text) as Json }; } catch { return { ok: false }; } }, [s.text]);134 const [raw, setRaw] = useState(false);135 const [wrap, setWrap] = useState(true);136 const [fold, setFold] = useState({ depth: 2, gen: 0 });137 const asText = raw || !parsed.ok;138 return (139 <>140 <Tools>141 {parsed.ok ? <>142 <ToolToggle active={!raw} onClick={() => setRaw(false)} testId="json-tree-toggle">{t('preview.tree')}</ToolToggle>143 <ToolToggle active={raw} onClick={() => setRaw(true)} testId="json-raw">{t('preview.source')}</ToolToggle>144 {!raw ? <><IconButton icon="expandAll" label={t('preview.expandAll')} onClick={() => setFold((f) => ({ depth: 6, gen: f.gen + 1 }))} /><IconButton icon="collapseAll" label={t('preview.collapseAll')} onClick={() => setFold((f) => ({ depth: 1, gen: f.gen + 1 }))} /></> : null}145 </> : null}146 {asText ? <WrapToggle wrap={wrap} setWrap={setWrap} /> : null}147 </Tools>148 <TruncatedNote state={s} />149 {!parsed.ok ? <Note tone="warn" testId="json-invalid">{s.truncated ? t('preview.jsonTruncated') : t('preview.jsonInvalid')}</Note> : null}150 {asText ? <TextBlock text={s.text} wrap={wrap} /> : (151 <div className="min-h-0 flex-1 overflow-auto bg-surface p-3 font-mono text-[13px] sm:p-5" data-testid="json-tree" tabIndex={0}>152 <JsonNode key={fold.gen} value={(parsed as { value: Json }).value} depth={0} open={fold.depth} />153 </div>154 )}155 </>156 );157}158export function JsonView({ source }: { source: PreviewSource }) { return <TextGate source={source}>{(s) => <JsonBody s={s} />}</TextGate>; }159