-
-
All clean
+
+
+
Finish setup
- Reclaimed {formatSizeStr(reclaimed)} this session.
+ Set your limit & run the first scan in the full window.
+
)
}
diff --git a/src/renderer/src/panel/PanelApp/PnpmStoreRow.tsx b/src/renderer/src/panel/PanelApp/PnpmStoreRow.tsx
deleted file mode 100644
index 8856c46..0000000
--- a/src/renderer/src/panel/PanelApp/PnpmStoreRow.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { UIIcon } from '@renderer/components/UIIcon'
-import { formatSizeStr } from '@renderer/lib/format'
-import type { PnpmStoreInfo } from '@shared/pnpm-store.types'
-import { type ReactNode, useState } from 'react'
-
-interface PnpmStoreRowProps {
- store: PnpmStoreInfo
- pruning: boolean
- onPrune: () => void
-}
-
-/** Caches section row: global pnpm store size with a safe prune action. */
-export function PnpmStoreRow({ store, pruning, onPrune }: PnpmStoreRowProps): ReactNode {
- const [hover, setHover] = useState(false)
- return (
- <>
-
- Caches
-
-
setHover(true)}
- onMouseLeave={() => setHover(false)}
- style={{
- display: 'flex',
- alignItems: 'center',
- gap: 9,
- padding: '6px 8px',
- margin: '0 6px 4px',
- borderRadius: 7,
- background: hover ? 'var(--hairline)' : 'transparent',
- }}
- >
-
- {UIIcon.hdd({ size: 13 })}
-
-
-
pnpm store
-
- {pruning ? 'Pruning unreferenced packagesβ¦' : store.displayPath}
-
-
-
- {formatSizeStr(store.sizeBytes)}
-
- {store.canPrune && (
-
- )}
-
- >
- )
-}
diff --git a/src/renderer/src/panel/PanelApp/TrackedSummary/TrackedSummary.tsx b/src/renderer/src/panel/PanelApp/TrackedSummary/TrackedSummary.tsx
new file mode 100644
index 0000000..77d6f8f
--- /dev/null
+++ b/src/renderer/src/panel/PanelApp/TrackedSummary/TrackedSummary.tsx
@@ -0,0 +1,93 @@
+import { PixelMeter } from '@renderer/components/PixelMeter'
+import { UIIcon } from '@renderer/components/UIIcon'
+import { statusColor } from '@renderer/lib/colors'
+import { formatSizeStr, GB } from '@renderer/lib/format'
+import type { ReactNode } from 'react'
+import type { TrackedSummaryProps } from './TrackedSummary.types'
+
+/** Panel hero: every tracked byte counted once, against the combined limit.
+ * "Tracked", not "reclaimable" β this is what these areas occupy, not a promise
+ * about what deleting would free (see the spec's D3). */
+export function TrackedSummary({
+ heroBytes,
+ combinedLimitGB,
+ trackMaxGB,
+ areaCount,
+ accent,
+}: TrackedSummaryProps): ReactNode {
+ const limitBytes = combinedLimitGB * GB
+ const over = heroBytes > limitBytes
+ const status = statusColor(limitBytes > 0 ? heroBytes / limitBytes : 0, accent)
+
+ return (
+
+
+ Tracked on disk
+
+
+
+ {formatSizeStr(heroBytes)}
+
+
+
+ {UIIcon.hdd({ size: 12 })}
+ {combinedLimitGB} GB limit
+
+
+ {over ? UIIcon.alert({ size: 12 }) : UIIcon.check({ size: 13 })}
+ {over ? `${formatSizeStr(heroBytes - limitBytes)} over` : `${formatSizeStr(limitBytes - heroBytes)} free`}
+
+
+
+
+ across {areaCount} {areaCount === 1 ? 'area' : 'areas'}
+
+
+
+ )
+}
diff --git a/src/renderer/src/panel/PanelApp/TrackedSummary/TrackedSummary.types.ts b/src/renderer/src/panel/PanelApp/TrackedSummary/TrackedSummary.types.ts
new file mode 100644
index 0000000..f0fc3d4
--- /dev/null
+++ b/src/renderer/src/panel/PanelApp/TrackedSummary/TrackedSummary.types.ts
@@ -0,0 +1,9 @@
+export interface TrackedSummaryProps {
+ /** Every tracked byte, counted once. */
+ heroBytes: number
+ combinedLimitGB: number
+ trackMaxGB: number
+ /** Number of size areas present, for the "across N areas" line. */
+ areaCount: number
+ accent: string
+}
diff --git a/src/renderer/src/panel/PanelApp/TrackedSummary/index.ts b/src/renderer/src/panel/PanelApp/TrackedSummary/index.ts
new file mode 100644
index 0000000..0891e51
--- /dev/null
+++ b/src/renderer/src/panel/PanelApp/TrackedSummary/index.ts
@@ -0,0 +1,2 @@
+export { TrackedSummary } from './TrackedSummary'
+export type { TrackedSummaryProps } from './TrackedSummary.types'
diff --git a/src/renderer/src/panel/PanelApp/index.ts b/src/renderer/src/panel/PanelApp/index.ts
index 4e34bca..06d256e 100644
--- a/src/renderer/src/panel/PanelApp/index.ts
+++ b/src/renderer/src/panel/PanelApp/index.ts
@@ -1,2 +1,2 @@
export { PanelApp } from './PanelApp'
-export type { PanelToast, PanelView } from './PanelApp.types'
+export type { PanelView } from './PanelApp.types'
diff --git a/src/renderer/src/panel/PanelApp/panelAreas.test.ts b/src/renderer/src/panel/PanelApp/panelAreas.test.ts
new file mode 100644
index 0000000..9a6ffe6
--- /dev/null
+++ b/src/renderer/src/panel/PanelApp/panelAreas.test.ts
@@ -0,0 +1,189 @@
+import { GB } from '@renderer/lib/format'
+import type { DockerInfo } from '@shared/docker.types'
+import type { PackageEntry, PackageInventory } from '@shared/package.types'
+import type { PnpmStoreInfo } from '@shared/pnpm-store.types'
+import type { Project } from '@shared/project.types'
+import { describe, expect, it } from 'vitest'
+import { type PanelAreaRow, type PanelAreasInput, panelAreas } from './panelAreas'
+
+const project = (uniqueSize: number): Project => ({
+ id: `p${uniqueSize}`,
+ name: 'demo',
+ path: '~/demo',
+ absPath: '/Users/x/demo',
+ kind: 'node',
+ size: uniqueSize,
+ uniqueSize,
+ lastUsed: 0,
+})
+
+const store = (sizeBytes: number, available = true): PnpmStoreInfo => ({
+ available,
+ path: '/store',
+ displayPath: '~/store',
+ sizeBytes,
+ checkedAt: 0,
+ source: 'pnpm',
+ canPrune: true,
+})
+
+const docker = (sizeBytes: number, available = true): DockerInfo => ({
+ available,
+ checkedAt: 0,
+ totals: [{ kind: 'image', sizeBytes, reclaimableBytes: 0, count: 1 }],
+ items: [],
+ projects: [],
+})
+
+// 34.8 GB of project-unique bytes + a 6.4 GB store + 23.6 GB of Docker.
+const base: PanelAreasInput = {
+ projects: [project(34.8 * GB)],
+ store: store(6.4 * GB),
+ docker: docker(23.6 * GB),
+ dockerEnabled: true,
+ inventory: null,
+ checkUpdates: true,
+ thresholdGB: 20,
+ cacheThresholdGB: 10,
+ dockerThresholdGB: 20,
+}
+
+const inventory = (packages: PackageEntry[], enrichmentError?: string): PackageInventory => ({
+ packages,
+ computedAt: 1,
+ projectCount: 1,
+ ...(enrichmentError ? { enrichmentError } : {}),
+})
+
+const pkg = (name: string, severity?: 'critical' | 'high'): PackageEntry => ({
+ name,
+ usages: [],
+ projectCount: 1,
+ versions: ['1.0.0'],
+ multipleVersions: false,
+ ...(severity ? { advisory: { severity, title: 't', vulnerableVersions: '<1' } } : {}),
+})
+
+const rowIds = (i: PanelAreasInput): string[] => panelAreas(i).rows.map((r) => r.id)
+const row = (i: PanelAreasInput, id: string): PanelAreaRow => {
+ const found = panelAreas(i).rows.find((r) => r.id === id)
+ if (!found) throw new Error(`no ${id} row`)
+ return found
+}
+
+describe('panelAreas hero', () => {
+ it('counts every byte once: hero = projects + store + docker', () => {
+ // 34.8 + 6.4 (store, once) + 23.6 = 64.8
+ expect(panelAreas(base).heroBytes).toBeCloseTo(64.8 * GB, 0)
+ })
+
+ it('combined limit sums the three limits', () => {
+ expect(panelAreas(base).combinedLimitGB).toBe(50)
+ expect(panelAreas(base).areaCount).toBe(3)
+ })
+
+ it('drops Docker bytes, limit and area when Docker is disabled', () => {
+ const r = panelAreas({ ...base, dockerEnabled: false })
+ expect(r.heroBytes).toBeCloseTo(41.2 * GB, 0)
+ expect(r.combinedLimitGB).toBe(30)
+ expect(r.areaCount).toBe(2)
+ })
+
+ it('drops Docker the same way when the daemon is unavailable', () => {
+ const r = panelAreas({ ...base, docker: docker(23.6 * GB, false) })
+ expect(r.heroBytes).toBeCloseTo(41.2 * GB, 0)
+ expect(r.combinedLimitGB).toBe(30)
+ expect(r.areaCount).toBe(2)
+ })
+
+ it('drops the cache limit and area when no store is available', () => {
+ // An unavailable store contributes no bytes, so the hero loses its 6.4 GB too.
+ const r = panelAreas({ ...base, store: store(6.4 * GB, false) })
+ expect(r.heroBytes).toBeCloseTo(58.4 * GB, 0)
+ expect(r.combinedLimitGB).toBe(40)
+ expect(r.areaCount).toBe(2)
+ })
+
+ it('falls back to apparent size for projects never rescanned', () => {
+ const p: Project = { ...project(0), size: 5 * GB, uniqueSize: undefined }
+ const r = panelAreas({ ...base, projects: [p], store: store(0), docker: docker(0) })
+ expect(r.heroBytes).toBeCloseTo(5 * GB, 0)
+ })
+
+ it('scales the meter track past the limit and past usage', () => {
+ // 64.8 used vs a 50 GB combined limit: track must clear usage.
+ expect(panelAreas(base).trackMaxGB).toBeGreaterThan(64.8)
+ })
+})
+
+describe('panelAreas rows', () => {
+ it('lists the four areas in order when everything is present', () => {
+ expect(rowIds(base)).toEqual(['projects', 'caches', 'packages', 'docker'])
+ })
+
+ it('every size row mirrors its tab headline number', () => {
+ const r = base
+ // Projects mirrors totalUsed, which INCLUDES the store (34.8 + 6.4).
+ // Assert the same summation the code performs so the comparison stays exact;
+ // 41.2*GB is not bit-identical to 34.8*GB + 6.4*GB due to IEEE 754 precision.
+ expect(row(r, 'projects')).toMatchObject({ kind: 'size', usedBytes: 34.8 * GB + 6.4 * GB, thresholdGB: 20 })
+ // Caches mirrors the Caches gauge: the store alone.
+ expect(row(r, 'caches')).toMatchObject({ kind: 'size', usedBytes: 6.4 * GB, thresholdGB: 10 })
+ expect(row(r, 'docker')).toMatchObject({ kind: 'size', usedBytes: 23.6 * GB, thresholdGB: 20 })
+ })
+
+ it('rows exceed the hero by exactly the pnpm store (D4 invariant)', () => {
+ const r = panelAreas(base)
+ const summed = r.rows.reduce((a, x) => a + (x.kind === 'size' ? x.usedBytes : 0), 0)
+ expect(summed - r.heroBytes).toBeCloseTo(6.4 * GB, 0)
+ })
+
+ it('each row carries the nav target for its tab', () => {
+ expect(row(base, 'projects').nav).toBe('projects')
+ expect(row(base, 'caches').nav).toBe('caches')
+ expect(row(base, 'packages').nav).toBe('packages')
+ expect(row(base, 'docker').nav).toBe('docker')
+ })
+
+ it('hides the Docker row when disabled or unavailable', () => {
+ expect(rowIds({ ...base, dockerEnabled: false })).toEqual(['projects', 'caches', 'packages'])
+ expect(rowIds({ ...base, docker: docker(1 * GB, false) })).toEqual(['projects', 'caches', 'packages'])
+ })
+
+ it('hides the Caches row when no store is available', () => {
+ expect(rowIds({ ...base, store: store(6.4 * GB, false) })).toEqual(['projects', 'packages', 'docker'])
+ })
+
+ it('shows severity once an inventory is cached', () => {
+ const r = row({ ...base, inventory: inventory([pkg('a', 'critical'), pkg('b', 'high'), pkg('c')]) }, 'packages')
+ expect(r).toMatchObject({ kind: 'severity', packagesTotal: 3 })
+ if (r.kind !== 'severity') throw new Error('expected severity')
+ expect(r.severity.vulnerable).toBe(2)
+ expect(r.severity.critical).toBe(1)
+ })
+
+ it('placeholders rather than claiming all clear when never computed', () => {
+ expect(row(base, 'packages')).toMatchObject({ kind: 'placeholder', note: 'Not checked yet' })
+ })
+
+ it('placeholders when the update check is off', () => {
+ const i = { ...base, checkUpdates: false, inventory: inventory([pkg('a')]) }
+ expect(row(i, 'packages')).toMatchObject({ kind: 'placeholder' })
+ })
+
+ it('placeholders when enrichment failed, so 0 vulns is not shown as all clear', () => {
+ const i = { ...base, inventory: inventory([pkg('a')], 'offline') }
+ expect(row(i, 'packages')).toMatchObject({ kind: 'placeholder' })
+ })
+
+ it('keeps the whole dashboard when there are no projects (D6b)', () => {
+ const r = panelAreas({ ...base, projects: [] })
+ expect(r.rows.map((x) => x.id)).toEqual(['projects', 'caches', 'packages', 'docker'])
+ // Projects still reports the store, which is real disk that projects link to.
+ expect(row({ ...base, projects: [] }, 'projects')).toMatchObject({ usedBytes: 6.4 * GB })
+ })
+
+ it('formats the value string for size rows', () => {
+ expect(row(base, 'docker')).toMatchObject({ value: '23.60 GB' })
+ })
+})
diff --git a/src/renderer/src/panel/PanelApp/panelAreas.ts b/src/renderer/src/panel/PanelApp/panelAreas.ts
new file mode 100644
index 0000000..1ac2000
--- /dev/null
+++ b/src/renderer/src/panel/PanelApp/panelAreas.ts
@@ -0,0 +1,140 @@
+import { formatSizeStr, GB } from '@renderer/lib/format'
+import { type SeverityCounts, severityCounts } from '@renderer/lib/severity'
+import type { DockerInfo } from '@shared/docker.types'
+import type { LauncherNavTarget } from '@shared/launcher-nav.types'
+import type { PackageInventory } from '@shared/package.types'
+import type { PnpmStoreInfo } from '@shared/pnpm-store.types'
+import type { Project } from '@shared/project.types'
+
+export interface PanelAreasInput {
+ projects: Project[]
+ store: PnpmStoreInfo | null
+ docker: DockerInfo | null
+ /** settings.docker !== false */
+ dockerEnabled: boolean
+ inventory: PackageInventory | null
+ /** settings.checkUpdates */
+ checkUpdates: boolean
+ thresholdGB: number
+ cacheThresholdGB: number
+ dockerThresholdGB: number
+}
+
+export interface PanelAreas {
+ /** Every tracked byte, counted once (see D4): projects + store + docker. */
+ heroBytes: number
+ /** Sum of the limits of the areas actually present. */
+ combinedLimitGB: number
+ /** Upper bound of the hero meter's track. */
+ trackMaxGB: number
+ /** How many size areas are present (max 3). Packages is never counted: it is not bytes. */
+ areaCount: number
+ rows: PanelAreaRow[]
+}
+
+interface PanelAreaRowBase {
+ id: 'projects' | 'caches' | 'packages' | 'docker'
+ /** Tab this row opens in the launcher when clicked. */
+ nav: LauncherNavTarget
+ label: string
+}
+
+/** A size area: renders a slim PixelMeter against its own limit. */
+export interface PanelSizeRow extends PanelAreaRowBase {
+ kind: 'size'
+ usedBytes: number
+ thresholdGB: number
+ /** Upper bound of this row's meter track. */
+ trackMaxGB: number
+ /** Preformatted for the row's right-hand value. */
+ value: string
+}
+
+/** The packages area: renders a SeverityMeter, which draws its own label. */
+export interface PanelSeverityRow extends PanelAreaRowBase {
+ kind: 'severity'
+ severity: SeverityCounts
+ packagesTotal: number
+}
+
+/** An area with nothing honest to show yet. Still clicks through. */
+export interface PanelPlaceholderRow extends PanelAreaRowBase {
+ kind: 'placeholder'
+ note: string
+}
+
+export type PanelAreaRow = PanelSizeRow | PanelSeverityRow | PanelPlaceholderRow
+
+/** Meter track: always clears the limit marker and the current usage. Named apart from
+ * the `trackMaxGB` fields it feeds, so the function and the value never read alike. */
+function meterTrackMax(usedGB: number, limitGB: number): number {
+ return Math.max(limitGB * 1.5, usedGB * 1.06)
+}
+
+function sizeRow(
+ id: 'projects' | 'caches' | 'docker',
+ label: string,
+ usedBytes: number,
+ thresholdGB: number,
+): PanelSizeRow {
+ return {
+ id,
+ nav: id,
+ label,
+ kind: 'size',
+ usedBytes,
+ thresholdGB,
+ trackMaxGB: meterTrackMax(usedBytes / GB, thresholdGB),
+ value: formatSizeStr(usedBytes),
+ }
+}
+
+/** Packages can only show a severity bar when the check is on, an inventory is cached,
+ * and enrichment actually succeeded. Anything else would render "all clear" for data
+ * that was never checked. */
+function packagesRow(input: PanelAreasInput): PanelSeverityRow | PanelPlaceholderRow {
+ const ready = !!input.inventory && !input.inventory.enrichmentError
+ if (!input.checkUpdates || !ready || !input.inventory) {
+ return { id: 'packages', nav: 'packages', label: 'Packages', kind: 'placeholder', note: 'Not checked yet' }
+ }
+ return {
+ id: 'packages',
+ nav: 'packages',
+ label: 'Packages',
+ kind: 'severity',
+ severity: severityCounts(input.inventory.packages),
+ packagesTotal: input.inventory.packages.length,
+ }
+}
+
+/** Hero totals and per-area presence for the menu bar panel.
+ *
+ * The pnpm store is counted ONCE here, inside the projects term (that is what
+ * `uniqueSize` means: bytes freed by deleting node_modules now, with store-backed
+ * content excluded). The Caches row reports the same store again because it mirrors
+ * the Caches tab, so the rows deliberately sum to MORE than the hero. See D4. */
+export function panelAreas(input: PanelAreasInput): PanelAreas {
+ const cachesAvailable = !!input.store?.available
+ const storeBytes = cachesAvailable ? (input.store?.sizeBytes ?? 0) : 0
+ const projectsUsed = input.projects.reduce((a, p) => a + (p.uniqueSize ?? p.size), 0) + storeBytes
+
+ const dockerAvailable = input.dockerEnabled && !!input.docker?.available
+ const dockerUsed = dockerAvailable ? (input.docker?.totals ?? []).reduce((s, t) => s + t.sizeBytes, 0) : 0
+
+ const heroBytes = projectsUsed + dockerUsed
+ const combinedLimitGB =
+ input.thresholdGB + (cachesAvailable ? input.cacheThresholdGB : 0) + (dockerAvailable ? input.dockerThresholdGB : 0)
+
+ const rows: PanelAreaRow[] = [sizeRow('projects', 'Projects', projectsUsed, input.thresholdGB)]
+ if (cachesAvailable) rows.push(sizeRow('caches', 'Caches', storeBytes, input.cacheThresholdGB))
+ rows.push(packagesRow(input))
+ if (dockerAvailable) rows.push(sizeRow('docker', 'Docker', dockerUsed, input.dockerThresholdGB))
+
+ return {
+ heroBytes,
+ combinedLimitGB,
+ trackMaxGB: meterTrackMax(heroBytes / GB, combinedLimitGB),
+ areaCount: 1 + (cachesAvailable ? 1 : 0) + (dockerAvailable ? 1 : 0),
+ rows,
+ }
+}
diff --git a/src/shared/docker.types.ts b/src/shared/docker.types.ts
new file mode 100644
index 0000000..469d689
--- /dev/null
+++ b/src/shared/docker.types.ts
@@ -0,0 +1,54 @@
+import type { FrameworkKind } from './project.types'
+
+export type DockerItemKind = 'image' | 'volume' | 'container' | 'buildcache'
+
+export type DockerPruneTarget = 'danglingImages' | 'unusedImages' | 'stoppedContainers' | 'buildCache' | 'unusedVolumes'
+
+export interface DockerItem {
+ /** image ID / volume name / container ID / build-cache ID */
+ id: string
+ kind: DockerItemKind
+ /** repo:tag, volume name, container name, or cache short id */
+ name: string
+ sizeBytes: number
+ /** ms epoch; 0 when docker does not report a creation time */
+ createdAt: number
+ /** referenced by an existing container (image/volume) or running (container) */
+ inUse: boolean
+ /** false whenever inUse, or for build-cache rows (no per-item removal) */
+ removable: boolean
+ /** Compose project this resource is grouped under; undefined = unaffiliated ("Other"). */
+ project?: string
+ /** Image repository (name before the last ':'), for repo sub-grouping in "Other". */
+ repository?: string
+}
+
+export interface DockerProject {
+ name: string
+ workingDir?: string
+ kind?: FrameworkKind
+ iconDataUrl?: string
+}
+
+export interface DockerCategoryTotal {
+ kind: DockerItemKind
+ sizeBytes: number
+ reclaimableBytes: number
+ count: number
+}
+
+export interface DockerInfo {
+ /** docker CLI found AND daemon reachable */
+ available: boolean
+ /** when unavailable: 'not installed' | 'daemon not running' */
+ reason?: string
+ checkedAt: number
+ totals: DockerCategoryTotal[]
+ items: DockerItem[]
+ projects: DockerProject[]
+}
+
+export interface DockerActionResult {
+ ok: boolean
+ freedBytes: number
+}
diff --git a/src/shared/ipc.constants.ts b/src/shared/ipc.constants.ts
index 32f8c6d..82e0e06 100644
--- a/src/shared/ipc.constants.ts
+++ b/src/shared/ipc.constants.ts
@@ -20,6 +20,9 @@ export const IPC = {
prunePnpmStore: 'pnpm-store:prune',
getPackages: 'packages:get',
computePackages: 'packages:compute',
+ getDocker: 'docker:get',
+ removeDockerItem: 'docker:remove',
+ pruneDocker: 'docker:prune',
openExternal: 'shell:open-external',
pickPath: 'dialog:pick-path',
getLicense: 'license:get',
diff --git a/src/shared/launcher-nav.types.ts b/src/shared/launcher-nav.types.ts
index a00fae4..8974cb9 100644
--- a/src/shared/launcher-nav.types.ts
+++ b/src/shared/launcher-nav.types.ts
@@ -1,2 +1,2 @@
-/** A view the launcher can be told to open on (e.g. from the menu-bar settings entry). */
-export type LauncherNavTarget = 'settings'
+/** A view or tab the launcher can be told to open on (e.g. from a menu-bar panel click). */
+export type LauncherNavTarget = 'settings' | 'projects' | 'caches' | 'packages' | 'docker'
diff --git a/src/shared/settings.constants.ts b/src/shared/settings.constants.ts
index 7dc088b..bdc4650 100644
--- a/src/shared/settings.constants.ts
+++ b/src/shared/settings.constants.ts
@@ -5,12 +5,15 @@ export const DEFAULT_SETTINGS: Settings = {
sizeStyle: 'plain',
density: 'roomy',
thresholdGB: 5,
+ cacheThresholdGB: 10,
+ dockerThresholdGB: 20,
scanInterval: 'daily',
notify: true,
onboarded: false,
checkUpdates: true,
analytics: true,
scanRoots: [],
+ docker: true,
}
export const SCAN_INTERVAL_MS: Record
, number> = {
diff --git a/src/shared/settings.types.ts b/src/shared/settings.types.ts
index f4d14aa..c821c06 100644
--- a/src/shared/settings.types.ts
+++ b/src/shared/settings.types.ts
@@ -7,6 +7,10 @@ export interface Settings {
sizeStyle: SizeStyle
density: Density
thresholdGB: number
+ /** pnpm cache (store) size limit, GB β headline gauge on the Caches tab. */
+ cacheThresholdGB: number
+ /** Docker total size limit, GB β headline gauge on the Docker tab. */
+ dockerThresholdGB: number
scanInterval: ScanInterval
notify: boolean
onboarded: boolean
@@ -20,4 +24,8 @@ export interface Settings {
/** Extra scan roots opted in by the user (absolute paths): toggled external
* volumes and arbitrary "Add folderβ¦" paths. Home is implicit, never stored. */
scanRoots: string[]
+ /** Show the Docker tab and allow Docker cleanup. */
+ docker?: boolean
+ /** Manual override: path to the docker executable. */
+ dockerBinaryPath?: string
}