Space: the picker itself — pools with layout and members, then disks on their own
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
e9fa21768e
commit
8f77cc23e1
+94
-47
@@ -3,11 +3,11 @@
|
|||||||
// a tree of folders largest-first, and a treemap of whatever folder is selected, where every
|
// 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.
|
// 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api, SpaceDisk, SpaceFile, SpaceScan, SpaceTree } from './api'
|
import { api, SpaceFile, SpaceTarget, SpaceTree } from './api'
|
||||||
import { fmtBytes } from './Dashboard'
|
import { fmtBytes } from './Dashboard'
|
||||||
import { useHashSel } from './hashsel'
|
import { useHashSel } from './hashsel'
|
||||||
import { useRefreshMs } from './prefs'
|
import { useRefreshMs } from './prefs'
|
||||||
import { Btn, Loading, Spinner, toast } from './ui'
|
import { Btn, Loading, Spinner, toast, useEscape } from './ui'
|
||||||
|
|
||||||
// ---- what kind of file a tile is: the colour it wears (the validated categorical set, dark and light) ----
|
// ---- what kind of file a tile is: the colour it wears (the validated categorical set, dark and light) ----
|
||||||
|
|
||||||
@@ -46,80 +46,127 @@ const fmtDur = (a: string, b?: string) => { const ms = (b ? new Date(b).getTime(
|
|||||||
const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/')
|
const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/')
|
||||||
|
|
||||||
export default function Space() {
|
export default function Space() {
|
||||||
const [disks, setDisks] = useState<SpaceDisk[] | null>(null)
|
const [targets, setTargets] = useState<SpaceTarget[] | null>(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [sel, setSel] = useHashSel('space')
|
const [sel, setSel] = useHashSel('space')
|
||||||
const [tab, setTab] = useState<'tree' | 'files'>('tree')
|
const [tab, setTab] = useState<'tree' | 'files'>('tree')
|
||||||
const rate = useRefreshMs()
|
const rate = useRefreshMs()
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try { const r = await api.space(); setDisks(r.disks); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
try { const r = await api.space(); setTargets(r.targets); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
}, [])
|
}, [])
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
const disk = disks?.find((d) => d.name === sel) ?? disks?.find((d) => d.roots.length > 0) ?? disks?.[0]
|
const target = targets?.find((t) => t.id === sel) ?? targets?.find((t) => t.roots.length > 0) ?? targets?.[0]
|
||||||
useEffect(() => { if (disk && disk.name !== sel) setSel(disk.name) }, [disk, sel, setSel])
|
useEffect(() => { if (target && target.id !== sel) setSel(target.id) }, [target, sel, setSel])
|
||||||
const scan = disk?.scan
|
const scan = target?.scan
|
||||||
// while a scan runs its status alone is polled at the refresh rate (the disk list means
|
// while a scan runs its status alone is polled at the refresh rate (the target list means
|
||||||
// lsblk and zfs list, which need not run twice a second); the list is read again when it ends
|
// lsblk and zpool, which need not run twice a second); the list is read again when it ends
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scan?.running || !disk) return
|
if (!scan?.running || !target) return
|
||||||
const name = disk.name
|
const id = target.id
|
||||||
const t = setInterval(async () => {
|
const t = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const st = await api.spaceStatus(name)
|
const st = await api.spaceStatus(id)
|
||||||
setDisks((ds) => ds?.map((d) => (d.name === name ? { ...d, scan: st } : d)) ?? ds)
|
setTargets((ts) => ts?.map((x) => (x.id === id ? { ...x, scan: st } : x)) ?? ts)
|
||||||
} catch { /* the next tick */ }
|
} catch { /* the next tick */ }
|
||||||
}, Math.max(250, rate))
|
}, Math.max(250, rate))
|
||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [scan?.running, disk, rate])
|
}, [scan?.running, target, rate])
|
||||||
|
|
||||||
const start = async () => { if (!disk) return; try { await api.spaceScan(disk.name); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
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 (!disk) return; try { await api.spaceCancel(disk.name); 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') } }
|
||||||
|
|
||||||
if (err && !disks) return <div className="dash"><div className="error">{err}</div></div>
|
if (err && !targets) return <div className="dash"><div className="error">{err}</div></div>
|
||||||
if (!disks) return <div className="dash"><Loading label="Looking at the disks…" /></div>
|
if (!targets) return <div className="dash"><Loading label="Looking at the disks…" /></div>
|
||||||
const done = !!scan && !scan.running && !scan.error && !!scan.finished
|
const done = !!scan && !scan.running && !scan.error && !!scan.finished
|
||||||
return (
|
return (
|
||||||
<div className="space">
|
<div className="space">
|
||||||
<div className="space-bar">
|
<div className="space-bar">
|
||||||
<div className="chips">
|
<div className="space-pick-row">
|
||||||
{disks.map((d) => (
|
<TargetPicker targets={targets} current={target ?? null} onPick={(t) => setSel(t.id)} />
|
||||||
<button key={d.name} className={'chip disk' + (disk?.name === d.name ? ' on' : '')} disabled={d.roots.length === 0} title={d.roots.length ? d.roots.join(' ') : 'nothing is mounted from this disk'} onClick={() => setSel(d.name)}>
|
{target && (
|
||||||
<b>{d.name}</b>{d.model && <span className="muted"> · {d.model}</span>} · {fmtBytes(d.size)}{d.pool && <span className="muted"> · pool {d.pool}</span>}{d.scan?.running && <Spinner size={10} />}
|
<div className="space-status">
|
||||||
</button>
|
{scan?.running ? (
|
||||||
))}
|
<>
|
||||||
{disks.length === 0 && <span className="muted small">No disks found.</span>}
|
<Spinner /><span className="mono small">{fmtN(scan.progress.files)} files · {fmtBytes(scan.progress.bytes)} · {fmtDur(scan.started)}</span>
|
||||||
|
<Btn className="btn danger" onClick={cancel}>Cancel</Btn>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{disk && (
|
{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 className="space-status">
|
|
||||||
{scan?.running ? (
|
|
||||||
<>
|
|
||||||
<Spinner /><span className="mono small">{fmtN(scan.progress.files)} files · {fmtBytes(scan.progress.bytes)} · {fmtDur(scan.started)}</span>
|
|
||||||
<span className="muted small mono cur" title={scan.progress.current}>{scan.progress.current}</span>
|
|
||||||
<Btn className="btn danger" onClick={cancel}>Cancel</Btn>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{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="error small">{scan.error}</span>}
|
|
||||||
{!scan && <span className="muted small">{disk.roots.length ? `Scans ${disk.roots.join(', ')}` : 'Nothing is mounted from this disk.'}</span>}
|
|
||||||
<Btn className="btn on" disabled={disk.roots.length === 0} onClick={start}>{done ? 'Rescan' : 'Scan'}</Btn>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{disk && done && (
|
{target && done && (
|
||||||
<>
|
<>
|
||||||
<div className="chips space-tabs">
|
<div className="chips space-tabs">
|
||||||
<button className={'chip' + (tab === 'tree' ? ' on' : '')} onClick={() => setTab('tree')}>Folders</button>
|
<button className={'chip' + (tab === 'tree' ? ' on' : '')} onClick={() => setTab('tree')}>Folders</button>
|
||||||
<button className={'chip' + (tab === 'files' ? ' on' : '')} onClick={() => setTab('files')}>Largest files</button>
|
<button className={'chip' + (tab === 'files' ? ' on' : '')} onClick={() => setTab('files')}>Largest files</button>
|
||||||
</div>
|
</div>
|
||||||
{tab === 'tree' ? <Explorer key={disk.name + (scan?.finished ?? '')} disk={disk.name} /> : <LargestFiles key={disk.name + (scan?.finished ?? '')} disk={disk.name} />}
|
{tab === 'tree' ? <Explorer key={target.id + (scan?.finished ?? '')} disk={target.id} /> : <LargestFiles key={target.id + (scan?.finished ?? '')} disk={target.id} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{disk && !done && !scan?.running && <div className="svc-empty muted">{scan?.error ? 'The last scan failed. Scan again.' : 'Scan the disk to see what is on it.'}</div>}
|
{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>}
|
||||||
{disk && scan?.running && <div className="svc-empty muted">Scanning…</div>}
|
{target && scan?.running && <div className="svc-empty muted">Scanning…</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -128,7 +128,7 @@ export interface TokenInfo { name: string; created: string; scope?: string; sens
|
|||||||
|
|
||||||
export interface SpaceProgress { files: number; dirs: number; bytes: number; denied: number; current: 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 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 SpaceDisk { name: string; kind: string; model?: string; size: number; pool?: string; roots: string[]; scan?: SpaceScan }
|
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 }
|
||||||
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
|
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 }
|
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
|
||||||
@@ -140,7 +140,7 @@ const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponen
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
procs: () => req<ProcTable>('GET', '/api/procs'),
|
procs: () => req<ProcTable>('GET', '/api/procs'),
|
||||||
space: () => req<{ disks: SpaceDisk[] }>('GET', '/api/space'),
|
space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'),
|
||||||
spaceStatus: (disk: string) => req<SpaceScan>('GET', `/api/space/${encodeURIComponent(disk)}`),
|
spaceStatus: (disk: string) => req<SpaceScan>('GET', `/api/space/${encodeURIComponent(disk)}`),
|
||||||
spaceScan: (disk: string) => req<SpaceScan>('POST', `/api/space/${encodeURIComponent(disk)}/scan`),
|
spaceScan: (disk: string) => req<SpaceScan>('POST', `/api/space/${encodeURIComponent(disk)}/scan`),
|
||||||
spaceCancel: (disk: string) => req<void>('POST', `/api/space/${encodeURIComponent(disk)}/cancel`),
|
spaceCancel: (disk: string) => req<void>('POST', `/api/space/${encodeURIComponent(disk)}/cancel`),
|
||||||
|
|||||||
+14
-3
@@ -672,10 +672,21 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
|||||||
/* ---- Space: what is using a disk ---- */
|
/* ---- Space: what is using a disk ---- */
|
||||||
.space { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
|
.space { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
|
||||||
.space-bar { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); }
|
.space-bar { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); }
|
||||||
.space-bar .chip.disk b { color: var(--fg); font-weight: 600; } .space-bar .chip.disk:disabled { opacity: .45; cursor: default; }
|
.space-pick-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
.space-bar .chip.disk .spin { margin-left: 6px; margin-right: 0; }
|
|
||||||
.space-status { display: flex; align-items: center; gap: 10px; min-height: 28px; flex-wrap: wrap; }
|
.space-status { display: flex; align-items: center; gap: 10px; min-height: 28px; flex-wrap: wrap; }
|
||||||
.space-status .cur { flex: 1 1 200px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
|
.space-about { display: flex; gap: 0; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.space-about .cur { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
/* the disk-or-pool picker */
|
||||||
|
.tpick { position: relative; min-width: 260px; max-width: 100%; }
|
||||||
|
.tpick-btn { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; text-align: left; max-width: 100%; }
|
||||||
|
.tpick-btn b { color: var(--accent); } .tpick-btn .chev { margin-left: auto; color: var(--muted); padding-left: 8px; } .tpick-btn .spin { margin: 0 0 0 4px; }
|
||||||
|
.ticon { color: var(--accent2); font-family: ui-monospace, monospace; width: 16px; text-align: center; flex-shrink: 0; }
|
||||||
|
.tmenu { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: min(520px, 90vw); max-height: 60vh; overflow-y: auto; background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.4); padding: 4px; }
|
||||||
|
.titem { width: 100%; display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; text-align: left; }
|
||||||
|
.titem:hover { background: var(--bg3); } .titem.on { box-shadow: inset 3px 0 0 var(--accent); background: var(--bg3); } .titem.off { opacity: .5; cursor: default; }
|
||||||
|
.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-tabs { padding: 8px 12px 0; }
|
.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-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; }
|
.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