The Space tab is Usage; the Cron tab is Jobs

Both are named for what they do rather than the tool underneath, the way Services is.
Old links and saved rail orders still land in the right place. The Go packages and their
endpoints keep their names, with a pointer in each to what the UI calls them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-24 12:54:23 -07:00
co-authored by Claude Opus 5
parent dfa9e88f5b
commit a07a444170
6 changed files with 48 additions and 20 deletions
+497
View File
@@ -0,0 +1,497 @@
// 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<string, Kind>()
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<SpaceTarget[] | null>(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 <div className="dash"><div className="error">{err}</div></div>
if (!targets) return <div className="dash"><Loading label="Looking at the disks…" /></div>
// 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 (
<div className="space">
<div className="space-bar">
<div className="space-pick-row">
<TargetPicker targets={targets} current={target ?? null} onPick={(t) => setSel(t.id)} />
{target && (
<div className="space-status">
{scan?.running ? (
<>
<Spinner /><span className="mono small">{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` : ''}</span>
<Btn className="btn danger" onClick={cancel}>Cancel</Btn>
</>
) : scan?.loading ? (
<><Spinner /><span className="muted small">reading the last scan back…</span></>
) : (
<>
{done && <span className="small" title={scan.by_root ? Object.entries(scan.by_root).map(([r, how]) => `${r}: ${how}`).join('\n') : undefined}>
<b>{fmtBytes(scan.size)}</b> in {fmtN(scan.files)} files, {fmtN(scan.dirs)} folders · {scan.partial ? <span className="warn">partly scanned, {fmtN(scan.partial_dirs)} folders not yet read</span> : `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` : ''}
</span>}
{scan?.error && scan.error !== 'cancelled' && <span className="error small">{scan.error}</span>}
<Btn className="btn on" disabled={target.roots.length === 0} onClick={start} title={done ? (scan.partial ? 'reads the folders the cancelled scan did not get to' : 'reads only what changed since the last scan (zfs diff on a pool with snapshots; folder times elsewhere)') : undefined}>{done ? (scan.partial ? 'Resume' : 'Rescan') : 'Scan'}</Btn>
{done && <Btn className="btn" onClick={forget} title="drop the saved result; the next scan reads everything">Forget</Btn>}
</>
)}
</div>
)}
</div>
{target && <div className="muted small space-about">{describe(target)}{scan?.running && scan.progress.current && <span className="mono cur" title={scan.progress.current}> · {scan.progress.current}</span>}</div>}
</div>
{target && have && (
<>
<div className="chips space-tabs">
<button className={'chip' + (tab === 'tree' ? ' on' : '')} onClick={() => setTab('tree')}>Folders</button>
<button className={'chip' + (tab === 'files' ? ' on' : '')} onClick={() => setTab('files')}>Largest files</button>
</div>
{tab === 'tree' ? <Explorer key={target.id + (scan?.finished ?? '')} disk={target.id} /> : <LargestFiles key={target.id + (scan?.finished ?? '')} disk={target.id} />}
</>
)}
{target && !have && !scan?.running && !scan?.loading && <div className="svc-empty muted">{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.`}</div>}
{target && !have && scan?.running && <div className="svc-empty muted">Scanning…</div>}
{target && scan?.loading && <div className="svc-empty muted">Reading the last scan back…</div>}
</div>
)
}
// 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<HTMLDivElement>(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 (
<div className="tpick" ref={box}>
<button className="tpick-btn" onClick={() => setOpen(!open)} title="pick a disk or pool">
{current ? <><span className="ticon">{icon(current)}</span><b>{current.name}</b><span className="muted small">{current.kind === 'pool' ? `ZFS pool${current.layout ? ' · ' + current.layout : ''}` : current.model || 'disk'} · {fmtBytes(current.size)}</span>{current.scan?.running && <Spinner size={10} />}</> : <span className="muted">no disks</span>}
<span className="chev">▾</span>
</button>
{open && (
<div className="tmenu">
{targets.map((t) => (
<button key={t.id} className={'titem' + (current?.id === t.id ? ' on' : '') + (t.roots.length === 0 ? ' off' : '')} disabled={t.roots.length === 0} onClick={() => { setOpen(false); onPick(t) }}>
<span className="ticon">{icon(t)}</span>
<span className="tmain">
<span className="tname"><b>{t.name}</b><span className="muted small"> · {t.kind === 'pool' ? `ZFS pool${t.layout ? ' · ' + t.layout : ''}${t.health && t.health !== 'ONLINE' ? ' · ' + t.health : ''}` : t.model || 'disk'} · {fmtBytes(t.size)}</span></span>
<span className="muted small tsub">{t.kind === 'pool' && t.disks?.length ? `${t.disks.length} disks: ${t.disks.join(' ')}` : t.roots.length ? t.roots.slice(0, 3).join(' ') + (t.roots.length > 3 ? ` +${t.roots.length - 3}` : '') : 'nothing mounted'}</span>
</span>
<span className="muted small tstate">{state(t)}</span>
</button>
))}
</div>
)}
</div>
)
}
// ---- 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<Record<string, SpaceTree>>({}) // one level per folder, for the rows
const [maps, setMaps] = useState<Record<string, SpaceTree>>({}) // deep, pruned to what a tile could show, for the map
const [open, setOpen] = useState<Set<string>>(() => new Set(['/']))
const [focus, setFocus] = useState('/')
const pending = useRef(new Set<string>())
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 <Loading label="Reading the scan…" />
const focused = cache[focus]
const map = maps[focus]
return (
<div className="space-split">
<div className="space-tree">
<div className="space-row head">
<span className="n">Name</span><span className="r">Size</span><span className="pct">of parent</span><span className="r c">Files</span><span className="r c">Folders</span><span className="r d">Modified</span>
</div>
<div className="space-rows">
{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 (
<div key={t.path} className={'space-row' + (t.path === focus ? ' active' : '') + (r.kind !== 'node' ? ' agg' : '')} style={{ paddingLeft: 8 + r.depth * 16 }}
onClick={() => { if (r.kind === 'node') { if (t.dir) { select(t.path); if (!isOpen) toggle(t.path) } else select(parentOf(t.path)) } }}>
<span className="n">
{t.dir && r.kind === 'node' ? <button className="tw" onClick={(e) => { e.stopPropagation(); toggle(t.path) }}>{isOpen ? '▾' : '▸'}</button> : <span className="tw" />}
<span className="ico" style={r.kind === 'node' && !t.dir ? { background: lightTheme() ? kindOf(t.name).light : kindOf(t.name).dark } : undefined}>{t.dir && r.kind === 'node' ? '▰' : ''}</span>
<span className="nm" title={t.path}>{t.name}{t.mount && t.dir && r.kind === 'node' ? <span className="muted small"> mount</span> : ''}{t.denied ? <span className="muted small"> unreadable</span> : ''}{t.partial ? <span className="warn small"> not yet read</span> : ''}</span>
</span>
<span className="r mono">{fmtBytes(t.size)}</span>
<span className="pct"><span className="bar"><span style={{ width: `${Math.min(100, pct)}%` }} /></span><span className="muted small">{pct >= 10 ? pct.toFixed(0) : pct.toFixed(1)}%</span></span>
<span className="r c muted">{t.dir && r.kind === 'node' ? fmtN(t.files) : r.kind === 'small' ? fmtN(t.files) : ''}</span>
<span className="r c muted">{t.dir && r.kind === 'node' ? fmtN(t.dirs) : ''}</span>
<span className="r d muted small">{r.kind === 'node' ? fmtWhen(t.mtime) : ''}</span>
</div>
)
})}
</div>
</div>
<div className="space-map">
<div className="space-crumbs">
<button className="btn" disabled={focus === '/'} title="up one folder" onClick={() => select(parentOf(focus))}>↑</button>
{crumbs(focus).map((c) => <button key={c.path} className={'crumb' + (c.path === focus ? ' on' : '')} onClick={() => select(c.path)}>{c.name}</button>)}
<span className="spacer" />
{focused && <span className="muted small">{fmtBytes(focused.size)} · {root.size > 0 ? ((focused.size / root.size) * 100).toFixed(1) : 0}% of the disk's {fmtBytes(root.size)}</span>}
</div>
{map ? <Treemap tree={map} onOpen={select} /> : <Loading label="Laying it out…" />}
<div className="space-legend">
{KINDS.map((k) => <span key={k.id}><i style={{ background: lightTheme() ? k.light : k.dark }} />{k.label}</span>)}
<span><i className="dir" />Folder</span><span><i className="small" />Smaller files</span>
</div>
</div>
</div>
)
}
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<HTMLDivElement>(null)
const canvas = useRef<HTMLCanvasElement>(null)
const tiles = useRef<Tile[]>([])
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 (
<div className="space-canvas" ref={box}>
<canvas ref={canvas} style={{ width: size.w, height: size.h }}
onMouseMove={(e) => { 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 && (
<div className="space-tip" style={{ left: Math.min(hover.x + 12, size.w - 280), top: Math.min(hover.y + 12, size.h - 70) }}>
<div className="mono small" style={{ wordBreak: 'break-all' }}>{hover.tile.agg ? hover.tile.t.name + ' in ' + (within(parentOf(hover.tile.t.path)) || '.') : within(hover.tile.t.path)}</div>
<div><b>{fmtBytes(hover.tile.t.size)}</b> <span className="muted">· {tree.size > 0 ? ((hover.tile.t.size / tree.size) * 100).toFixed(1) : 0}% of {tree.name === '/' ? 'the disk' : tree.name}</span></div>
{hover.tile.t.dir && !hover.tile.agg && <div className="muted small">{fmtN(hover.tile.t.files)} files · {fmtN(hover.tile.t.dirs)} folders · click to open</div>}
{!hover.tile.t.dir && !hover.tile.agg && <div className="muted small">{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` : ''}</div>}
</div>
)}
</div>
)
}
// ---- the largest files on the disk ----
function LargestFiles({ disk }: { disk: string }) {
const [files, setFiles] = useState<SpaceFile[] | null>(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 <Loading label="Finding the largest files…" />
const q = filter.toLowerCase()
const shown = q ? files.filter((f) => f.path.toLowerCase().includes(q)) : files
const top = shown[0]?.size ?? 1
return (
<div className="space-files">
<div className="dash-bar"><input className="filter" placeholder="filter by path" value={filter} onChange={(e) => setFilter(e.target.value)} spellCheck={false} /><span className="muted small">the {fmtN(files.length)} largest files{q ? ` · ${fmtN(shown.length)} match` : ''}</span></div>
<div className="space-rows">
{shown.map((f) => (
<div key={f.path} className="space-row file">
<span className="n"><span className="ico" style={{ background: lightTheme() ? kindOf(f.path).light : kindOf(f.path).dark }} /><span className="nm mono" title={f.path}>{f.path}</span></span>
<span className="r mono">{fmtBytes(f.size)}</span>
<span className="pct"><span className="bar"><span style={{ width: `${(f.size / top) * 100}%` }} /></span></span>
<span className="r d muted small">{fmtWhen(f.mtime)}</span>
</div>
))}
</div>
</div>
)
}