// Usage: what is using a disk, WizTree-style. (The tab was called Space until 2026-09-24; // the Go package and the /api/space endpoints behind it still carry the old name.) Pick a disk, scan it (a parallel walk of every // filesystem the disk backs, as root when the host allows), then read the result two ways: // a tree of folders largest-first, and a treemap of whatever folder is selected, where every // file is a tile sized by what it takes on disk and coloured by what kind of thing it is. import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { api, SpaceFile, SpaceTarget, SpaceTree } from './api' import { fmtBytes } from './fmt' import { useHashSel } from './hashsel' import { useRefreshMs } from './prefs' import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui' // ---- what kind of file a tile is: the colour it wears (the validated categorical set, dark and light) ---- interface Kind { id: string; label: string; dark: string; light: string; ext: string[] } const KINDS: Kind[] = [ { id: 'video', label: 'Video', dark: '#9085e9', light: '#4a3aa7', ext: ['mp4', 'mkv', 'avi', 'mov', 'webm', 'm4v', 'ts', 'wmv', 'flv', 'mpg', 'mpeg', 'vob', 'm2ts'] }, { id: 'image', label: 'Images', dark: '#d55181', light: '#e87ba4', ext: ['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'webp', 'raw', 'dng', 'cr2', 'nef', 'arw', 'tif', 'tiff', 'bmp', 'svg', 'psd'] }, { id: 'audio', label: 'Audio', dark: '#199e70', light: '#1baf7a', ext: ['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'aiff'] }, { id: 'archive', label: 'Archives & images', dark: '#d95926', light: '#eb6834', ext: ['zip', 'tar', 'gz', 'tgz', 'xz', 'zst', 'bz2', '7z', 'rar', 'iso', 'img', 'qcow2', 'vmdk', 'vdi', 'vhd', 'vhdx', 'deb', 'rpm', 'apk', 'jar', 'whl'] }, { id: 'doc', label: 'Documents', dark: '#c98500', light: '#eda100', ext: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'txt', 'md', 'epub', 'mobi', 'rtf', 'csv'] }, { id: 'code', label: 'Code & config', dark: '#3987e5', light: '#2a78d6', ext: ['js', 'mjs', 'ts', 'tsx', 'jsx', 'go', 'py', 'rs', 'c', 'h', 'cpp', 'java', 'sh', 'css', 'html', 'json', 'yaml', 'yml', 'toml', 'xml', 'sql', 'wasm', 'so', 'a', 'o', 'node'] }, { id: 'data', label: 'Databases & logs', dark: '#e66767', light: '#e34948', ext: ['db', 'sqlite', 'sqlite3', 'log', 'ldb', 'sst', 'wal', 'journal', 'bin', 'dat', 'idx', 'pack', 'ibd', 'frm', 'mdb', 'rdb', 'aof'] }, { id: 'other', label: 'Other', dark: '#008300', light: '#008300', ext: [] }, ] const BY_EXT = new Map() for (const k of KINDS) for (const e of k.ext) BY_EXT.set(e, k) function kindOf(name: string): Kind { const dot = name.lastIndexOf('.') const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : '' return (ext && BY_EXT.get(ext)) || KINDS[KINDS.length - 1] } function lightTheme(): boolean { const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() return /^#[0-9a-f]{6}$/i.test(bg) && parseInt(bg.slice(1, 3), 16) > 128 } function cssVar(name: string): string { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() } const fmtN = (n: number) => n.toLocaleString() const fmtWhen = (t: number) => (t ? new Date(t * 1000).toLocaleDateString() : '') const ago = (iso?: string) => { if (!iso) return '' const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000) return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago` } const fmtDur = (a: string, b?: string) => { const ms = (b ? new Date(b).getTime() : Date.now()) - new Date(a).getTime(); return ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.floor(ms / 60000)} min ${Math.round((ms % 60000) / 1000)} s` } const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/') export default function Usage() { const [targets, setTargets] = useState(null) const [err, setErr] = useState('') const [sel, setSel] = useHashSel('usage') const [tab, setTab] = useState<'tree' | 'files'>('tree') const rate = useRefreshMs() const load = useCallback(async () => { try { const r = await api.space(); setTargets(r.targets); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, []) useEffect(() => { load() }, [load]) const target = targets?.find((t) => t.id === sel) ?? targets?.find((t) => t.roots.length > 0) ?? targets?.[0] useEffect(() => { if (target && target.id !== sel) setSel(target.id) }, [target, sel, setSel]) const scan = target?.scan // while a scan runs its status alone is polled at the refresh rate (the target list means // lsblk and zpool, which need not run twice a second); the list is read again when it ends useEffect(() => { if (!scan?.running || !target) return const id = target.id const t = setInterval(async () => { try { const st = await api.spaceStatus(id) setTargets((ts) => ts?.map((x) => (x.id === id ? { ...x, scan: st } : x)) ?? ts) } catch { /* the next tick */ } }, Math.max(250, rate)) return () => clearInterval(t) }, [scan?.running, target, rate]) const start = async () => { if (!target) return; try { await api.spaceScan(target.id); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const cancel = async () => { if (!target) return; try { await api.spaceCancel(target.id); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const forget = async () => { if (!target || !await askYes(`Forget the saved scan of ${target.name}? The next scan reads everything again.`)) return; try { await api.spaceForget(target.id); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } // the target list is not polled during a scan; when one ends it is read again, so the // picker's "scanned … ago" and the new tree come in together const wasRunning = useRef(false) useEffect(() => { if (wasRunning.current && !scan?.running) load(); wasRunning.current = !!scan?.running }, [scan?.running, load]) if (err && !targets) return
{err}
if (!targets) return
// a result to show: the last finished one, kept while a refresh of it runs const have = !!scan && !scan.loading && scan.files > 0 && (!scan.error || scan.error === 'cancelled') const done = have && !scan.running return (
setSel(t.id)} /> {target && (
{scan?.running ? ( <> {fmtN(scan.progress.files)} files · {fmtBytes(scan.progress.bytes)} · {fmtDur(scan.started)}{scan.progress.reused ? ` · ${fmtN(scan.progress.reused)} folders kept, ${fmtN(scan.progress.dirs)} read` : ''} Cancel ) : scan?.loading ? ( <>reading the last scan back… ) : ( <> {done && `${r}: ${how}`).join('\n') : undefined}> {fmtBytes(scan.size)} in {fmtN(scan.files)} files, {fmtN(scan.dirs)} folders · {scan.partial ? partly scanned, {fmtN(scan.partial_dirs)} folders not yet read : `scanned ${ago(scan.finished)}`} in {fmtDur(scan.started, scan.finished)} {scan.method && scan.method !== 'full' ? ` · ${scan.method === 'resume' ? 'resumed' : 'refreshed'}: ${fmtN(scan.read)} folders read, ${fmtN(scan.reused)} kept` : ''} {scan.as_root ? '' : ' · not as root, so some folders were skipped'}{scan.denied ? ` · ${fmtN(scan.denied)} unreadable` : ''} } {scan?.error && scan.error !== 'cancelled' && {scan.error}} {done ? (scan.partial ? 'Resume' : 'Rescan') : 'Scan'} {done && Forget} )}
)}
{target &&
{describe(target)}{scan?.running && scan.progress.current && · {scan.progress.current}}
}
{target && have && ( <>
{tab === 'tree' ? : } )} {target && !have && !scan?.running && !scan?.loading &&
{target.roots.length === 0 ? 'Nothing is mounted from it, so there is nothing to scan.' : scan?.error && scan.error !== 'cancelled' ? 'The last scan failed. Scan again.' : `Scan the ${target.kind} to see what is on it.`}
} {target && !have && scan?.running &&
Scanning…
} {target && scan?.loading &&
Reading the last scan back…
}
) } // What a target is, in a line: "ZFS pool · raidz2 · 8 disks: sda sdb … · 29 TiB, 13 TiB used · scans /tank and 9 more" function describe(t: SpaceTarget): string { const parts: string[] = [] if (t.kind === 'pool') { parts.push(`ZFS pool${t.layout ? ' · ' + t.layout : ''}${t.health && t.health !== 'ONLINE' ? ' · ' + t.health : ''}`) if (t.disks?.length) parts.push(`${t.disks.length} disk${t.disks.length === 1 ? '' : 's'}: ${t.disks.join(' ')}`) parts.push(`${fmtBytes(t.size)}${t.used ? `, ${fmtBytes(t.used)} used` : ''}`) } else { parts.push(`disk${t.model ? ' · ' + t.model : ''} · ${fmtBytes(t.size)}`) } if (t.roots.length === 0) parts.push('nothing mounted') else if (t.roots.length <= 3) parts.push('scans ' + t.roots.join(', ')) else parts.push(`scans ${t.roots.slice(0, 2).join(', ')} and ${t.roots.length - 2} more`) return parts.join(' · ') } // The dropdown of what can be scanned: pools first, each with its layout and members, then // disks on their own. A disk with nothing mounted is listed but can't be picked. function TargetPicker({ targets, current, onPick }: { targets: SpaceTarget[]; current: SpaceTarget | null; onPick: (t: SpaceTarget) => void }) { const [open, setOpen] = useState(false) const box = useRef(null) useEffect(() => { const h = (e: MouseEvent) => { if (box.current && !box.current.contains(e.target as Node)) setOpen(false) } window.addEventListener('click', h) return () => window.removeEventListener('click', h) }, []) useEscape(() => setOpen(false), open) const state = (t: SpaceTarget) => t.scan?.running ? 'scanning' : t.scan?.finished && !t.scan.error ? `scanned ${ago(t.scan.finished)}` : t.roots.length === 0 ? 'nothing mounted' : 'not scanned' const icon = (t: SpaceTarget) => (t.kind === 'pool' ? '⛁' : '▤') return (
{open && (
{targets.map((t) => ( ))}
)}
) } // ---- the folder tree and the treemap of the selected folder ---- interface Row { t: SpaceTree; depth: number; kind: 'node' | 'small' | 'more'; parent: number } function Explorer({ disk }: { disk: string }) { const [cache, setCache] = useState>({}) // one level per folder, for the rows const [maps, setMaps] = useState>({}) // deep, pruned to what a tile could show, for the map const [open, setOpen] = useState>(() => new Set(['/'])) const [focus, setFocus] = useState('/') const pending = useRef(new Set()) const fetch = useCallback(async (path: string) => { if (pending.current.has(path)) return pending.current.add(path) try { const t = await api.spaceTree(disk, path, 1, 200); setCache((c) => ({ ...c, [path]: t })) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { pending.current.delete(path) } }, [disk]) // The map of a folder reaches as deep as there is room to draw: everything under a // four-thousandth of the folder would be a tile smaller than a few pixels, so it is left out. const fetchMap = useCallback(async (path: string, size: number) => { const key = 'map:' + path if (pending.current.has(key)) return pending.current.add(key) try { const t = await api.spaceTree(disk, path, 24, 300, Math.max(1, Math.floor(size / 6000))); setMaps((m) => ({ ...m, [path]: t })) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { pending.current.delete(key) } }, [disk]) useEffect(() => { if (!cache['/']) fetch('/') }, [cache, fetch]) useEffect(() => { if (!cache[focus]) fetch(focus) }, [focus, cache, fetch]) useEffect(() => { const c = cache[focus]; if (c && !maps[focus]) fetchMap(focus, c.size) }, [focus, cache, maps, fetchMap]) const toggle = (path: string) => { setOpen((o) => { const n = new Set(o); if (n.has(path)) n.delete(path); else { n.add(path); if (!cache[path]) fetch(path) } return n }) } const select = (path: string) => { setFocus(path) // open every folder down to it so the row is on screen setOpen((o) => { const n = new Set(o); let p = path; while (p !== '/') { p = parentOf(p); n.add(p) } return n }) if (!cache[path]) fetch(path) } const rows = useMemo(() => { const out: Row[] = [] const walk = (t: SpaceTree, depth: number) => { out.push({ t, depth, kind: 'node', parent: t.size }) if (!t.dir || !open.has(t.path)) return const full = cache[t.path] ?? t // the row's own fetch has the full list; the parent's cut copy does until then for (const c of full.children ?? []) walk(c, depth + 1) if (full.small_n) out.push({ t: { ...full, name: `${fmtN(full.small_n)} smaller files`, dir: false, size: full.small_size ?? 0, files: full.small_n, path: full.path + '/*' }, depth: depth + 1, kind: 'small', parent: full.size }) if (full.more_n) out.push({ t: { ...full, name: `${fmtN(full.more_n)} more items`, dir: false, size: full.more_size ?? 0, path: full.path + '/…' }, depth: depth + 1, kind: 'more', parent: full.size }) } if (cache['/']) walk(cache['/'], 0) // parent sizes for the bars: each row's share of the folder it sits in const stack: number[] = [] for (const r of out) { stack.length = r.depth; r.parent = stack[r.depth - 1] ?? r.t.size; stack[r.depth] = r.t.size } return out }, [cache, open]) const root = cache['/'] if (!root) return const focused = cache[focus] const map = maps[focus] return (
NameSizeof parentFilesFoldersModified
{rows.map((r) => { const t = r.t const pct = r.parent > 0 ? (t.size / r.parent) * 100 : 0 const isOpen = open.has(t.path) return (
{ if (r.kind === 'node') { if (t.dir) { select(t.path); if (!isOpen) toggle(t.path) } else select(parentOf(t.path)) } }}> {t.dir && r.kind === 'node' ? : } {t.dir && r.kind === 'node' ? '▰' : ''} {t.name}{t.mount && t.dir && r.kind === 'node' ? mount : ''}{t.denied ? unreadable : ''}{t.partial ? not yet read : ''} {fmtBytes(t.size)} {pct >= 10 ? pct.toFixed(0) : pct.toFixed(1)}% {t.dir && r.kind === 'node' ? fmtN(t.files) : r.kind === 'small' ? fmtN(t.files) : ''} {t.dir && r.kind === 'node' ? fmtN(t.dirs) : ''} {r.kind === 'node' ? fmtWhen(t.mtime) : ''}
) })}
{crumbs(focus).map((c) => )} {focused && {fmtBytes(focused.size)} · {root.size > 0 ? ((focused.size / root.size) * 100).toFixed(1) : 0}% of the disk's {fmtBytes(root.size)}}
{map ? : }
{KINDS.map((k) => {k.label})} FolderSmaller files
) } function crumbs(path: string): { name: string; path: string }[] { const out = [{ name: '/', path: '/' }] let p = '' for (const part of path.split('/').filter(Boolean)) { p += '/' + part; out.push({ name: part, path: p }) } return out } // ---- the treemap: every file a tile, grouped by folder, drawn on a canvas ---- // // WizTree's picture: the whole area is files, sized by what they take and coloured by kind, // and folders are only the grouping — a thin line, and a name on the biggest ones directly // under the folder being looked at. No folder gets a box of its own deeper down, because a // box costs a header and padding at every level, and a chain of single-child folders (as // every Linux system has) turned the picture into a staircase of headers with nothing inside. interface Tile { x: number; y: number; w: number; h: number; t: SpaceTree; agg?: 'small' | 'more'; depth: number; head: number } // squarify lays items (largest first) into rect, keeping tiles as square as it can. function squarify(items: { size: number; t: SpaceTree; agg?: 'small' | 'more' }[], x: number, y: number, w: number, h: number, depth: number, out: Tile[]) { const total = items.reduce((a, b) => a + b.size, 0) if (total <= 0 || w <= 1 || h <= 1) return let i = 0 let cx = x, cy = y, cw = w, ch = h while (i < items.length) { const vertical = cw < ch // lay the next row along the shorter side const side = vertical ? cw : ch const scale = (cw * ch) / items.slice(i).reduce((a, b) => a + b.size, 0) let row: typeof items = [] let rowSum = 0 let worst = Infinity for (let j = i; j < items.length; j++) { const s = items[j].size * scale if (s <= 0) { i = j + 1; continue } const sum = rowSum + s const thick = sum / side let wst = 0 for (const r of [...row, items[j]]) { const len = (r.size * scale) / thick; wst = Math.max(wst, len / thick, thick / len) } if (row.length && wst > worst) break row.push(items[j]); rowSum = sum; worst = wst } if (row.length === 0) break const thick = rowSum / side let off = 0 for (const r of row) { const len = (r.size * scale) / thick const tile: Tile = vertical ? { x: cx, y: cy + off, w: thick, h: len, t: r.t, agg: r.agg, depth, head: 0 } : { x: cx + off, y: cy, w: len, h: thick, t: r.t, agg: r.agg, depth, head: 0 } out.push(tile) off += len } if (vertical) { cy += thick; ch -= thick } else { cx += thick; cw -= thick } i += row.length } } function layout(tree: SpaceTree, x: number, y: number, w: number, h: number, depth: number, out: Tile[]) { const items: { size: number; t: SpaceTree; agg?: 'small' | 'more' }[] = (tree.children ?? []).filter((c) => c.size > 0).map((c) => ({ size: c.size, t: c })) if (tree.small_n && tree.small_size) items.push({ size: tree.small_size, t: { ...tree, name: `${fmtN(tree.small_n)} smaller files`, dir: false, path: tree.path + '/*', children: undefined }, agg: 'small' }) if (tree.more_n && tree.more_size) items.push({ size: tree.more_size, t: { ...tree, name: `${fmtN(tree.more_n)} more items`, dir: false, path: tree.path + '/…', children: undefined }, agg: 'more' }) items.sort((a, b) => b.size - a.size) const tiles: Tile[] = [] squarify(items, x, y, w, h, depth, tiles) for (const t of tiles) { out.push(t) const kids = (t.t.children?.length ?? 0) + (t.t.small_size ? 1 : 0) + (t.t.more_size ? 1 : 0) if (!t.t.dir || kids === 0 || t.w < 3 || t.h < 3) continue // the folders directly under the one being looked at get a name strip when there is room; // everything deeper is grouping only t.head = depth === 0 && t.h >= 34 && t.w >= 50 ? 14 : 0 const pad = depth === 0 ? 1 : 0 layout(t.t, t.x + pad, t.y + pad + t.head, t.w - 2 * pad, t.h - 2 * pad - t.head, depth + 1, out) } } function Treemap({ tree, onOpen }: { tree: SpaceTree; onOpen: (path: string) => void }) { const box = useRef(null) const canvas = useRef(null) const tiles = useRef([]) const [size, setSize] = useState({ w: 0, h: 0 }) const [hover, setHover] = useState<{ x: number; y: number; tile: Tile } | null>(null) useEffect(() => { const el = box.current if (!el) return const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })) ro.observe(el) setSize({ w: el.clientWidth, h: el.clientHeight }) return () => ro.disconnect() }, []) useEffect(() => { const c = canvas.current if (!c || size.w === 0 || size.h === 0) return const dpr = window.devicePixelRatio || 1 c.width = Math.round(size.w * dpr); c.height = Math.round(size.h * dpr) const g = c.getContext('2d') if (!g) return g.scale(dpr, dpr) const light = lightTheme() const bg = cssVar('--bg2') || '#171b21', border = cssVar('--border') || '#2a313b', fg = cssVar('--fg') || '#d0d7de', muted = cssVar('--muted') || '#7d8590' g.fillStyle = bg g.fillRect(0, 0, size.w, size.h) const out: Tile[] = [] layout(tree, 0, 0, size.w, size.h, 0, out) tiles.current = out const font = cssVar('--font') || 'system-ui, sans-serif' const dirFill = light ? '#dfe3e8' : '#1c2229' for (const t of out) { if (t.w < 0.5 || t.h < 0.5) continue const isDir = t.t.dir && !t.agg if (isDir) { // the folder's own colour shows only where its children don't cover it: the strip, // the padding, and anything too small to have been asked for g.fillStyle = dirFill g.fillRect(t.x, t.y, t.w, t.h) if (t.depth <= 1 && t.w >= 4 && t.h >= 4) { g.strokeStyle = border; g.lineWidth = 1; g.strokeRect(t.x + 0.5, t.y + 0.5, t.w - 1, t.h - 1) } if (t.head) { g.save() g.beginPath(); g.rect(t.x + 1, t.y + 1, t.w - 2, t.head - 1); g.clip() g.font = `600 11px ${font}`; g.fillStyle = fg; g.textBaseline = 'top' const label = t.t.name g.fillText(label, t.x + 4, t.y + 1, t.w - 8) const sz = fmtBytes(t.t.size) if (t.w > g.measureText(label).width + g.measureText(sz).width + 20) { g.font = `10px ${font}`; g.fillStyle = muted; g.textAlign = 'right'; g.fillText(sz, t.x + t.w - 4, t.y + 2) } g.restore() } continue } let fill: string if (t.agg) fill = t.agg === 'small' ? (light ? '#c4cad1' : '#39424c') : (light ? '#d3d8de' : '#2f3740') else { const k = kindOf(t.t.name); fill = light ? k.light : k.dark } g.fillStyle = fill g.fillRect(t.x, t.y, t.w, t.h) if (t.w >= 3 && t.h >= 3) { g.strokeStyle = bg; g.lineWidth = 1; g.strokeRect(t.x + 0.5, t.y + 0.5, t.w - 1, t.h - 1) } if (t.w >= 46 && t.h >= 14) { g.save() g.beginPath(); g.rect(t.x + 1, t.y + 1, t.w - 2, t.h - 2); g.clip() g.font = `11px ${font}`; g.textBaseline = 'top' g.fillStyle = t.agg ? muted : (light ? '#111' : '#fff') g.fillText(t.t.name, t.x + 4, t.y + 2, t.w - 8) if (t.h >= 28) { g.font = `10px ${font}`; g.fillStyle = t.agg ? muted : (light ? 'rgba(0,0,0,.7)' : 'rgba(255,255,255,.8)'); g.fillText(fmtBytes(t.t.size), t.x + 4, t.y + 15, t.w - 8) } g.restore() } } }, [tree, size]) const at = (e: React.MouseEvent) => { const r = canvas.current!.getBoundingClientRect() const x = e.clientX - r.left, y = e.clientY - r.top // the deepest tile under the pointer let best: Tile | null = null for (const t of tiles.current) if (x >= t.x && x < t.x + t.w && y >= t.y && y < t.y + t.h && (!best || t.depth > best.depth)) best = t return { x, y, tile: best } } // a path relative to the folder being looked at, for the tooltip const within = (p: string) => { const base = tree.path === '/' ? '' : tree.path; return p.startsWith(base + '/') ? p.slice(base.length + 1) : p } return (
{ const h = at(e); setHover(h.tile ? { x: h.x, y: h.y, tile: h.tile } : null) }} onMouseLeave={() => setHover(null)} onClick={(e) => { const h = at(e); if (!h.tile) return; const t = h.tile.t; onOpen(t.dir && !h.tile.agg ? t.path : parentOf(t.path)) }} /> {hover && (
{hover.tile.agg ? hover.tile.t.name + ' in ' + (within(parentOf(hover.tile.t.path)) || '.') : within(hover.tile.t.path)}
{fmtBytes(hover.tile.t.size)} · {tree.size > 0 ? ((hover.tile.t.size / tree.size) * 100).toFixed(1) : 0}% of {tree.name === '/' ? 'the disk' : tree.name}
{hover.tile.t.dir && !hover.tile.agg &&
{fmtN(hover.tile.t.files)} files · {fmtN(hover.tile.t.dirs)} folders · click to open
} {!hover.tile.t.dir && !hover.tile.agg &&
{kindOf(hover.tile.t.name).label} · {fmtWhen(hover.tile.t.mtime)}{hover.tile.t.apparent !== hover.tile.t.size ? ` · ${fmtBytes(hover.tile.t.apparent)} apparent` : ''}
}
)}
) } // ---- the largest files on the disk ---- function LargestFiles({ disk }: { disk: string }) { const [files, setFiles] = useState(null) const [filter, setFilter] = useState('') useEffect(() => { api.spaceLargest(disk, 1000).then(setFiles).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [disk]) if (!files) return const q = filter.toLowerCase() const shown = q ? files.filter((f) => f.path.toLowerCase().includes(q)) : files const top = shown[0]?.size ?? 1 return (
setFilter(e.target.value)} spellCheck={false} />the {fmtN(files.length)} largest files{q ? ` · ${fmtN(shown.length)} match` : ''}
{shown.map((f) => (
{f.path} {fmtBytes(f.size)} {fmtWhen(f.mtime)}
))}
) }