Space keeps its scans and refreshes them: zfs diff, folder mtimes, resume after cancel
A scan is saved beside the config and read back after a restart. The next scan starts from it: on a pool with snapshots zfs diff names what changed and only those folders are read; elsewhere a folder whose mtime is unchanged keeps its list and only its kept files are stat'ed again. A cancelled scan keeps what it walked and Resume reads the rest. Forget drops the saved result. The treemap is every file a tile, grouped by folder, with no nested boxes: the folder chains of a Linux system made a staircase of empty headers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c96d6facad
commit
53d1da08a5
+72
-37
@@ -7,7 +7,7 @@ import { api, SpaceFile, SpaceTarget, SpaceTree } from './api'
|
||||
import { fmtBytes } from './Dashboard'
|
||||
import { useHashSel } from './hashsel'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { Btn, Loading, Spinner, toast, useEscape } from './ui'
|
||||
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) ----
|
||||
|
||||
@@ -75,10 +75,17 @@ export default function Space() {
|
||||
|
||||
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>
|
||||
const done = !!scan && !scan.running && !scan.error && !!scan.finished
|
||||
// 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">
|
||||
@@ -88,14 +95,21 @@ export default function Space() {
|
||||
<div className="space-status">
|
||||
{scan?.running ? (
|
||||
<>
|
||||
<Spinner /><span className="mono small">{fmtN(scan.progress.files)} files · {fmtBytes(scan.progress.bytes)} · {fmtDur(scan.started)}</span>
|
||||
<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"><b>{fmtBytes(scan.size)}</b> in {fmtN(scan.files)} files, {fmtN(scan.dirs)} folders · scanned {ago(scan.finished)} in {fmtDur(scan.started, scan.finished)}{scan.as_root ? '' : ' · not as root, so some folders were skipped'}{scan.denied ? ` · ${fmtN(scan.denied)} unreadable` : ''}</span>}
|
||||
{scan?.error && <span className={(scan.error === 'cancelled' ? 'muted' : 'error') + ' small'}>{scan.error === 'cancelled' ? 'the last scan was cancelled' : scan.error}</span>}
|
||||
<Btn className="btn on" disabled={target.roots.length === 0} onClick={start}>{done ? 'Rescan' : 'Scan'}</Btn>
|
||||
{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>
|
||||
@@ -103,7 +117,7 @@ export default function Space() {
|
||||
</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 && done && (
|
||||
{target && have && (
|
||||
<>
|
||||
<div className="chips space-tabs">
|
||||
<button className={'chip' + (tab === 'tree' ? ' on' : '')} onClick={() => setTab('tree')}>Folders</button>
|
||||
@@ -112,8 +126,9 @@ export default function Space() {
|
||||
{tab === 'tree' ? <Explorer key={target.id + (scan?.finished ?? '')} disk={target.id} /> : <LargestFiles key={target.id + (scan?.finished ?? '')} disk={target.id} />}
|
||||
</>
|
||||
)}
|
||||
{target && !done && !scan?.running && <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 && scan?.running && <div className="svc-empty muted">Scanning…</div>}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -195,7 +210,7 @@ function Explorer({ disk }: { disk: string }) {
|
||||
const key = 'map:' + path
|
||||
if (pending.current.has(key)) return
|
||||
pending.current.add(key)
|
||||
try { const t = await api.spaceTree(disk, path, 12, 200, Math.max(1, Math.floor(size / 4000))); setMaps((m) => ({ ...m, [path]: t })) }
|
||||
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])
|
||||
@@ -251,7 +266,7 @@ function Explorer({ disk }: { disk: string }) {
|
||||
<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> : ''}</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>
|
||||
@@ -287,9 +302,15 @@ function crumbs(path: string): { name: string; path: string }[] {
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- the treemap: squarified, nested three deep, drawn on a canvas ----
|
||||
// ---- 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; inner?: boolean }
|
||||
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[]) {
|
||||
@@ -319,7 +340,7 @@ function squarify(items: { size: number; t: SpaceTree; agg?: 'small' | 'more' }[
|
||||
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 } : { x: cx + off, y: cy, w: len, h: thick, t: r.t, agg: r.agg, depth }
|
||||
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
|
||||
}
|
||||
@@ -338,12 +359,12 @@ function layout(tree: SpaceTree, x: number, y: number, w: number, h: number, dep
|
||||
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 && depth < 12 && t.w > 20 && t.h > 20) {
|
||||
// a title strip when there is room for one; a tiny folder is just a box of its children
|
||||
t.head = t.h > 40 && t.w > 40 ? 15 : 0
|
||||
t.inner = true
|
||||
layout(t.t, t.x + 2, t.y + 2 + t.head, t.w - 4, t.h - 4 - t.head, depth + 1, out)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,29 +400,41 @@ function Treemap({ tree, onOpen }: { tree: SpaceTree; onOpen: (path: string) =>
|
||||
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 < 1 || t.h < 1) continue
|
||||
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 = light ? '#c9ced4' : '#3a414b'
|
||||
else if (isDir) fill = light ? (t.depth % 2 ? '#eef0f3' : '#e3e6ea') : (t.depth % 2 ? '#262d36' : '#1e242c')
|
||||
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)
|
||||
g.strokeStyle = isDir ? border : bg
|
||||
g.lineWidth = 1
|
||||
g.strokeRect(t.x + 0.5, t.y + 0.5, t.w - 1, t.h - 1)
|
||||
// a label when there is room and it would not sit on the folder's own children: the
|
||||
// name, and the size on a second line
|
||||
const labelled = !isDir || !t.inner || (t.head ?? 0) > 0
|
||||
if (labelled && t.w > 36 && t.h > 14) {
|
||||
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 = `${isDir ? '600 ' : ''}11px ${font}`
|
||||
g.fillStyle = isDir || t.agg ? (isDir ? fg : muted) : (light ? '#111' : '#fff')
|
||||
g.textBaseline = 'top'
|
||||
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 > 30 && !t.inner) { g.font = `10px ${font}`; g.fillStyle = isDir || 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 + 16, 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()
|
||||
}
|
||||
}
|
||||
@@ -415,6 +448,8 @@ function Treemap({ tree, onOpen }: { tree: SpaceTree; onOpen: (path: string) =>
|
||||
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 }}
|
||||
@@ -422,10 +457,10 @@ function Treemap({ tree, onOpen }: { tree: SpaceTree; onOpen: (path: string) =>
|
||||
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 - 260), top: hover.y + 12 }}>
|
||||
<div className="mono small" style={{ wordBreak: 'break-all' }}>{hover.tile.agg ? hover.tile.t.name + ' in ' + parentOf(hover.tile.t.path) : hover.tile.t.path}</div>
|
||||
<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</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>
|
||||
)}
|
||||
|
||||
+4
-3
@@ -126,10 +126,10 @@ export interface User { name: string; created: string; last_seen?: string; sessi
|
||||
export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean }
|
||||
export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string }
|
||||
|
||||
export interface SpaceProgress { files: number; dirs: number; bytes: number; denied: number; current: string }
|
||||
export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; error?: string; progress: SpaceProgress; as_root: boolean; files: number; dirs: number; size: number; denied: number }
|
||||
export interface SpaceProgress { files: number; dirs: number; reused: number; bytes: number; denied: number; current: string }
|
||||
export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; loading?: boolean; error?: string; progress: SpaceProgress; as_root: boolean; method?: string; by_root?: Record<string, string>; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record<string, string> }
|
||||
export interface SpaceTarget { id: string; kind: 'disk' | 'pool'; name: string; model?: string; layout?: string; health?: string; size: number; used?: number; disks?: string[]; roots: string[]; scan?: SpaceScan }
|
||||
export interface SpaceNode { name: string; dir: boolean; size: number; apparent: number; mtime: number; files: number; dirs: number; small_n?: number; small_size?: number; mount?: boolean; denied?: boolean }
|
||||
export interface SpaceNode { name: string; dir: boolean; size: number; apparent: number; mtime: number; files: number; dirs: number; small_n?: number; small_size?: number; mount?: boolean; denied?: boolean; partial?: boolean }
|
||||
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
|
||||
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
|
||||
|
||||
@@ -144,6 +144,7 @@ export const api = {
|
||||
spaceStatus: (disk: string) => req<SpaceScan>('GET', `/api/space/${encodeURIComponent(disk)}`),
|
||||
spaceScan: (disk: string) => req<SpaceScan>('POST', `/api/space/${encodeURIComponent(disk)}/scan`),
|
||||
spaceCancel: (disk: string) => req<void>('POST', `/api/space/${encodeURIComponent(disk)}/cancel`),
|
||||
spaceForget: (disk: string) => req<void>('DELETE', `/api/space/${encodeURIComponent(disk)}`),
|
||||
spaceTree: (disk: string, path: string, depth = 1, limit = 200, min = 0) => req<SpaceTree>('GET', `/api/space/${encodeURIComponent(disk)}/tree?path=${encodeURIComponent(path)}&depth=${depth}&limit=${limit}&min=${min}`),
|
||||
spaceLargest: (disk: string, n = 500) => req<SpaceFile[]>('GET', `/api/space/${encodeURIComponent(disk)}/largest?n=${n}`),
|
||||
procSignal: (pid: number, signal: 'TERM' | 'KILL') => req<void>('POST', `/api/procs/${pid}/signal`, { signal }),
|
||||
|
||||
@@ -687,6 +687,7 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.titem .tmain { display: flex; flex-direction: column; min-width: 0; flex: 1; } .titem .tname b { color: var(--fg); }
|
||||
.titem .tsub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; }
|
||||
.titem .tstate { flex-shrink: 0; white-space: nowrap; }
|
||||
.space .warn { color: var(--s2); }
|
||||
.space-tabs { padding: 8px 12px 0; }
|
||||
.space-split { flex: 1; display: flex; flex-direction: column; min-height: 0; padding: 8px 12px 12px; gap: 8px; }
|
||||
.space-tree { flex: 1 1 55%; min-height: 120px; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; background: var(--bg2); overflow: hidden; }
|
||||
|
||||
Reference in New Issue
Block a user