SPB Git

Repository Public

ArchiveView.tsx 6.0 KB 91 lines tsx

1// Archive inventory: the worker listed the entries (names and declared sizes) without extracting anything.2import { 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 { DerivedGate, fmtSize, Note, Tools, useDerived, useWindow } from './shared.tsx';89/** Same shape as ArchiveListing in services/api/src/tools.ts. */10export interface ArchiveListing { format: 'zip' | 'tar'; entries: { path: string; size: number; dir: boolean }[]; totalEntries: number; totalBytes: number; truncated: boolean; suspicious: boolean }11interface Node { name: string; path: string; dir: boolean; size: number; files: number; children: Node[] }1213function buildTree(entries: ArchiveListing['entries']): Node {14  const root: Node = { name: '', path: '', dir: true, size: 0, files: 0, children: [] };15  const index = new Map<string, Node>([['', root]]);16  const ensureDir = (path: string): Node => {17    const hit = index.get(path); if (hit) return hit;18    const cut = path.lastIndexOf('/');19    const parent = ensureDir(cut < 0 ? '' : path.slice(0, cut));20    const node: Node = { name: path.slice(cut + 1), path, dir: true, size: 0, files: 0, children: [] };21    parent.children.push(node); index.set(path, node);22    return node;23  };24  for (const e of entries) {25    const path = e.path.replace(/\/+$/, '');26    if (!path) continue;27    if (e.dir) { ensureDir(path); continue; }28    const cut = path.lastIndexOf('/');29    const parent = ensureDir(cut < 0 ? '' : path.slice(0, cut));30    if (index.get(path)?.dir) continue; // a name used both as a file and as a folder: keep the folder31    parent.children.push({ name: path.slice(cut + 1), path, dir: false, size: e.size, files: 1, children: [] });32  }33  const finish = (n: Node): void => {34    if (!n.dir) return;35    n.children.sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));36    for (const c of n.children) { finish(c); n.size += c.size; n.files += c.files; }37  };38  finish(root);39  return root;40}41const ROW = 40;4243function Inventory({ data }: { data: ArchiveListing }) {44  const t = useT();45  const root = useMemo(() => buildTree(data.entries), [data]);46  const allDirs = useMemo(() => { const out: string[] = []; const walk = (n: Node) => { for (const c of n.children) if (c.dir) { out.push(c.path); walk(c); } }; walk(root); return out; }, [root]);47  const [open, setOpen] = useState<Set<string>>(() => new Set(root.children.length <= 8 ? root.children.filter((c) => c.dir).map((c) => c.path) : []));48  const rows = useMemo(() => {49    const out: { node: Node; depth: number }[] = [];50    const walk = (n: Node, depth: number) => { for (const c of n.children) { out.push({ node: c, depth }); if (c.dir && open.has(c.path)) walk(c, depth + 1); } };51    walk(root, 0);52    return out;53  }, [root, open]);54  const { ref, start, end, before, after } = useWindow(rows.length, ROW);55  const toggle = (path: string) => setOpen((s) => { const n = new Set(s); if (!n.delete(path)) n.add(path); return n; });56  return (57    <>58      <Tools>59        <IconButton icon="expandAll" label={t('preview.expandAll')} disabled={!allDirs.length} onClick={() => setOpen(new Set(allDirs))} data-testid="archive-expand" />60        <IconButton icon="collapseAll" label={t('preview.collapseAll')} disabled={!allDirs.length} onClick={() => setOpen(new Set())} />61      </Tools>62      <Note testId="archive-note">{t('preview.archiveNote')} <strong className="font-semibold text-ink" data-testid="archive-totals">{t('preview.archiveTotals', { n: data.totalEntries, size: fmtSize(data.totalBytes), format: data.format.toUpperCase() })}</strong></Note>63      {data.suspicious ? <Note tone="warn" testId="archive-suspicious">{t('preview.archiveSuspicious')}</Note> : null}64      {data.truncated ? <Note tone="warn" testId="archive-truncated">{t('preview.archiveTruncated', { shown: data.entries.length, n: data.totalEntries })}</Note> : null}65      <div ref={ref} className="min-h-0 flex-1 overflow-auto bg-surface" tabIndex={0} role="tree" aria-label={t('preview.archiveContents')} data-testid="archive-tree">66        {rows.length === 0 ? <p className="p-6 text-center text-sm text-ink-3">{t('preview.archiveEmpty')}</p> : null}67        <div style={{ height: before }} />68        {rows.slice(start, end).map(({ node, depth }) => {69          const pad = { paddingLeft: 12 + depth * 18 };70          const cells = <>71            {node.dir ? <Glyph name={open.has(node.path) ? 'chevronDown' : 'chevronRight'} size={16} className="shrink-0 text-ink-3" /> : <span className="w-4 shrink-0" />}72            <Glyph name={node.dir ? 'folder' : 'file'} size={18} className={node.dir ? 'shrink-0 text-accent' : 'shrink-0 text-ink-3'} />73            <span className="min-w-0 flex-1 truncate text-sm text-ink" title={node.path}>{node.name}</span>74            {node.dir ? <span className="hidden shrink-0 text-xs text-ink-3 sm:block">{t('preview.archiveFiles', { n: node.files })}</span> : null}75            <span className="w-20 shrink-0 text-right text-xs tabular-nums text-ink-3">{fmtSize(node.size)}</span>76          </>;77          return node.dir78            ? <button key={node.path} type="button" role="treeitem" aria-expanded={open.has(node.path)} onClick={() => toggle(node.path)} style={{ ...pad, height: ROW }} data-testid="archive-row" data-path={node.path}79                className="flex w-full items-center gap-2 border-b border-line pr-4 text-left hover:bg-hover">{cells}</button>80            : <div key={node.path} role="treeitem" style={{ ...pad, height: ROW }} data-testid="archive-row" data-path={node.path} className="flex items-center gap-2 border-b border-line pr-4">{cells}</div>;81        })}82        <div style={{ height: after }} />83      </div>84    </>85  );86}87export function ArchiveView({ source }: { source: PreviewSource }) {88  const [state, retry] = useDerived<ArchiveListing>(source.previewUrl('archive'), 'json');89  return <DerivedGate state={state} retry={retry} source={source}>{(data) => <Inventory data={data} />}</DerivedGate>;90}91
Files main
Menu

Theme