Repository Public
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