SPB Git

Repository Public

TextViews.tsx 9.2 KB 159 lines tsx

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
Files main
Menu

Theme