diff --git a/STATUS.html b/STATUS.html index de29818..3a00db4 100644 --- a/STATUS.html +++ b/STATUS.html @@ -6,8 +6,7 @@ TidyDisk β€” Project Status diff --git a/docs/superpowers/plans/2026-07-07-docker-cleanup.md b/docs/superpowers/plans/2026-07-07-docker-cleanup.md new file mode 100644 index 0000000..f78fb14 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-docker-cleanup.md @@ -0,0 +1,1069 @@ +# Docker Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a launcher "Docker" tab that lists Docker images/volumes/stopped-containers/build-cache with size, created date, and in-use status, and reclaims space via the `docker` CLI, with a tiered permanent-delete safety model. + +**Architecture:** Clone the pnpm-store/Caches feature end to end: `src/shared/docker.types.ts` (data model), `src/main/docker/` (find-docker + cached CLI wrappers via `execFileAsync`, no shell), gated IPC handlers in `register-ipc.ts`, and a `DockerView` renderer tab. Listing is free (ungated); every mutation opens with the license gate. Docker removals are permanent (no Trash), so volumes get an extra typed-name confirmation and in-use items are read-only. + +**Tech Stack:** Electron + TypeScript, React renderer, vitest (`pnpm test`), biome (`pnpm lint`), electron-vite (`pnpm build`), two tsconfig typechecks (`pnpm typecheck`). Spec: `docs/superpowers/specs/2026-07-07-docker-cleanup-design.md`. + +## Global Constraints + +- Package manager: pnpm. All gates green per task: `pnpm typecheck && pnpm test && pnpm lint && pnpm build`. +- All `docker` CLI calls use `promisify(execFile)` with **array args, no shell** (injection-safe) and a timeout. Never interpolate item names/ids into a shell string. +- Every mutating IPC handler OPENS with `if (!ctx.license.get().pro) return { ok: false, freedBytes: 0 }` (read-only `docker:get` is ungated). +- In-use items are never force-removed: `removable === false` β‡’ no remove action. No `-f` on `rmi`/`rm`/`volume rm` of in-use items (the `-f` on `prune` only skips docker's own y/N prompt). +- Volume removals (per-item and unused-volume prune) require a typed-name confirmation in the UI before the IPC call is made. +- Docker removals are labeled "permanent, not sent to the Trash" in the UI. +- Follow existing conventions: one folder per component; shared types in `src/shared`; biome formatting (single quotes, no semicolons, 2-space). +- Commit style `feat(app): …` / `test(app): …`; run gates before each commit. + +## File Structure + +- Create `src/shared/docker.types.ts` β€” data model. +- Create `src/main/docker/find-docker.ts` β€” binary resolution (mirrors `find-pnpm.ts`). +- Create `src/main/docker/docker-parse.ts` β€” pure parsers (`docker system df -v` + `docker ps -a` β†’ `DockerItem[]`/totals). Pure, no exec β€” fully unit-tested. +- Create `src/main/docker/docker.ts` β€” `getDockerInfo`, `removeDockerItem`, `pruneDocker` (exec + cache; exec injected for tests). +- Create `src/main/docker/validate-docker-arg.ts` β€” IPC payload validation (kind/id/prune target). +- Modify `src/shared/ipc.constants.ts`, `src/shared/settings.types.ts`, `src/shared/settings.constants.ts`, `src/main/settings/validate-setting.ts`, `src/main/analytics/analytics.ts`. +- Modify `src/main/ipc/register-ipc.ts`, `src/preload/index.ts`, `src/preload/api.types.ts`. +- Create `src/renderer/src/hooks/useDocker.ts`, `src/renderer/src/launcher/views/DockerView.tsx`. +- Modify `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx`, `.../LauncherApp.types.ts`, `.../components/Segmented`, `SettingsView.tsx`. + +--- + +## PHASE A β€” read-only listing (free, ungated) + +### Task 1: Shared model, IPC channels, settings keys + +**Files:** +- Create: `src/shared/docker.types.ts` +- Modify: `src/shared/ipc.constants.ts`, `src/shared/settings.types.ts`, `src/shared/settings.constants.ts` +- Modify + Test: `src/main/settings/validate-setting.ts`, `src/main/settings/validate-setting.test.ts` + +**Interfaces:** +- Produces: `DockerItemKind`, `DockerItem`, `DockerCategoryTotal`, `DockerInfo`, `DockerActionResult`, `DockerPruneTarget`; IPC channels `getDocker='docker:get'`, `removeDockerItem='docker:remove'`, `pruneDocker='docker:prune'`; settings `docker?: boolean`, `dockerBinaryPath?: string`. + +- [ ] **Step 1: Create `src/shared/docker.types.ts`** + +```ts +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 +} + +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[] +} + +export interface DockerActionResult { + ok: boolean + freedBytes: number +} +``` + +- [ ] **Step 2: Add IPC channels** to `src/shared/ipc.constants.ts` β€” after the `getPackages`/`computePackages` lines, add: + +```ts + getDocker: 'docker:get', + removeDockerItem: 'docker:remove', + pruneDocker: 'docker:prune', +``` + +- [ ] **Step 3: Add settings keys** β€” in `src/shared/settings.types.ts`, inside `Settings`, after `pnpmBinaryPath?: string`: + +```ts + /** Show the Docker tab and allow Docker cleanup. */ + docker?: boolean + /** Manual override: path to the docker executable. */ + dockerBinaryPath?: string +``` + +In `src/shared/settings.constants.ts`, add to `DEFAULT_SETTINGS` (after `analytics: true,`): + +```ts + docker: true, +``` + +- [ ] **Step 4: Write failing test** β€” append to `src/main/settings/validate-setting.test.ts`: + +```ts + it('validates the docker settings keys', () => { + expect(coerceSetting('docker', true)).toEqual({ key: 'docker', value: true }) + expect(coerceSetting('docker', 'yes')).toBeNull() + expect(coerceSetting('dockerBinaryPath', ' /usr/local/bin/docker ')).toEqual({ + key: 'dockerBinaryPath', + value: '/usr/local/bin/docker', + }) + expect(coerceSetting('dockerBinaryPath', 42)).toBeNull() + }) +``` + +- [ ] **Step 5: Run it, expect FAIL** β€” `pnpm test src/main/settings/validate-setting.test.ts` β†’ fails (`docker` hits the `default: return null`). + +- [ ] **Step 6: Implement** β€” in `src/main/settings/validate-setting.ts`, add a `docker` boolean case next to `checkUpdates`, and add `dockerBinaryPath` to the path-string case: + +```ts + case 'checkUpdates': + case 'docker': + return typeof value === 'boolean' ? { key, value } : null +``` + +```ts + case 'pnpmStorePath': + case 'pnpmBinaryPath': + case 'dockerBinaryPath': + return typeof value === 'string' ? { key, value: value.trim() } : null +``` + +- [ ] **Step 7: Gates + commit** β€” `pnpm typecheck && pnpm test && pnpm lint`; `git commit -m "feat(app): Docker shared model, IPC channels, settings keys"`. + +### Task 2: `find-docker.ts` β€” resolve the docker binary + +**Files:** +- Create: `src/main/docker/find-docker.ts` +- Create Test: `src/main/docker/find-docker.test.ts` + +**Interfaces:** +- Consumes: `versionManagerBinDirs`, `nvmNodeBins` from `../pnpm-store/runtime-bins`; `pnpmExecEnv` from `../pnpm-store/find-node`. +- Produces: `dockerCandidates(env, home, nvmBins, overrideBin?): string[]`, `findDocker(overrideBin?): Promise`, and re-exports the spawn env as `dockerExecEnv = pnpmExecEnv`. + +- [ ] **Step 1: Write failing test** β€” `src/main/docker/find-docker.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { dockerCandidates } from './find-docker' + +describe('dockerCandidates', () => { + it('puts an explicit override first and includes Docker Desktop + homebrew paths', () => { + const c = dockerCandidates({ PATH: '/usr/bin' }, '/Users/x', [], '/opt/homebrew/bin/docker') + expect(c[0]).toBe('/opt/homebrew/bin/docker') + expect(c).toContain('/usr/local/bin/docker') + expect(c).toContain('/Applications/Docker.app/Contents/Resources/bin/docker') + }) + + it('dedupes and works with no override', () => { + const c = dockerCandidates({ PATH: '/usr/local/bin' }, '/Users/x', []) + expect(new Set(c).size).toBe(c.length) + expect(c).toContain('/usr/local/bin/docker') + }) +}) +``` + +- [ ] **Step 2: Run it, expect FAIL** β€” `pnpm test src/main/docker/find-docker.test.ts` (module missing). + +- [ ] **Step 3: Implement `src/main/docker/find-docker.ts`** + +```ts +import { constants } from 'node:fs' +import { access } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { pnpmExecEnv } from '../pnpm-store/find-node' +import { nvmNodeBins, versionManagerBinDirs } from '../pnpm-store/runtime-bins' + +/** Finder-launched apps get a minimal PATH; reuse the pnpm spawn env (real PATH). */ +export const dockerExecEnv = pnpmExecEnv + +/** + * Candidate docker binary locations: an explicit override first, then every + * runtime bin dir, then the standard Docker Desktop / Homebrew / /usr/local + * install paths. Deduped, order-preserving. + */ +export function dockerCandidates( + env: NodeJS.ProcessEnv, + home: string, + nvmBins: string[] = [], + overrideBin?: string, +): string[] { + const fromBinDirs = versionManagerBinDirs(env, home, nvmBins).map((dir) => join(dir, 'docker')) + const standard = [ + '/usr/local/bin/docker', + '/opt/homebrew/bin/docker', + '/Applications/Docker.app/Contents/Resources/bin/docker', + ] + const all = overrideBin ? [overrideBin, ...fromBinDirs, ...standard] : [...fromBinDirs, ...standard] + return [...new Set(all)] +} + +let resolved: string | null | undefined + +/** Resolves the docker binary; null when not installed. Override bypasses the cache. */ +export async function findDocker(overrideBin?: string): Promise { + if (overrideBin === undefined && resolved !== undefined) return resolved + const home = homedir() + const candidates = dockerCandidates(process.env, home, await nvmNodeBins(home), overrideBin || undefined) + for (const candidate of candidates) { + try { + await access(candidate, constants.X_OK) + if (overrideBin === undefined) resolved = candidate + return candidate + } catch { + // keep looking + } + } + if (overrideBin === undefined) resolved = null + return null +} +``` + +- [ ] **Step 4: Run test, expect PASS.** Gates + commit `feat(app): find-docker binary resolution`. + +### Task 3: `docker-parse.ts` β€” pure parsers (the core logic) + +**Files:** +- Create: `src/main/docker/docker-parse.ts` +- Create Test: `src/main/docker/docker-parse.test.ts` + +**Interfaces:** +- Produces: + - `parseDf(dfJson: string): { images: DfImage[]; volumes: DfVolume[]; containers: DfContainer[]; buildCache: DfCache[] }` + - `parseContainers(psJsonLines: string): PsContainer[]` + - `buildDockerItems(df, ps): { items: DockerItem[]; totals: DockerCategoryTotal[] }` + - Consumed by Task 4. + +`docker system df -v --format '{{json .}}'` prints ONE JSON object with arrays `Images`, `Containers`, `Volumes`, `BuildCache`. Sizes come as human strings ("1.2GB", "512MB", "0B") and must be parsed to bytes. `docker ps -a --format '{{json .}}'` prints ONE JSON object PER LINE. + +- [ ] **Step 1: Write failing test** β€” `src/main/docker/docker-parse.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { buildDockerItems, parseContainers, parseDf, parseSize } from './docker-parse' + +const DF = JSON.stringify({ + Images: [ + { ID: 'sha256:aaa', Repository: 'node', Tag: '20', CreatedAt: '2026-01-02 10:00:00 +0000 UTC', Size: '1.1GB', Containers: '1' }, + { ID: 'sha256:bbb', Repository: '', Tag: '', CreatedAt: '2026-01-01 10:00:00 +0000 UTC', Size: '400MB', Containers: '0' }, + ], + Volumes: [ + { Name: 'pgdata', Size: '2.0GB', Links: '1' }, + { Name: 'scratch', Size: '512MB', Links: '0' }, + ], + Containers: [ + { ID: 'ctr111', Names: 'web', State: 'running', Image: 'node:20', CreatedAt: '2026-01-03 10:00:00 +0000 UTC', Size: '10MB' }, + { ID: 'ctr222', Names: 'old', State: 'exited', Image: 'node:20', CreatedAt: '2026-01-02 09:00:00 +0000 UTC', Size: '5MB' }, + ], + BuildCache: [{ ID: 'cache1', Size: '800MB', CreatedAt: '2026-01-01 08:00:00 +0000 UTC', InUse: 'false' }], +}) + +describe('parseSize', () => { + it('parses docker human sizes to bytes', () => { + expect(parseSize('0B')).toBe(0) + expect(parseSize('512MB')).toBe(512 * 1000 * 1000) + expect(parseSize('1.1GB')).toBeCloseTo(1.1 * 1e9, -6) + expect(parseSize('')).toBe(0) + }) +}) + +describe('buildDockerItems', () => { + const df = parseDf(DF) + const ps = parseContainers('') // df already carries containers; ps not needed for this fixture + const { items, totals } = buildDockerItems(df, ps) + + it('marks dangling/untagged images unused+removable and tagged-in-container in-use', () => { + const dangling = items.find((i) => i.id === 'sha256:bbb') + expect(dangling?.inUse).toBe(false) + expect(dangling?.removable).toBe(true) + const used = items.find((i) => i.id === 'sha256:aaa') + expect(used?.inUse).toBe(true) // Containers > 0 + expect(used?.removable).toBe(false) + }) + + it('marks volumes with Links>0 in-use (not removable) and Links=0 removable', () => { + expect(items.find((i) => i.name === 'pgdata')?.removable).toBe(false) + expect(items.find((i) => i.name === 'scratch')?.removable).toBe(true) + }) + + it('only stopped containers are removable', () => { + expect(items.find((i) => i.id === 'ctr111')?.removable).toBe(false) // running + expect(items.find((i) => i.id === 'ctr222')?.removable).toBe(true) // exited + }) + + it('build-cache rows are never per-item removable', () => { + expect(items.find((i) => i.kind === 'buildcache')?.removable).toBe(false) + }) + + it('totals reclaimable = sum of removable item sizes per kind', () => { + const vol = totals.find((t) => t.kind === 'volume') + expect(vol?.reclaimableBytes).toBe(512 * 1000 * 1000) // only scratch + expect(vol?.count).toBe(2) + }) +}) +``` + +- [ ] **Step 2: Run it, expect FAIL.** + +- [ ] **Step 3: Implement `src/main/docker/docker-parse.ts`** + +```ts +import type { DockerCategoryTotal, DockerItem, DockerItemKind } from '@shared/docker.types' + +// docker prints decimal (SI) sizes: 1kB = 1000 B. +const UNIT: Record = { B: 1, KB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12, KIB: 1024, MIB: 1024 ** 2, GIB: 1024 ** 3 } + +/** "1.1GB" | "512MB" | "0B" β†’ bytes. Unknown/empty β†’ 0. */ +export function parseSize(s: string): number { + const m = /^([\d.]+)\s*([A-Za-z]+)$/.exec((s ?? '').trim()) + if (!m) return 0 + const n = Number(m[1]) + const mult = UNIT[m[2].toUpperCase()] ?? 0 + return Number.isFinite(n) ? Math.round(n * mult) : 0 +} + +/** "2026-01-02 10:00:00 +0000 UTC" β†’ ms epoch (0 if unparseable). */ +export function parseDate(s: string): number { + if (!s) return 0 + const t = Date.parse(s.replace(' UTC', '').replace(' +0000', 'Z').replace(' ', 'T')) + return Number.isFinite(t) ? t : 0 +} + +export interface DfImage { ID: string; Repository: string; Tag: string; CreatedAt: string; Size: string; Containers: string } +export interface DfVolume { Name: string; Size: string; Links: string } +export interface DfContainer { ID: string; Names: string; State: string; Image: string; CreatedAt: string; Size: string } +export interface DfCache { ID: string; Size: string; CreatedAt: string; InUse: string } +export interface DfParsed { images: DfImage[]; volumes: DfVolume[]; containers: DfContainer[]; buildCache: DfCache[] } + +export function parseDf(json: string): DfParsed { + let o: Record = {} + try { + o = JSON.parse(json) as Record + } catch { + // leave empty + } + return { + images: (o.Images as DfImage[]) ?? [], + volumes: (o.Volumes as DfVolume[]) ?? [], + containers: (o.Containers as DfContainer[]) ?? [], + buildCache: (o.BuildCache as DfCache[]) ?? [], + } +} + +export interface PsContainer { ID: string; Image: string; State: string } + +/** `docker ps -a --format '{{json .}}'` = one JSON object per line. */ +export function parseContainers(text: string): PsContainer[] { + return (text ?? '') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => { + try { + return JSON.parse(l) as PsContainer + } catch { + return null + } + }) + .filter((x): x is PsContainer => x !== null) +} + +function totalsOf(items: DockerItem[]): DockerCategoryTotal[] { + const kinds: DockerItemKind[] = ['image', 'volume', 'container', 'buildcache'] + return kinds.map((kind) => { + const of = items.filter((i) => i.kind === kind) + return { + kind, + sizeBytes: of.reduce((s, i) => s + i.sizeBytes, 0), + reclaimableBytes: of.filter((i) => i.removable).reduce((s, i) => s + i.sizeBytes, 0), + count: of.length, + } + }) +} + +export function buildDockerItems(df: DfParsed, _ps: PsContainer[]): { items: DockerItem[]; totals: DockerCategoryTotal[] } { + const items: DockerItem[] = [] + + for (const img of df.images) { + const dangling = img.Repository === '' || img.Tag === '' + const inUse = Number(img.Containers) > 0 + const tag = dangling ? '' : `${img.Repository}:${img.Tag}` + items.push({ + id: img.ID, + kind: 'image', + name: tag, + sizeBytes: parseSize(img.Size), + createdAt: parseDate(img.CreatedAt), + inUse, + removable: !inUse, + }) + } + for (const vol of df.volumes) { + const inUse = Number(vol.Links) > 0 + items.push({ id: vol.Name, kind: 'volume', name: vol.Name, sizeBytes: parseSize(vol.Size), createdAt: 0, inUse, removable: !inUse }) + } + for (const c of df.containers) { + const running = c.State === 'running' + items.push({ + id: c.ID, + kind: 'container', + name: c.Names || c.ID.slice(0, 12), + sizeBytes: parseSize(c.Size), + createdAt: parseDate(c.CreatedAt), + inUse: running, + removable: !running, + }) + } + for (const cache of df.buildCache) { + items.push({ + id: cache.ID, + kind: 'buildcache', + name: cache.ID.slice(0, 12), + sizeBytes: parseSize(cache.Size), + createdAt: parseDate(cache.CreatedAt), + inUse: cache.InUse === 'true', + removable: false, // build cache is bulk-prune-only + }) + } + + return { items, totals: totalsOf(items) } +} +``` + +Note: the `_ps` param is reserved β€” for this df-driven derivation the image in-use flag comes from `Images[].Containers`; the ps list is threaded through for a future refinement and kept in the signature so Task 4 wires it without a signature change. + +- [ ] **Step 4: Run test, expect PASS.** Gates + commit `feat(app): docker df/ps parsers`. + +### Task 4: `docker.ts` β€” availability + cached `getDockerInfo` + +**Files:** +- Create: `src/main/docker/docker.ts` +- Create Test: `src/main/docker/docker.test.ts` + +**Interfaces:** +- Consumes: `findDocker`, `dockerExecEnv` (Task 2); `parseDf`, `parseContainers`, `buildDockerItems` (Task 3). +- Produces: `getDockerInfo(force?, opts?): Promise` and an injectable `__setExecForTests(fn)` seam. `opts: { binaryPath?: string }`. + +- [ ] **Step 1: Write failing test** β€” `src/main/docker/docker.test.ts` (inject a fake exec so no daemon is needed): + +```ts +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tidydisk-test' } })) +vi.mock('./find-docker', () => ({ + findDocker: vi.fn(async () => '/usr/local/bin/docker'), + dockerExecEnv: vi.fn(async () => ({})), +})) + +import { __setExecForTests, getDockerInfo } from './docker' + +const DF = JSON.stringify({ + Images: [{ ID: 'i1', Repository: '', Tag: '', CreatedAt: '', Size: '400MB', Containers: '0' }], + Volumes: [], Containers: [], BuildCache: [], +}) + +describe('getDockerInfo', () => { + it('reports available with parsed items when the daemon responds', async () => { + __setExecForTests(async (_bin, args) => { + if (args[0] === 'version') return { stdout: '27.0.0', stderr: '' } + if (args[0] === 'system' && args[1] === 'df') return { stdout: DF, stderr: '' } + return { stdout: '', stderr: '' } + }) + const info = await getDockerInfo(true) + expect(info.available).toBe(true) + expect(info.items).toHaveLength(1) + expect(info.items[0].removable).toBe(true) + }) + + it('reports daemon not running when version fails', async () => { + __setExecForTests(async (_bin, args) => { + if (args[0] === 'version') throw new Error('Cannot connect to the Docker daemon') + return { stdout: '', stderr: '' } + }) + const info = await getDockerInfo(true) + expect(info.available).toBe(false) + expect(info.reason).toBe('daemon not running') + }) +}) +``` + +- [ ] **Step 2: Run it, expect FAIL.** + +- [ ] **Step 3: Implement `src/main/docker/docker.ts`** + +```ts +import { execFile } from 'node:child_process' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { promisify } from 'node:util' +import type { DockerInfo } from '@shared/docker.types' +import { app } from 'electron' +import { buildDockerItems, parseContainers, parseDf } from './docker-parse' +import { dockerExecEnv, findDocker } from './find-docker' + +const CLI_TIMEOUT_MS = 60_000 +const PRUNE_TIMEOUT_MS = 10 * 60_000 +const MAX_BUFFER = 32 * 1024 * 1024 + +type Exec = (bin: string, args: string[], opts: { timeout: number; env: NodeJS.ProcessEnv; maxBuffer: number }) => Promise<{ stdout: string; stderr: string }> + +let execImpl: Exec = promisify(execFile) as unknown as Exec +/** Test seam: swap the exec used by all docker calls. */ +export function __setExecForTests(fn: Exec): void { + execImpl = fn +} + +export interface DockerOpts { binaryPath?: string } + +let cached: DockerInfo | null = null +let cachedKey = '' +let diskLoaded = false +let inFlight: Promise | null = null + +const cacheFile = (): string => join(app.getPath('userData'), 'docker-cache.json') + +function loadDiskCache(): void { + if (diskLoaded) return + diskLoaded = true + try { + const raw = JSON.parse(readFileSync(cacheFile(), 'utf8')) as { key: string; info: DockerInfo } + if (raw?.info) { + cached = raw.info + cachedKey = raw.key ?? '' + } + } catch { + // no cache yet + } +} + +function saveDiskCache(): void { + if (!cached) return + try { + const file = cacheFile() + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, JSON.stringify({ key: cachedKey, info: cached })) + } catch (err) { + console.error('Failed to persist docker cache', err) + } +} + +async function run(bin: string, args: string[], timeout = CLI_TIMEOUT_MS): Promise { + const env = (await dockerExecEnv()) as NodeJS.ProcessEnv + const { stdout } = await execImpl(bin, args, { timeout, env, maxBuffer: MAX_BUFFER }) + return stdout +} + +async function readDockerInfo(opts: DockerOpts): Promise { + const now = Date.now() + const bin = await findDocker(opts.binaryPath) + if (!bin) return { available: false, reason: 'not installed', checkedAt: now, totals: [], items: [] } + try { + await run(bin, ['version', '--format', '{{.Server.Version}}']) + } catch { + return { available: false, reason: 'daemon not running', checkedAt: now, totals: [], items: [] } + } + let df = '' + let ps = '' + try { + df = await run(bin, ['system', 'df', '-v', '--format', '{{json .}}']) + ps = await run(bin, ['ps', '-a', '--no-trunc', '--format', '{{json .}}']) + } catch { + return { available: false, reason: 'daemon not running', checkedAt: now, totals: [], items: [] } + } + const { items, totals } = buildDockerItems(parseDf(df), parseContainers(ps)) + return { available: true, checkedAt: now, totals, items } +} + +export async function getDockerInfo(force = false, opts: DockerOpts = {}): Promise { + loadDiskCache() + const key = opts.binaryPath ?? '' + if (!force && cached && key === cachedKey) return cached + if (inFlight) return inFlight + const pending = (async (): Promise => { + const info = await readDockerInfo(opts) + cached = info + cachedKey = key + saveDiskCache() + return info + })() + inFlight = pending + void pending.finally(() => { + if (inFlight === pending) inFlight = null + }) + return pending +} + +export { CLI_TIMEOUT_MS, PRUNE_TIMEOUT_MS, run } +``` + +- [ ] **Step 4: Run test, expect PASS.** Gates + commit `feat(app): getDockerInfo with availability probe + cache`. + +### Task 5: IPC `docker:get` + preload bridge + +**Files:** +- Modify: `src/main/ipc/register-ipc.ts`, `src/preload/index.ts`, `src/preload/api.types.ts` + +**Interfaces:** +- Produces: `window.clean.getDocker(force?): Promise`. + +- [ ] **Step 1: Register the read-only handler** β€” in `register-ipc.ts`, import at top: + +```ts +import { getDockerInfo } from '../docker/docker' +``` + +and add near the `getPnpmStore` handler (ungated β€” listing is free): + +```ts + ipcMain.handle(IPC.getDocker, (_e, force?: boolean) => { + const s = ctx.settings.get() + return getDockerInfo(force, { binaryPath: s.dockerBinaryPath }) + }) +``` + +- [ ] **Step 2: Preload** β€” in `src/preload/index.ts` add after `getPnpmStore`: + +```ts + getDocker: (force) => ipcRenderer.invoke(IPC.getDocker, force), +``` + +In `src/preload/api.types.ts` add the import and method: + +```ts +import type { DockerInfo } from '@shared/docker.types' +``` +```ts + getDocker(force?: boolean): Promise +``` + +- [ ] **Step 3: Gates + commit** β€” `pnpm typecheck && pnpm test && pnpm lint && pnpm build`; `git commit -m "feat(app): docker:get IPC + preload"`. + +### Task 6: `useDocker` hook + `DockerView` + tab wiring + +**Files:** +- Create: `src/renderer/src/hooks/useDocker.ts`, `src/renderer/src/launcher/views/DockerView.tsx` +- Create Test: `src/renderer/src/launcher/views/DockerView.test.tsx` +- Modify: `src/renderer/src/launcher/LauncherApp/LauncherApp.types.ts`, `LauncherApp.tsx`, `SortTab` usage, `Segmented` options + +**Interfaces:** +- Consumes: `window.clean.getDocker`. +- Produces: `useDocker()` β†’ `{ info, loading, refresh, remove, prune }` (remove/prune added in Phase B β€” for Task 6 provide `info/loading/refresh` and stub `remove/prune` returning null so the view compiles; Task 9 fills them). + +- [ ] **Step 1: Implement `useDocker.ts`** (Phase-A subset; Phase B extends it): + +```ts +import type { DockerInfo } from '@shared/docker.types' +import { useCallback, useEffect, useState } from 'react' + +export function useDocker(): { + info: DockerInfo | null + loading: boolean + refresh: () => Promise +} { + const [info, setInfo] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let alive = true + void window.clean.getDocker().then((i) => { + if (alive) { + setInfo(i) + setLoading(false) + } + }) + return () => { + alive = false + } + }, []) + + const refresh = useCallback(async (): Promise => { + setLoading(true) + try { + setInfo(await window.clean.getDocker(true)) + } finally { + setLoading(false) + } + }, []) + + return { info, loading, refresh } +} +``` + +- [ ] **Step 2: Write failing test** β€” `DockerView.test.tsx` (render grouping + query filter + unavailable state). Use the repo's existing renderer test setup (vitest + jsdom; see `PackagesView`/`CachesView` tests if present, else `@testing-library/react`). Assert: given a `DockerInfo` with an image + a volume, both group headers render; a `query` of "node" filters to the node image; an unavailable `info` renders `reason`. + +```tsx +import { render, screen } from '@testing-library/react' +import type { DockerInfo } from '@shared/docker.types' +import { describe, expect, it } from 'vitest' +import { DockerView } from './DockerView' + +const base: DockerInfo = { available: true, checkedAt: 0, totals: [], items: [ + { id: 'i1', kind: 'image', name: 'node:20', sizeBytes: 1e9, createdAt: 0, inUse: false, removable: true }, + { id: 'v1', kind: 'volume', name: 'pgdata', sizeBytes: 2e9, createdAt: 0, inUse: true, removable: false }, +] } + +describe('DockerView', () => { + it('groups by category and filters by query', () => { + const { rerender } = render( {}} />) + expect(screen.getByText('Images')).toBeTruthy() + expect(screen.getByText('Volumes')).toBeTruthy() + rerender( {}} />) + expect(screen.queryByText('pgdata')).toBeNull() + expect(screen.getByText('node:20')).toBeTruthy() + }) + + it('shows the reason when docker is unavailable', () => { + render( {}} />) + expect(screen.getByText(/daemon not running/i)).toBeTruthy() + }) +}) +``` + +- [ ] **Step 3: Run it, expect FAIL.** + +- [ ] **Step 4: Implement `DockerView.tsx`** β€” Phase-A read-only view. Props: `{ info, loading, query, onRefresh, selectedIndex?, onSelectIndex?, onRemove?, onPrune? }` (remove/prune optional; unused in Phase A). Group `info.items` into the four kinds; render a section header per non-empty group (`Images`, `Volumes`, `Containers`, `Build cache`), each a list of `CacheRow`s (icon per kind from `UIIcon`; `detail` = `${created} Β· ${inUse ? 'in use' : 'unused'}` using `relativeTime(createdAt)` when `createdAt>0` else 'unknown date'; `size`; NO action button in Phase A). When `!info?.available`, render the centered reason state (clone the `CachesView` unavailable/empty markup). Filter items by `query` (name includes, case-insensitive). + +- [ ] **Step 5: Tab wiring in `LauncherApp`:** + - `LauncherApp.types.ts`: `export type LauncherTab = 'projects' | 'caches' | 'packages' | 'docker'`. + - `Segmented` options array: add `{ value: 'docker', label: 'Docker' }`. + - `⌘1..⌘4` branch β€” replace the `'1'|'2'|'3'` guard with `'1'..'4'` and map `'4' β†’ 'docker'`: + ```ts + if (meta && ['1', '2', '3', '4'].includes(e.key)) { + e.preventDefault() + setTab(e.key === '1' ? 'projects' : e.key === '2' ? 'caches' : e.key === '3' ? 'packages' : 'docker') + setSel(0); setConfirm(null); setUnlock(null); collapsePkg() + return + } + ``` + - `⌘R` refresh branch: add `else if (tab === 'docker') void docker.refresh()`. + - Body switch: add a `tab === 'docker'` branch rendering ` void docker.refresh()} />`. Instantiate `const docker = useDocker()` near the other hooks. + +- [ ] **Step 6: Run test, gates, commit** `feat(app): Docker tab (read-only listing)`. Deliverable: launching the app on a machine with Docker shows a populated Docker tab; without Docker, an explanatory state. + +### Task 7: Settings toggle + docker binary override + +**Files:** +- Modify: `src/renderer/src/launcher/views/SettingsView.tsx` + +- [ ] **Step 1:** Add a `Toggle` row bound to `settings.docker ?? true` calling `setSetting('docker', v)` (clone the `checkUpdates` toggle block), labeled "Docker cleanup" / "Show the Docker tab and let you reclaim image, volume, container and build-cache space." Add an optional path override input for `dockerBinaryPath` (clone the pnpm binary override row). +- [ ] **Step 2:** Gate the Docker tab's Segmented option on `settings.docker !== false` in `LauncherApp` (hide the tab when the user disables it; default shows it). +- [ ] **Step 3:** Gates + commit `feat(app): Docker settings toggle + binary override`. + +--- + +## PHASE B β€” cleanup actions (Pro-gated, permanent) + +### Task 8: `docker.ts` β€” `removeDockerItem` + `pruneDocker` + +**Files:** +- Modify + Test: `src/main/docker/docker.ts`, `src/main/docker/docker.test.ts` + +**Interfaces:** +- Produces: + - `removeDockerItem(kind: DockerItemKind, id: string, opts?): Promise` + - `pruneDocker(target: DockerPruneTarget, opts?): Promise` + - freed bytes via `docker system df` (grand total) before/after diff. + +- [ ] **Step 1: Write failing tests** (inject exec; assert the exact docker args and freed-bytes math). Add to `docker.test.ts`: + +```ts +import { pruneDocker, removeDockerItem } from './docker' + +it('removeDockerItem maps kindβ†’command and reports freed bytes from df delta', async () => { + const calls: string[][] = [] + let phase = 0 + __setExecForTests(async (_bin, args) => { + calls.push(args) + if (args[0] === 'system' && args[1] === 'df' && !args.includes('-v')) { + // grand-total df prints ONE JSON object per line; shrink after removal. + return { stdout: JSON.stringify({ Type: 'Images', Size: phase++ === 0 ? '1GB' : '0B' }), stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const r = await removeDockerItem('image', 'sha256:bbb') + expect(r.ok).toBe(true) + expect(calls.some((a) => a[0] === 'rmi' && a[1] === 'sha256:bbb')).toBe(true) +}) + +it('pruneDocker maps each target to the right prune command', async () => { + const seen: string[] = [] + __setExecForTests(async (_bin, args) => { + if (args[0] === 'system' && args[1] === 'df') return { stdout: '[]', stderr: '' } + seen.push(args.join(' ')) + return { stdout: '', stderr: '' } + }) + await pruneDocker('buildCache') + await pruneDocker('unusedVolumes') + expect(seen).toContain('builder prune -f') + expect(seen).toContain('volume prune -f') +}) +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** β€” add to `docker.ts`: + +```ts +import type { DockerActionResult, DockerItemKind, DockerPruneTarget } from '@shared/docker.types' + +/** Grand-total docker disk usage in bytes (sum of `docker system df` rows). */ +async function totalDiskBytes(bin: string): Promise { + try { + const out = await run(bin, ['system', 'df', '--format', '{{json .}}']) + // df grand total prints one JSON object per row (Type, Size, Reclaimable, ...) + const { parseSize } = await import('./docker-parse') + return out + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .reduce((sum, line) => { + try { + return sum + parseSize((JSON.parse(line) as { Size: string }).Size) + } catch { + return sum + } + }, 0) + } catch { + return 0 + } +} + +const REMOVE_ARGS: Record, (id: string) => string[]> = { + image: (id) => ['rmi', id], + volume: (id) => ['volume', 'rm', id], + container: (id) => ['rm', id], +} + +export async function removeDockerItem(kind: DockerItemKind, id: string, opts: DockerOpts = {}): Promise { + const bin = await findDocker(opts.binaryPath) + if (!bin || kind === 'buildcache') return { ok: false, freedBytes: 0 } + const before = await totalDiskBytes(bin) + try { + // in-use guard lives in the UI/validation; the CLI itself also refuses an + // in-use image/volume without -f (which we never pass), so this is safe. + await run(bin, REMOVE_ARGS[kind](id)) + } catch { + return { ok: false, freedBytes: 0 } + } + const after = await totalDiskBytes(bin) + return { ok: true, freedBytes: Math.max(0, before - after) } +} + +const PRUNE_ARGS: Record = { + danglingImages: ['image', 'prune', '-f'], + unusedImages: ['image', 'prune', '-a', '-f'], + stoppedContainers: ['container', 'prune', '-f'], + buildCache: ['builder', 'prune', '-f'], + unusedVolumes: ['volume', 'prune', '-f'], +} + +export async function pruneDocker(target: DockerPruneTarget, opts: DockerOpts = {}): Promise { + const bin = await findDocker(opts.binaryPath) + if (!bin) return { ok: false, freedBytes: 0 } + const before = await totalDiskBytes(bin) + try { + await run(bin, PRUNE_ARGS[target], PRUNE_TIMEOUT_MS) + } catch { + return { ok: false, freedBytes: 0 } + } + const after = await totalDiskBytes(bin) + return { ok: true, freedBytes: Math.max(0, before - after) } +} +``` + +- [ ] **Step 4: Run tests, expect PASS.** Gates + commit `feat(app): docker remove + prune with freed-bytes diff`. + +### Task 9: Gated IPC handlers + payload validation + analytics + +**Files:** +- Create + Test: `src/main/docker/validate-docker-arg.ts`, `src/main/docker/validate-docker-arg.test.ts` +- Modify: `src/main/ipc/register-ipc.ts`, `src/main/analytics/analytics.ts`, `src/preload/index.ts`, `src/preload/api.types.ts` + +**Interfaces:** +- Produces: `window.clean.removeDockerItem(kind, id)`, `window.clean.pruneDocker(target)` β†’ `Promise`. + +- [ ] **Step 1: Write failing validation test** β€” `validate-docker-arg.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { coercePruneTarget, coerceRemoveArgs } from './validate-docker-arg' + +describe('docker arg validation', () => { + it('accepts valid remove kind/id and rejects junk', () => { + expect(coerceRemoveArgs('image', 'sha256:aaa')).toEqual({ kind: 'image', id: 'sha256:aaa' }) + expect(coerceRemoveArgs('buildcache', 'x')).toBeNull() // no per-item build-cache removal + expect(coerceRemoveArgs('image', '')).toBeNull() + expect(coerceRemoveArgs('bogus', 'x')).toBeNull() + expect(coerceRemoveArgs('image', 42)).toBeNull() + }) + it('accepts only known prune targets', () => { + expect(coercePruneTarget('buildCache')).toBe('buildCache') + expect(coercePruneTarget('rm -rf /')).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run, expect FAIL. Implement `validate-docker-arg.ts`:** + +```ts +import type { DockerItemKind, DockerPruneTarget } from '@shared/docker.types' + +const REMOVABLE_KINDS: DockerItemKind[] = ['image', 'volume', 'container'] +const PRUNE_TARGETS: DockerPruneTarget[] = ['danglingImages', 'unusedImages', 'stoppedContainers', 'buildCache', 'unusedVolumes'] + +export function coerceRemoveArgs(kind: unknown, id: unknown): { kind: DockerItemKind; id: string } | null { + if (typeof kind !== 'string' || !(REMOVABLE_KINDS as string[]).includes(kind)) return null + if (typeof id !== 'string' || id.trim() === '') return null + return { kind: kind as DockerItemKind, id } +} + +export function coercePruneTarget(target: unknown): DockerPruneTarget | null { + return typeof target === 'string' && (PRUNE_TARGETS as string[]).includes(target) + ? (target as DockerPruneTarget) + : null +} +``` + +- [ ] **Step 3: Extend analytics** β€” in `analytics.ts`, `clean_performed` already carries a `kind`; no new event needed. (The `kind` prop is a free-form string in `AnalyticsProps`.) No code change required beyond passing new kind values in Step 4. + +- [ ] **Step 4: Register gated handlers** β€” in `register-ipc.ts`, import: + +```ts +import { pruneDocker, removeDockerItem } from '../docker/docker' +import { coercePruneTarget, coerceRemoveArgs } from '../docker/validate-docker-arg' +``` + +and add: + +```ts + ipcMain.handle(IPC.removeDockerItem, async (_e, kind: unknown, id: unknown) => { + if (!ctx.license.get().pro) return { ok: false, freedBytes: 0 } + const args = coerceRemoveArgs(kind, id) + if (!args) return { ok: false, freedBytes: 0 } + const s = ctx.settings.get() + const result = await removeDockerItem(args.kind, args.id, { binaryPath: s.dockerBinaryPath }) + if (result.ok) { + ctx.analytics.capture('clean_performed', { + kind: `docker_${args.kind}`, + freed_gb: Math.round((result.freedBytes / GB) * 10) / 10, + }) + } + return result + }) + + ipcMain.handle(IPC.pruneDocker, async (_e, target: unknown) => { + if (!ctx.license.get().pro) return { ok: false, freedBytes: 0 } + const t = coercePruneTarget(target) + if (!t) return { ok: false, freedBytes: 0 } + const s = ctx.settings.get() + const result = await pruneDocker(t, { binaryPath: s.dockerBinaryPath }) + if (result.ok) { + ctx.analytics.capture('clean_performed', { + kind: 'docker_prune', + freed_gb: Math.round((result.freedBytes / GB) * 10) / 10, + }) + } + return result + }) +``` + +- [ ] **Step 5: Preload** β€” `src/preload/index.ts`: + +```ts + removeDockerItem: (kind, id) => ipcRenderer.invoke(IPC.removeDockerItem, kind, id), + pruneDocker: (target) => ipcRenderer.invoke(IPC.pruneDocker, target), +``` + +`api.types.ts`: + +```ts +import type { DockerActionResult, DockerInfo, DockerItemKind, DockerPruneTarget } from '@shared/docker.types' +``` +```ts + removeDockerItem(kind: DockerItemKind, id: string): Promise + pruneDocker(target: DockerPruneTarget): Promise +``` + +- [ ] **Step 6: Run tests, gates, commit** `feat(app): gated docker remove/prune IPC + validation`. + +### Task 10: Confirmation UX + wire actions in the view + +**Files:** +- Modify: `src/renderer/src/hooks/useDocker.ts`, `src/renderer/src/launcher/views/DockerView.tsx`, `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx` +- Create + Test: `src/renderer/src/launcher/views/docker-confirm.ts`, `docker-confirm.test.ts` (the typed-name gating logic, unit-tested pure) + +**Interfaces:** +- Consumes: `window.clean.removeDockerItem`, `window.clean.pruneDocker`, `ctx.license` (via existing `license` prop/hook in LauncherApp). + +- [ ] **Step 1: Extend `useDocker`** with `remove(kind, id)` and `prune(target)` that call the IPC then `refresh()`; add `busyId: string | null`: + +```ts + const remove = useCallback(async (kind, id) => { + setBusyId(id) + try { + const r = await window.clean.removeDockerItem(kind, id) + await refresh() + return r + } finally { + setBusyId(null) + } + }, [refresh]) +``` +(and a parallel `prune(target)` keyed by `busyId = 'prune:' + target`.) + +- [ ] **Step 2: TDD the typed-name gate** β€” `docker-confirm.ts`: + +```ts +import type { DockerItem } from '@shared/docker.types' + +/** True when a pending removal needs the extra typed confirmation (all volumes). */ +export function needsTypedConfirm(item: { kind: DockerItem['kind'] }): boolean { + return item.kind === 'volume' +} + +/** The string the user must type: the volume name (per-item) or 'prune' (bulk volumes). */ +export function requiredConfirmText(target: { kind: 'volume'; name: string } | { kind: 'prune' }): string { + return target.kind === 'prune' ? 'prune' : target.name +} + +export function confirmSatisfied(required: string, typed: string): boolean { + return typed.trim() === required +} +``` + +Test `docker-confirm.test.ts`: `needsTypedConfirm({kind:'volume'})===true`, `image===false`; `confirmSatisfied('pgdata','pgdata')===true`, `confirmSatisfied('pgdata','pg')===false`. + +- [ ] **Step 3: Wire the two-tier confirm** in `LauncherApp` (extend the existing `confirm` footer, do not invent a modal): + - Free user (`!license.pro`): any remove/prune sets `unlock` + `trackEvent('paywall_shown', { trigger: 'docker' })` β€” clone the delete path. + - Pro user, non-volume (image/container/build-cache prune): set `confirm`-style state showing "Remove ? Frees ~N. Permanent β€” not sent to the Trash." with Cancel(`esc`)/Remove(`↡`). On confirm call `docker.remove(kind,id)` or `docker.prune(target)`. + - Pro user, volume (per-item or `unusedVolumes` prune): render the SAME footer PLUS a text input; the Remove button/`↡` stays disabled until `confirmSatisfied(requiredConfirmText(...), typed)`. Only then call the IPC. + - `DockerView` gets `onRemove(item)` / `onPrune(target)` callbacks and per-category "Prune" buttons (Dangling images / Unused images / Stopped containers / Build cache / Unused volumes), each firing `onPrune`. `CacheRow` action button is shown only when `item.removable` (in-use/build-cache rows show a badge instead). + +- [ ] **Step 4:** Run tests, gates (`pnpm typecheck && pnpm test && pnpm lint && pnpm build`), commit `feat(app): docker cleanup actions + tiered confirmation`. + +### Task 11: Integration verify + STATUS + +- [ ] **Step 1:** Full gates green. Manually (or note as a user action, since it needs a real daemon): launch the app, open Docker tab (⌘4), confirm images/volumes/containers/build-cache list with sizes + in-use badges; as a free user a remove shows the paywall; as Pro, removing an unused image works and the meter updates; a volume removal requires typing its name; in-use items have no remove button. +- [ ] **Step 2:** Update `STATUS.html` data block (new "done" roadmap item for the Docker cleanup layer; a userAction for the manual daemon test; log entry). Bump `updated`. +- [ ] **Step 3:** Push `feat/docker-cleanup`, open PR to main with a summary + the manual-test checklist; confirm CI green. diff --git a/docs/superpowers/plans/2026-07-08-docker-project-grouping.md b/docs/superpowers/plans/2026-07-08-docker-project-grouping.md new file mode 100644 index 0000000..eb322e4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-docker-project-grouping.md @@ -0,0 +1,547 @@ +# Docker Project-Grouping Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Regroup the Docker tab by project (Compose labels + used-by references) with type badges, a type-filter chip row, and Size/Name/Recent sort tabs, keeping the cleanup + volume-confirmation safety flow untouched. + +**Architecture:** Enrich each `DockerItem` with a `project?`/`repository?` in the main process from `docker container/volume inspect` (compose labels + image/mount references), attach a per-project logo via the scanner's `detectKind`/`findProjectIcon`, and rewrite the renderer's grouping (`groupDockerForDisplay`) + `DockerView` to render project group headers with badged rows. Presentation + enrichment only; cleanup identifiers (`DockerItem.id`/`kind`) are unchanged. Spec: `docs/superpowers/specs/2026-07-08-docker-project-grouping-design.md`. + +**Tech Stack:** Electron + TypeScript, React renderer, vitest (`pnpm test`), biome (`pnpm lint`), electron-vite (`pnpm build`), two tsconfig typechecks (`pnpm typecheck`). + +## Global Constraints + +- Package manager: pnpm. All gates green per task: `pnpm typecheck && pnpm test && pnpm lint && pnpm build`. +- **Safety is untouched:** every row must keep flowing its real `DockerItem.id` and `kind` to `onRemove(item)`/`onPrune(target)`. Do NOT change `LauncherApp`'s `dockerConfirm`/`dockerConfirmBlocked`/`commitDockerConfirm` logic or `docker-confirm.ts`. The typed-name volume gate must keep working. +- Project identity = a Docker Compose project only (name + working_dir). Never invent a project for non-compose resources; those go to "Other". +- An item belongs to exactly ONE group (no double-counting in the size sort): an image/volume used by MULTIPLE compose projects β†’ "Other". +- All docker CLI calls: `execFile` with array args, no shell, fail-soft (association failure degrades to "everything in Other", never an error/crash). +- Repo biome style (single quotes, no semicolons, 2-space). Commit style `feat(app): …`. + +## File structure + +- Modify: `src/shared/docker.types.ts` (new fields + `DockerProject`). +- Create: `src/main/docker/docker-inspect.ts` (parse `docker container/volume inspect` JSON) + `docker-associate.ts` (`associateProjects`) + tests. +- Modify: `src/main/docker/docker.ts` (`readDockerInfo` runs the inspect calls + logo detection). +- Modify: `src/renderer/src/launcher/views/DockerView.constants.ts` (`groupDockerForDisplay`) + test. +- Create: `src/renderer/src/components/TypeBadge/` and `DockerProjectIcon` (in DockerView or its own file). +- Modify: `src/renderer/src/launcher/views/DockerView.tsx`, `DockerView.types.ts`, `LauncherApp.tsx`, `LauncherApp.types.ts`. + +--- + +### Task 1: Data model + pure `associateProjects` + +**Files:** +- Modify: `src/shared/docker.types.ts` +- Create: `src/main/docker/docker-inspect.ts`, `src/main/docker/docker-inspect.test.ts`, `src/main/docker/docker-associate.ts`, `src/main/docker/docker-associate.test.ts` + +**Interfaces:** +- Produces: + - `DockerItem` gains `project?: string`, `repository?: string`. + - `DockerProject { name: string; workingDir?: string; kind?: FrameworkKind; iconDataUrl?: string }`. + - `DockerInfo` gains `projects: DockerProject[]`. + - `parseContainerInspect(json: string): InspectedContainer[]` where `InspectedContainer = { id: string; imageRef: string; imageId: string; project?: string; workingDir?: string; mounts: string[] }`. + - `parseVolumeInspect(json: string): Map` (volume name β†’ compose project). + - `associateProjects(items, containers, volumeProjects): { items: DockerItem[]; projects: DockerProject[] }`. + +- [ ] **Step 1: Types** β€” in `src/shared/docker.types.ts`, add the import and fields: + +```ts +import type { FrameworkKind } from './project.types' +``` +Add to `DockerItem` (after `removable`): +```ts + /** 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 +``` +Add the interface + `projects` field: +```ts +export interface DockerProject { + name: string + workingDir?: string + kind?: FrameworkKind + iconDataUrl?: string +} +``` +In `DockerInfo`, add `projects: DockerProject[]`. Update the four `readDockerInfo` early-return objects in `docker.ts` to include `projects: []` (done in Task 2, but the type change forces it β€” for THIS task, also add `projects: []` to those returns so typecheck passes). + +- [ ] **Step 2: Write failing test** `docker-inspect.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { parseContainerInspect, parseVolumeInspect } from './docker-inspect' + +const CONTAINERS = JSON.stringify([ + { + Id: 'c1abc', + Image: 'node:20', + Config: { Image: 'node:20', Labels: { + 'com.docker.compose.project': 'myapp', + 'com.docker.compose.project.working_dir': '/Users/x/code/myapp', + } }, + Mounts: [{ Type: 'volume', Name: 'myapp_pgdata' }], + }, + { Id: 'c2def', Image: 'redis:7', Config: { Image: 'redis:7', Labels: {} }, Mounts: [] }, +]) + +describe('parseContainerInspect', () => { + it('extracts compose project, working_dir, image ref, and named-volume mounts', () => { + const r = parseContainerInspect(CONTAINERS) + expect(r[0]).toMatchObject({ id: 'c1abc', imageRef: 'node:20', project: 'myapp', workingDir: '/Users/x/code/myapp', mounts: ['myapp_pgdata'] }) + expect(r[1].project).toBeUndefined() + expect(r[1].mounts).toEqual([]) + }) + it('survives malformed input', () => { + expect(parseContainerInspect('not json')).toEqual([]) + }) +}) + +describe('parseVolumeInspect', () => { + it('maps volume name to compose project', () => { + const json = JSON.stringify([ + { Name: 'myapp_pgdata', Labels: { 'com.docker.compose.project': 'myapp' } }, + { Name: 'loose', Labels: null }, + ]) + const m = parseVolumeInspect(json) + expect(m.get('myapp_pgdata')).toBe('myapp') + expect(m.has('loose')).toBe(false) + }) +}) +``` + +- [ ] **Step 3: Run it, expect FAIL.** `pnpm test src/main/docker/docker-inspect.test.ts` + +- [ ] **Step 4: Implement `docker-inspect.ts`:** + +```ts +export interface InspectedContainer { + id: string + /** image reference the container runs (repo:tag), from Config.Image */ + imageRef: string + /** resolved image id when present (Image field), for id-based matching */ + imageId: string + project?: string + workingDir?: string + /** named volume mounts (Mounts[].Name where Type === 'volume') */ + mounts: string[] +} + +const P = 'com.docker.compose.project' +const WD = 'com.docker.compose.project.working_dir' + +export function parseContainerInspect(json: string): InspectedContainer[] { + let arr: unknown + try { + arr = JSON.parse(json) + } catch { + return [] + } + if (!Array.isArray(arr)) return [] + return arr.map((c: Record) => { + const config = (c.Config ?? {}) as Record + const labels = (config.Labels ?? {}) as Record + const mounts = Array.isArray(c.Mounts) ? (c.Mounts as Record[]) : [] + return { + id: String(c.Id ?? ''), + imageRef: String(config.Image ?? c.Image ?? ''), + imageId: String(c.Image ?? ''), + project: labels[P] || undefined, + workingDir: labels[WD] || undefined, + mounts: mounts.filter((m) => m.Type === 'volume' && m.Name).map((m) => String(m.Name)), + } + }) +} + +export function parseVolumeInspect(json: string): Map { + const out = new Map() + let arr: unknown + try { + arr = JSON.parse(json) + } catch { + return out + } + if (!Array.isArray(arr)) return out + for (const v of arr as Record[]) { + const labels = (v.Labels ?? {}) as Record | null + const project = labels?.[P] + if (v.Name && project) out.set(String(v.Name), project) + } + return out +} +``` + +- [ ] **Step 5: Run test, expect PASS. Commit** `feat(app): docker inspect parsers (compose labels + refs)`. + +- [ ] **Step 6: Write failing test** `docker-associate.test.ts`: + +```ts +import type { DockerItem } from '@shared/docker.types' +import { describe, expect, it } from 'vitest' +import { associateProjects } from './docker-associate' + +const mk = (o: Partial & Pick): DockerItem => ({ + sizeBytes: 0, createdAt: 0, inUse: false, removable: true, ...o, +}) + +describe('associateProjects', () => { + it('groups a container by its compose project and lists the project', () => { + const items = [mk({ id: 'c1abc', kind: 'container', name: 'myapp-web' })] + const containers = [{ id: 'c1abc', imageRef: 'node:20', imageId: 'sha256:img1', project: 'myapp', workingDir: '/w/myapp', mounts: ['myapp_pgdata'] }] + const { items: out, projects } = associateProjects(items, containers, new Map()) + expect(out[0].project).toBe('myapp') + expect(projects).toEqual([{ name: 'myapp', workingDir: '/w/myapp' }]) + }) + + it('associates an image to the single project whose container uses it (used-by)', () => { + const items = [mk({ id: 'sha256:img1', kind: 'image', name: 'node:20' })] + const containers = [{ id: 'c1abc', imageRef: 'node:20', imageId: 'sha256:img1', project: 'myapp', workingDir: '/w/myapp', mounts: [] }] + const { items: out } = associateProjects(items, containers, new Map()) + expect(out[0].project).toBe('myapp') + expect(out[0].repository).toBe('node') + }) + + it('leaves an image used by multiple projects unassociated (no double-count)', () => { + const items = [mk({ id: 'sha256:img1', kind: 'image', name: 'node:20' })] + const containers = [ + { id: 'a', imageRef: 'node:20', imageId: 'sha256:img1', project: 'app1', workingDir: '/w/a', mounts: [] }, + { id: 'b', imageRef: 'node:20', imageId: 'sha256:img1', project: 'app2', workingDir: '/w/b', mounts: [] }, + ] + const { items: out } = associateProjects(items, containers, new Map()) + expect(out[0].project).toBeUndefined() + expect(out[0].repository).toBe('node') + }) + + it('associates a volume by compose label, else by mounting container', () => { + const items = [mk({ id: 'myapp_pgdata', kind: 'volume', name: 'myapp_pgdata' }), mk({ id: 'mounted', kind: 'volume', name: 'mounted' })] + const containers = [{ id: 'c1', imageRef: 'x', imageId: 'x', project: 'byMount', workingDir: '/w', mounts: ['mounted'] }] + const volProjects = new Map([['myapp_pgdata', 'myapp']]) + const { items: out } = associateProjects(items, containers, volProjects) + expect(out.find((i) => i.name === 'myapp_pgdata')?.project).toBe('myapp') + expect(out.find((i) => i.name === 'mounted')?.project).toBe('byMount') + }) + + it('build cache and dangling images stay unassociated; repository parsed', () => { + const items = [mk({ id: 'bc', kind: 'buildcache', name: 'bc' }), mk({ id: 'd', kind: 'image', name: '' })] + const { items: out } = associateProjects(items, [], new Map()) + expect(out[0].project).toBeUndefined() + expect(out[1].project).toBeUndefined() + expect(out[1].repository).toBeUndefined() // '' has no repository + }) +}) +``` + +- [ ] **Step 7: Run it, expect FAIL. Implement `docker-associate.ts`:** + +```ts +import type { DockerItem, DockerProject } from '@shared/docker.types' +import type { InspectedContainer } from './docker-inspect' + +/** Repository = name before the last ':'; undefined for dangling ''. */ +function repositoryOf(name: string): string | undefined { + if (name === '' || !name.includes(':')) return name === '' ? undefined : name + return name.slice(0, name.lastIndexOf(':')) +} + +/** The single compose project referencing this image (by id or repo:tag), or undefined if 0 or >1. */ +function soleProject(projects: Set): string | undefined { + return projects.size === 1 ? [...projects][0] : undefined +} + +export function associateProjects( + items: DockerItem[], + containers: InspectedContainer[], + volumeProjects: Map, +): { items: DockerItem[]; projects: DockerProject[] } { + // project name β†’ working_dir (from any container that carries both) + const workingDirs = new Map() + // image ref/id β†’ set of projects whose containers reference it + const imageProjects = new Map>() + // volume name β†’ set of projects whose containers mount it + const volumeMountProjects = new Map>() + + for (const c of containers) { + if (c.project && c.workingDir && !workingDirs.has(c.project)) workingDirs.set(c.project, c.workingDir) + if (c.project) { + for (const key of [c.imageRef, c.imageId].filter(Boolean)) { + if (!imageProjects.has(key)) imageProjects.set(key, new Set()) + imageProjects.get(key)!.add(c.project) + } + for (const vol of c.mounts) { + if (!volumeMountProjects.has(vol)) volumeMountProjects.set(vol, new Set()) + volumeMountProjects.get(vol)!.add(c.project) + } + } + } + + const byId = new Map(containers.map((c) => [c.id, c])) + const projectNames = new Set() + + const out = items.map((item): DockerItem => { + let project: string | undefined + let repository: string | undefined + if (item.kind === 'container') { + project = byId.get(item.id)?.project + } else if (item.kind === 'image') { + repository = repositoryOf(item.name) + project = soleProject(imageProjects.get(item.id) ?? imageProjects.get(item.name) ?? new Set()) + } else if (item.kind === 'volume') { + project = volumeProjects.get(item.name) ?? soleProject(volumeMountProjects.get(item.name) ?? new Set()) + } + if (project) projectNames.add(project) + return { ...item, project, repository } + }) + + const projects: DockerProject[] = [...projectNames].map((name) => ({ + name, + workingDir: workingDirs.get(name), + })) + return { items: out, projects } +} +``` + +- [ ] **Step 8: Run tests, expect PASS.** Full suite + typecheck green. **Commit** `feat(app): associateProjects (compose + used-by) with types`. + +### Task 2: Main-process wiring β€” inspect calls + per-project logo + +**Files:** +- Modify: `src/main/docker/docker.ts` +- Modify: `src/main/docker/docker.test.ts` + +**Interfaces:** +- Consumes: `parseContainerInspect`, `parseVolumeInspect` (Task 1), `associateProjects` (Task 1), `detectKind`/`findProjectIcon` from `../scanner/detect-kind` and `../scanner/find-project-icon`. +- Produces: `readDockerInfo` returns `DockerInfo` with enriched `items` + populated `projects` (incl. logos). + +- [ ] **Step 1: Extend `readDockerInfo`** in `docker.ts`. After the existing `df` fetch + `buildDockerItems`, add the inspect + associate + logo pass. Replace the tail of `readDockerInfo` (from `const { items, totals } = buildDockerItems(parseDf(df))` onward) with: + +```ts + const { items, totals } = buildDockerItems(parseDf(df)) + + // Enrich with project association (fail-soft: any inspect failure β†’ everything in "Other"). + let enriched = items + let projects: DockerProject[] = [] + try { + const ids = (await run(bin, ['ps', '-aq', '--no-trunc'])).split('\n').map((s) => s.trim()).filter(Boolean) + const containers = ids.length ? parseContainerInspect(await run(bin, ['container', 'inspect', ...ids])) : [] + const volNames = items.filter((i) => i.kind === 'volume').map((i) => i.id) + const volProjects = volNames.length ? parseVolumeInspect(await run(bin, ['volume', 'inspect', ...volNames])) : new Map() + const assoc = associateProjects(items, containers, volProjects) + enriched = assoc.items + projects = await withLogos(assoc.projects) + } catch { + // leave items unassociated + } + return { available: true, checkedAt: now, totals, items: enriched, projects } +``` + +Add the helper (below `readDockerInfo`), and the imports at the top: + +```ts +import type { DockerInfo, DockerProject } from '@shared/docker.types' +import { detectKind } from '../scanner/detect-kind' +import { findProjectIcon } from '../scanner/find-project-icon' +import { associateProjects } from './docker-associate' +import { parseContainerInspect, parseVolumeInspect } from './docker-inspect' +``` + +```ts +/** Attach a project logo (framework kind + favicon) from each project's working_dir. + * Fail-soft per project: a missing/unreadable dir leaves kind/iconDataUrl undefined, + * and the renderer shows a generic Docker icon. */ +async function withLogos(projects: DockerProject[]): Promise { + return Promise.all( + projects.map(async (p) => { + if (!p.workingDir) return p + try { + const [kind, iconDataUrl] = await Promise.all([detectKind(p.workingDir), findProjectIcon(p.workingDir)]) + return { ...p, kind, iconDataUrl } + } catch { + return p + } + }), + ) +} +``` + +Also add `projects: []` to the four early-return objects in `readDockerInfo` (not installed / daemon not running Γ—2) if Task 1 did not already (it should have). + +- [ ] **Step 2: Test** β€” extend `docker.test.ts`. The existing tests mock `./find-docker` and inject exec via `__setExecForTests`. Add a test where the injected exec answers `ps -aq`, `container inspect`, `volume inspect`, and `system df -v`, and mock the scanner: + +```ts +vi.mock('../scanner/detect-kind', () => ({ detectKind: vi.fn(async () => 'node') })) +vi.mock('../scanner/find-project-icon', () => ({ findProjectIcon: vi.fn(async () => undefined) })) +``` + +Test asserts: given a df image `node:20` (id `sha256:img1`) and a container inspect that runs `node:20` under compose project `myapp` (working_dir `/w/myapp`), `getDockerInfo(true)` returns `info.projects` containing `{ name: 'myapp', workingDir: '/w/myapp', kind: 'node' }` and the image item has `project: 'myapp'`. Add a second test: when `container inspect` throws, items come back with `project` undefined and `projects` empty (fail-soft), still `available: true`. + +- [ ] **Step 3:** Run tests, gates green. **Commit** `feat(app): docker project enrichment (inspect + logos) in readDockerInfo`. + +### Task 3: Renderer grouping β€” `groupDockerForDisplay` + +**Files:** +- Modify: `src/renderer/src/launcher/views/DockerView.constants.ts` +- Modify: `src/renderer/src/launcher/views/DockerView.constants.test.ts` + +**Interfaces:** +- Produces: `DockerSortKey = 'size' | 'name' | 'recent'`; `DockerTypeFilter = 'all' | DockerItemKind`; `DisplayGroup` union; `groupDockerForDisplay(info, opts): DisplayGroup[]`. Keeps `dockerItemDetail`, `PRUNE_TARGET_LABEL`, `pruneEstimateBytes` (unchanged). `groupDockerItems` is REPLACED. + +- [ ] **Step 1: Write failing test** in `DockerView.constants.test.ts` (add to the existing file; keep other tests): + +```ts +import type { DockerInfo, DockerItem } from '@shared/docker.types' +import { groupDockerForDisplay } from './DockerView.constants' + +const item = (o: Partial & Pick): DockerItem => ({ + sizeBytes: 0, createdAt: 0, inUse: false, removable: true, ...o, +}) +const info = (items: DockerItem[], projects = [{ name: 'myapp', workingDir: '/w', kind: 'node' as const }]): DockerInfo => ({ + available: true, checkedAt: 0, totals: [], items, projects, +}) + +describe('groupDockerForDisplay', () => { + const items = [ + item({ id: 'c1', kind: 'container', name: 'myapp-web', project: 'myapp', sizeBytes: 10 }), + item({ id: 'i1', kind: 'image', name: 'node:20', project: 'myapp', repository: 'node', sizeBytes: 100 }), + item({ id: 'i2', kind: 'image', name: 'redis:7', repository: 'redis', sizeBytes: 500 }), + item({ id: 'bc', kind: 'buildcache', name: 'bc', removable: false, sizeBytes: 300 }), + item({ id: 'v9', kind: 'volume', name: 'orphan', sizeBytes: 5 }), + ] + + it('puts project groups first, then repository/buildcache/unaffiliated under "Other"', () => { + const g = groupDockerForDisplay(info(items), { sortBy: 'size', typeFilter: 'all', query: '' }) + expect(g[0]).toMatchObject({ kind: 'project', project: { name: 'myapp' } }) + const kinds = g.map((x) => x.kind) + expect(kinds).toContain('repository') // redis + expect(kinds).toContain('buildcache') + expect(kinds).toContain('unaffiliated') // orphan volume + // project group before any "Other" group + expect(kinds.indexOf('project')).toBeLessThan(kinds.indexOf('repository')) + }) + + it('sorts groups by total size when sortBy=size', () => { + const g = groupDockerForDisplay(info(items), { sortBy: 'size', typeFilter: 'all', query: '' }) + const other = g.filter((x) => x.kind !== 'project') + // redis repo (500) sorts before buildcache (300) before orphan volume (5) + expect(other[0].kind === 'repository').toBe(true) + }) + + it('type filter keeps only matching items and drops empty groups', () => { + const g = groupDockerForDisplay(info(items), { sortBy: 'size', typeFilter: 'volume', query: '' }) + expect(g.every((x) => x.items.every((i) => i.kind === 'volume'))).toBe(true) + expect(g.some((x) => x.kind === 'repository')).toBe(false) // images filtered out + }) + + it('query filters by item name or project name', () => { + expect(groupDockerForDisplay(info(items), { sortBy: 'size', typeFilter: 'all', query: 'redis' }).length).toBe(1) + }) +}) +``` + +- [ ] **Step 2: Run it, expect FAIL. Implement** β€” in `DockerView.constants.ts`, add (keep the existing `dockerItemDetail`, `PRUNE_TARGET_LABEL`, `pruneEstimateBytes`; remove `groupDockerItems` + `DockerGroup` + `GROUP_ORDER`): + +```ts +import type { DockerInfo, DockerItem, DockerItemKind, DockerProject } from '@shared/docker.types' + +export type DockerSortKey = 'size' | 'name' | 'recent' +export type DockerTypeFilter = 'all' | DockerItemKind + +export type DisplayGroup = + | { kind: 'project'; id: string; label: string; project: DockerProject; items: DockerItem[] } + | { kind: 'repository'; id: string; label: string; items: DockerItem[] } + | { kind: 'buildcache'; id: string; label: string; items: DockerItem[] } + | { kind: 'unaffiliated'; id: string; label: string; items: DockerItem[] } + +const bytes = (items: DockerItem[]): number => items.reduce((s, i) => s + i.sizeBytes, 0) +const recent = (items: DockerItem[]): number => items.reduce((m, i) => Math.max(m, i.createdAt), 0) +const bySizeDesc = (a: DockerItem, b: DockerItem): number => b.sizeBytes - a.sizeBytes + +function sortGroups(groups: DisplayGroup[], sortBy: DockerSortKey): DisplayGroup[] { + return [...groups].sort((a, b) => { + if (sortBy === 'name') return a.label.localeCompare(b.label) + if (sortBy === 'recent') return recent(b.items) - recent(a.items) + return bytes(b.items) - bytes(a.items) + }) +} + +export function groupDockerForDisplay( + info: DockerInfo, + opts: { sortBy: DockerSortKey; typeFilter: DockerTypeFilter; query: string }, +): DisplayGroup[] { + const q = opts.query.trim().toLowerCase() + const items = info.items.filter((i) => { + if (opts.typeFilter !== 'all' && i.kind !== opts.typeFilter) return false + if (!q) return true + return i.name.toLowerCase().includes(q) || (i.project?.toLowerCase().includes(q) ?? false) + }) + + // Project groups + const projectGroups: DisplayGroup[] = [] + for (const p of info.projects) { + const of = items.filter((i) => i.project === p.name).sort(bySizeDesc) + if (of.length) projectGroups.push({ kind: 'project', id: `project:${p.name}`, label: p.name, project: p, items: of }) + } + + // "Other": unaffiliated items β†’ repository groups (images) + buildcache + unaffiliated (rest) + const other = items.filter((i) => !i.project) + const repoGroups: DisplayGroup[] = [] + const repos = new Map() + for (const i of other) { + if (i.kind === 'image' && i.repository) { + if (!repos.has(i.repository)) repos.set(i.repository, []) + repos.get(i.repository)!.push(i) + } + } + for (const [repo, of] of repos) repoGroups.push({ kind: 'repository', id: `repo:${repo}`, label: repo, items: of.sort(bySizeDesc) }) + + const cache = other.filter((i) => i.kind === 'buildcache').sort(bySizeDesc) + const buildcacheGroup: DisplayGroup[] = cache.length ? [{ kind: 'buildcache', id: 'buildcache', label: 'Build cache', items: cache }] : [] + + const rest = other.filter((i) => i.kind !== 'buildcache' && !(i.kind === 'image' && i.repository)).sort(bySizeDesc) + const unaffiliatedGroup: DisplayGroup[] = rest.length ? [{ kind: 'unaffiliated', id: 'unaffiliated', label: 'Not linked to a project', items: rest }] : [] + + return [...sortGroups(projectGroups, opts.sortBy), ...sortGroups([...repoGroups, ...buildcacheGroup, ...unaffiliatedGroup], opts.sortBy)] +} +``` + +- [ ] **Step 3: Run tests, expect PASS.** Gates green. **Commit** `feat(app): project-aware docker grouping (groupDockerForDisplay)`. + +### Task 4: DockerView rework + TypeBadge + project icon + +**Files:** +- Create: `src/renderer/src/components/TypeBadge/TypeBadge.tsx` (+ `index.ts`) +- Modify: `src/renderer/src/launcher/views/DockerView.tsx`, `DockerView.types.ts` + +**Interfaces:** +- Consumes: `groupDockerForDisplay`, `DockerSortKey`, `DockerTypeFilter` (Task 3); `ProjectIcon` (`@renderer/components/ProjectIcon`); the existing `onRemove(item)`/`onPrune(target)`/`busyId` props. +- `DockerViewProps` gains `sortBy: DockerSortKey`, `typeFilter: DockerTypeFilter` (both provided by LauncherApp in Task 5). + +- [ ] **Step 1: `TypeBadge.tsx`** β€” a small chip mapping `DockerItemKind` β†’ label + color (image=IMAGE, volume=VOLUME, container=CONTAINER, buildcache=CACHE), styled with the existing CSS vars (`--surface-2`, `--text-3`). One-line pure component; no test needed (JSX). Export via `index.ts`. + +- [ ] **Step 2: Rework `DockerView.tsx`** β€” replace `groupDockerItems` usage with `groupDockerForDisplay(info, { sortBy, typeFilter, query })`. Render: + - A `'Not linked to a project'` divider row before the first non-`project` group (once). + - Group header: for `kind === 'project'`, render the logo via a small local `DockerProjectIcon` that renders `` when `group.project.kind` is defined, else a generic `UIIcon.box`/docker glyph (so a logo-less compose project still gets an icon). Plus the label + total size + count. For `repository`/`buildcache`/`unaffiliated`, a plain header with a generic icon. + - Rows: keep using `CacheRow` (icon from a per-kind map, name, `dockerItemDetail`, size), and add the `` into the row (pass through a new `badge` slot on `CacheRow`, or render the badge in the `detail`/name area β€” reuse `CacheRow`'s existing structure; if `CacheRow` can't host a badge, render the row inline in DockerView instead of via CacheRow, preserving the same `onAction`/size/selected props so behavior is identical). The row's remove button must still call `onRemove(item)` with the real `item` (unchanged). + - Keep the per-group prune buttons where they apply: image/repository groups β†’ dangling/unused image prune; buildcache group β†’ build cache prune; volume-bearing groups β†’ unused volumes prune; container groups β†’ stopped containers prune. Use `GROUP_PRUNE_TARGETS`-style mapping keyed by the group's item kinds. + - Preserve the `loading`/unavailable/empty states already in the file. + +- [ ] **Step 3:** `pnpm typecheck && pnpm build`; run `pnpm test` (the pure tests cover the logic). **Commit** `feat(app): DockerView project-grouped rendering + type badges`. + +### Task 5: LauncherApp β€” sort tabs + type-filter chips + +**Files:** +- Modify: `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx`, `LauncherApp.types.ts` + +- [ ] **Step 1:** In `LauncherApp.types.ts` (or inline), add `DockerSortKey`/`DockerTypeFilter` re-exports if needed. In `LauncherApp.tsx` add state: `const [dockerSortBy, setDockerSortBy] = useState('size')` and `const [dockerTypeFilter, setDockerTypeFilter] = useState('all')` (import the types from `../views/DockerView.constants`). + +- [ ] **Step 2:** In the `cc-listhead` area where the Projects/Packages sort tabs render (search `tab === 'packages'` sort block), add a `tab === 'docker'` block with: three `SortTab`s (Size β†’ 'size', Name β†’ 'name', Recent β†’ 'recent') bound to `dockerSortBy`/`setDockerSortBy`, plus a small type-filter chip row (All/Images/Volumes/Containers/Cache) bound to `dockerTypeFilter`. Mirror the existing `SortTab` markup. + +- [ ] **Step 3:** Pass `sortBy={dockerSortBy}` and `typeFilter={dockerTypeFilter}` into `` in the body switch. Do NOT touch the `dockerConfirm`/`commitDockerConfirm`/`onRemove`/`onPrune` wiring. + +- [ ] **Step 4:** `pnpm typecheck && pnpm test && pnpm lint && pnpm build`. **Commit** `feat(app): docker sort tabs + type-filter chips`. + +### Task 6: Integration verify + STATUS + PR + +- [ ] **Step 1: Safety regression check** β€” confirm the volume typed-confirm path is untouched: `grep -n "dockerConfirmBlocked\|commitDockerConfirm" src/renderer/src/launcher/LauncherApp/LauncherApp.tsx` still shows the guard; run the full suite (existing `docker-confirm`, `register-ipc`, `docker*` tests green). +- [ ] **Step 2:** Full gates: `pnpm typecheck && pnpm test && pnpm lint && pnpm build`. +- [ ] **Step 3:** Update `STATUS.html` (note the Docker display redesign under the M1d Docker item or a follow-on line; bump `updated`; log entry; a userAction noting the manual daemon+compose test). Manual verification is a user action (needs a real daemon with a compose project). +- [ ] **Step 4:** Push `feat/docker-project-grouping`; open a PR based on `feat/docker-cleanup` (it stacks on PR #40) with a summary + the manual-test checklist; confirm CI green. diff --git a/docs/superpowers/plans/2026-07-09-per-tab-headline-dataviz.md b/docs/superpowers/plans/2026-07-09-per-tab-headline-dataviz.md new file mode 100644 index 0000000..aee4ad8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-per-tab-headline-dataviz.md @@ -0,0 +1,1040 @@ +# Per-Tab Headline Data-Viz + Docker Auto-Scan Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make each launcher tab's header slot and footer line reflect that tab's core metric (node_modules / caches / docker size-vs-limit gauges, packages severity meter), and auto-refresh Docker when its tab opens stale. + +**Architecture:** A pure `severityCounts()` helper and a pure `tabSummary()` helper feed two presentational components β€” the existing `Gauge` (reused for the three size tabs) and a new `SeverityMeter` (packages) β€” routed by a new `TabHeadline` switcher that replaces the hardwired header `Gauge`. `LauncherApp` computes per-tab inputs it already holds. Two new clamped settings (`cacheThresholdGB`, `dockerThresholdGB`) drive the caches/docker limits. A Docker staleness effect calls the existing `docker.refresh()`. + +**Tech Stack:** Electron + React + TypeScript, Vitest (pure `.test.ts` only β€” no jsdom/testing-library in this repo), Biome. + +## Global Constraints + +- Package manager: **pnpm**. Gates for every task: `pnpm typecheck && pnpm test && pnpm lint && pnpm build` (all green before commit). +- Biome style: **single quotes, no semicolons, 2-space indent**. Run `pnpm lint` (biome) β€” do not hand-format against it. +- **No em dashes** in any user-facing copy (labels, hints, tooltips, footer text). Use "Β·", ",", or "β€”"-free phrasing. +- **One folder per component**: `index.ts`, `Component.tsx`, `Component.types.ts`, optional `.constants.ts`, and a co-located `*.test.ts`. +- **Test files must be named `*.test.ts`** and live under `src/**` β€” Vitest `include` is `src/**/*.test.ts`. There is **no** React render-test harness; do **not** write `.test.tsx` or import `@testing-library/*`. Test pure functions only; gate JSX on typecheck + build + manual verification. +- Threshold values are clamped to **0.1–1000 GB** in `validate-setting.ts` (`MIN_THRESHOLD_GB`/`MAX_THRESHOLD_GB`). +- New setting defaults: `cacheThresholdGB: 10`, `dockerThresholdGB: 20`. +- Header slot is ~132px wide β€” the packages severity breakdown lives in the **tooltip**, not inline. +- Docker staleness window: **5 minutes** (`DOCKER_STALE_MS = 5 * 60 * 1000`). +- Severity palette (may be tuned to the app palette, keep the four distinct): critical `#ff453a`, high `#ff9f0a`, moderate `#ffd60a`, low `#5e9eff`. +- After all tasks, update the `STATUS` data block in `STATUS.html` (bump `updated`, move roadmap items, add a `log` line). Not a coding task β€” done in the final wrap-up. + +--- + +### Task 1: Settings β€” two per-tab limit fields + validation + +**Files:** +- Modify: `src/shared/settings.types.ts` (add two fields to `Settings`) +- Modify: `src/shared/settings.constants.ts` (add two defaults) +- Modify: `src/main/settings/validate-setting.ts:43` (extend the `thresholdGB` clamp case) +- Test: `src/main/settings/validate-setting.test.ts` (add cases) + +**Interfaces:** +- Produces: `Settings.cacheThresholdGB: number`, `Settings.dockerThresholdGB: number`; both validated/clamped exactly like `thresholdGB`. + +- [ ] **Step 1: Write the failing tests** + +Append to `src/main/settings/validate-setting.test.ts` (inside the existing top-level `describe`, near the `thresholdGB` test): + +```ts +it('clamps cacheThresholdGB and dockerThresholdGB and rejects bad values', () => { + expect(coerceSetting('cacheThresholdGB', 10)).toEqual({ key: 'cacheThresholdGB', value: 10 }) + expect(coerceSetting('cacheThresholdGB', 99999)).toEqual({ key: 'cacheThresholdGB', value: 1000 }) + expect(coerceSetting('cacheThresholdGB', -3)).toEqual({ key: 'cacheThresholdGB', value: 0.1 }) + expect(coerceSetting('cacheThresholdGB', Number.NaN)).toBeNull() + expect(coerceSetting('cacheThresholdGB', '10')).toBeNull() + + expect(coerceSetting('dockerThresholdGB', 20)).toEqual({ key: 'dockerThresholdGB', value: 20 }) + expect(coerceSetting('dockerThresholdGB', 99999)).toEqual({ key: 'dockerThresholdGB', value: 1000 }) + expect(coerceSetting('dockerThresholdGB', 0)).toEqual({ key: 'dockerThresholdGB', value: 0.1 }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest run src/main/settings/validate-setting.test.ts` +Expected: FAIL β€” `coerceSetting('cacheThresholdGB', 10)` returns `null` (unknown key hits the `default` branch). + +- [ ] **Step 3: Add the fields to the Settings type** + +In `src/shared/settings.types.ts`, add inside `interface Settings` (right after `thresholdGB: number`): + +```ts + /** 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 +``` + +- [ ] **Step 4: Add the defaults** + +In `src/shared/settings.constants.ts`, add to `DEFAULT_SETTINGS` (after `thresholdGB: 5,`): + +```ts + cacheThresholdGB: 10, + dockerThresholdGB: 20, +``` + +- [ ] **Step 5: Extend the validation clamp case** + +In `src/main/settings/validate-setting.ts`, change the `case 'thresholdGB':` line so all three keys share the clamp: + +```ts + case 'thresholdGB': + case 'cacheThresholdGB': + case 'dockerThresholdGB': + return typeof value === 'number' && Number.isFinite(value) + ? { key, value: Math.min(MAX_THRESHOLD_GB, Math.max(MIN_THRESHOLD_GB, value)) } + : null +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `pnpm vitest run src/main/settings/validate-setting.test.ts` +Expected: PASS. + +- [ ] **Step 7: Typecheck (catches any full `Settings` literal that now needs the fields)** + +Run: `pnpm typecheck` +Expected: PASS. If a test fixture builds a full `Settings` object literal, add `cacheThresholdGB` / `dockerThresholdGB` to it. + +- [ ] **Step 8: Commit** + +```bash +git add src/shared/settings.types.ts src/shared/settings.constants.ts src/main/settings/validate-setting.ts src/main/settings/validate-setting.test.ts +git commit -m "feat(settings): add cacheThresholdGB and dockerThresholdGB limits" +``` + +--- + +### Task 2: `severityCounts` pure helper (`lib/severity`) + +**Files:** +- Create: `src/renderer/src/lib/severity/severity.ts` +- Create: `src/renderer/src/lib/severity/index.ts` +- Test: `src/renderer/src/lib/severity/severity.test.ts` + +**Interfaces:** +- Consumes: `PackageEntry`, `AdvisorySeverity` from `@shared/package.types` (`PackageEntry` has optional `advisory?: { severity: AdvisorySeverity }` and `outdated?: boolean`; `AdvisorySeverity = 'low' | 'moderate' | 'high' | 'critical'`). +- Produces: + ```ts + export type SeverityKey = AdvisorySeverity + export interface SeverityCounts { + critical: number; high: number; moderate: number; low: number + vulnerable: number // critical+high+moderate+low + outdated: number + } + export function severityCounts(packages: PackageEntry[]): SeverityCounts + ``` + +- [ ] **Step 1: Write the failing test** + +Create `src/renderer/src/lib/severity/severity.test.ts`: + +```ts +import type { PackageEntry } from '@shared/package.types' +import { describe, expect, it } from 'vitest' +import { severityCounts } from './severity' + +const pkg = (o: Partial & Pick): PackageEntry => ({ + usages: [], + projectCount: 1, + versions: [], + multipleVersions: false, + ...o, +}) + +describe('severityCounts', () => { + it('buckets each package by its advisory severity and sums vulnerable', () => { + const c = severityCounts([ + pkg({ name: 'a', advisory: { severity: 'critical', title: '', vulnerableVersions: '' } }), + pkg({ name: 'b', advisory: { severity: 'high', title: '', vulnerableVersions: '' } }), + pkg({ name: 'c', advisory: { severity: 'high', title: '', vulnerableVersions: '' } }), + pkg({ name: 'd', advisory: { severity: 'low', title: '', vulnerableVersions: '' } }), + pkg({ name: 'e' }), // no advisory + ]) + expect(c).toEqual({ critical: 1, high: 2, moderate: 0, low: 1, vulnerable: 4, outdated: 0 }) + }) + + it('counts outdated independently of advisories', () => { + const c = severityCounts([ + pkg({ name: 'a', outdated: true }), + pkg({ name: 'b', outdated: true, advisory: { severity: 'moderate', title: '', vulnerableVersions: '' } }), + pkg({ name: 'c', outdated: false }), + ]) + expect(c.outdated).toBe(2) + expect(c.moderate).toBe(1) + expect(c.vulnerable).toBe(1) + }) + + it('returns all zeros for an empty list', () => { + expect(severityCounts([])).toEqual({ critical: 0, high: 0, moderate: 0, low: 0, vulnerable: 0, outdated: 0 }) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest run src/renderer/src/lib/severity/severity.test.ts` +Expected: FAIL β€” cannot resolve `./severity`. + +- [ ] **Step 3: Implement the helper** + +Create `src/renderer/src/lib/severity/severity.ts`: + +```ts +import type { AdvisorySeverity, PackageEntry } from '@shared/package.types' + +export type SeverityKey = AdvisorySeverity + +export interface SeverityCounts { + critical: number + high: number + moderate: number + low: number + /** critical + high + moderate + low */ + vulnerable: number + outdated: number +} + +/** Tally packages by their worst-advisory severity (already stored on the entry), + * plus a separate count of packages behind `latest`. A package with no advisory + * contributes to no severity bucket; `outdated` is independent of advisories. */ +export function severityCounts(packages: PackageEntry[]): SeverityCounts { + const c: SeverityCounts = { critical: 0, high: 0, moderate: 0, low: 0, vulnerable: 0, outdated: 0 } + for (const p of packages) { + const sev = p.advisory?.severity + if (sev) { + c[sev] += 1 + c.vulnerable += 1 + } + if (p.outdated) c.outdated += 1 + } + return c +} +``` + +Create `src/renderer/src/lib/severity/index.ts`: + +```ts +export type { SeverityCounts, SeverityKey } from './severity' +export { severityCounts } from './severity' +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm vitest run src/renderer/src/lib/severity/severity.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/lib/severity +git commit -m "feat(app): severityCounts helper for the packages headline" +``` + +--- + +### Task 3: `SeverityMeter` component + +**Files:** +- Create: `src/renderer/src/components/SeverityMeter/SeverityMeter.types.ts` +- Create: `src/renderer/src/components/SeverityMeter/SeverityMeter.constants.ts` +- Create: `src/renderer/src/components/SeverityMeter/SeverityMeter.tsx` +- Create: `src/renderer/src/components/SeverityMeter/index.ts` +- Test: `src/renderer/src/components/SeverityMeter/SeverityMeter.constants.test.ts` + +**Interfaces:** +- Consumes: `SeverityCounts`, `SeverityKey` from `@renderer/lib/severity` (Task 2). The `Gauge` uses a `cc-ghost` CSS class for the "calculating" shimmer β€” reuse it. +- Produces: + ```ts + export interface SeverityMeterProps { counts: SeverityCounts; total: number; computing?: boolean } + export interface SeveritySegment { key: SeverityKey; count: number; color: string; frac: number } + export function severitySegments(counts: SeverityCounts): SeveritySegment[] + export function severityMeterTooltip(counts: SeverityCounts, total: number): string + export const SEVERITY_COLORS: Record + export const SEVERITY_ORDER: SeverityKey[] + ``` + +- [ ] **Step 1: Write the failing test (pure logic only)** + +Create `src/renderer/src/components/SeverityMeter/SeverityMeter.constants.test.ts`: + +```ts +import type { SeverityCounts } from '@renderer/lib/severity' +import { describe, expect, it } from 'vitest' +import { severityMeterTooltip, severitySegments } from './SeverityMeter.constants' + +const counts = (o: Partial): SeverityCounts => ({ + critical: 0, high: 0, moderate: 0, low: 0, vulnerable: 0, outdated: 0, ...o, +}) + +describe('severitySegments', () => { + it('returns severity-ordered segments with fractions of the vulnerable total', () => { + const segs = severitySegments(counts({ critical: 1, high: 3, vulnerable: 4 })) + expect(segs.map((s) => s.key)).toEqual(['critical', 'high']) + expect(segs[0].frac).toBeCloseTo(0.25) + expect(segs[1].frac).toBeCloseTo(0.75) + }) + + it('drops zero-count severities', () => { + const segs = severitySegments(counts({ high: 2, low: 1, vulnerable: 3 })) + expect(segs.map((s) => s.key)).toEqual(['high', 'low']) + }) + + it('returns an empty array when nothing is vulnerable', () => { + expect(severitySegments(counts({ outdated: 5 }))).toEqual([]) + }) +}) + +describe('severityMeterTooltip', () => { + it('lists every severity plus outdated and the total', () => { + expect(severityMeterTooltip(counts({ critical: 2, high: 5, moderate: 3, low: 1, vulnerable: 11, outdated: 28 }), 142)).toBe( + '2 critical Β· 5 high Β· 3 moderate Β· 1 low Β· 28 outdated of 142 packages', + ) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest run src/renderer/src/components/SeverityMeter/SeverityMeter.constants.test.ts` +Expected: FAIL β€” cannot resolve `./SeverityMeter.constants`. + +- [ ] **Step 3: Create the types** + +Create `src/renderer/src/components/SeverityMeter/SeverityMeter.types.ts`: + +```ts +import type { SeverityCounts, SeverityKey } from '@renderer/lib/severity' + +export interface SeveritySegment { + key: SeverityKey + count: number + color: string + /** Share of the vulnerable total (0–1). */ + frac: number +} + +export interface SeverityMeterProps { + counts: SeverityCounts + /** Total package count, for the tooltip. */ + total: number + /** Inventory still computing β€” show the ghost shimmer. */ + computing?: boolean +} +``` + +- [ ] **Step 4: Create the constants + pure helpers** + +Create `src/renderer/src/components/SeverityMeter/SeverityMeter.constants.ts`: + +```ts +import type { SeverityCounts, SeverityKey } from '@renderer/lib/severity' +import type { SeveritySegment } from './SeverityMeter.types' + +/** Worst-first, so the bar reads critical β†’ low left to right. */ +export const SEVERITY_ORDER: SeverityKey[] = ['critical', 'high', 'moderate', 'low'] + +export const SEVERITY_COLORS: Record = { + critical: '#ff453a', + high: '#ff9f0a', + moderate: '#ffd60a', + low: '#5e9eff', +} + +/** Non-empty severity buckets, in severity order, each with its share of the + * vulnerable total. Empty when nothing is vulnerable. */ +export function severitySegments(counts: SeverityCounts): SeveritySegment[] { + if (counts.vulnerable <= 0) return [] + return SEVERITY_ORDER.filter((k) => counts[k] > 0).map((k) => ({ + key: k, + count: counts[k], + color: SEVERITY_COLORS[k], + frac: counts[k] / counts.vulnerable, + })) +} + +/** Full breakdown for the header tooltip (the 132px slot has no room for a legend). */ +export function severityMeterTooltip(counts: SeverityCounts, total: number): string { + return `${counts.critical} critical Β· ${counts.high} high Β· ${counts.moderate} moderate Β· ${counts.low} low Β· ${counts.outdated} outdated of ${total} packages` +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `pnpm vitest run src/renderer/src/components/SeverityMeter/SeverityMeter.constants.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 6: Create the component** + +Create `src/renderer/src/components/SeverityMeter/SeverityMeter.tsx`: + +```tsx +import type { ReactNode } from 'react' +import { SEVERITY_COLORS, severityMeterTooltip, severitySegments } from './SeverityMeter.constants' +import type { SeverityMeterProps } from './SeverityMeter.types' + +/** Compact packages headline for the ~132px header slot: a stacked severity bar + * (critical β†’ low) with a vulnerable count and a trailing outdated count. The + * full breakdown lives in the tooltip. */ +export function SeverityMeter({ counts, total, computing = false }: SeverityMeterProps): ReactNode { + const segments = severitySegments(counts) + const tip = severityMeterTooltip(counts, total) + const clear = counts.vulnerable === 0 + + return ( +
+
+ {clear ? 'all clear' : `${counts.vulnerable} vuln`} +
+
+ {clear ? ( +
+ ) : ( + segments.map((s) => ( +
+ )) + )} + {computing && ( + + {counts.outdated > 0 && ( +
{counts.outdated} old
+ )} +
+ ) +} +``` + +Create `src/renderer/src/components/SeverityMeter/index.ts`: + +```ts +export { SeverityMeter } from './SeverityMeter' +export type { SeverityMeterProps } from './SeverityMeter.types' +``` + +- [ ] **Step 7: Typecheck, lint, build** + +Run: `pnpm typecheck && pnpm lint && pnpm build` +Expected: PASS. (The component has no unit test β€” its logic is covered by Step 5; JSX is gated here.) + +- [ ] **Step 8: Commit** + +```bash +git add src/renderer/src/components/SeverityMeter +git commit -m "feat(app): SeverityMeter β€” compact packages severity headline" +``` + +--- + +### Task 4: `tabSummary` pure helper (footer line) + +**Files:** +- Create: `src/renderer/src/launcher/LauncherApp/tabSummary.ts` +- Test: `src/renderer/src/launcher/LauncherApp/tabSummary.test.ts` + +**Interfaces:** +- Consumes: `SeverityCounts` from `@renderer/lib/severity`; `GB`, `formatSizeStr` from `@renderer/lib/format`; `LauncherTab` from `./LauncherApp.types` (`'projects' | 'caches' | 'packages' | 'docker'`). +- Produces: + ```ts + export interface TabSummaryInput { + tab: LauncherTab + projectsUsed: number + cachesUsed: number; cachesAvailable: boolean + dockerUsed: number; dockerAvailable: boolean + thresholdGB: number; cacheThresholdGB: number; dockerThresholdGB: number + severity: SeverityCounts + packagesComputing: boolean; packagesHasData: boolean + } + export function tabSummary(input: TabSummaryInput): string | null + ``` + +- [ ] **Step 1: Write the failing test** + +Create `src/renderer/src/launcher/LauncherApp/tabSummary.test.ts`: + +```ts +import type { SeverityCounts } from '@renderer/lib/severity' +import { GB } from '@renderer/lib/format' +import { describe, expect, it } from 'vitest' +import { type TabSummaryInput, tabSummary } from './tabSummary' + +const sev = (o: Partial = {}): SeverityCounts => ({ + critical: 0, high: 0, moderate: 0, low: 0, vulnerable: 0, outdated: 0, ...o, +}) +const base: TabSummaryInput = { + tab: 'projects', + projectsUsed: 0, + cachesUsed: 0, cachesAvailable: true, + dockerUsed: 0, dockerAvailable: true, + thresholdGB: 5, cacheThresholdGB: 10, dockerThresholdGB: 20, + severity: sev(), packagesComputing: false, packagesHasData: true, +} + +describe('tabSummary', () => { + it('projects: percent under limit, over amount past it', () => { + expect(tabSummary({ ...base, tab: 'projects', projectsUsed: 2.5 * GB })).toBe('50% of your 5 GB limit') + expect(tabSummary({ ...base, tab: 'projects', projectsUsed: 6 * GB })).toBe('1.00 GB over your 5 GB limit') + }) + + it('caches: uses the cache limit and wording, null when unavailable', () => { + expect(tabSummary({ ...base, tab: 'caches', cachesUsed: 5 * GB })).toBe('50% of your 10 GB cache limit') + expect(tabSummary({ ...base, tab: 'caches', cachesAvailable: false })).toBeNull() + }) + + it('docker: uses the docker limit and wording, null when unavailable', () => { + expect(tabSummary({ ...base, tab: 'docker', dockerUsed: 10 * GB })).toBe('50% of your 20 GB Docker limit') + expect(tabSummary({ ...base, tab: 'docker', dockerAvailable: false })).toBeNull() + }) + + it('packages: vulnerable + outdated, all-clear, and null while computing with no data', () => { + expect(tabSummary({ ...base, tab: 'packages', severity: sev({ vulnerable: 7, outdated: 28 }) })).toBe( + '7 vulnerable Β· 28 outdated', + ) + expect(tabSummary({ ...base, tab: 'packages', severity: sev({ outdated: 3 }) })).toBe('all clear Β· 3 outdated') + expect(tabSummary({ ...base, tab: 'packages', severity: sev() })).toBe('all clear') + expect(tabSummary({ ...base, tab: 'packages', packagesComputing: true, packagesHasData: false })).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm vitest run src/renderer/src/launcher/LauncherApp/tabSummary.test.ts` +Expected: FAIL β€” cannot resolve `./tabSummary`. + +- [ ] **Step 3: Implement the helper** + +Create `src/renderer/src/launcher/LauncherApp/tabSummary.ts`: + +```ts +import { formatSizeStr, GB } from '@renderer/lib/format' +import type { SeverityCounts } from '@renderer/lib/severity' +import type { LauncherTab } from './LauncherApp.types' + +export interface TabSummaryInput { + tab: LauncherTab + projectsUsed: number + cachesUsed: number + cachesAvailable: boolean + dockerUsed: number + dockerAvailable: boolean + thresholdGB: number + cacheThresholdGB: number + dockerThresholdGB: number + severity: SeverityCounts + packagesComputing: boolean + packagesHasData: boolean +} + +/** "{pct}% of your {n} GB {qualifier}limit" under the cap, or + * "{over} over your {n} GB {qualifier}limit" past it. `qualifier` is '' + * for node_modules, 'cache ' for caches, 'Docker ' for docker. */ +function sizeLimitSummary(usedBytes: number, thresholdGB: number, qualifier: string): string { + const threshold = thresholdGB * GB + if (usedBytes > threshold) { + return `${formatSizeStr(usedBytes - threshold)} over your ${thresholdGB} GB ${qualifier}limit` + } + const pct = threshold > 0 ? (usedBytes / threshold) * 100 : 0 + return `${pct.toFixed(0)}% of your ${thresholdGB} GB ${qualifier}limit` +} + +/** The footer line for the active tab, or null when there is nothing to show. */ +export function tabSummary(input: TabSummaryInput): string | null { + switch (input.tab) { + case 'projects': + return sizeLimitSummary(input.projectsUsed, input.thresholdGB, '') + case 'caches': + return input.cachesAvailable ? sizeLimitSummary(input.cachesUsed, input.cacheThresholdGB, 'cache ') : null + case 'docker': + return input.dockerAvailable ? sizeLimitSummary(input.dockerUsed, input.dockerThresholdGB, 'Docker ') : null + case 'packages': { + if (input.packagesComputing && !input.packagesHasData) return null + const { vulnerable, outdated } = input.severity + if (vulnerable === 0) return outdated > 0 ? `all clear Β· ${outdated} outdated` : 'all clear' + return `${vulnerable} vulnerable Β· ${outdated} outdated` + } + default: + return null + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm vitest run src/renderer/src/launcher/LauncherApp/tabSummary.test.ts` +Expected: PASS (4 tests). If the projects "over" assertion mismatches, check `formatSizeStr(1 * GB)` output and align the expectation to the real format (it renders GB with 2 decimals, e.g. `1.00 GB`). + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/launcher/LauncherApp/tabSummary.ts src/renderer/src/launcher/LauncherApp/tabSummary.test.ts +git commit -m "feat(app): tabSummary β€” per-tab footer line" +``` + +--- + +### Task 5: `TabHeadline` switcher component + +**Files:** +- Create: `src/renderer/src/components/TabHeadline/TabHeadline.types.ts` +- Create: `src/renderer/src/components/TabHeadline/TabHeadline.tsx` +- Create: `src/renderer/src/components/TabHeadline/index.ts` + +**Interfaces:** +- Consumes: `Gauge` from `@renderer/components/Gauge` (`GaugeProps = { used; threshold; accent; linkedBytes?; calculating? }`), `SeverityMeter` from `@renderer/components/SeverityMeter`, `SeverityCounts` from `@renderer/lib/severity`, `GB` from `@renderer/lib/format`, `LauncherTab` from `@renderer/launcher/LauncherApp/LauncherApp.types`. +- Produces: `TabHeadline` rendering the right headline for the active tab, or `null` when a size tab has no data. + ```ts + export interface TabHeadlineProps { + tab: LauncherTab; accent: string + projectsUsed: number; linkedBytes: number; projectsCalculating: boolean; thresholdGB: number + cachesUsed: number; cachesAvailable: boolean; cachesCalculating: boolean; cacheThresholdGB: number + dockerUsed: number; dockerAvailable: boolean; dockerThresholdGB: number + severity: SeverityCounts; packagesTotal: number; packagesComputing: boolean + } + ``` + +- [ ] **Step 1: Create the types** + +Create `src/renderer/src/components/TabHeadline/TabHeadline.types.ts`: + +```ts +import type { SeverityCounts } from '@renderer/lib/severity' +import type { LauncherTab } from '@renderer/launcher/LauncherApp/LauncherApp.types' + +export interface TabHeadlineProps { + tab: LauncherTab + accent: string + // projects (node_modules) + projectsUsed: number + linkedBytes: number + projectsCalculating: boolean + thresholdGB: number + // caches (pnpm store) + cachesUsed: number + cachesAvailable: boolean + cachesCalculating: boolean + cacheThresholdGB: number + // docker + dockerUsed: number + dockerAvailable: boolean + dockerThresholdGB: number + // packages + severity: SeverityCounts + packagesTotal: number + packagesComputing: boolean +} +``` + +- [ ] **Step 2: Create the component** + +Create `src/renderer/src/components/TabHeadline/TabHeadline.tsx`: + +```tsx +import { Gauge } from '@renderer/components/Gauge' +import { SeverityMeter } from '@renderer/components/SeverityMeter' +import { GB } from '@renderer/lib/format' +import type { ReactNode } from 'react' +import type { TabHeadlineProps } from './TabHeadline.types' + +/** The header data-viz slot, routed by active tab: a size-vs-limit Gauge for + * node_modules / caches / docker, a SeverityMeter for packages. Renders null + * when a size tab has no data to show (no pnpm store / docker unavailable). */ +export function TabHeadline(props: TabHeadlineProps): ReactNode { + const { tab, accent } = props + if (tab === 'packages') { + return + } + if (tab === 'caches') { + if (!props.cachesAvailable) return null + return ( + + ) + } + if (tab === 'docker') { + if (!props.dockerAvailable) return null + return + } + // projects + return ( + + ) +} +``` + +Create `src/renderer/src/components/TabHeadline/index.ts`: + +```ts +export { TabHeadline } from './TabHeadline' +export type { TabHeadlineProps } from './TabHeadline.types' +``` + +- [ ] **Step 3: Typecheck, lint, build** + +Run: `pnpm typecheck && pnpm lint && pnpm build` +Expected: PASS. (Presentational switch, no unit test β€” verified via the integration task and manual checks.) + +- [ ] **Step 4: Commit** + +```bash +git add src/renderer/src/components/TabHeadline +git commit -m "feat(app): TabHeadline β€” per-tab header data-viz switcher" +``` + +--- + +### Task 6: Wire `TabHeadline` + `tabSummary` into `LauncherApp` + +**Files:** +- Modify: `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx` (imports; add `dockerTotal`, `severity`, `packagesHasData` memos; swap header `Gauge` β†’ `TabHeadline`; swap footer text β†’ `tabSummary`) + +**Interfaces:** +- Consumes: `TabHeadline` (Task 5), `severityCounts` (Task 2), `tabSummary` (Task 4). Existing in scope: `totalUsed`, `linkedTotal`, `storeBytes`, `store`, `scanning`, `storeLoading`, `calculating`, `inventory`, `pkgComputing`, `docker.info`, `settings.thresholdGB`, `settings.cacheThresholdGB`, `settings.dockerThresholdGB`, `isEmpty`, `accent`, `tab`, `view`. + +- [ ] **Step 1: Update imports** + +In `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx`: +- Remove the `Gauge` import line (`import { Gauge } from '@renderer/components/Gauge'`) β€” `Gauge` is now used only inside `TabHeadline`. +- Add: + +```ts +import { TabHeadline } from '@renderer/components/TabHeadline' +import { severityCounts } from '@renderer/lib/severity' +import { tabSummary } from './tabSummary' +``` + +- [ ] **Step 2: Add the derived values** + +After the existing `dockerMaxBytes` memo (around line 153-156), add: + +```ts + const dockerTotal = useMemo( + () => (docker.info?.totals ?? []).reduce((s, t) => s + t.sizeBytes, 0), + [docker.info], + ) + const severity = useMemo(() => severityCounts(inventory?.packages ?? []), [inventory]) + const packagesHasData = (inventory?.packages.length ?? 0) > 0 + const cachesAvailable = !!store?.available + const dockerAvailable = !!docker.info?.available +``` + +- [ ] **Step 3: Swap the header gauge for `TabHeadline`** + +Replace the header `` block (currently around lines 648-654): + +```tsx + +``` + +with: + +```tsx + +``` + +- [ ] **Step 4: Compute the footer summary + over-state** + +Just before the `return (` of the component (near line 600, after `const overBy = totalUsed - threshold`), add: + +```ts + const summaryText = tabSummary({ + tab, + projectsUsed: totalUsed, + cachesUsed: storeBytes, + cachesAvailable, + dockerUsed: dockerTotal, + dockerAvailable, + thresholdGB: settings.thresholdGB, + cacheThresholdGB: settings.cacheThresholdGB, + dockerThresholdGB: settings.dockerThresholdGB, + severity, + packagesComputing: pkgComputing, + packagesHasData, + }) + const summaryOver = + tab === 'projects' + ? totalUsed > threshold + : tab === 'caches' + ? cachesAvailable && storeBytes > settings.cacheThresholdGB * GB + : tab === 'docker' + ? dockerAvailable && dockerTotal > settings.dockerThresholdGB * GB + : severity.vulnerable > 0 + // The disk spinner only makes sense on the size tabs that background-calculate. + const showCalcSpinner = calculating && (tab === 'projects' || tab === 'caches') + const showSummary = !!summaryText && !(tab === 'projects' && isEmpty) +``` + +- [ ] **Step 5: Swap the footer text block** + +Replace the footer summary block (currently around lines 991-1032, the `{view === 'list' && !isEmpty && (calculating ? (spinner) : (span))}`) with: + +```tsx + {view === 'list' && + (showCalcSpinner ? ( + + + + {scanning ? 'scanning' : 'pnpm'} + + + {scanning + ? `${(scanProgress?.foldersChecked ?? 0).toLocaleString()} folders…` + : 'sizing store…'} + + + ) : ( + showSummary && ( + + {summaryText} + + ) + ))} +``` + +- [ ] **Step 6: Typecheck, lint, build** + +Run: `pnpm typecheck && pnpm test && pnpm lint && pnpm build` +Expected: PASS. If `Gauge` shows as unused-import error, confirm Step 1 removed it. If `calculating`/`ratio`/`overBy` are now unused, remove only the ones no longer referenced (leave `ratio`/`status` β€” still used by the window boxShadow glow). + +- [ ] **Step 7: Manual verification** + +Run `pnpm dev`. Confirm: +- Projects tab: header gauge + footer read exactly as before (`{pct}% of your {n} GB limit`). +- Caches tab: gauge fills against the cache limit; footer says "… cache limit". No gauge if no pnpm store. +- Packages tab: severity meter shows (or "all clear"); footer "{n} vulnerable Β· {m} outdated". Tooltip shows the full breakdown. +- Docker tab: gauge fills against the docker limit; footer "… Docker limit". No gauge when docker unavailable. + +- [ ] **Step 8: Commit** + +```bash +git add src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +git commit -m "feat(app): per-tab header data-viz and footer summary in LauncherApp" +``` + +--- + +### Task 7: Docker auto-scan on open when stale + +**Files:** +- Modify: `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx` (add a `DOCKER_STALE_MS` constant + one effect) + +**Interfaces:** +- Consumes: `docker.info` (`DockerInfo | null`, has `checkedAt: number`), `docker.loading`, `docker.refresh` (stable `useCallback`), `view`, `tab`, `dockerEnabled` β€” all already in scope. + +- [ ] **Step 1: Add the staleness constant** + +Near the top-of-file constants (after `LIST_PADDING` around line 52) add: + +```ts +/** Re-scan Docker when its tab opens and the data is older than this. */ +const DOCKER_STALE_MS = 5 * 60 * 1000 +``` + +- [ ] **Step 2: Add the effect** + +Add alongside the other `useEffect`s (e.g. just after the Docker-tab-fallback effect around line 132): + +```ts + // Docker refreshes on its own cadence, decoupled from the disk rescan: when the + // Docker tab opens with missing or stale (>5 min) data, kick a background scan. + // Guarded on `docker.loading` and freshened `checkedAt` so it can't loop. + useEffect(() => { + if (view !== 'list' || tab !== 'docker' || !dockerEnabled || docker.loading) return + const stale = !docker.info || Date.now() - docker.info.checkedAt > DOCKER_STALE_MS + if (stale) void docker.refresh() + }, [view, tab, dockerEnabled, docker.info, docker.loading, docker.refresh]) +``` + +- [ ] **Step 3: Typecheck, lint, build** + +Run: `pnpm typecheck && pnpm test && pnpm lint && pnpm build` +Expected: PASS. + +- [ ] **Step 4: Manual verification** + +Run `pnpm dev`. Confirm: +- Opening the Docker tab with no prior scan triggers a refresh (loading state, then data). +- Switching away and back within 5 minutes does **not** re-scan (data stays put, no flicker). +- The manual refresh button and ⌘R still force a scan. +- No refresh loop (watch the busy/loading indicator settle and stay settled). + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +git commit -m "feat(app): auto-scan Docker on tab open when data is stale" +``` + +--- + +### Task 8: Settings UI β€” Caches + Docker limit inputs + +**Files:** +- Modify: `src/renderer/src/launcher/views/SettingsView.tsx` (add a `GbInput` local control + a "Storage limits" group in the Scanning tab) + +**Interfaces:** +- Consumes: `settings.cacheThresholdGB`, `settings.dockerThresholdGB` (Task 1); existing `setSetting`, `SettingsRow`, `Divider`, `SectionHeading`. +- Note: node_modules keeps its existing `PixelStepper` "Alert threshold" row (1-10 GB, tied to notifications). The two new limits use number inputs because they need a larger range (docker default is 20 GB, above the stepper's 10 GB ceiling). + +- [ ] **Step 1: Add a `GbInput` local control** + +In `src/renderer/src/launcher/views/SettingsView.tsx`, add near the other local components (after `SectionHeading`): + +```tsx +function GbInput({ valueGB, onChange }: { valueGB: number; onChange: (v: number) => void }): ReactNode { + return ( +
+ { + const v = Number(e.target.value) + if (Number.isFinite(v)) onChange(v) + }} + style={{ + width: 74, + background: 'var(--surface-2)', + border: '1px solid var(--hairline)', + borderRadius: 7, + padding: '6px 9px', + fontSize: 12.5, + color: 'var(--text)', + outline: 'none', + fontVariantNumeric: 'tabular-nums', + textAlign: 'right', + }} + /> + GB +
+ ) +} +``` + +- [ ] **Step 2: Add the "Storage limits" group to the Scanning tab** + +In the `{tab === 'scanning' && ( … )}` block, after the "Threshold notifications" `SettingsRow` (the last row in that block), insert: + +```tsx + + + + setSetting('cacheThresholdGB', v)} /> + + {(settings.docker ?? true) && ( + <> + + + setSetting('dockerThresholdGB', v)} /> + + + )} +``` + +- [ ] **Step 3: Typecheck, lint, build** + +Run: `pnpm typecheck && pnpm test && pnpm lint && pnpm build` +Expected: PASS. + +- [ ] **Step 4: Manual verification** + +Run `pnpm dev` β†’ Settings β†’ Scanning. Confirm: +- "Storage limits" group shows pnpm cache limit (default 10) and Docker limit (default 20). +- Editing a value updates the corresponding tab's gauge fill (open Caches / Docker to see the limit line move). +- Docker limit row hides when the Docker tab is disabled (Packages settings tab β†’ Docker cleanup off). +- Out-of-range values are clamped by `validate-setting` (persisted value never exceeds 1000 or drops below 0.1). + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/launcher/views/SettingsView.tsx +git commit -m "feat(settings): cache and docker limit inputs in Settings" +``` + +--- + +## Final wrap-up (after all tasks pass review) + +- [ ] Run the full gate once more: `pnpm typecheck && pnpm test && pnpm lint && pnpm build`. +- [ ] Update the `STATUS` data block in `STATUS.html`: bump `updated` to today, add a `done` roadmap item for per-tab headline data-viz + Docker auto-scan, and append one `log` line. +- [ ] Push the branch and open / update the PR for review. diff --git a/docs/superpowers/plans/2026-07-14-docker-buildcache-to-caches-and-clearer-deletes.md b/docs/superpowers/plans/2026-07-14-docker-buildcache-to-caches-and-clearer-deletes.md new file mode 100644 index 0000000..98b9f72 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-docker-buildcache-to-caches-and-clearer-deletes.md @@ -0,0 +1,730 @@ +# Docker build cache β†’ Caches tab + clearer deletion buttons β€” Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move Docker build cache out of the Docker tab into the Caches tab as a single deletable row, and make the Docker deletion buttons unmistakably read as deletions. + +**Architecture:** Pure display/wiring change. `groupDockerForDisplay` stops emitting a build-cache group (build-cache items stay in `docker.info.items`, so gauge/scale accounting is untouched); the Caches tab renders a generalized list of "live caches" (pnpm store + Docker build cache) whose keyboard nav and Delete/Prune actions are driven by data built in `LauncherApp`. Bulk-prune buttons and the build-cache Delete button get delete-verb labels + trash icon + danger (red) styling. Every delete still routes through the existing docker confirm gate; no safety-model change. + +**Tech Stack:** Electron + React 18 (renderer), TypeScript, Vitest (pure-logic tests only β€” no `.test.tsx`/testing-library; JSX is verified by typecheck + build + manual). Biome for lint/format. pnpm. + +## Global Constraints + +- Package manager: **pnpm** (`pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build`). +- **No em dashes** in any user-facing copy (labels, tooltips, detail lines). Ellipsis `…` is fine and already used ("Pruning…"). +- Tests live beside sources as `*.test.ts` and use Vitest (`import { describe, expect, it } from 'vitest'`). There is **no** component-render testing β€” do not add `.test.tsx` or testing-library. +- Danger (destructive) styling matches the node_modules `RowAction`: border `rgba(255,99,99,0.4)`, background `rgba(255,99,99,0.10)`, text `rgba(255,99,99,0.95)` (dim to `var(--text-dim)` while busy). +- Safety model is frozen: all deletes go through the existing `requestDockerPrune` / `requestDockerRemove` confirm gate; the typed-name volume gate is byte-identical. Build cache is never individually removable. +- Conventional-commit subjects. Commit at the end of each task. + +--- + +## File Structure + +- `src/renderer/src/launcher/views/DockerView.constants.ts` β€” drop the `buildcache` display group; add `PRUNE_BUTTON_LABEL` (delete-verb button labels) and `dockerBuildCacheBytes` helper. (Keep `PRUNE_TARGET_LABEL`, `pruneEstimateBytes` β€” still used by the confirm footer/toast and the Caches delete estimate.) +- `src/renderer/src/launcher/views/DockerView.tsx` β€” prune buttons use `PRUNE_BUTTON_LABEL` + trash icon + danger styling; drop `buildcache` from `GROUP_ICON`. +- `src/renderer/src/components/CacheRow/CacheRow.types.ts` + `CacheRow.tsx` β€” optional `danger` + `actionIcon` on the action button. +- `src/renderer/src/launcher/views/CachesView.constants.ts` β€” add `LiveCache` interface + `visibleCaches` helper. +- `src/renderer/src/launcher/views/CachesView.tsx` β€” render a list of `LiveCache` rows (query-filtered, index-preserving) above the placeholders. +- `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx` β€” build `liveCaches` (pnpm + Docker build cache), pass to `CachesView`, extend the Docker auto-scan effect to the Caches tab, update the Caches keyboard nav. + +Tests: `DockerView.constants.test.ts`, `CachesView.constants.test.ts`. + +--- + +## Task 1: DockerView.constants β€” remove build-cache group, add button labels + size helper + +**Files:** +- Modify: `src/renderer/src/launcher/views/DockerView.constants.ts` +- Test: `src/renderer/src/launcher/views/DockerView.constants.test.ts` + +**Interfaces:** +- Produces: + - `PRUNE_BUTTON_LABEL: Record` β€” delete-verb button labels. + - `dockerBuildCacheBytes(items: DockerItem[]): number` β€” total bytes of build-cache items. + - `DisplayGroup` union no longer has a `{ kind: 'buildcache' }` variant. +- Consumes: existing `DockerItem`, `DockerPruneTarget` from `@shared/docker.types`. + +- [ ] **Step 1: Update the failing tests first** + +In `DockerView.constants.test.ts`, replace the first `groupDockerForDisplay` test (the one asserting `kinds` contains `'buildcache'`) with a version that asserts build cache is gone, and add two new `describe` blocks. Apply these edits: + +Replace this block (currently around lines 40-49): + +```ts + it('puts project groups first, then repository/buildcache/unaffiliated under "Other"', () => { + const g = groupDockerForDisplay(info(items), { sortBy: 'size', typeFilter: 'all', query: '' }) + expect(g[0]).toMatchObject({ kind: 'project', project: { name: 'myapp' } }) + const kinds = g.map((x) => x.kind) + expect(kinds).toContain('repository') // redis + expect(kinds).toContain('buildcache') + expect(kinds).toContain('unaffiliated') // orphan volume + // project group before any "Other" group + expect(kinds.indexOf('project')).toBeLessThan(kinds.indexOf('repository')) + }) +``` + +with: + +```ts + it('puts project groups first, then repository/unaffiliated under "Other"', () => { + const g = groupDockerForDisplay(info(items), { sortBy: 'size', typeFilter: 'all', query: '' }) + expect(g[0]).toMatchObject({ kind: 'project', project: { name: 'myapp' } }) + const kinds = g.map((x) => x.kind) + expect(kinds).toContain('repository') // redis + expect(kinds).toContain('unaffiliated') // orphan volume + // project group before any "Other" group + expect(kinds.indexOf('project')).toBeLessThan(kinds.indexOf('repository')) + }) + + it('never groups build-cache items (they moved to the Caches tab)', () => { + const g = groupDockerForDisplay(info(items), { sortBy: 'size', typeFilter: 'all', query: '' }) + expect(g.some((x) => x.kind === ('buildcache' as unknown))).toBe(false) + expect(g.every((x) => x.items.every((i) => i.kind !== 'buildcache'))).toBe(true) + }) +``` + +Then delete the `prunesForGroup` test titled `'buildcache group offers buildCache'` (currently around lines 180-183): + +```ts + it('buildcache group offers buildCache', () => { + const g = group({ kind: 'buildcache', items: [item({ id: 'b1', kind: 'buildcache', name: 'b1' })] }) + expect(prunesForGroup(g)).toEqual(['buildCache']) + }) +``` + +Then add these two `describe` blocks at the end of the file (before the final line), and add `PRUNE_BUTTON_LABEL, dockerBuildCacheBytes` to the top `import { … } from './DockerView.constants'`: + +```ts +describe('PRUNE_BUTTON_LABEL', () => { + it('leads every label with the word "Delete" so the button reads as destructive', () => { + for (const label of Object.values(PRUNE_BUTTON_LABEL)) { + expect(label.startsWith('Delete ')).toBe(true) + } + }) + + it('has no em dashes', () => { + for (const label of Object.values(PRUNE_BUTTON_LABEL)) { + expect(label.includes('β€”')).toBe(false) + } + }) +}) + +describe('dockerBuildCacheBytes', () => { + it('sums only build-cache items', () => { + const items: DockerItem[] = [ + { id: 'b1', kind: 'buildcache', name: 'b1', sizeBytes: 100, createdAt: 0, inUse: false, removable: false }, + { id: 'b2', kind: 'buildcache', name: 'b2', sizeBytes: 250, createdAt: 0, inUse: false, removable: false }, + { id: 'i1', kind: 'image', name: 'redis', sizeBytes: 999, createdAt: 0, inUse: false, removable: true }, + ] + expect(dockerBuildCacheBytes(items)).toBe(350) + }) + + it('is 0 when there are no build-cache items', () => { + expect(dockerBuildCacheBytes([])).toBe(0) + }) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm test src/renderer/src/launcher/views/DockerView.constants.test.ts` +Expected: FAIL β€” `PRUNE_BUTTON_LABEL` / `dockerBuildCacheBytes` are not exported yet, and the removed-buildcache assertions fail against the current implementation. + +- [ ] **Step 3: Implement the constants changes** + +In `DockerView.constants.ts`: + +(a) Remove the `buildcache` variant from the `DisplayGroup` union: + +```ts +export type DisplayGroup = + | { kind: 'project'; id: string; label: string; project: DockerProject; items: DockerItem[] } + | { kind: 'repository'; id: string; label: string; items: DockerItem[] } + | { kind: 'unaffiliated'; id: string; label: string; items: DockerItem[] } +``` + +(b) In `groupDockerForDisplay`, delete the `cache` / `buildcacheGroup` construction and drop it from the return. The `rest` filter already excludes build-cache items, so they simply no longer appear. The tail of the function becomes: + +```ts + const rest = other.filter((i) => i.kind !== 'buildcache' && !(i.kind === 'image' && i.repository)).sort(bySizeDesc) + const unaffiliatedGroup: DisplayGroup[] = rest.length + ? [{ kind: 'unaffiliated', id: 'unaffiliated', label: 'Not linked to a project', items: rest }] + : [] + + return [ + ...sortGroups(projectGroups, opts.sortBy), + ...sortGroups([...repoGroups, ...unaffiliatedGroup], opts.sortBy), + ] +} +``` + +(c) In `prunesForGroup`, remove the build-cache branch. After the repository/unaffiliated block it becomes just: + +```ts +export function prunesForGroup(group: DisplayGroup): DockerPruneTarget[] { + if (group.kind === 'repository' || group.kind === 'unaffiliated') { + const targets: DockerPruneTarget[] = [] + if (group.items.some((i) => i.kind === 'image' && i.name === '')) targets.push('danglingImages') + if (group.items.some((i) => i.kind === 'image' && i.removable)) targets.push('unusedImages') + if (group.kind === 'unaffiliated') { + if (group.items.some((i) => i.kind === 'volume')) targets.push('unusedVolumes') + if (group.items.some((i) => i.kind === 'container' && i.removable)) targets.push('stoppedContainers') + } + return targets + } + return [] +} +``` + +(d) Add the new exports (place `PRUNE_BUTTON_LABEL` next to `PRUNE_TARGET_LABEL`, and `dockerBuildCacheBytes` next to `pruneEstimateBytes`): + +```ts +/** Button labels for the bulk-prune actions. Unlike `PRUNE_TARGET_LABEL` (the noun used in + * the confirm footer + toast), these lead with a delete verb so the button reads as the + * destructive action it is. */ +export const PRUNE_BUTTON_LABEL: Record = { + danglingImages: 'Delete dangling images', + unusedImages: 'Delete unused images', + stoppedContainers: 'Delete stopped containers', + buildCache: 'Delete build cache', + unusedVolumes: 'Delete unused volumes', +} + +/** Total bytes of Docker build-cache items. Surfaced as a single row in the Caches tab β€” + * build cache is a global cache, not a per-project resource, so it lives with the other + * package-manager caches rather than in the project-grouped Docker list. */ +export function dockerBuildCacheBytes(items: DockerItem[]): number { + return items.filter((i) => i.kind === 'buildcache').reduce((s, i) => s + i.sizeBytes, 0) +} +``` + +Note: keep `PRUNE_TARGET_LABEL` and `pruneEstimateBytes` unchanged β€” they are still used by the confirm footer/toast and the Caches-tab delete estimate. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pnpm test src/renderer/src/launcher/views/DockerView.constants.test.ts` +Expected: PASS (all `groupDockerForDisplay`, `prunesForGroup`, `PRUNE_BUTTON_LABEL`, `dockerBuildCacheBytes` tests green). + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/launcher/views/DockerView.constants.ts src/renderer/src/launcher/views/DockerView.constants.test.ts +git commit -m "refactor(app): drop Docker build-cache group; add delete-verb labels + size helper" +``` + +--- + +## Task 2: DockerView β€” danger prune buttons with trash icon + +**Files:** +- Modify: `src/renderer/src/launcher/views/DockerView.tsx` + +**Interfaces:** +- Consumes: `PRUNE_BUTTON_LABEL` (Task 1), `UIIcon.trash`. + +- [ ] **Step 1: Swap the prune-button import** + +In `DockerView.tsx`, the import from `./DockerView.constants` currently pulls `PRUNE_TARGET_LABEL`. `PRUNE_TARGET_LABEL` is no longer used in this file β€” replace it with `PRUNE_BUTTON_LABEL`. The import block becomes: + +```ts +import { + type DisplayGroup, + dockerGroupActive, + dockerItemDetail, + groupDockerForDisplay, + PRUNE_BUTTON_LABEL, + projectRowExpanded, + prunesForGroup, +} from './DockerView.constants' +``` + +- [ ] **Step 2: Drop `buildcache` from `GROUP_ICON`** + +`GroupHeader`'s `group` type is now `Exclude` = `repository | unaffiliated`. Update `GROUP_ICON` to match: + +```ts +/** Generic header glyph for the non-project ("Other") groups. */ +const GROUP_ICON: Record<'repository' | 'unaffiliated', IconRenderer> = { + repository: UIIcon.box, + unaffiliated: UIIcon.hdd, +} +``` + +Leave `KIND_ICON` unchanged (it is keyed by `DockerItemKind`, which still includes `buildcache`). + +- [ ] **Step 3: Restyle the prune buttons as danger + trash icon** + +Inside `GroupHeader`, replace the button JSX (the `targets.map(...)` button) with: + +```tsx + {targets.map((target) => { + const busy = busyId === `prune:${target}` + return ( + + ) + })} +``` + +- [ ] **Step 4: Verify typecheck + build (no unit test for JSX)** + +Run: `pnpm typecheck && pnpm test` +Expected: typecheck PASS (no `PRUNE_TARGET_LABEL`/`buildcache`-group type errors); all tests still green. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/launcher/views/DockerView.tsx +git commit -m "feat(app): Docker prune buttons read as deletes (verb + trash icon + danger)" +``` + +--- + +## Task 3: CacheRow β€” optional danger + action icon + +**Files:** +- Modify: `src/renderer/src/components/CacheRow/CacheRow.types.ts` +- Modify: `src/renderer/src/components/CacheRow/CacheRow.tsx` + +**Interfaces:** +- Produces: `CacheRowProps` gains optional `danger?: boolean` and `actionIcon?: IconRenderer`. When `danger`, the action button uses the destructive red styling; when `actionIcon` is set it renders before the label inside the button. +- Consumes: existing `IconRenderer` (already imported in the types file). + +- [ ] **Step 1: Extend the props type** + +In `CacheRow.types.ts`, add two properties inside `CacheRowProps` (after `busyLabel`): + +```ts + /** Optional leading glyph inside the action button (e.g. a trash icon for a delete action). */ + actionIcon?: IconRenderer + /** Style the action button as destructive (red), matching the node_modules RowAction. */ + danger?: boolean +``` + +- [ ] **Step 2: Consume the new props in the component** + +In `CacheRow.tsx`, add `actionIcon` and `danger = false` to the destructured props, then replace the action ` + ) : disabled ? ( +``` + +Leave the `disabled` ("soon") and `null` branches unchanged. + +- [ ] **Step 3: Verify typecheck + build** + +Run: `pnpm typecheck && pnpm test` +Expected: PASS. The pnpm store row (no `danger`, no `actionIcon`) renders identically to before β€” the new styling is opt-in. + +- [ ] **Step 4: Commit** + +```bash +git add src/renderer/src/components/CacheRow/CacheRow.types.ts src/renderer/src/components/CacheRow/CacheRow.tsx +git commit -m "feat(app): CacheRow supports a danger action button with an optional icon" +``` + +--- + +## Task 4: CachesView β€” live-cache list + +**Files:** +- Modify: `src/renderer/src/launcher/views/CachesView.constants.ts` +- Test: `src/renderer/src/launcher/views/CachesView.constants.test.ts` +- Modify: `src/renderer/src/launcher/views/CachesView.tsx` + +**Interfaces:** +- Produces: + - `LiveCache` interface (the data shape `LauncherApp` builds per live cache). + - `visibleCaches(caches: LiveCache[], query: string): { cache: LiveCache; index: number }[]` β€” query-filters by name (case-insensitive substring) while preserving each cache's original index. + - `CachesView` new props: `{ caches: LiveCache[]; selectedIndex: number; query: string; onSelectIndex: (i: number) => void }` (the old `store`/`pruning`/`onPrune` props are removed). +- Consumes: `CacheRow` (Task 3), `IconRenderer`, `UIIcon`. + +- [ ] **Step 1: Write the failing helper tests** + +In `CachesView.constants.test.ts`, add to the top import and append a new `describe`: + +```ts +import { CACHE_PLACEHOLDERS, type LiveCache, visibleCaches } from './CachesView.constants' +import type { IconRenderer } from '@renderer/components/UIIcon' +``` + +```ts +describe('visibleCaches', () => { + const stubIcon: IconRenderer = () => null + const cache = (name: string): LiveCache => ({ id: name, icon: stubIcon, name, detail: '' }) + const caches = [cache('pnpm store'), cache('Docker build cache')] + + it('returns every cache with its original index when the query is empty', () => { + expect(visibleCaches(caches, '')).toEqual([ + { cache: caches[0], index: 0 }, + { cache: caches[1], index: 1 }, + ]) + }) + + it('filters by name case-insensitively, preserving the original index', () => { + const r = visibleCaches(caches, 'DOCKER') + expect(r).toHaveLength(1) + expect(r[0]).toEqual({ cache: caches[1], index: 1 }) + }) + + it('returns nothing when no cache matches', () => { + expect(visibleCaches(caches, 'zzz')).toEqual([]) + }) +}) +``` + +(Keep the existing `CACHE_PLACEHOLDERS` import in that first line β€” the edit above replaces the whole import line, so `CACHE_PLACEHOLDERS` must stay in it.) + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm test src/renderer/src/launcher/views/CachesView.constants.test.ts` +Expected: FAIL β€” `LiveCache` / `visibleCaches` not exported yet. + +- [ ] **Step 3: Implement `LiveCache` + `visibleCaches`** + +In `CachesView.constants.ts`, add the import and the two exports (keep `CachePlaceholder` / `CACHE_PLACEHOLDERS` as-is): + +```ts +import type { IconRenderer } from '@renderer/components/UIIcon' +``` + +```ts +/** One live (present, actionable) cache shown at the top of the Caches tab, above the + * "coming soon" placeholders. `LauncherApp` builds these β€” the view stays presentational. */ +export interface LiveCache { + id: string + icon: IconRenderer + name: string + /** Secondary line: store path, a status, or a short description. */ + detail: string + size?: number + disabled?: boolean + busy?: boolean + actionLabel?: string + busyLabel?: string + title?: string + /** Render the action button as destructive (red) with a trash icon. */ + danger?: boolean + onAction?: () => void +} + +/** Query-filter live caches by name (case-insensitive substring), preserving each cache's + * original index so the keyboard-selected row stays correct while the list is filtered. */ +export function visibleCaches(caches: LiveCache[], query: string): { cache: LiveCache; index: number }[] { + const q = query.trim().toLowerCase() + return caches + .map((cache, index) => ({ cache, index })) + .filter(({ cache }) => !q || cache.name.toLowerCase().includes(q)) +} +``` + +- [ ] **Step 4: Run the helper tests to verify they pass** + +Run: `pnpm test src/renderer/src/launcher/views/CachesView.constants.test.ts` +Expected: PASS. + +- [ ] **Step 5: Rewrite `CachesView.tsx` to render the live-cache list** + +Replace the entire contents of `CachesView.tsx` with: + +```tsx +import { CacheRow } from '@renderer/components/CacheRow' +import { UIIcon } from '@renderer/components/UIIcon' +import type { ReactNode } from 'react' +import { CACHE_PLACEHOLDERS, type LiveCache, visibleCaches } from './CachesView.constants' + +interface CachesViewProps { + /** Live caches (pnpm store, Docker build cache, …) in keyboard order. */ + caches: LiveCache[] + /** Index (into `caches`) of the keyboard-selected live cache. */ + selectedIndex: number + /** Search text from the shared header input; filters cache rows by name. */ + query: string + onSelectIndex: (i: number) => void +} + +/** The launcher's "Caches" tab: live global caches (pnpm store, Docker build cache) above + * the not-yet-built placeholders (npm/yarn/bun). */ +export function CachesView({ caches, selectedIndex, query, onSelectIndex }: CachesViewProps): ReactNode { + const q = query.trim().toLowerCase() + const rows = visibleCaches(caches, query) + const placeholders = CACHE_PLACEHOLDERS.filter((c) => !q || c.name.toLowerCase().includes(q)) + + return ( +
+
+ {rows.map(({ cache, index }) => ( + onSelectIndex(index)} + onAction={cache.onAction} + /> + ))} + {placeholders.map((c) => ( + + ))} + {rows.length === 0 && placeholders.length === 0 && ( +
+ No caches match β€œ{query}”. +
+ )} +
+
+ ) +} +``` + +Note: this file will not typecheck against `LauncherApp` until Task 5 updates the call site β€” that is expected and fixed in Task 5. + +- [ ] **Step 6: Run the tests (helpers) to confirm green** + +Run: `pnpm test src/renderer/src/launcher/views/CachesView.constants.test.ts` +Expected: PASS. (Full `pnpm typecheck` is deferred to Task 5, which updates the `CachesView` call site.) + +- [ ] **Step 7: Commit** + +```bash +git add src/renderer/src/launcher/views/CachesView.constants.ts src/renderer/src/launcher/views/CachesView.constants.test.ts src/renderer/src/launcher/views/CachesView.tsx +git commit -m "feat(app): Caches tab renders a live-cache list (pnpm + future caches)" +``` + +--- + +## Task 5: LauncherApp β€” wire the Docker build cache into the Caches tab + +**Files:** +- Modify: `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx` + +**Interfaces:** +- Consumes: `dockerBuildCacheBytes` (Task 1), `LiveCache` (Task 4), the new `CachesView` props (Task 4), existing `requestDockerPrune`, `handlePrune`, `dockerAvailable`, `dockerEnabled`, `docker.busyId`, `store`, `pruning`. + +- [ ] **Step 1: Add imports** + +Ensure these imports exist in `LauncherApp.tsx`: +- From `../views/DockerView.constants`: add `dockerBuildCacheBytes` (alongside the existing `pruneEstimateBytes` / `PRUNE_TARGET_LABEL` imports). +- From `../views/CachesView.constants`: add `type LiveCache`. +- `UIIcon` is already imported. + +- [ ] **Step 2: Build `buildCacheBytes` and `liveCaches` memos** + +Add these near the other docker-derived memos (after `const dockerAvailable = !!docker.info?.available`, around line 174). `useMemo` is already imported. + +```tsx + const buildCacheBytes = useMemo( + () => (dockerAvailable ? dockerBuildCacheBytes(docker.info?.items ?? []) : 0), + [dockerAvailable, docker.info], + ) + + const liveCaches = useMemo(() => { + const list: LiveCache[] = [ + { + id: 'pnpm', + icon: UIIcon.hdd, + name: 'pnpm store', + detail: pruning + ? 'Pruning unreferenced packages…' + : store?.available + ? (store?.displayPath ?? '') + : (store?.reason ?? 'pnpm store not found'), + size: store?.available ? store?.sizeBytes : undefined, + disabled: !store?.available, + busy: pruning, + actionLabel: store?.canPrune ? 'Prune' : undefined, + onAction: handlePrune, + }, + ] + if (dockerEnabled && dockerAvailable && buildCacheBytes > 0) { + list.push({ + id: 'docker-buildcache', + icon: UIIcon.hdd, + name: 'Docker build cache', + detail: 'Docker layer build cache', + size: buildCacheBytes, + busy: docker.busyId === 'prune:buildCache', + actionLabel: 'Delete', + busyLabel: 'Deleting…', + danger: true, + title: 'Delete all Docker build cache. Permanent, not sent to the Trash.', + onAction: () => requestDockerPrune('buildCache'), + }) + } + return list + }, [store, pruning, handlePrune, dockerEnabled, dockerAvailable, buildCacheBytes, docker.busyId, requestDockerPrune]) +``` + +- [ ] **Step 3: Update the `CachesView` call site** + +Replace the `` block (around lines 837-844) with: + +```tsx + ) : tab === 'caches' ? ( + +``` + +- [ ] **Step 4: Extend the Docker auto-scan effect to the Caches tab** + +In the auto-scan effect (currently around lines 139-145), change the tab guard so it also runs on the Caches tab. Replace: + +```tsx + if (view !== 'list' || tab !== 'docker' || !dockerEnabled || docker.loading) return +``` + +with: + +```tsx + if (view !== 'list' || (tab !== 'docker' && tab !== 'caches') || !dockerEnabled || docker.loading) return +``` + +Add `tab` is already a dep; no dep change needed (the effect already depends on `tab`). Leave the rest of the effect unchanged. + +- [ ] **Step 5: Update the Caches keyboard nav** + +Replace the `if (tab === 'caches') { … }` block (around lines 504-517) with: + +```tsx + if (tab === 'caches') { + const cacheCount = liveCaches.length + if (e.key === 'ArrowDown') { + e.preventDefault() + setSel((s) => Math.min(cacheCount - 1, s + 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSel((s) => Math.max(0, s - 1)) + } else if (e.key === 'Enter') { + e.preventDefault() + const c = liveCaches[sel] + if (c && !c.disabled && !c.busy) c.onAction?.() + } + return + } +``` + +Then update the keyboard effect's dependency array (around lines 559-584): remove `store`, `pruning`, `handlePrune` (no longer referenced directly in the handler β€” they now live inside the `liveCaches` memo) and add `liveCaches`. `sel` is already present. The array's caches-related entries become just `liveCaches`. + +- [ ] **Step 6: Verify the full suite** + +Run: `pnpm typecheck && pnpm lint && pnpm test` +Expected: all PASS. Typecheck confirms the new `CachesView` props line up; lint confirms no unused deps/imports (`store`/`pruning`/`handlePrune` may still be referenced elsewhere in the component β€” that is fine; only remove them from the keyboard effect's dep array, not their declarations). + +- [ ] **Step 7: Commit** + +```bash +git add src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +git commit -m "feat(app): Docker build cache lives in the Caches tab (delete + auto-scan + keyboard nav)" +``` + +--- + +## Task 6: Full verification + STATUS.html + +**Files:** +- Modify: `STATUS.html` (the `STATUS` data block only) + +- [ ] **Step 1: Run the complete gate** + +Run: `pnpm typecheck && pnpm lint && pnpm test && pnpm build` +Expected: all green. Note the final test count. + +- [ ] **Step 2: Manual smoke (needs a running app + Docker daemon)** + +With `pnpm dev` and Docker running: +- Docker tab (⌘4): build cache no longer appears; the bulk-delete buttons on "Not linked to a project" headers read "Delete unused images" / "Delete dangling images" / "Delete stopped containers" / "Delete unused volumes", are red, and carry a trash icon; hovering an unused image/volume/container still reveals the red trash. +- Caches tab (⌘2): a "Docker build cache" row appears with its size and a red "Delete" button (trash icon). ↑/↓ moves between pnpm store and the build-cache row; Enter on the build-cache row opens the confirm; confirming prunes and shows the reclaimed toast. As a free user, Delete shows the paywall. +- Open the Caches tab cold (without visiting Docker first) and confirm the build-cache row populates via auto-scan. + +(Restart `pnpm dev` fully β€” the main-process docker changes are not part of this change, but the auto-scan timing is renderer-side and hot-reloads.) + +- [ ] **Step 3: Update the `STATUS` data block in `STATUS.html`** + +- Bump `updated` to `2026-07-14`. +- Add a `done` roadmap item under the Docker milestones, e.g. "M1h β€” Docker build cache moved to the Caches tab + clearer (verb + trash + danger) deletion buttons". +- Append one `log` entry dated `2026-07-14` summarizing this session. +- Leave `userActions` as-is except: the existing Docker manual-test entries already cover the daemon pass; add a note there only if a new user-only obligation appeared (it did not β€” same daemon test surface). + +- [ ] **Step 4: Commit** + +```bash +git add STATUS.html +git commit -m "docs: STATUS β€” Docker build cache to Caches tab + clearer delete buttons" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Build cache dropped from Docker tab β†’ Task 1 (`groupDockerForDisplay`). +- Aggregated "Docker build cache" row in Caches with Delete β†’ Tasks 4 (view) + 5 (data/handler). +- Reuses existing confirm/gate/toast (`requestDockerPrune('buildCache')`) β†’ Task 5. +- Auto-scan extended to Caches tab β†’ Task 5 Step 4. +- Live-cache list + keyboard nav across pnpm + build cache β†’ Tasks 4 + 5. +- Gauge accounting unchanged β†’ no task needed (build-cache items stay in `docker.info.items`; `dockerTotal`/`dockerMaxBytes` untouched β€” noted in Architecture). +- Bulk-prune buttons: delete verb + trash + danger β†’ Tasks 1 (labels) + 2 (styling). +- Docker build-cache Delete button: danger + trash β†’ Task 3 (`CacheRow`) + 5 (data). +- Per-item hover trash unchanged (already danger) β†’ no task, verified in Task 6 manual. +- Tests for constants/helpers β†’ Tasks 1, 4. + +**Placeholder scan:** none β€” every code step shows full code. + +**Type consistency:** `LiveCache` (Task 4) is produced by Task 5 and consumed by Task 4's view; `visibleCaches` return shape `{ cache, index }[]` is consumed identically in `CachesView`; `PRUNE_BUTTON_LABEL` keyed by `DockerPruneTarget` matches its use in Task 2; `dockerBuildCacheBytes(items)` signature matches its Task 5 call. `CachesView` prop set changed in Task 4 and the call site updated in Task 5 (the interim non-compile between tasks is called out). diff --git a/docs/superpowers/plans/2026-07-15-panel-visualization-only.md b/docs/superpowers/plans/2026-07-15-panel-visualization-only.md new file mode 100644 index 0000000..deea199 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-panel-visualization-only.md @@ -0,0 +1,1396 @@ +# Visualization-Only Menu Bar Panel β€” Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the menu bar popover from a mini node_modules cleaner into a read-only dashboard β€” an aggregate "Tracked on disk" hero plus four click-through rows (Projects / Caches / Packages / Docker) that open their tab in the main window. + +**Architecture:** All arithmetic, gating and copy live in one pure module (`panelAreas.ts`) that takes projects/store/docker/inventory/settings and returns the hero totals plus a resolved row list. `PanelApp.tsx` and a new `AreaBar` are thin layout over that. Click-through widens the existing `LauncherNavTarget` union (transport already works) and routes through a second pure module (`launcherNav.ts`). + +**Tech Stack:** Electron + React 18 + TypeScript, vitest, biome, pnpm. + +**Spec:** `docs/superpowers/specs/2026-07-15-panel-visualization-only-design.md` β€” read it before Task 1. Decisions are referenced below as D1–D8. + +## Global Constraints + +- **Package manager is pnpm.** Never `npm`/`yarn`. Verify with `pnpm test`, `pnpm typecheck`, `pnpm lint`. +- **Tests are pure-logic only.** `vitest.config.ts` includes `src/**/*.test.ts` β€” `.tsx` is NOT matched and there is no React Testing Library. Never write a component test; put logic in `.ts` so it can be tested. +- **No em dashes in user-facing copy.** Use `Β·`, `,` or a full stop. (Applies to every string rendered in the UI.) +- **One folder per component:** `index.ts`, `Component.tsx`, `Component.types.ts`, optionally `.constants.ts` + tests. +- **Conventional commits:** `feat(app): …`, `fix(app): …`, `refactor(app): …`, `test(app): …`, `docs: …`. +- **Path aliases:** `@shared/*` β†’ `src/shared/*`, `@renderer/*` β†’ `src/renderer/src/*`. Use them; never write deep relative imports across those roots. +- **Branch:** `feat/docker-project-grouping` (already checked out). +- **Byte units** come from `@shared/units.constants` (re-exported by `@renderer/lib/format`): `GB = 1024**3`. Never hand-roll `1e9`. +- **Do not touch** launcher tab content, delete paths, or the license gate. This change is panel + nav only. + +--- + +## File Structure + +**Create:** +- `src/renderer/src/lib/staleness/staleness.ts` β€” `DOCKER_STALE_MS`, shared by launcher + panel (Task 2) +- `src/renderer/src/lib/staleness/index.ts` +- `src/renderer/src/launcher/LauncherApp/launcherNav.ts` β€” pure nav target β†’ `{view, tab}` map (Task 1) +- `src/renderer/src/launcher/LauncherApp/launcherNav.test.ts` +- `src/renderer/src/panel/PanelApp/panelAreas.ts` β€” the brain: hero math + row gating (Tasks 3, 4) +- `src/renderer/src/panel/PanelApp/panelAreas.test.ts` +- `src/renderer/src/panel/PanelApp/AreaBar/{AreaBar.tsx,AreaBar.types.ts,index.ts}` β€” one click-through row (Task 5) +- `src/renderer/src/panel/PanelApp/TrackedSummary/{TrackedSummary.tsx,TrackedSummary.types.ts,index.ts}` β€” the hero (Task 6) + +**Modify:** +- `src/shared/launcher-nav.types.ts` β€” widen the union (Task 1) +- `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx` β€” use `launcherNav`, import shared `DOCKER_STALE_MS` (Tasks 1, 2) +- `src/renderer/src/panel/PanelApp/PanelApp.tsx` β€” strip (Task 7), then rewire (Task 8) +- `src/renderer/src/panel/PanelApp/PanelEmpty.tsx` β€” drop `reclaimed` + the "All clean" branch (Task 7, D6b) +- `src/renderer/src/panel/PanelApp/PanelApp.types.ts` + `index.ts` β€” drop `PanelToast` (Task 7) +- `STATUS.html` β€” session log (Task 9) + +**Delete:** +- `src/renderer/src/panel/PanelApp/PnpmStoreRow.tsx`, `CleanStaleCta.tsx` (Task 7) +- `src/renderer/src/panel/PanelApp/PanelApp.constants.ts` (Task 7 β€” `STALE_DAYS`/`VISIBLE_ROWS` are used only by the code Task 7 removes; LauncherApp has its own local `VISIBLE_ROWS = 7` and is unaffected) +- `src/renderer/src/panel/PanelApp/DiskSummary.tsx` (Task 6 β€” superseded by `TrackedSummary`) + +--- + +### Task 1: Widen the nav target + pure nav map + +Enables click-through. The transport (`openLauncher(nav)` β†’ `pendingNav` / `onLauncherNavigate` β†’ `consumeLauncherNav`) already handles fresh and already-open windows and needs no change (D8). + +**Files:** +- Modify: `src/shared/launcher-nav.types.ts` +- Create: `src/renderer/src/launcher/LauncherApp/launcherNav.ts` +- Test: `src/renderer/src/launcher/LauncherApp/launcherNav.test.ts` +- Modify: `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx:122-129` + +**Interfaces:** +- Produces: `LauncherNavTarget = 'settings' | 'projects' | 'caches' | 'packages' | 'docker'` (from `@shared/launcher-nav.types`); `launcherNavState(target: LauncherNavTarget): LauncherNavState` where `LauncherNavState = { view: LauncherView; tab?: LauncherTab }`. + +- [ ] **Step 1: Widen the union** + +Replace the whole of `src/shared/launcher-nav.types.ts`: + +```ts +/** 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' +``` + +- [ ] **Step 2: Write the failing test** + +Create `src/renderer/src/launcher/LauncherApp/launcherNav.test.ts`: + +```ts +import type { LauncherNavTarget } from '@shared/launcher-nav.types' +import { describe, expect, it } from 'vitest' +import { launcherNavState } from './launcherNav' + +describe('launcherNavState', () => { + it('settings opens the settings view, not a tab', () => { + expect(launcherNavState('settings')).toEqual({ view: 'settings' }) + }) + + it('each tab target opens the list view on that tab', () => { + expect(launcherNavState('projects')).toEqual({ view: 'list', tab: 'projects' }) + expect(launcherNavState('caches')).toEqual({ view: 'list', tab: 'caches' }) + expect(launcherNavState('packages')).toEqual({ view: 'list', tab: 'packages' }) + expect(launcherNavState('docker')).toEqual({ view: 'list', tab: 'docker' }) + }) + + it('ignores an unknown target rather than selecting a bogus tab (arrives over IPC)', () => { + expect(launcherNavState('nope' as LauncherNavTarget)).toEqual({ view: 'list' }) + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pnpm vitest run src/renderer/src/launcher/LauncherApp/launcherNav.test.ts` +Expected: FAIL β€” cannot find module `./launcherNav`. + +- [ ] **Step 4: Write the implementation** + +Create `src/renderer/src/launcher/LauncherApp/launcherNav.ts`: + +```ts +import type { LauncherNavTarget } from '@shared/launcher-nav.types' +import type { LauncherTab, LauncherView } from './LauncherApp.types' + +export interface LauncherNavState { + view: LauncherView + /** Only set for tab targets; undefined leaves the current tab alone. */ + tab?: LauncherTab +} + +const TAB_TARGETS: readonly LauncherTab[] = ['projects', 'caches', 'packages', 'docker'] + +/** Where a nav target lands the launcher. The target crosses an IPC boundary, so an + * unrecognized value falls back to the list rather than selecting a bogus tab. */ +export function launcherNavState(target: LauncherNavTarget): LauncherNavState { + if (target === 'settings') return { view: 'settings' } + const tab = TAB_TARGETS.find((t) => t === target) + return tab ? { view: 'list', tab } : { view: 'list' } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm vitest run src/renderer/src/launcher/LauncherApp/launcherNav.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 6: Use it in LauncherApp** + +In `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx`, add to the imports: + +```ts +import { launcherNavState } from './launcherNav' +``` + +Replace the effect at lines 122-129 (the one containing the two `if (nav === 'settings') setView('settings')` checks) with: + +```ts + const applyNav = useCallback((nav: LauncherNavTarget | null): void => { + if (!nav) return + const next = launcherNavState(nav) + setView(next.view) + if (next.tab) setTab(next.tab) + }, []) + useEffect(() => { + void window.clean.consumeLauncherNav().then(applyNav) + return window.clean.onLauncherNavigate(applyNav) + }, [applyNav]) +``` + +`LauncherNavTarget` is **not** currently imported in this file β€” add it: + +```ts +import type { LauncherNavTarget } from '@shared/launcher-nav.types' +``` + +`useCallback`, `useEffect` and `useState` are already imported. + +- [ ] **Step 7: Verify nothing regressed** + +Run: `pnpm typecheck && pnpm vitest run && pnpm lint` +Expected: typecheck clean, all tests pass, lint clean. + +- [ ] **Step 8: Commit** + +```bash +git add src/shared/launcher-nav.types.ts src/renderer/src/launcher/LauncherApp/launcherNav.ts src/renderer/src/launcher/LauncherApp/launcherNav.test.ts src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +git commit -m "feat(app): launcher can be told to open on any tab, not just settings" +``` + +--- + +### Task 2: Share DOCKER_STALE_MS + +The panel needs the same staleness rule as the Docker tab (D7). Move the constant out of `LauncherApp.tsx` so both import one definition instead of duplicating `5 * 60 * 1000`. + +**Files:** +- Create: `src/renderer/src/lib/staleness/staleness.ts`, `src/renderer/src/lib/staleness/index.ts` +- Modify: `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx:56-57` + +**Interfaces:** +- Consumes: nothing. +- Produces: `DOCKER_STALE_MS: number` from `@renderer/lib/staleness`. + +- [ ] **Step 1: Create the module** + +Create `src/renderer/src/lib/staleness/staleness.ts`: + +```ts +/** Re-probe Docker when a surface that shows Docker data opens and the cached + * info is older than this. `getDockerInfo` caches until forced, so without a + * staleness rule the numbers would never move. */ +export const DOCKER_STALE_MS = 5 * 60 * 1000 +``` + +Create `src/renderer/src/lib/staleness/index.ts`: + +```ts +export { DOCKER_STALE_MS } from './staleness' +``` + +- [ ] **Step 2: Point LauncherApp at it** + +In `src/renderer/src/launcher/LauncherApp/LauncherApp.tsx`, delete lines 56-57: + +```ts +/** Re-scan Docker when its tab opens and the data is older than this. */ +const DOCKER_STALE_MS = 5 * 60 * 1000 +``` + +and add to the imports: + +```ts +import { DOCKER_STALE_MS } from '@renderer/lib/staleness' +``` + +Leave the effect at `:142-146` otherwise untouched. + +- [ ] **Step 3: Verify** + +Run: `pnpm typecheck && pnpm vitest run && pnpm lint` +Expected: all green. No behavior change β€” this is a pure move. + +- [ ] **Step 4: Commit** + +```bash +git add src/renderer/src/lib/staleness src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +git commit -m "refactor(app): share DOCKER_STALE_MS so the panel can reuse the staleness rule" +``` + +--- + +### Task 3: panelAreas β€” hero totals (D3, D4, D5) + +The hero arithmetic. **Read D3, D4 and D5 in the spec before writing this** β€” the invariant "hero is smaller than the rows added up, by exactly the pnpm store" is deliberate, not a bug. + +Key facts you need: +- `totalUsed` = `sum(project.uniqueSize ?? project.size) + storeBytes`. It **already includes the pnpm store** (this mirrors `LauncherApp.tsx:157` and the panel's current line 48). +- The Caches gauge is `storeBytes` β€” the same store again. That overlap is intentional in the launcher; the hero must count it once. +- Hero = `totalUsed + dockerTotal`. +- `dockerTotal` = sum of `docker.totals[].sizeBytes` (mirrors `LauncherApp.tsx:174`). It includes build cache; there is no double count because the Caches *gauge* is store-only. + +**Files:** +- Create: `src/renderer/src/panel/PanelApp/panelAreas.ts` +- Test: `src/renderer/src/panel/PanelApp/panelAreas.test.ts` + +**Interfaces:** +- Consumes: `GB` from `@renderer/lib/format`; types from `@shared/*`. +- Produces: `panelAreas(input: PanelAreasInput): PanelAreas` with `PanelAreas = { heroBytes, combinedLimitGB, trackMaxGB, areaCount, rows }`. Task 4 adds `rows`; this task returns `rows: []`. + +- [ ] **Step 1: Write the failing test** + +Create `src/renderer/src/panel/PanelApp/panelAreas.test.ts`: + +```ts +import { GB } from '@renderer/lib/format' +import type { DockerInfo } from '@shared/docker.types' +import type { PnpmStoreInfo } from '@shared/pnpm-store.types' +import type { Project } from '@shared/project.types' +import { describe, expect, it } from 'vitest' +import { 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, +} + +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) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/renderer/src/panel/PanelApp/panelAreas.test.ts` +Expected: FAIL β€” cannot find module `./panelAreas`. + +- [ ] **Step 3: Write the implementation** + +Create `src/renderer/src/panel/PanelApp/panelAreas.ts`: + +```ts +import { GB } from '@renderer/lib/format' +import type { DockerInfo } from '@shared/docker.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[] +} + +export type PanelAreaRow = never + +/** 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) +} + +/** 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) + + return { + heroBytes, + combinedLimitGB, + trackMaxGB: meterTrackMax(heroBytes / GB, combinedLimitGB), + areaCount: 1 + (cachesAvailable ? 1 : 0) + (dockerAvailable ? 1 : 0), + rows: [], + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/renderer/src/panel/PanelApp/panelAreas.test.ts` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/panel/PanelApp/panelAreas.ts src/renderer/src/panel/PanelApp/panelAreas.test.ts +git commit -m "feat(app): panelAreas computes the panel hero totals, each byte counted once" +``` + +--- + +### Task 4: panelAreas β€” the row list (D4, D6, D6b) + +Adds the four rows with their gating resolved. **Read D6 and D6b in the spec first.** The rule that matters: never render a gauge for data that was never measured, and never let one empty area collapse the dashboard. + +**Files:** +- Modify: `src/renderer/src/panel/PanelApp/panelAreas.ts` +- Test: `src/renderer/src/panel/PanelApp/panelAreas.test.ts` + +**Interfaces:** +- Consumes: `panelAreas` / `PanelAreasInput` from Task 3; `severityCounts`, `SeverityCounts` from `@renderer/lib/severity`; `LauncherNavTarget` from `@shared/launcher-nav.types` (Task 1). +- Produces: the `PanelAreaRow` discriminated union (replacing Task 3's `type PanelAreaRow = never`), populated on `PanelAreas.rows`. Task 5's `AreaBar` renders exactly one of these. + +- [ ] **Step 1: Write the failing tests** + +Append to `src/renderer/src/panel/PanelApp/panelAreas.test.ts`. Two import lines at the top of that file must change first β€” add the package types, and pull in the row type the helpers below annotate: + +```ts +import type { PackageEntry, PackageInventory } from '@shared/package.types' +// widen the existing panelAreas import to include the row type: +import { type PanelAreaRow, type PanelAreasInput, panelAreas } from './panelAreas' +``` + +```ts +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 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). + expect(row(r, 'projects')).toMatchObject({ kind: 'size', usedBytes: 41.2 * 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' }) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run src/renderer/src/panel/PanelApp/panelAreas.test.ts` +Expected: FAIL β€” `rows` is empty, so every `row()` call throws "no row". + +- [ ] **Step 3: Write the implementation** + +In `src/renderer/src/panel/PanelApp/panelAreas.ts`, add these imports: + +```ts +import { formatSizeStr, GB } from '@renderer/lib/format' +import { type SeverityCounts, severityCounts } from '@renderer/lib/severity' +import type { LauncherNavTarget } from '@shared/launcher-nav.types' +``` + +(the existing `import { GB } from '@renderer/lib/format'` is replaced by the line above) + +Replace `export type PanelAreaRow = never` with: + +```ts +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 +``` + +Add this helper above `panelAreas`: + +```ts +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, + } +} +``` + +Then in `panelAreas`, replace `rows: []` with `rows`, building it just before the `return`: + +```ts + 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)) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run src/renderer/src/panel/PanelApp/panelAreas.test.ts` +Expected: PASS (19 tests total). Then `pnpm typecheck && pnpm lint` β€” both clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/panel/PanelApp/panelAreas.ts src/renderer/src/panel/PanelApp/panelAreas.test.ts +git commit -m "feat(app): panelAreas resolves the four panel rows with honest gating" +``` + +--- + +### Task 5: AreaBar component + +One click-through row. Dumb: it renders a resolved `PanelAreaRow` and calls `onOpen`. No logic, no tests (vitest does not match `.tsx`). + +**Files:** +- Create: `src/renderer/src/panel/PanelApp/AreaBar/AreaBar.tsx`, `AreaBar.types.ts`, `index.ts` + +**Interfaces:** +- Consumes: `PanelAreaRow` from `./panelAreas` (Task 4); `PixelMeter` (props: `usedGB`, `thresholdGB`, `trackMaxGB`, `accent`, `cells?`), `SeverityMeter` (props: `counts`, `total`, `computing?`), `UIIcon.chevronRight`. +- Produces: ` …} />`. + +- [ ] **Step 1: Write the types** + +Create `src/renderer/src/panel/PanelApp/AreaBar/AreaBar.types.ts`: + +```ts +import type { PanelAreaRow } from '../panelAreas' + +export interface AreaBarProps { + row: PanelAreaRow + accent: string + /** Opens this area's tab in the launcher. */ + onOpen: () => void +} +``` + +- [ ] **Step 2: Write the component** + +Create `src/renderer/src/panel/PanelApp/AreaBar/AreaBar.tsx`: + +```tsx +import { PixelMeter } from '@renderer/components/PixelMeter' +import { SeverityMeter } from '@renderer/components/SeverityMeter' +import { UIIcon } from '@renderer/components/UIIcon' +import { GB } from '@renderer/lib/format' +import type { ReactNode } from 'react' +import type { AreaBarProps } from './AreaBar.types' + +/** One area of the panel dashboard: label, its meter, its value, and a chevron. + * Clicking anywhere opens that area's tab in the main window. Read-only: the + * panel visualizes, the launcher acts. */ +export function AreaBar({ row, accent, onOpen }: AreaBarProps): ReactNode { + return ( + + ) +} +``` + +Note: `PixelMeter` renders a full-width flex row of cells, so it fills the middle column. `SeverityMeter` draws its own "N vuln" / "all clear" label, which is why severity rows have no `value`. + +- [ ] **Step 3: Write the barrel** + +Create `src/renderer/src/panel/PanelApp/AreaBar/index.ts`: + +```ts +export { AreaBar } from './AreaBar' +export type { AreaBarProps } from './AreaBar.types' +``` + +- [ ] **Step 4: Verify** + +Run: `pnpm typecheck && pnpm lint` +Expected: both clean. (No tests β€” vitest matches `.test.ts` only.) + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/panel/PanelApp/AreaBar +git commit -m "feat(app): AreaBar renders one click-through area row for the panel" +``` + +--- + +### Task 6: TrackedSummary (the hero) + +Generalizes `DiskSummary` from "node_modules on disk" into the aggregate hero (D2, D3). Same visual structure: 27px number, limit + over/free lines, 32-cell `PixelMeter`. + +**Sub-line β€” read this, the spec is ambiguous here.** D2's ASCII diagram sketches the hero sub-line as a fixed composition string ("node_modules + caches + Docker"), while D6 requires a label that adapts when an area is absent ("across 2 areas"). **Implement D6's version** β€” `across {areaCount} areas`. It is the one that stays truthful when Docker is off or no store exists; the D2 diagram was illustrative and drawn with all three areas present. + +**Files:** +- Create: `src/renderer/src/panel/PanelApp/TrackedSummary/TrackedSummary.tsx`, `TrackedSummary.types.ts`, `index.ts` +- Delete: `src/renderer/src/panel/PanelApp/DiskSummary.tsx` + +**Interfaces:** +- Consumes: `PixelMeter`, `UIIcon.hdd/alert/check`, `statusColor` from `@renderer/lib/colors`, `formatSizeStr`/`GB` from `@renderer/lib/format`. +- Produces: ``. + +- [ ] **Step 1: Write the types** + +Create `src/renderer/src/panel/PanelApp/TrackedSummary/TrackedSummary.types.ts`: + +```ts +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 +} +``` + +- [ ] **Step 2: Write the component** + +Create `src/renderer/src/panel/PanelApp/TrackedSummary/TrackedSummary.tsx`: + +```tsx +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'} +
+ +
+ ) +} +``` + +- [ ] **Step 3: Write the barrel** + +Create `src/renderer/src/panel/PanelApp/TrackedSummary/index.ts`: + +```ts +export { TrackedSummary } from './TrackedSummary' +export type { TrackedSummaryProps } from './TrackedSummary.types' +``` + +- [ ] **Step 4: Verify** + +Run: `pnpm typecheck && pnpm lint` +Expected: clean. `DiskSummary.tsx` still exists and is still imported by `PanelApp.tsx` β€” it is deleted in Task 7, so do not delete it yet. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/src/panel/PanelApp/TrackedSummary +git commit -m "feat(app): TrackedSummary generalizes the panel hero beyond node_modules" +``` + +--- + +### Task 7: Strip the panel (D1, D6b) + +Subtraction only. After this the panel is hero + menu + footer, still building and running, with no dashboard rows yet. **Read D1 in the spec** β€” removing the paywall from the panel is deliberate and takes all three `paywall_shown` triggers with it. + +**Files:** +- Modify: `src/renderer/src/panel/PanelApp/PanelApp.tsx` +- Modify: `src/renderer/src/panel/PanelApp/PanelEmpty.tsx` +- Modify: `src/renderer/src/panel/PanelApp/PanelApp.types.ts`, `index.ts` +- Delete: `PnpmStoreRow.tsx`, `CleanStaleCta.tsx`, `PanelApp.constants.ts`, `DiskSummary.tsx` + +- [ ] **Step 1: Delete the dead components** + +```bash +git rm src/renderer/src/panel/PanelApp/PnpmStoreRow.tsx \ + src/renderer/src/panel/PanelApp/CleanStaleCta.tsx \ + src/renderer/src/panel/PanelApp/PanelApp.constants.ts \ + src/renderer/src/panel/PanelApp/DiskSummary.tsx +``` + +`PanelApp.constants.ts` only held `STALE_DAYS` and `VISIBLE_ROWS`, both used solely by the code removed in this task. `MiniRow`, `UnlockPrompt`, `RescanHint` and `useToast` stay in the tree β€” the launcher uses them. + +- [ ] **Step 2: Drop PanelToast** + +Replace `src/renderer/src/panel/PanelApp/PanelApp.types.ts` with: + +```ts +export type PanelView = 'main' | 'scan' +``` + +Replace `src/renderer/src/panel/PanelApp/index.ts` with: + +```ts +export { PanelApp } from './PanelApp' +export type { PanelView } from './PanelApp.types' +``` + +- [ ] **Step 3: Simplify PanelEmpty (D6b)** + +Replace `src/renderer/src/panel/PanelApp/PanelEmpty.tsx` with: + +```tsx +import { AppIcon } from '@renderer/components/AppIcon' +import type { ReactNode } from 'react' + +interface PanelEmptyProps { + accent: string + onOpenSetup: () => void +} + +/** Panel body before the first scan: there is genuinely nothing to visualize yet. + * There is deliberately no "all clean" state β€” zero projects does not mean zero + * disk used, and a verdict about one area must never replace the whole dashboard + * (see the spec's D6b). */ +export function PanelEmpty({ accent, onOpenSetup }: PanelEmptyProps): ReactNode { + return ( +
+ +
Finish setup
+
+ Set your limit & run the first scan in the full window. +
+ +
+ ) +} +``` + +The `onboarded` and `reclaimed` props are gone: the caller now decides when to show it, and `reclaimed` was fed only by the delete path this task removes. + +- [ ] **Step 4: Rewrite PanelApp** + +Replace `src/renderer/src/panel/PanelApp/PanelApp.tsx` with: + +```tsx +import { MItem } from '@renderer/components/MItem' +import { UIIcon } from '@renderer/components/UIIcon' +import { useAutoHeight } from '@renderer/hooks/useAutoHeight' +import { useSettings } from '@renderer/hooks/useSettings' +import { type ReactNode, useEffect, useRef, useState } from 'react' +import { DiskSummaryPlaceholder } from './DiskSummaryPlaceholder' +import type { PanelView } from './PanelApp.types' +import { PanelEmpty } from './PanelEmpty' +import { ScanPanel } from './ScanPanel' +import { Separator } from './Separator' + +export function PanelApp(): ReactNode { + const [settings, , settingsLoaded] = useSettings() + const accent = settings.accent + + const [view, setView] = useState('main') + const [lastScan, setLastScan] = useState(0) + const rootRef = useRef(null) + useAutoHeight(rootRef) + + useEffect(() => { + void window.clean.getLastScanTime().then(setLastScan) + }, [view]) + + // keyboard shortcuts: ⌘O full window, ⌘R scan, ⌘, settings, esc close + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + const meta = e.metaKey || e.ctrlKey + if (meta && (e.key === 'o' || e.key === 'O')) { + e.preventDefault() + void window.clean.openLauncher() + } else if (meta && (e.key === 'q' || e.key === 'Q')) { + e.preventDefault() + window.clean.quitApp() + } else if (meta && (e.key === 'r' || e.key === 'R')) { + e.preventDefault() + setView('scan') + } else if (meta && e.key === ',') { + e.preventDefault() + void window.clean.openLauncher('settings') + } else if (e.key === 'Escape') { + if (view !== 'main') setView('main') + else void window.clean.closeWindow() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [view]) + + const nextScanLabel = + settings.scanInterval === '6h' + ? '6 h' + : settings.scanInterval === 'daily' + ? '18 h' + : settings.scanInterval === 'weekly' + ? '5 d' + : 'β€”' + const lastScanLabel = lastScan ? `${Math.max(1, Math.round((Date.now() - lastScan) / 60000))} min ago` : 'never' + + return ( +
+ {view === 'scan' && setView('main')} />} + + {view === 'main' && ( + <> + {settingsLoaded && !settings.onboarded ? ( + void window.clean.openLauncher()} /> + ) : ( + + )} + +
+ void window.clean.openLauncher()} + /> + setView('scan')} /> + void window.clean.openLauncher('settings')} + /> + window.clean.quitApp()} /> +
+ +
+ Last scan {lastScanLabel} Β· next in {nextScanLabel} +
+ + )} +
+ ) +} +``` + +- [ ] **Step 5: Add the temporary placeholder** + +Task 8 replaces this with the real dashboard. It exists only so this task ends on a building app. + +Create `src/renderer/src/panel/PanelApp/DiskSummaryPlaceholder.tsx`: + +```tsx +import type { ReactNode } from 'react' + +/** Temporary: replaced by TrackedSummary + AreaBar rows in Task 8. */ +export function DiskSummaryPlaceholder(): ReactNode { + return
Dashboard loading…
+} +``` + +- [ ] **Step 6: Verify** + +Run: `pnpm typecheck && pnpm vitest run && pnpm lint && pnpm build` +Expected: all green. Typecheck is the real gate here β€” it proves no dangling import of a deleted component survives. + +- [ ] **Step 7: Commit** + +```bash +git add -A src/renderer/src/panel +git commit -m "refactor(app): strip the panel to read-only (no list, CTA, store prune or paywall)" +``` + +--- + +### Task 8: Wire the dashboard (D2, D6b, D7, D8) + +Additive: the panel becomes the real dashboard. **Read D7 before writing the effects** β€” the Docker and store refresh triggers are deliberately different, and getting this wrong makes the popover slow. + +- **Docker:** stale-refresh on panel open (missing or >5 min), mirroring `LauncherApp.tsx:142-146`. +- **Store:** cached mount fetch only. Force-refresh **only when a scan finishes**, mirroring `LauncherApp.tsx:112-117`. Never on a timer: `getPnpmStoreInfo` has no TTL and a forced call runs a `du` that takes seconds. +- **Packages:** `usePackages().inventory` is the cached read. **Never call `ensure()` or `refresh()`** β€” computing the inventory from the panel would block the popover. + +**Files:** +- Modify: `src/renderer/src/panel/PanelApp/PanelApp.tsx` +- Delete: `src/renderer/src/panel/PanelApp/DiskSummaryPlaceholder.tsx` + +**Interfaces:** +- Consumes: `panelAreas` (Tasks 3-4), `TrackedSummary` (Task 6), `AreaBar` (Task 5), `DOCKER_STALE_MS` (Task 2), `LauncherNavTarget` (Task 1). + +- [ ] **Step 1: Delete the placeholder** + +```bash +git rm src/renderer/src/panel/PanelApp/DiskSummaryPlaceholder.tsx +``` + +- [ ] **Step 2: Wire the hooks and the dashboard** + +In `src/renderer/src/panel/PanelApp/PanelApp.tsx`, replace the import block with: + +```tsx +import { MItem } from '@renderer/components/MItem' +import { UIIcon } from '@renderer/components/UIIcon' +import { useAutoHeight } from '@renderer/hooks/useAutoHeight' +import { useDocker } from '@renderer/hooks/useDocker' +import { usePackages } from '@renderer/hooks/usePackages' +import { usePnpmStore } from '@renderer/hooks/usePnpmStore' +import { useProjects } from '@renderer/hooks/useProjects' +import { useSettings } from '@renderer/hooks/useSettings' +import { DOCKER_STALE_MS } from '@renderer/lib/staleness' +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AreaBar } from './AreaBar' +import type { PanelView } from './PanelApp.types' +import { PanelEmpty } from './PanelEmpty' +import { panelAreas } from './panelAreas' +import { ScanPanel } from './ScanPanel' +import { Separator } from './Separator' +import { TrackedSummary } from './TrackedSummary' +``` + +Add these hooks and derived values directly below `const accent = settings.accent`: + +```tsx + const projects = useProjects() + const { store, refresh: refreshStore } = usePnpmStore() + const docker = useDocker() + const { inventory } = usePackages() + const dockerEnabled = settings.docker !== false + + const areas = useMemo( + () => + panelAreas({ + projects, + store, + docker: docker.info, + dockerEnabled, + inventory, + checkUpdates: settings.checkUpdates, + thresholdGB: settings.thresholdGB, + cacheThresholdGB: settings.cacheThresholdGB, + dockerThresholdGB: settings.dockerThresholdGB, + }), + [ + projects, + store, + docker.info, + dockerEnabled, + inventory, + settings.checkUpdates, + settings.thresholdGB, + settings.cacheThresholdGB, + settings.dockerThresholdGB, + ], + ) +``` + +Add these two effects below the `getLastScanTime` effect: + +```tsx + // Docker's cache never expires on its own, so re-probe it when the panel opens on + // missing or stale data. Guarded on `loading` and a freshened `checkedAt` so it + // cannot loop; the last-known numbers render instantly meanwhile. + useEffect(() => { + if (view !== 'main' || !dockerEnabled || docker.loading) return + const stale = !docker.info || Date.now() - docker.info.checkedAt > DOCKER_STALE_MS + if (stale) void docker.refresh() + }, [view, dockerEnabled, docker.info, docker.loading, docker.refresh]) +``` + +and replace the `ScanPanel` line so a finished scan re-sizes the store (new installs change it) and re-probes Docker: + +```tsx + {view === 'scan' && ( + { + setView('main') + void refreshStore() + }} + /> + )} +``` + +Add the nav callback next to the other callbacks: + +```tsx + const openArea = useCallback((nav: LauncherNavTarget): void => { + void window.clean.openLauncher(nav) + }, []) +``` + +with `import type { LauncherNavTarget } from '@shared/launcher-nav.types'` added to the imports. + +- [ ] **Step 3: Render the dashboard** + +Replace the `{settingsLoaded && !settings.onboarded ? … : }` block with: + +```tsx + {settingsLoaded && !settings.onboarded ? ( + void window.clean.openLauncher()} /> + ) : ( + <> + + +
+ {areas.rows.map((row) => ( + openArea(row.nav)} /> + ))} +
+ + )} +``` + +- [ ] **Step 4: Verify** + +Run: `pnpm typecheck && pnpm vitest run && pnpm lint && pnpm build` +Expected: all green. + +- [ ] **Step 5: Confirm the panel never computes the inventory** + +Run: `grep -n "computePackages\|ensure()\|deleteMany\|deleteNodeModules\|prunePnpmStore\|pruneDocker\|paywall_shown" src/renderer/src/panel/PanelApp/PanelApp.tsx` +Expected: **no output.** Any hit means an action or an expensive compute leaked back into the read-only panel. + +- [ ] **Step 6: Commit** + +```bash +git add -A src/renderer/src/panel +git commit -m "feat(app): the menu bar panel is a read-only dashboard of every disk area" +``` + +--- + +### Task 9: Update STATUS.html + +Mandatory per `CLAUDE.md`: the `STATUS` data block is the user's single source of truth and must not go stale. + +**Files:** +- Modify: `STATUS.html` (the `STATUS` data block near the top only β€” leave the markup and render script alone) + +- [ ] **Step 1: Update the data block** + +Set `updated: "2026-07-15"`, then prepend this sentence to `summary` (keep the existing prior-session text after it, per the file's established style): + +> This session (2026-07-15): the menu bar panel is no longer a mini node_modules cleaner. It is a read-only dashboard: an aggregate "Tracked on disk" hero (every tracked byte counted once, against the sum of your three limits) plus four click-through rows (Projects / Caches / Packages / Docker) that open that tab in the main window. Gone from the panel: the reclaimable list, the Clean stale CTA, the pnpm prune row, and the paywall. NOTE: that removes all three paywall_shown triggers from the panel, so the launcher is now the only paid-conversion surface. + +Append to `log` (matching the surrounding entry shape): + +```js +{ date: "2026-07-15", text: "Menu bar panel became a read-only dashboard: aggregate hero + four click-through rows into the launcher; the panel's list, actions and paywall are gone." }, +``` + +Add these two `userActions` entries. The shape is `{ id, category, label, detail }` and `category` is rendered as a CSS class (`c-${a.category}` at `STATUS.html:435`), so it must be one of the existing values: `testing`, `review`, `external`, `vision`. Both ids below are new β€” neither appears in the file today, and no removed id may ever be reused (checkbox state is stored against them). + +```js +{ id: "qa-panel-dashboard", category: "testing", label: "Manual pass of the new menu bar panel", detail: "Needs a running app + a Docker daemon (CI has neither). Open the popover: confirm the 'Tracked on disk' hero and the four rows (Projects / Caches / Packages / Docker), and that clicking each row opens the main window on THAT tab (⌘, still opens Settings). Confirm the popover opens instantly with a cold Docker cache (numbers fill in a moment later, they never block the open). Turn Docker off in Settings: its row disappears, its bytes leave the hero, and the hero reads 'across 2 areas'. Before ever visiting the Packages tab, its row should read 'Not checked yet' rather than claiming 'all clear', and clicking it should compute the inventory there. Finally, delete a project's node_modules in the launcher and confirm the panel's Projects row and hero both move." }, +{ id: "paywall-surface-decision", category: "vision", label: "Decide where the paywall lives now that the panel has none", detail: "The panel rework made the popover read-only, which removed all three paywall_shown triggers it used to fire (clean_stale, prune, affordance). The launcher is now the only paid-conversion surface in the app. Decide whether that is fine or whether the launcher needs a stronger paywall moment before this ships β€” the spec deliberately ruled this out of scope rather than guessing." }, +``` + +- [ ] **Step 2: Verify the block still parses** + +Run: `node --input-type=module -e "import('node:fs').then(async fs => { const s = fs.readFileSync('STATUS.html','utf8'); console.log(/updated:\s*\"2026-07-15\"/.test(s) ? 'updated ok' : 'MISSING updated') })"` +Expected: `updated ok`. Then open `STATUS.html` in a browser and confirm it renders without a blank page (a JS syntax error in the data block renders nothing). + +- [ ] **Step 3: Commit** + +```bash +git add STATUS.html +git commit -m "docs: STATUS β€” the menu bar panel is now a read-only dashboard" +``` + +--- + +## Final Verification + +- [ ] `pnpm typecheck` β€” clean +- [ ] `pnpm vitest run` β€” all tests pass (293 existing + ~22 new) +- [ ] `pnpm lint` β€” clean +- [ ] `pnpm build` β€” succeeds +- [ ] `grep -rn "DiskSummary\|PnpmStoreRow\|CleanStaleCta\|PanelToast\|VISIBLE_ROWS" src/renderer/src/panel/` returns nothing +- [ ] The launcher is unregressed: `tabSummary.test.ts`, `CachesView.constants.test.ts`, `DockerView.constants.test.ts`, `docker-confirm.test.ts` all green + +**Manual QA (user β€” needs a running app + a Docker daemon):** +- Each of the four rows opens the main window on the matching tab; ⌘, still opens Settings. +- The popover opens instantly with a cold Docker cache (numbers fill in a moment later). +- Turning Docker off in Settings hides the Docker row, drops its bytes from the hero, and the hero reads "across 2 areas". +- Before ever visiting the Packages tab, the Packages row reads "Not checked yet" and clicking it computes the inventory there. +- Deleting a project's node_modules in the launcher updates the panel's Projects row and hero. diff --git a/docs/superpowers/specs/2026-07-07-docker-cleanup-design.md b/docs/superpowers/specs/2026-07-07-docker-cleanup-design.md new file mode 100644 index 0000000..f6aeb04 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-docker-cleanup-design.md @@ -0,0 +1,186 @@ +# Docker images/volumes/containers/build-cache cleanup (launcher "Docker" tab) + +Date: 2026-07-07 +Status: awaiting user review +Branch (implementation): feat/docker-cleanup + +## Goal + +Add a fourth cleanup surface to the TidyDisk launcher: a "Docker" tab that lists +Docker's disk usage (images, volumes, stopped containers, build cache) with size, +created date, and in-use status, sortable like projects, and lets the user +reclaim space via the `docker` CLI. This is a large disk-cleaning win (Docker +images and build cache are frequently the single heaviest thing on a dev Mac) +and a natural extension of the free-scan / paid-clean model. Because Docker +removals are PERMANENT (no Trash), the safety model is the crux of the design. + +## Decisions made with the user (2026-07-07) + +- Safety model: **tiered by recoverability**. Images / build cache / stopped + containers get a single confirm (like today's project delete); volumes get an + EXTRA typed-name confirmation because the data is unrecoverable. Every Docker + action is labeled "permanent, not sent to the Trash". +- Scope: **all four** `docker system df` categories (images, volumes, build + cache, stopped containers). +- Row semantics: **created date + in-use flag** (honest; Docker exposes no true + per-item "last used", so we do not fake one). Sort by size, created, unused-first. +- Cleanup: **per-item removal + bulk prune**. +- In-use items are **read-only** (no `-f`/force escape hatch): an image or volume + a container holds cannot be removed through the GUI. This is Docker's own rule + and a strong safety property. +- Listing/scanning is cache-and-refresh (never blocks the UI), mirroring the pnpm + store. + +## Architecture + +Follows the pnpm-store / Caches feature verbatim (it is the closest analog). + +### Shared β€” `src/shared/docker.types.ts` +```ts +export type DockerItemKind = 'image' | 'volume' | 'container' | 'buildcache'; + +export interface DockerItem { + id: string; // image ID / volume name / container ID / cache ID + kind: DockerItemKind; + name: string; // repo:tag, volume name, container name, cache short-id + sizeBytes: number; + createdAt: number; // ms epoch; 0 when docker does not report it + inUse: boolean; // referenced by an existing container / mounted + removable: boolean; // false when inUse (GUI never force-removes) +} + +export interface DockerCategoryTotal { + kind: DockerItemKind; + sizeBytes: number; + reclaimableBytes: number; + count: number; +} + +export interface DockerInfo { + available: boolean; // docker CLI found AND daemon reachable + reason?: string; // when unavailable: "not installed" | "daemon not running" + checkedAt: number; + totals: DockerCategoryTotal[]; + items: DockerItem[]; +} + +export interface DockerActionResult { ok: boolean; freedBytes: number } +``` + +### Main β€” `src/main/docker/` +- `find-docker.ts` β€” mirrors `find-pnpm.ts`: resolves the `docker` binary from an + optional `settings.dockerBinaryPath` override, then the runtime bin dirs + (`versionManagerBinDirs`), then known locations (`/usr/local/bin/docker`, + `/opt/homebrew/bin/docker`, Docker Desktop's `/Applications/Docker.app/...`). + Reuses the `pnpmExecEnv`-style spawn env so Finder-launched builds get a real + PATH. Cached per run. +- `docker.ts` β€” CLI wrappers via `promisify(execFile)` (NO shell; args as arrays + so item names/ids can never inject). Functions: + - `getDockerInfo(force?)` β€” availability + full listing. In-memory + on-disk + cache (`docker-cache.json` in userData) so the tab renders last-known state + instantly; concurrent recomputes coalesced via an `inFlight` promise. Exactly + the `getPnpmStoreInfo` shape. + - `removeDockerItem(kind, id)` β€” per-item removal for images (`docker rmi `), + volumes (`docker volume rm `), and stopped containers (`docker rm `). + Build cache has no clean single-record removal in the docker CLI, so it is + listed for size awareness but reclaimed via bulk prune only (no per-item + action on build-cache rows). + - `pruneDocker(target)` where `target ∈ { danglingImages, unusedImages, + stoppedContainers, buildCache, unusedVolumes }` β€” the corresponding + `docker prune -f` (the `-f` skips docker's own prompt; the GUI supplies + the confirmation). Freed bytes come from a `docker system df` before/after + diff, mirroring `prunePnpmStore` (`freedBytes = max(0, before - after)`). +- Availability probe: `docker version --format '{{.Server.Version}}'` β€” a + nonzero exit or missing binary distinguishes "not installed" from "daemon not + running", surfaced as `reason` (renders an explanatory empty state like the + pnpm store's `reason`, not an error). +- Data source: `docker system df -v --format '{{json .}}'` is the richest single + call (images, volumes with size + mount `Links`, containers, build cache). + Enriched with `docker ps -a --format json` (containerβ†’image in-use map, states) + and `docker image ls --all --format json` (tags, created, dangling). `inUse`: + image referenced by any container; volume with `Links > 0`; container in + `running` state (only stopped containers are removable). + +### IPC β€” `src/shared/ipc.constants.ts` + `src/main/ipc/register-ipc.ts` +- Channels: `docker:get` (read-only, ungated), `docker:remove`, `docker:prune`. +- `docker:remove` / `docker:prune` handlers each OPEN with the license gate, + identical to prune/delete: + ```ts + if (!ctx.license.get().pro) return { ok: false, freedBytes: 0 } + ``` +- Payload validation: `kind` checked against the union, `id` non-empty string, + `prune target` checked against the allowed set β€” same discipline as + `coerceSetting` / the analytics whitelist. Anything else is rejected. +- Preload: expose `getDocker`, `removeDockerItem`, `pruneDocker` on `window.clean` + (`src/preload/index.ts` + `api.types.ts`). + +### Renderer +- `src/renderer/src/launcher/views/DockerView.tsx` β€” clones `CachesView`: + category sections (Images / Volumes / Containers / Build cache), each a list of + rows filtered by the shared header `query`, plus per-category "Prune" actions + and a totals/reclaimable summary. Unavailable β†’ explanatory state from `reason`. +- Rows reuse `CacheRow` (icon, name, detail = created date + in-use badge, size, + action). In-use rows render the badge and NO action button (`removable: false`); + build-cache rows likewise have no per-item action (bulk prune only). +- `useDocker` hook clones `usePnpmStore`: `{ info, loading, busyId, remove, + prune, refresh }`; mutations re-fetch on completion. +- Formatting reuses `formatSizeStr` and `relativeTime` (created date). + +### Confirmation UX (extends the existing footer) +- Single-confirm items (image / container / build cache): reuse the current + two-step footer bar (`confirm` state in `LauncherApp.tsx`), worded + "Remove ? Frees N. This is permanent (not sent to the Trash)." +- Volume removals (per-item and the unused-volume prune): a SECOND step β€” the + footer shows a small text input; the action stays disabled until the user + types the exact volume name (per-item) or the word `prune` (bulk). This is the + only new confirmation primitive. +- Free users hit the existing `UnlockPrompt` paywall (fires `paywall_shown` with + a `docker` trigger), same as delete/prune. + +### Launcher wiring β€” `src/renderer/src/launcher/LauncherApp/` +- `LauncherTab` union gains `'docker'`; Segmented gains `{ value:'docker', + label:'Docker' }`; `⌘4` added to the shortcut branch; body switch renders + `DockerView`; per-tab footer hints extended. + +### Settings β€” `src/shared/settings.types.ts` + validation +- `docker?: boolean` (default true; when docker is absent the tab shows the + explanatory empty state rather than hiding, so users learn the feature exists) + and `dockerBinaryPath?: string` override. Add `DEFAULT_SETTINGS.docker = true`, + a `coerceSetting` case for each (boolean / path string), and a Settings toggle + + path override row mirroring the pnpm block. + +### Analytics +- Extend `clean_performed`'s `kind` values with `docker_image | docker_volume | + docker_container | docker_buildcache | docker_prune`; captured main-side in the + `docker:remove` / `docker:prune` handlers with `freed_gb`, exactly like the + existing prune/delete instrumentation. No new renderer-originated events beyond + the shared `paywall_shown`. + +## Error handling / edge cases +- Docker CLI missing β†’ `available:false, reason:"not installed"`. +- CLI present, daemon down β†’ `available:false, reason:"daemon not running"` with a + hint to start Docker Desktop. +- A removal that Docker refuses (e.g. an image gained a container between listing + and action) β†’ `{ ok:false }`; the view refreshes and re-reads state (no crash). +- Slow `docker system df -v` on large setups β†’ cache-and-refresh keeps the UI + responsive; a lightweight spinner/pill during recompute (mirrors the pnpm + store's footer pill). +- All CLI calls use `execFileAsync` with a timeout (prune can be slow; reuse the + 10-minute ceiling pattern) and array args (injection-safe). + +## Testing +- Unit (main): `find-docker` resolution order; `docker system df -v` JSON parsing + β†’ `DockerItem[]`/totals (fixture-driven, no live daemon); `inUse`/`removable` + derivation; freed-bytes diff math; payload validation rejects bad kind/id/target. +- Unit (renderer): `DockerView` category grouping + query filtering; the volume + typed-name confirmation gating (action disabled until the name matches). +- Existing suites stay green; typecheck/lint/build green; the packaged app's + Docker tab is a manual (user) verification since it needs a real daemon. + +## Out of scope (later) +- Pricing bump to €29 that this unlocks (already in the business model / GTM doc). +- Menu-bar panel surfacing β€” Docker lives in the launcher only, to keep the tiny + panel focused. +- Xcode / DerivedData / simulators and other non-Docker caches. +- Podman/nerdctl support (docker CLI only for v1). +- Any `-f`/force removal of in-use items. diff --git a/docs/superpowers/specs/2026-07-08-docker-project-grouping-design.md b/docs/superpowers/specs/2026-07-08-docker-project-grouping-design.md new file mode 100644 index 0000000..f0a1cb5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-docker-project-grouping-design.md @@ -0,0 +1,158 @@ +# Docker tab: project-grouped display with type badges + used-by association + +Date: 2026-07-08 +Status: awaiting user review +Branch (implementation): feat/docker-project-grouping (stacked on feat/docker-cleanup / PR #40) + +## Goal + +Replace the Docker tab's category-grouped display (Images / Volumes / Containers / +Build cache sections) with a **project-grouped** list, so a developer can see at a +glance which Docker resources belong to which project. Each resource keeps a +**type badge**, there's a **type-filter chip row** and **Size / Name / Recent** +sort tabs mirroring the node_modules Projects tab, and resources are associated to +projects as aggressively as the data honestly allows (Compose labels + used-by +references), with the genuinely project-less remainder in an "Other" section. + +## Decisions made with the user (2026-07-08) + +- Presentation: **one project-grouped list + a type-filter chip row** (All / + Images / Volumes / Containers / Cache). Not category tabs (that is the type + axis, which fights the project-awareness goal). +- Association: **Compose labels + used-by references** (maximize coverage). +- Missing working_dir β†’ generic Docker icon + Compose project name (group still + works for cleanup). +- Sort: **Size / Name / Recent** tabs that reorder the GROUPS (Recent = newest + resource, since Docker has no true "last used"); items within a group sort by + size. +- Keep containers as a badged type. + +## Honest ceiling (stated plainly) + +Project *identity* only exists for **Docker Compose** projects (name + +`working_dir` from `com.docker.compose.project*` labels). A pure `docker run` +setup with no compose has no project structure, so everything falls to "Other". +The "used-by" logic extends coverage of images/volumes to the compose projects +that do exist; it does not invent projects for non-compose resources. This is a +data limitation, not a design shortcut. + +## Association model (main process) + +Project identity is always a Compose project. For each resource: + +- **Containers**: `com.docker.compose.project` label β†’ that project; + `com.docker.compose.project.working_dir` β†’ working_dir (for the logo). No + compose label β†’ Unaffiliated ("Other"). +- **Images**: (a) a compose-built image carrying `com.docker.compose.project` β†’ + that project; else (b) **used-by**: if exactly ONE compose project's containers + reference this image (by ImageID or repo:tag), group it under that project. + Used by multiple compose projects, or unused/dangling β†’ "Other" (repo-grouped). + Rationale for single-project-only: an item lives in exactly one group so the + size sort never double-counts. +- **Volumes**: (a) `com.docker.compose.project` label β†’ that project; else (b) + **used-by**: the single compose project whose container mounts it; else β†’ "Other". +- **Build cache**: never associated β†’ its own "Build cache" group in "Other". + +## Data sources (behind the existing cache-and-refresh) + +Current: `docker version`, `docker system df -v` (inventory), `docker system df` +(freed-bytes). This adds two batched reads: + +- `docker ps -aq` then `docker container inspect ` β€” one call returning + each container's `.Config.Labels` (compose project + working_dir), `.Image` / + `.Image` id (image reference for used-by), and `.Mounts[].Name` (named-volume + mounts for used-by). This is the enrichment backbone. +- `docker volume inspect ` β€” one call for volume `.Labels` (compose + project). (df -v already gives volume size + link count; inspect adds labels.) + +Both fail-soft: if inspect fails or the daemon hiccups, association degrades to +"everything in Other" rather than erroring (the tab still lists + cleans). Cached +in `docker-cache.json` like today; recomputed on refresh/⌘R. + +Per distinct project working_dir, reuse the scanner's `detectKind(workingDir)` + +`findProjectIcon(workingDir)` (from `src/main/scanner/`) to compute the project +logo. Guard when the dir no longer exists on disk β†’ `kind`/`iconDataUrl` +undefined β†’ the renderer falls back to a generic Docker icon + the compose name. + +## Data model changes (`src/shared/docker.types.ts`) + +```ts +export interface DockerItem { + // …existing: id, kind, name, sizeBytes, createdAt, inUse, removable + /** Compose project this resource is grouped under; undefined = unaffiliated. */ + project?: string + /** Image repository (name before the last ':'), for repo sub-grouping in Other. */ + repository?: string +} + +/** A Compose project surfaced as a group header, with a detected logo. */ +export interface DockerProject { + name: string + workingDir?: string + /** Reused from the scanner; undefined β†’ renderer shows a generic Docker icon. */ + kind?: FrameworkKind + iconDataUrl?: string +} + +export interface DockerInfo { + // …existing: available, reason?, checkedAt, totals, items + projects: DockerProject[] +} +``` + +`buildDockerItems` stays pure; a new pure `associateProjects(items, containers, +volumeLabels)` computes `project`/`repository` and the `DockerProject[]` from the +inspect data β€” fully unit-testable with fixtures, no daemon. + +## Renderer + +- Pure `groupDockerForDisplay(info, { sortBy, typeFilter, query })` in + `DockerView.constants.ts` β†’ an ordered list of groups. A group is one of: + `{ kind: 'project', project: DockerProject, items }` | + `{ kind: 'repository', repository, items }` | + `{ kind: 'buildcache', items }` | `{ kind: 'unaffiliated', items }`. + Ordering: project groups first (sorted by the active tab), then a + "Not linked to a project" divider, then repository/buildcache/unaffiliated + groups (sorted). `typeFilter` drops non-matching items (and now-empty groups); + `query` filters by item name or project name. Group sort keys: Size = total + group bytes; Name = project/repo name; Recent = max item `createdAt`. Items + within a group sort by size desc. Unit-tested. +- `DockerView.tsx` reworked: group headers render the project logo (reuse + `ProjectIcon`-style rendering driven by `DockerProject.kind` + `iconDataUrl`, + generic Docker icon fallback) or a repo/cache/orphan header; rows render via + the existing row with a new `TypeBadge` (IMAGE / VOLUME / CONTAINER / CACHE). + Per-group prune actions stay (dangling images, build cache, unused volumes, + stopped containers) where they map cleanly. +- `LauncherApp.tsx`: add `dockerSortBy` state + a type-filter chip row, cloning + the Projects/Packages sort-tab pattern. The Docker keyboard guard and body + branch already exist. + +## Safety (unchanged β€” regression-guarded) + +This is presentation + data-enrichment only. Every row still carries its +`DockerItem.id` and `kind`, and cleanup is still driven by +`requestDockerRemove(item)` / `requestDockerPrune(target)`. The typed-name +volume-confirmation gate (`dockerConfirm`, `dockerConfirmBlocked`, +`commitDockerConfirm`, `docker-confirm.ts`) is untouched. A test asserts the +confirm-flow wiring is unchanged, and the existing docker + confirm tests stay +green. + +## Testing + +- Pure unit tests (vitest, no daemon): `associateProjects` (compose-label, + used-by single project, used-by multi-project β†’ Other, orphan, image + repository extraction), the working_dir β†’ generic-icon fallback path, and + `groupDockerForDisplay` (group ordering, each type-filter chip, each sort key, + query filter, empty-group omission). +- Container/volume inspect JSON parsing tested with fixtures. +- Existing `docker-parse`, `docker`, `validate-docker-arg`, `docker-confirm`, and + `register-ipc` docker tests remain green; typecheck / lint / build green. +- Manual (user, needs a real daemon + a compose project): the group with the + right logo, images appearing under the project whose container uses them, the + filter chips, the sort tabs, and the volume typed-name confirm still firing. + +## Out of scope + +- Docker networks (no meaningful disk footprint). +- Any change to what the cleanup actions do (remove/prune commands unchanged). +- Inventing project identity for non-compose (`docker run`) resources. diff --git a/docs/superpowers/specs/2026-07-09-docker-collapsible-project-rows-design.md b/docs/superpowers/specs/2026-07-09-docker-collapsible-project-rows-design.md new file mode 100644 index 0000000..06b1294 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-docker-collapsible-project-rows-design.md @@ -0,0 +1,192 @@ +# Docker Tab β€” Collapsible Project Rows (shared Accordion) β€” Design + +**Date:** 2026-07-09 +**Status:** Approved +**Branch context:** builds on `feat/docker-project-grouping` + +## Goal + +The Docker tab renders every group's header plus all its item rows always-visible, +producing a long flat list. Make each **project** group a clickable, collapsed-by-default +row (icon/logo, name, item count, total size, active status); clicking reveals its items. +Extract the Packages tab's accordion into a shared primitive and use it in both tabs. + +## Decisions (locked during brainstorming) + +1. **Expand mode:** accordion β€” one project open at a time (matches Packages). +2. **Scope of collapsing:** only **project** groups collapse. The "Other" groups + (unassociated images by repository, build cache, orphan volumes/containers) render + exactly as today β€” always-expanded, under the "Not linked to a project" divider, + with their bulk-prune buttons. +3. **Reuse:** extract a generic `Accordion` primitive and retrofit **both** tabs onto it + (the Packages tab must stay pixel-identical). +4. **Collapsed-row size:** plain text (no SizeViz bar), to keep the row calm. + +## Background β€” current code + +- `DockerView.tsx` renders `groupDockerForDisplay(info, …)` β†’ `DisplayGroup[]` + (`project` | `repository` | `buildcache` | `unaffiliated`). Each group renders a + `GroupHeader` + all `g.items` as `DockerItemRow`s. A shared `.cc-hl` sliding highlight + (single absolute div positioned from the hovered row's `offsetTop`/`offsetHeight`) + sits behind the rows; each `DockerItemRow` calls `onHover(el)` to move it. +- `DockerProjectIcon` (local to DockerView) already resolves: real logo (`iconDataUrl`) + β†’ framework mark (`FrameworkIcon`) β†’ generic box. +- `InUseDot` (local to DockerView) is the green in-use dot. +- **Packages accordion** lives in `PackagesView.tsx`: a wrapper `
` per entry that, + **when open**, becomes a solid card via this exact inline style: + ```ts + { position: 'relative', zIndex: 1, background: 'var(--surface-2)', borderRadius: 10, + boxShadow: 'inset 0 0 0 1px var(--hairline)', overflow: 'hidden' } + ``` + Inside: `` (header; chevron rotates 90Β° when + open) + `{open && }` (body). The Packages `.cc-hl` skips the open + row (`entry.name !== expandedName`) because the open row is its own card. + +## Architecture + +### 1. `Accordion` β€” shared primitive + +New `components/Accordion/` (`Accordion.tsx`, `Accordion.types.ts`, `Accordion.constants.ts`, `index.ts`). + +```ts +export interface AccordionProps { + open: boolean + /** The always-visible clickable header (caller owns the click + chevron). */ + header: ReactNode + /** The body, rendered only when open. */ + children?: ReactNode + /** When true, the open block becomes one solid rounded card (header + body share a + * surface). Packages uses this; Docker does not (its rows stay flat for the sliding + * highlight). */ + card?: boolean +} +``` + +```tsx +export function Accordion({ open, header, children, card = false }: AccordionProps): ReactNode { + return ( +
+ {header} + {open ? children : null} +
+ ) +} +``` + +`Accordion.constants.ts` exports `ACCORDION_OPEN_CARD` = the exact style above (so the +Packages retrofit is byte-identical). + +**Why `card` is optional (the `.cc-hl` reconciliation):** the open card is a +`position: relative` context. In Packages the body (`PackageDetails`) is a static panel +with no per-row highlight, so a card is fine. In Docker the body is interactive +`DockerItemRow`s that rely on the outer list's sliding `.cc-hl`; wrapping them in a +`position: relative` card would make their `offsetTop` card-relative and break the +highlight. With `card` omitted, `Accordion` renders a plain **static** `
` wrapper, +which is not an `offsetParent`, so the item rows' `offsetTop` stays relative to the outer +positioned list container and `.cc-hl` keeps working across headers and items. + +### 2. Packages retrofit (pixel-identical) + +In `PackagesView.tsx`, replace the inline open-card wrapper + `{open && }` +with: + +```tsx + +}> + onOpen(entry)} /> + +``` + +The rendered DOM/CSS is identical (same wrapper style via `ACCORDION_OPEN_CARD`, header +then body-when-open). No change to `usePackagesTab`, `PackageRow`, `PackageDetails`, the +`.cc-hl` measurement, keyboard nav, or the selected-row logic. + +### 3. Docker β€” collapsible project rows + +**New local `DockerProjectRow`** (in `DockerView.tsx`, matching the file's local-component +pattern) β€” the collapsed/clickable project header: +- left: `DockerProjectIcon` (logo β†’ framework β†’ generic), +- name, and a subtitle `"{n} item{s}"`, +- right: total size (plain, tabular-nums) Β· an `InUseDot` when any item is in-use Β· a + chevron (`UIIcon.chevronRight`, `transform: rotate(90deg)` + `transition` when expanded, + mirroring `PackageRow`). +- The whole row is a button (`onClick` toggles); `onMouseEnter` calls `onHover(el)` to + drive `.cc-hl`, exactly like `DockerItemRow`. It carries `position: relative; zIndex: 1` + so it sits above the highlight. +- No prune buttons (projects never expose prune; `prunesForGroup` already returns `[]` for + them). + +**DockerView changes:** +- Local state `const [expandedProjectId, setExpandedProjectId] = useState(null)`. + Clicking a project toggles it (accordion: opening one replaces the previous). +- Expansion resolution uses the new pure helper `projectRowExpanded(hasQuery, expandedProjectId, group.id)`: + when the header search box has text, all project groups render expanded (their items are + already query-filtered by `groupDockerForDisplay`, so results are never hidden); with no + query, only the accordion-selected project is open. +- Render: for `kind === 'project'` groups, wrap in ` setExpandedProjectId(id => id === g.id ? null : g.id)} onHover={…} />}>` + and put the `DockerItemRow`s as children. For the "Other" groups, render exactly as + today (`GroupHeader` + always-visible `DockerItemRow`s). +- `GroupHeader` keeps handling the Other groups (its project branch becomes dead once + projects use `DockerProjectRow`; remove the now-unused project branch from `GroupHeader` + to keep it focused). +- **Spacing:** the current 4px vertical rhythm inside a group (headerβ†’items and + between items) is preserved by having the Docker caller wrap the `Accordion` body items + in a `
`. `Accordion` + itself imposes no gap (so the Packages `card` wrapper stays flush/pixel-identical). The + 14px gap between groups is the outer list container's existing gap and is unchanged. + +### 4. Pure helpers (tested) + +Added to `DockerView.constants.ts` (with tests in `DockerView.constants.test.ts`): +```ts +/** True when any resource in the group is in use by a container (drives the active dot). */ +export function dockerGroupActive(items: DockerItem[]): boolean +/** Whether a project row shows expanded: a non-empty search expands all projects; + * otherwise only the accordion-selected id is open. */ +export function projectRowExpanded(hasQuery: boolean, expandedId: string | null, groupId: string): boolean +``` + +## Data flow + +``` +groupDockerForDisplay(info, {sortBy,typeFilter,query}) -> DisplayGroup[] +DockerView + expandedProjectId (accordion state) + query (search override) + per project group: + expanded = projectRowExpanded(!!query.trim(), expandedProjectId, g.id) + }> + g.items -> (flat children; .cc-hl works) + + other groups: + items (unchanged) +``` + +## Error / edge handling + +- Project groups are only created when they have items (`groupDockerForDisplay` guards + `of.length`), so no empty project rows. +- If `expandedProjectId` points at a group filtered out by a query, the id simply doesn't + match any rendered group β€” no crash, nothing expanded from it. +- Loading / `!available` states in DockerView are unchanged. +- The Docker safety flow (typed-name confirm, `onRemove`/`onPrune`, per-item hover trash, + `canRemove` gate) is untouched β€” this is presentation only. + +## Testing + +- `dockerGroupActive`: true when any item `inUse`, false when none, false for empty. +- `projectRowExpanded`: query true β†’ always expanded regardless of id; query false β†’ + expanded only when `expandedId === groupId`; `expandedId` null β†’ collapsed. +- `Accordion`, `DockerProjectRow`, and the Packages retrofit are JSX β€” gated by + typecheck/lint/build. Packages pixel-parity and Docker interaction are manual QA + (no jsdom in this repo). + +## Out of scope + +- No main-process change (renderer-only; hot-reloads in dev). +- No keyboard navigation for the Docker tab (it is mouse-driven today; unchanged). +- No SizeViz bar on the collapsed project row (plain text). +- "Other" groups' layout and prune behavior unchanged. +- No change to `usePackagesTab` or the Packages keyboard/selection logic. diff --git a/docs/superpowers/specs/2026-07-09-per-tab-headline-dataviz-design.md b/docs/superpowers/specs/2026-07-09-per-tab-headline-dataviz-design.md new file mode 100644 index 0000000..6fb6965 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-per-tab-headline-dataviz-design.md @@ -0,0 +1,250 @@ +# Per-Tab Headline Data-Viz + Docker Auto-Scan β€” Design + +**Date:** 2026-07-09 +**Status:** Approved +**Branch context:** `feat/docker-project-grouping` (this work builds on it) + +## Goal + +Give every launcher tab its own headline data-visualization, instead of the +header gauge and footer line always reflecting node_modules. The header slot +and footer summary become tab-contextual. Docker additionally refreshes itself +when its tab is opened and the data is stale. + +## Background β€” what exists today + +- `LauncherApp` renders a compact 132px `Gauge` in the header top-right, always + fed node_modules `totalUsed` vs `settings.thresholdGB` (default 5 GB). It is + visible on every tab. +- The footer shows a text line `"{pct}% of your {thresholdGB} GB limit"` (or + `"{over} over your {thresholdGB} GB limit"`), also node_modules-only. +- Data available per tab: + - **Projects**: `totalUsed` (real disk: per-project unique size + pnpm store + counted once) vs `thresholdGB`. + - **Caches**: `store.sizeBytes` (pnpm store), `store.available`. No limit today. + - **Packages**: `inventory.packages: PackageEntry[]`, each with optional + `advisory.severity` (`low|moderate|high|critical`) and `outdated: boolean`. + Also `inventory.computing`, `inventory.enrichmentError`. + - **Docker**: `docker.info.totals: DockerCategoryTotal[]` (per-kind size + + reclaimable + count), `docker.info.items`, `docker.info.checkedAt`, + `docker.info.available`. `docker.refresh()` already exists, decoupled from + the disk rescan. +- Settings clamp: `validate-setting.ts` clamps threshold values with + `MIN_THRESHOLD_GB = 0.1`, `MAX_THRESHOLD_GB = 1000`. + +## Decisions (locked during brainstorming) + +1. **Limit model:** own limit per tab. Caches and Docker each get their own + configurable GB limit in Settings, alongside the existing node_modules limit. +2. **Packages viz:** a severity stacked bar (critical/high/moderate/low) plus a + secondary outdated count. Duplicate-version stays a per-row concern, not in + the headline. +3. **Placement:** reuse the compact header gauge slot, made tab-contextual (not + a new full-width band). Because the slot is ~132px, the Packages severity + legend lives in the tooltip, not inline. +4. **Docker refresh:** auto-scan when the Docker tab opens and data is missing or + older than 5 minutes; keep the manual ⌘R / refresh button. + +## Architecture + +### 1. Settings β€” two new per-tab limits + +Add to `Settings` (`src/shared/settings.types.ts`): + +```ts +/** 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 +``` + +Defaults in `DEFAULT_SETTINGS` (`src/shared/settings.constants.ts`): +`cacheThresholdGB: 10`, `dockerThresholdGB: 20`. + +Validation in `validate-setting.ts`: add both keys to the existing +`thresholdGB` clamp case (same `MIN_THRESHOLD_GB`/`MAX_THRESHOLD_GB` bounds), so +one `case 'thresholdGB': case 'cacheThresholdGB': case 'dockerThresholdGB':` +branch covers all three. + +Settings UI (`SettingsView`): a small "Limits" group with three labeled GB +number inputs β€” node_modules / pnpm caches / Docker β€” following the existing +SettingsRow pattern. The node_modules input reuses whatever control edits +`thresholdGB` today (moved into this group if it lives elsewhere). + +These are renderer-facing settings that already flow through the existing +`useSettings` / `setSetting` IPC path; no new IPC channel is needed. + +### 2. `severityCounts` β€” pure helper + +New pure function (co-located with the Packages tab logic, e.g. +`views/PackagesView.constants.ts` or a dedicated util file with a test): + +```ts +export interface SeverityCounts { + critical: number + high: number + moderate: number + low: number + vulnerable: number // sum of the four above + outdated: number +} +export function severityCounts(packages: PackageEntry[]): SeverityCounts +``` + +Rules: +- Each package contributes to exactly one severity bucket: its `advisory.severity` + (the entry's already-worst-severity advisory). Packages without an `advisory` + contribute to none. +- `vulnerable` = critical + high + moderate + low. +- `outdated` = count of packages with `outdated === true` (independent of + advisories β€” an outdated package may or may not be vulnerable). + +### 3. `SeverityMeter` β€” compact packages headline + +New component `components/SeverityMeter/` (index.ts, `SeverityMeter.tsx`, +`SeverityMeter.types.ts`, `SeverityMeter.constants.ts`, test). + +Props: `{ counts: SeverityCounts; total: number; computing?: boolean }`. + +Render (fits the ~132px header slot, matching `Gauge`'s footprint): +- A stacked horizontal bar whose segments are proportional to + critical/high/moderate/low counts, each colored from a severity palette in + `SeverityMeter.constants.ts`: + - critical `#ff453a`, high `#ff9f0a`, moderate `#ffd60a`, low `#5e9eff` + (final hexes may be tuned to the app palette during implementation). +- When `vulnerable === 0`: a full-width muted/ok-toned bar (an "all clear" + state) instead of empty segments. +- A leading numeric: total vulnerable count; a trailing muted `Β· {outdated} outdated`. +- `title` (tooltip) carries the full breakdown: + `"{critical} critical Β· {high} high Β· {moderate} moderate Β· {low} low Β· {outdated} outdated of {total} packages"`. +- When `computing`, show the same ghost-shimmer treatment `Gauge` uses (reuse + the `cc-ghost` class) so the bar reads as "still calculating". + +### 4. `TabHeadline` β€” the header switcher + +New component `components/TabHeadline/` (index.ts, `TabHeadline.tsx`, +`TabHeadline.types.ts`, test). + +Props (a small descriptor computed in `LauncherApp`): +```ts +type TabHeadlineProps = { + tab: LauncherTab + accent: string +} & ( + | { tab: 'projects'; used: number; threshold: number; linkedBytes: number; calculating: boolean } + | { tab: 'caches'; used: number; threshold: number; calculating: boolean } + | { tab: 'docker'; used: number; threshold: number } + | { tab: 'packages'; counts: SeverityCounts; total: number; computing: boolean } +) +``` +(Implementation may use a single flattened prop object rather than a strict +discriminated union if that reads cleaner in JSX β€” the switch on `tab` is the +essential part.) + +Behavior: +- `projects` / `caches` / `docker` β†’ render ``. + Projects passes `linkedBytes` + `calculating` as today; caches passes + `calculating`; docker passes neither (0/false). +- `packages` β†’ render ``. +- When a size tab has nothing to show (caches: `!store.available`; docker: + `!info` or `!info.available`) `TabHeadline` renders `null` for that tab so the + header slot is simply empty, rather than a 0-of-limit gauge. + +`LauncherApp` replaces its inline `` in the header with +``, computing the per-tab inputs it already has in scope +(`totalUsed`, `linkedTotal`, `storeBytes`, docker total, `severityCounts(...)`). + +Docker total is the sum of `info.totals[*].sizeBytes` (a small memo). + +### 5. `tabSummary` β€” tab-contextual footer line + +New pure helper (co-located with `LauncherApp` logic, with a test): + +```ts +function tabSummary(input): string | null +``` +Returns the footer text for the active tab: +- `projects`: unchanged wording β€” `"{pct}% of your {thresholdGB} GB limit"` / + `"{over} over your {thresholdGB} GB limit"`. +- `caches` (only when `store.available`): same shape vs `cacheThresholdGB`, + worded `"… of your {cacheThresholdGB} GB cache limit"` / `"… over …"`. +- `docker` (only when `info?.available`): same shape vs `dockerThresholdGB`, + worded `"… of your {dockerThresholdGB} GB Docker limit"`. +- `packages`: `"{vulnerable} vulnerable Β· {outdated} outdated"`, or `"all clear"` + when both are 0. `null` while `computing` with no data yet (footer stays quiet). +- Returns `null` when there is nothing meaningful to show (e.g. caches with no + store), so the footer simply omits the line. + +The existing calculating-spinner branch (scan / pnpm sizing) is preserved for +`projects` and `caches`; `tabSummary` only supplies the settled text. + +### 6. Docker auto-scan on open + +In `LauncherApp`, add an effect keyed on the Docker tab being active: + +```ts +useEffect(() => { + if (view !== 'list' || tab !== 'docker' || !dockerEnabled) return + const info = docker.info + const stale = !info || Date.now() - info.checkedAt > 5 * 60 * 1000 + if (stale && !docker.loading) void docker.refresh() +}, [view, tab, dockerEnabled, docker]) +``` + +Use a 5-minute staleness window constant (`DOCKER_STALE_MS = 5 * 60 * 1000`). +The manual ⌘R handler and the DockerView refresh button are unchanged. Guard +against a refresh loop: the effect must not fire while `docker.loading` is true, +and `checkedAt` updates after a refresh so a fresh scan won't immediately +re-trigger. + +## Data flow + +``` +useSettings ──► thresholdGB / cacheThresholdGB / dockerThresholdGB +usePnpmStore ─► store.sizeBytes, store.available +usePackagesTab β–Ί inventory.packages ──► severityCounts() ─► SeverityCounts +useDocker ────► info.totals ─► dockerTotal ; info.checkedAt ─► staleness + β”‚ + β–Ό + LauncherApp ──► TabHeadline (header slot, per active tab) + ──► tabSummary() (footer line, per active tab) + ──► docker auto-scan effect +``` + +## Error / edge handling + +- **Caches, no store** (`!store.available`): `TabHeadline` renders null; + `tabSummary` returns null. No gauge, no footer line. +- **Docker unavailable / not scanned** (`!info || !info.available`): + `TabHeadline` renders null; `tabSummary` returns null. DockerView's own + unavailable UI is untouched. +- **Packages still computing**: `SeverityMeter` shows the ghost shimmer; + `tabSummary` returns null until counts exist. +- **Zero vulnerabilities**: `SeverityMeter` shows the "all clear" bar; + `tabSummary` returns `"all clear"` (with outdated count when > 0: + `"all clear Β· {outdated} outdated"`). +- **Threshold inputs**: clamped by `validate-setting` (0.1–1000 GB); invalid + types rejected exactly like `thresholdGB`. +- **Docker auto-scan**: never fires while `docker.loading`; 5-min window + prevents re-scan on every glance. + +## Testing + +- `severityCounts()`: worst-severity-per-package bucketing; packages with no + advisory excluded; `vulnerable` = sum; `outdated` tallied independently; + empty input β†’ all zeros. +- `tabSummary()`: each tab β€” under limit, over limit, unavailable/no-data (null), + packages zero β†’ "all clear", packages with counts, computing β†’ null. +- `validate-setting`: `cacheThresholdGB` and `dockerThresholdGB` clamp high/low, + reject NaN and string, exactly as `thresholdGB`. +- `SeverityMeter`: render smoke β€” segments present for non-zero counts, all-clear + state for zero, tooltip text composed correctly. +- `TabHeadline`: renders `Gauge` for size tabs, `SeverityMeter` for packages, + `null` when a size tab has no data. + +## Out of scope + +- No new IPC channels (settings flow through the existing path). +- No change to Docker grouping, prune/remove flows, or the safety confirm gate. +- Duplicate-version count is not surfaced in the headline (per-row only). +- No menu-bar tray changes (tray still tracks node_modules `thresholdGB`). diff --git a/docs/superpowers/specs/2026-07-14-docker-buildcache-to-caches-and-clearer-deletes-design.md b/docs/superpowers/specs/2026-07-14-docker-buildcache-to-caches-and-clearer-deletes-design.md new file mode 100644 index 0000000..af9c6c8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-docker-buildcache-to-caches-and-clearer-deletes-design.md @@ -0,0 +1,119 @@ +# Docker feature finish: build cache β†’ Caches tab + clearer deletion buttons + +**Date:** 2026-07-14 +**Branch:** `feat/docker-project-grouping` (continues the existing Docker work) +**Status:** approved design, pending implementation plan + +## Problem + +Two rough edges left on the Docker tab before it's done: + +1. **Build cache sits in the wrong place.** Docker's build cache is a global, + not-project-linked cache β€” conceptually it belongs with the other package-manager + caches (pnpm store, and the future npm/yarn/bun), not mixed into the + project-grouped Docker list where it currently shows as a "Build cache" group in + the "Not linked to a project" section. +2. **The deletion buttons don't read as deletion.** The bulk-prune buttons on the + Docker "Other" group headers are labelled with just the *category* + ("Unused images", "Unused volumes", "Build cache"), so they look like filter + chips rather than destructive actions. + +## Decisions (settled with the user) + +- **Build cache presentation in Caches:** a single aggregated "Docker build cache" + row (total size + one Delete button), mirroring the pnpm store row. Not a list of + individual build-cache records (they aren't individually removable anyway β€” the + CLI only prunes build cache as a whole). +- **Gauge accounting:** unchanged. Build cache keeps counting toward the Docker + header gauge (it is genuinely Docker's disk). The Caches gauge is untouched. The + Caches tab is simply *where you delete it*. +- **Bulk-prune button style:** delete verb + trash icon + danger (red) styling β€” + "Delete unused images", "Delete dangling images", "Delete stopped containers", + "Delete unused volumes". The "permanent, not sent to the Trash" tooltip stays. +- **Per-item delete:** keep the hover-revealed trash icon (matches the node_modules + rows) β€” it already renders as a danger `RowAction`, so no behavioural change. + +## Design + +### Goal 1 β€” build cache moves to the Caches tab + +**Docker tab stops showing build cache.** +`groupDockerForDisplay` (`DockerView.constants.ts`) no longer emits the `buildcache` +`DisplayGroup`. The `buildCache` prune target, `pruneEstimateBytes`, and +`PRUNE_TARGET_LABEL['buildCache']` all remain β€” they're still used by the shared +confirm/prune flow, now triggered from the Caches tab instead of a Docker group +header. + +**Caches tab gains a "Docker build cache" row.** +- Size = sum of `docker.info.items` where `kind === 'buildcache'`. +- Rendered only when Docker is available (`docker.info.available`) and that size > 0. +- Its **Delete** button calls the existing `requestDockerPrune('buildCache')`, + reusing: the Pro license gate, the confirm dialog, the reclaimed-bytes toast, and + the permanent/no-Trash wording. Build cache does **not** need the typed-name gate + (only volumes do), so it's a single-click confirm. +- Busy state driven by `docker.busyId === 'prune:buildCache'`. + +**Caches tab becomes a list of live caches.** +Today the tab hard-codes the pnpm store at keyboard index 0. It becomes a small +ordered list of *live* caches (pnpm store, then Docker build cache when present); +keyboard ↑/↓ moves across them and Enter fires the selected cache's action. The +"soon" placeholders (npm/yarn/bun) stay below and remain non-selectable. + +**Auto-scan extended to the Caches tab.** +The existing effect that background-scans Docker when the Docker tab opens with +missing/stale (>5 min) data also fires for the Caches tab (when `dockerEnabled`), so +the build-cache row is populated even if the user never opened the Docker tab. Same +staleness guard, same loop guard. + +### Goal 2 β€” deletion buttons read as deletion + +**Bulk-prune buttons (Docker tab group headers).** +New delete-verb button labels, distinct from the confirm-footer noun labels: + +| Prune target | Button label | Confirm-footer label (unchanged) | +|-------------------|---------------------------|----------------------------------| +| danglingImages | Delete dangling images | Dangling images | +| unusedImages | Delete unused images | Unused images | +| stoppedContainers | Delete stopped containers | Stopped containers | +| unusedVolumes | Delete unused volumes | Unused volumes | +| buildCache | (no button in Docker tab; deleted from Caches) | Build cache | + +Buttons get a trash icon and danger (red border/text) styling. Tooltip unchanged. + +**Docker build cache Delete button (Caches tab).** +Same danger + trash treatment. Requires a small optional `danger` + leading-icon +capability added to `CacheRow` (its action button). The pnpm store keeps its neutral +"Prune" button (reversible prune, and this change is scoped to Docker). + +**Per-item hover trash.** +No change β€” already a danger `RowAction`, hover-revealed to match node_modules rows. + +### Files touched + +- `launcher/views/DockerView.constants.ts` β€” drop the `buildcache` display group; + add delete-verb button labels (e.g. `PRUNE_BUTTON_LABEL`). +- `launcher/views/DockerView.tsx` β€” prune-button trash icon + danger styling, using + the new button labels. +- `launcher/views/CachesView.tsx` (+ `.types` / `.constants`) β€” live-cache list; + Docker build-cache row with a danger Delete button. +- `components/CacheRow/*` β€” optional `danger` + action-icon on the action button. +- `launcher/LauncherApp/LauncherApp.tsx` β€” compute build-cache size; pass it + the + delete handler + busy state to `CachesView`; extend the Docker auto-scan effect to + the Caches tab; update the caches keyboard nav to the live-cache list. + +### Safety + +No change to the safety model. Every delete still routes through the existing docker +confirm gate; the typed-name volume gate is byte-identical; build cache remains +non-individually-removable and is only ever cleared via the whole-cache prune. + +### Testing + +- `DockerView.constants` test: `groupDockerForDisplay` no longer returns a + `buildcache` group even when build-cache items are present; the buildCache prune + estimate/labels still resolve. +- `CachesView` test: the Docker build-cache row appears only when available + size>0; + Delete invokes the build-cache handler; keyboard selection spans pnpm + build cache. +- `CacheRow` test: danger/icon action variant renders. +- Prune-button label test: Docker group headers render the delete-verb labels. +- Existing docker safety / confirm tests stay green (no gate change). diff --git a/docs/superpowers/specs/2026-07-15-panel-visualization-only-design.md b/docs/superpowers/specs/2026-07-15-panel-visualization-only-design.md new file mode 100644 index 0000000..872e9e4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-panel-visualization-only-design.md @@ -0,0 +1,259 @@ +# Menu bar panel: visualization + shortcuts only + +**Date:** 2026-07-15 +**Status:** approved, ready for planning +**Branch:** rides `feat/docker-project-grouping` or its own branch (user's call) + +## Problem + +The menu bar popover (`src/renderer/src/panel/PanelApp/PanelApp.tsx`, 307 lines) is a +miniature of the main window: it lists the four oldest reclaimable `node_modules` +folders as `MiniRow`s, offers a "Clean N stale folders" CTA, and carries a pnpm store +prune row. It shows one of the app's four areas (`node_modules`) and lets you delete +from it, while caches, packages/vulnerabilities and Docker are invisible until you open +the launcher. + +The app has since become TidyDisk: all developer disk waste, not just `node_modules`. +The panel should reflect that. It becomes a **read-only dashboard**: one glance at every +area, and a click into the main window for anything actionable. + +## Decisions + +Every decision below was made explicitly during brainstorming. They are recorded with +their rationale because several are non-obvious and one is a real product tradeoff. + +### D1 β€” The panel is fully read-only + +Remove from the panel: the `MiniRow` list and its "Reclaimable Β· oldest first" header, +`CleanStaleCta`, `PnpmStoreRow`, the "Free version Β· scanning is free forever" paywall +affordance button, and `UnlockPrompt`. + +**Consequence, accepted deliberately:** all three `paywall_shown` triggers currently +fired from the panel (`clean_stale`, `prune`, `affordance`) disappear with them. The +launcher becomes the app's only paywall surface. This is a funnel change, not just a UI +change. + +`ScanPanel` and its ⌘R shortcut stay β€” scanning is read-only and is a shortcut, not an +action. + +### D2 β€” Layout: aggregate hero + four rows + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ TRACKED ON DISK β”‚ +β”‚ 64.8 GB πŸ–΄ 50 GB limit β”‚ ← hero: big number + combined limit +β”‚ ⚠ 14.8 GB over β”‚ +β”‚ [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–“β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] β”‚ ← 32-cell PixelMeter vs combined limit +β”‚ node_modules + caches + Docker β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Projects [β–ͺβ–ͺβ–ͺβ–ͺβ–ͺβ–‘β–‘β–‘] 41.2 GB β€Ί β”‚ ← four click-through rows +β”‚ Caches [β–ͺβ–ͺβ–‘β–‘β–‘β–‘β–‘β–‘] 6.4 GB β€Ί β”‚ +β”‚ Packages [β–ˆβ–ˆβ–“β–“β–‘β–‘β–‘β–‘] 7 vuln β€Ί β”‚ +β”‚ Docker [β–ͺβ–ͺβ–ͺβ–ͺβ–ͺβ–ͺβ–“β–‘] 23.6 GB β€Ί β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Open full window… ⌘O β”‚ ← unchanged +β”‚ Scan now ⌘R β”‚ +β”‚ Settings… ⌘, β”‚ +β”‚ Quit ⌘Q β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Last scan 3 min ago Β· next in 18 h β”‚ ← unchanged +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Rejected alternatives: keeping a `node_modules` hero with three secondary rows (says +`node_modules` is still the product); four equal rows with no hero (loses the headline +number the business model treats as the shareable artifact). + +`useAutoHeight` already drives `setWindowHeight` from a `ResizeObserver`, so the native +popover resizes to the new content with no window-sizing work. + +### D3 β€” The hero means "tracked", not "reclaimable" + +Hero = `totalUsed + dockerTotal`, labelled **"Tracked on disk"**. Purely descriptive: +what these areas occupy. It makes no reclaim promise, so it cannot be dishonest. + +"Reclaimable" was rejected as the headline: Docker exposes `reclaimableBytes` per +category and `node_modules` has a defensible stale set, but **the pnpm store's prunable +share is unknowable until you actually prune it**. A "reclaimable" headline would have to +silently omit an entire area. + +### D4 β€” Each byte counted once; rows mirror their tabs + +`totalUsed` (`LauncherApp.tsx:157`, mirrored in `PanelApp.tsx:48`) is +`sum(project.uniqueSize ?? size) + storeBytes` β€” it **already includes the pnpm store**, +by design, because that is real `node_modules` disk. The Caches gauge +(`LauncherApp.tsx:674`) is `storeBytes`, that same store again. The launcher can afford +this overlap (separate tabs answer separate questions); a summed hero cannot. + +Therefore: + +- **Hero** = `totalUsed + dockerTotal`. Each byte once. +- **Rows mirror their tab headline exactly**: Projects `totalUsed`, Caches `storeBytes`, + Docker `dockerTotal`, Packages the `SeverityMeter`. +- **The hero is therefore smaller than the four rows added up**, by exactly the pnpm + store. Accepted: click-through must never lie (clicking a row showing 41.2 GB must land + on a tab reading 41.2 GB), and only a user manually summing rows can see the gap. The + hero carries a tooltip explaining the store is shared and counted once β€” the same + device `DiskSummary` already uses for linked bytes. + +There is **no Docker build-cache double count**: the Caches *gauge* is `storeBytes` only; +the build cache appears in the Caches tab's *list*, not its headline. + +### D5 β€” Combined limit = sum of the present areas' limits + +No new setting. Combined limit is derived at render time from the existing +`thresholdGB` (20) + `cacheThresholdGB` (10) + `dockerThresholdGB` (20), including only +areas actually present (see D6). The "over" line is `hero βˆ’ combinedLimit`: one number, +internally consistent. + +**Known approximation, written down rather than hidden:** the store sits inside the +hero's numerator once, while `cacheThresholdGB` is added to the denominator. The combined +budget is a rough sum, not an airtight one. Rejected: a fourth `combinedThresholdGB` +setting (one more limit to explain and keep in sync). + +### D6 β€” Honesty gating + +Follows the rule the Packages headline already learned: never show a false "all clear", +never render a gauge for data that was never measured. + +| Condition | Behavior | +|---|---| +| `settings.docker === false` or `!docker.info.available` | Docker row hidden; its bytes leave the hero; `dockerThresholdGB` leaves the denominator; label reads "across 2 areas" | +| `!store.available` | Caches row hidden; `cacheThresholdGB` leaves the denominator; `storeBytes` is already 0 in `totalUsed` | +| `checkUpdates` off, no cached inventory, or `inventory.enrichmentError` | Packages row renders a muted "Not checked yet" placeholder that still clicks through to the tab | +| Packages OK | `SeverityMeter` from `severityCounts(inventory.packages)` | + +Packages is never part of the hero total or the combined limit β€” vulnerabilities are not +bytes. The hero label counts size areas only ("across N areas", N ≀ 3). + +### D6b β€” The "All clean" empty state goes away + +`PanelEmpty` currently replaces the **entire** panel body whenever `projects.length === 0` +(`PanelApp.tsx:175-183`). Under D2 that is a lie: a user with 23 GB of Docker and zero +`node_modules` folders would see a full-panel "All clean" with their Docker bar hidden +behind it. Its "Reclaimed {n} this session" line is also permanently `0 B` once D1 removes +the panel's delete path. + +Therefore: + +- **Keep** the `!onboarded` first-run branch β€” before the first scan there is genuinely + nothing to visualize, so it still owns the whole body. +- **Delete** the "All clean" branch and the `reclaimed` prop. With zero projects the + Projects row simply reads `0 B` and every other area still renders. The dashboard is + never replaced by a verdict about one area. + +### D7 β€” Freshness: cheap live, expensive lazy + +The popover opens on every menu bar click and must never block. + +- **Docker:** render last-known numbers instantly; refresh in the background when data is + missing or >5 min stale, reusing the Docker tab's existing `DOCKER_STALE_MS` rule + (`LauncherApp.tsx:142-146`). `getDockerInfo` caches to disk and dedupes in-flight calls, + so this is cheap. +- **Store:** cached mount fetch only (`getPnpmStore()` returns the cached size instantly); + force-refresh **only when a panel scan completes**, mirroring `LauncherApp.tsx:112-117` + ("the pnpm store can change during a scan (new installs), so re-size it once a scan + finishes"). **Corrected during planning:** an earlier draft of this section put the store + on the same >5 min staleness rule as Docker. That was wrong β€” `getPnpmStoreInfo` has no + TTL (it caches until forced, exactly like `getDockerInfo`), so a staleness rule would + force a multi-second `du` on the store every time the popover opened past the TTL. The + launcher never does this: it refreshes the store on scan-completion only, and its stale + rule at `:142-146` refreshes Docker alone even on the Caches tab. The panel now matches + that precedent, which is both cheaper and consistent. +- **Packages:** read `getPackages()` (cached-or-null) only. **Never** call + `computePackages()` from the panel. An uncomputed inventory shows the D6 placeholder; + clicking through to the Packages tab computes it there, as it does today. + +Rejected: always-live (every menu bar click would spawn docker CLI calls and a full +inventory recompute). + +### D8 β€” Nav: widen the existing mechanism + +The transport already exists and handles both cases correctly (fresh window β†’ +`pendingNav` β†’ `consumeLauncherNav()`; already-open window β†’ `onLauncherNavigate` send). +It needs no change. + +- `src/shared/launcher-nav.types.ts:2`: widen `LauncherNavTarget` from the single-member + `'settings'` to `'settings' | 'projects' | 'caches' | 'packages' | 'docker'`. +- `LauncherApp.tsx:124` and `:127`: replace the two hardcoded `nav === 'settings'` checks + with one shared target β†’ `{view, tab}` map. +- Clicking a row calls `window.clean.openLauncher()`, which already hides the + panel before opening the launcher (`register-ipc.ts:197-200`). + +**Edge case:** navigating to `docker` while `settings.docker === false` must not select a +hidden tab. The Docker row is hidden in exactly that case (D6), so it is unreachable from +the panel; the existing fallback at `LauncherApp.tsx:132-137` remains the backstop. + +## Architecture + +Tests in this repo are **pure-logic only** β€” `vitest.config.ts` includes +`src/**/*.test.ts` (not `.tsx`), and there is no React Testing Library. Every decision +therefore lives in a pure `.ts` module, and the `.tsx` files stay thin layout. + +### New + +- **`panel/PanelApp/panelAreas.ts`** β€” the whole brain. One pure function: + + ```ts + panelAreas(input: PanelAreasInput): PanelAreas + ``` + + Input: projects, store, docker info, inventory, settings. Output: `heroBytes`, + `combinedLimitGB`, `areaCount`, and the ordered row list with gating resolved + (each row: id/nav target, label, value, meter kind, whether it's a placeholder). + Modelled on `tabSummary.ts` β€” same shape, same testing style. This is where D3–D6 are + encoded and tested. + +- **`panel/PanelApp/AreaBar/`** β€” `AreaBar.tsx`, `AreaBar.types.ts`, `index.ts`. Renders + one click-through row: label, slim meter (`PixelMeter` at reduced `cells`/height, or + `SeverityMeter`), value, chevron. Dumb; takes a resolved row from `panelAreas`. + +- **`launcher/LauncherApp/launcherNav.ts`** β€” the pure target β†’ `{view, tab}` map, tested. + It lives in the launcher, not `shared/`, because `LauncherView` and `LauncherTab` are + defined in `LauncherApp.types.ts`; only the `LauncherNavTarget` union itself is shared + (the panel and main both need it). + +### Changed + +- **`DiskSummary` β†’ `TrackedSummary`** β€” the hero generalizes: label, total, combined + limit, meter, composition sub-line. It is no longer `node_modules`-specific; the + existing structure (27px number, limit/over lines, 32-cell `PixelMeter`) carries over + as-is. +- **`PanelApp.tsx`** β€” shrinks from 307 lines to layout: hero, `areas.map(AreaBar)`, + menu items, footer. Loses `removeMany`, `pruneStore`, `deleting`, `reclaimed`, + `useLicense`, `useLiveProjects`, and the store-prune wiring; gains `useDocker` and + `usePackages` (cached reads). +- **`launcher-nav.types.ts`**, **`LauncherApp.tsx:122-129`** β€” per D8. + +### Removed + +`PnpmStoreRow.tsx`, `CleanStaleCta.tsx`, the panel's `MiniRow` usage, and `PanelEmpty`'s +"All clean" branch + `reclaimed` prop (D6b). `MiniRow` itself stays β€” the launcher uses +it. No dead props are left behind. + +## Testing + +TDD, per project convention. All pure: + +- `panelAreas.test.ts` β€” the bulk. Hero counts the store once; hero < sum of rows by + exactly the store (D4, asserted explicitly); each byte-area's row value equals its tab + headline value; Docker absent/disabled drops bytes + limit + row and reports 2 areas; + store unavailable drops the caches row and limit; Packages placeholder under each of + the three D6 conditions; Packages never enters the hero or the limit; combined limit + math; over/free line; zero projects still yields a full row list with Projects at `0 B` + (D6b β€” the dashboard is never collapsed by one empty area). +- `launcher-nav.test.ts` β€” every target maps to the right `{view, tab}`; `'settings'` + still resolves to the settings view (no regression). +- Existing `tabSummary.test.ts` and the Docker/Caches constants tests must stay green β€” + this change must not alter launcher behavior beyond nav. + +Manual QA (user, needs a running app + Docker daemon): every row opens the right tab; +panel opens instantly with a cold Docker cache; Docker off hides the row and rebalances +the hero; Packages placeholder appears before the Packages tab is ever visited. + +## Out of scope + +- The npm/yarn/bun cache placeholders in `CachesView.constants.ts` (still "coming soon"). +- Any change to launcher tab content, delete paths, or the license gate. +- Moving or redesigning the paywall inside the launcher β€” D1 removes the panel's paywall + surface; where the launcher's paywall lives is a separate question. diff --git a/package.json b/package.json index 36f9181..65040d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tidydisk", - "version": "1.0.0", + "version": "1.1.0", "description": "TidyDisk: macOS menu bar app that shows what your dev projects cost in disk space and reclaims it in one click", "private": true, "type": "module", diff --git a/src/main/docker/docker-associate.test.ts b/src/main/docker/docker-associate.test.ts new file mode 100644 index 0000000..d38f4a5 --- /dev/null +++ b/src/main/docker/docker-associate.test.ts @@ -0,0 +1,90 @@ +import type { DockerItem } from '@shared/docker.types' +import { describe, expect, it } from 'vitest' +import { associateProjects } from './docker-associate' + +const mk = (o: Partial & Pick): DockerItem => ({ + sizeBytes: 0, + createdAt: 0, + inUse: false, + removable: true, + ...o, +}) + +describe('associateProjects', () => { + it('groups a container by its compose project and lists the project', () => { + const items = [mk({ id: 'c1abc', kind: 'container', name: 'myapp-web' })] + const containers = [ + { + id: 'c1abc', + imageRef: 'node:20', + imageId: 'sha256:img1', + project: 'myapp', + workingDir: '/w/myapp', + mounts: ['myapp_pgdata'], + }, + ] + const { items: out, projects } = associateProjects(items, containers, new Map()) + expect(out[0].project).toBe('myapp') + expect(projects).toEqual([{ name: 'myapp', workingDir: '/w/myapp' }]) + }) + + it('associates an image to the single project whose container uses it (used-by)', () => { + const items = [mk({ id: 'sha256:img1', kind: 'image', name: 'node:20' })] + const containers = [ + { + id: 'c1abc', + imageRef: 'node:20', + imageId: 'sha256:img1', + project: 'myapp', + workingDir: '/w/myapp', + mounts: [], + }, + ] + const { items: out } = associateProjects(items, containers, new Map()) + expect(out[0].project).toBe('myapp') + expect(out[0].repository).toBe('node') + }) + + it('leaves an image used by multiple projects unassociated (no double-count)', () => { + const items = [mk({ id: 'sha256:img1', kind: 'image', name: 'node:20' })] + const containers = [ + { id: 'a', imageRef: 'node:20', imageId: 'sha256:img1', project: 'app1', workingDir: '/w/a', mounts: [] }, + { id: 'b', imageRef: 'node:20', imageId: 'sha256:img1', project: 'app2', workingDir: '/w/b', mounts: [] }, + ] + const { items: out } = associateProjects(items, containers, new Map()) + expect(out[0].project).toBeUndefined() + expect(out[0].repository).toBe('node') + }) + + it('leaves an image unassociated when one project matches by imageId and a different project matches by imageRef (union, not fallback)', () => { + const items = [mk({ id: 'imgIdX', kind: 'image', name: 'shared:tag' })] + const containers = [ + { id: 'c1', imageRef: 'other:tag', imageId: 'imgIdX', project: 'app1', workingDir: '/w/a', mounts: [] }, + { id: 'c2', imageRef: 'shared:tag', imageId: 'imgIdY', project: 'app2', workingDir: '/w/b', mounts: [] }, + ] + const { items: out } = associateProjects(items, containers, new Map()) + expect(out[0].project).toBeUndefined() + }) + + it('associates a volume by compose label, else by mounting container', () => { + const items = [ + mk({ id: 'myapp_pgdata', kind: 'volume', name: 'myapp_pgdata' }), + mk({ id: 'mounted', kind: 'volume', name: 'mounted' }), + ] + const containers = [ + { id: 'c1', imageRef: 'x', imageId: 'x', project: 'byMount', workingDir: '/w', mounts: ['mounted'] }, + ] + const volProjects = new Map([['myapp_pgdata', 'myapp']]) + const { items: out } = associateProjects(items, containers, volProjects) + expect(out.find((i) => i.name === 'myapp_pgdata')?.project).toBe('myapp') + expect(out.find((i) => i.name === 'mounted')?.project).toBe('byMount') + }) + + it('build cache and dangling images stay unassociated; repository parsed', () => { + const items = [mk({ id: 'bc', kind: 'buildcache', name: 'bc' }), mk({ id: 'd', kind: 'image', name: '' })] + const { items: out } = associateProjects(items, [], new Map()) + expect(out[0].project).toBeUndefined() + expect(out[1].project).toBeUndefined() + expect(out[1].repository).toBeUndefined() // '' has no repository + }) +}) diff --git a/src/main/docker/docker-associate.ts b/src/main/docker/docker-associate.ts new file mode 100644 index 0000000..51c1bbe --- /dev/null +++ b/src/main/docker/docker-associate.ts @@ -0,0 +1,64 @@ +import type { DockerItem, DockerProject } from '@shared/docker.types' +import type { InspectedContainer } from './docker-inspect' + +/** Repository = name before the last ':'; undefined for dangling ''. */ +function repositoryOf(name: string): string | undefined { + if (name === '' || !name.includes(':')) return name === '' ? undefined : name + return name.slice(0, name.lastIndexOf(':')) +} + +/** The single compose project referencing this image (by id or repo:tag), or undefined if 0 or >1. */ +function soleProject(projects: Set): string | undefined { + return projects.size === 1 ? [...projects][0] : undefined +} + +export function associateProjects( + items: DockerItem[], + containers: InspectedContainer[], + volumeProjects: Map, +): { items: DockerItem[]; projects: DockerProject[] } { + // project name β†’ working_dir (from any container that carries both) + const workingDirs = new Map() + // image ref/id β†’ set of projects whose containers reference it + const imageProjects = new Map>() + // volume name β†’ set of projects whose containers mount it + const volumeMountProjects = new Map>() + + for (const c of containers) { + if (c.project && c.workingDir && !workingDirs.has(c.project)) workingDirs.set(c.project, c.workingDir) + if (c.project) { + for (const key of [c.imageRef, c.imageId].filter(Boolean)) { + if (!imageProjects.has(key)) imageProjects.set(key, new Set()) + imageProjects.get(key)!.add(c.project) + } + for (const vol of c.mounts) { + if (!volumeMountProjects.has(vol)) volumeMountProjects.set(vol, new Set()) + volumeMountProjects.get(vol)!.add(c.project) + } + } + } + + const byId = new Map(containers.map((c) => [c.id, c])) + const projectNames = new Set() + + const out = items.map((item): DockerItem => { + let project: string | undefined + let repository: string | undefined + if (item.kind === 'container') { + project = byId.get(item.id)?.project + } else if (item.kind === 'image') { + repository = repositoryOf(item.name) + project = soleProject(new Set([...(imageProjects.get(item.id) ?? []), ...(imageProjects.get(item.name) ?? [])])) + } else if (item.kind === 'volume') { + project = volumeProjects.get(item.name) ?? soleProject(volumeMountProjects.get(item.name) ?? new Set()) + } + if (project) projectNames.add(project) + return { ...item, project, repository } + }) + + const projects: DockerProject[] = [...projectNames].map((name) => ({ + name, + workingDir: workingDirs.get(name), + })) + return { items: out, projects } +} diff --git a/src/main/docker/docker-inspect.test.ts b/src/main/docker/docker-inspect.test.ts new file mode 100644 index 0000000..576c30c --- /dev/null +++ b/src/main/docker/docker-inspect.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { parseContainerInspect, parseVolumeLabels } from './docker-inspect' + +const CONTAINERS = JSON.stringify([ + { + Id: 'c1abc', + Image: 'node:20', + Config: { + Image: 'node:20', + Labels: { + 'com.docker.compose.project': 'myapp', + 'com.docker.compose.project.working_dir': '/Users/x/code/myapp', + }, + }, + Mounts: [{ Type: 'volume', Name: 'myapp_pgdata' }], + }, + { Id: 'c2def', Image: 'redis:7', Config: { Image: 'redis:7', Labels: {} }, Mounts: [] }, +]) + +describe('parseContainerInspect', () => { + it('extracts compose project, working_dir, image ref, and named-volume mounts', () => { + const r = parseContainerInspect(CONTAINERS) + expect(r[0]).toMatchObject({ + id: 'c1abc', + imageRef: 'node:20', + project: 'myapp', + workingDir: '/Users/x/code/myapp', + mounts: ['myapp_pgdata'], + }) + expect(r[1].project).toBeUndefined() + expect(r[1].mounts).toEqual([]) + }) + it('survives malformed input', () => { + expect(parseContainerInspect('not json')).toEqual([]) + }) +}) + +describe('parseVolumeLabels', () => { + it('maps volume name to compose project from `name::project` lines, skipping unlabeled/blank/malformed lines', () => { + const out = 'a::proj1\nb::\nc::proj2' + const m = parseVolumeLabels(out) + expect(m).toEqual( + new Map([ + ['a', 'proj1'], + ['c', 'proj2'], + ]), + ) + }) + + it('ignores blank lines', () => { + const m = parseVolumeLabels('a::proj1\n\n\nc::proj2\n') + expect(m).toEqual( + new Map([ + ['a', 'proj1'], + ['c', 'proj2'], + ]), + ) + }) + + it('ignores lines with no `::` separator', () => { + const m = parseVolumeLabels('a::proj1\nmalformed-line\nc::proj2') + expect(m).toEqual( + new Map([ + ['a', 'proj1'], + ['c', 'proj2'], + ]), + ) + }) + + it('splits on the FIRST `::` only, so a project value containing `::` is preserved', () => { + const m = parseVolumeLabels('a::proj::withcolon') + expect(m.get('a')).toBe('proj::withcolon') + }) + + it('returns an empty map for empty input', () => { + expect(parseVolumeLabels('')).toEqual(new Map()) + }) +}) diff --git a/src/main/docker/docker-inspect.ts b/src/main/docker/docker-inspect.ts new file mode 100644 index 0000000..95b60a2 --- /dev/null +++ b/src/main/docker/docker-inspect.ts @@ -0,0 +1,55 @@ +export interface InspectedContainer { + id: string + /** image reference the container runs (repo:tag), from Config.Image */ + imageRef: string + /** resolved image id when present (Image field), for id-based matching */ + imageId: string + project?: string + workingDir?: string + /** named volume mounts (Mounts[].Name where Type === 'volume') */ + mounts: string[] +} + +const P = 'com.docker.compose.project' +const WD = 'com.docker.compose.project.working_dir' + +export function parseContainerInspect(json: string): InspectedContainer[] { + let arr: unknown + try { + arr = JSON.parse(json) + } catch { + return [] + } + if (!Array.isArray(arr)) return [] + return arr.map((c: Record) => { + const config = (c.Config ?? {}) as Record + const labels = (config.Labels ?? {}) as Record + const mounts = Array.isArray(c.Mounts) ? (c.Mounts as Record[]) : [] + return { + id: String(c.Id ?? ''), + imageRef: String(config.Image ?? c.Image ?? ''), + imageId: String(c.Image ?? ''), + project: labels[P] || undefined, + workingDir: labels[WD] || undefined, + mounts: mounts.filter((m) => m.Type === 'volume' && m.Name).map((m) => String(m.Name)), + } + }) +} + +/** Parses `docker volume ls --format '{{.Name}}::{{.Label "com.docker.compose.project"}}'` + * output into a nameβ†’project map. One `volume ls` call lists only volumes that currently + * exist, so (unlike `docker volume inspect `) it can never 404 on a stale/removed + * name and fail the whole batch. Lines without the label print `name::` (empty after the + * separator) and are skipped, along with blank and malformed (no `::`) lines. */ +export function parseVolumeLabels(output: string): Map { + const out = new Map() + for (const line of output.split('\n')) { + if (!line.trim()) continue + const sep = line.indexOf('::') + if (sep === -1) continue + const name = line.slice(0, sep) + const project = line.slice(sep + 2) + if (name && project) out.set(name, project) + } + return out +} diff --git a/src/main/docker/docker-parse.test.ts b/src/main/docker/docker-parse.test.ts new file mode 100644 index 0000000..4934e16 --- /dev/null +++ b/src/main/docker/docker-parse.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { buildDockerItems, parseDate, parseDf, parseSize } from './docker-parse' + +const DF = JSON.stringify({ + Images: [ + { + ID: 'sha256:aaa', + Repository: 'node', + Tag: '20', + CreatedAt: '2026-01-02 10:00:00 +0000 UTC', + Size: '1.1GB', + Containers: '1', + }, + { + ID: 'sha256:bbb', + Repository: '', + Tag: '', + CreatedAt: '2026-01-01 10:00:00 +0000 UTC', + Size: '400MB', + Containers: '0', + }, + ], + Volumes: [ + { Name: 'pgdata', Size: '2.0GB', Links: '1' }, + { Name: 'scratch', Size: '512MB', Links: '0' }, + ], + Containers: [ + { + ID: 'ctr111', + Names: 'web', + State: 'running', + Image: 'node:20', + CreatedAt: '2026-01-03 10:00:00 +0000 UTC', + Size: '10MB', + }, + { + ID: 'ctr222', + Names: 'old', + State: 'exited', + Image: 'node:20', + CreatedAt: '2026-01-02 09:00:00 +0000 UTC', + Size: '5MB', + }, + { + ID: 'ctr333', + Names: 'paused-one', + State: 'paused', + Image: 'node:20', + CreatedAt: '2026-01-02 09:30:00 +0000 UTC', + Size: '6MB', + }, + ], + BuildCache: [{ ID: 'cache1', Size: '800MB', CreatedAt: '2026-01-01 08:00:00 +0000 UTC', InUse: 'false' }], +}) + +describe('parseSize', () => { + it('parses docker human sizes to bytes', () => { + expect(parseSize('0B')).toBe(0) + expect(parseSize('512MB')).toBe(512 * 1000 * 1000) + expect(parseSize('1.1GB')).toBeCloseTo(1.1 * 1e9, -6) + expect(parseSize('')).toBe(0) + }) +}) + +describe('parseDate', () => { + it('parses docker UTC timestamps to the correct epoch', () => { + expect(parseDate('2026-01-02 10:00:00 +0000 UTC')).toBe(Date.parse('2026-01-02T10:00:00Z')) + expect(parseDate('2026-01-02 10:00:00.482635131 +0000 UTC')).toBe(Date.parse('2026-01-02T10:00:00Z')) + expect(parseDate('')).toBe(0) + expect(parseDate('not a date')).toBe(0) + }) + + it('honors a non-UTC numeric offset', () => { + expect(parseDate('2026-01-02 10:00:00 +0530 IST')).toBe(Date.parse('2026-01-02T10:00:00+05:30')) + }) +}) + +describe('buildDockerItems', () => { + const df = parseDf(DF) + const { items, totals } = buildDockerItems(df) + + it('marks dangling/untagged images unused+removable and tagged-in-container in-use', () => { + const dangling = items.find((i) => i.id === 'sha256:bbb') + expect(dangling?.inUse).toBe(false) + expect(dangling?.removable).toBe(true) + const used = items.find((i) => i.id === 'sha256:aaa') + expect(used?.inUse).toBe(true) // Containers > 0 + expect(used?.removable).toBe(false) + }) + + it('marks volumes with Links>0 in-use (not removable) and Links=0 removable', () => { + expect(items.find((i) => i.name === 'pgdata')?.removable).toBe(false) + expect(items.find((i) => i.name === 'scratch')?.removable).toBe(true) + }) + + it('only stopped containers are removable', () => { + expect(items.find((i) => i.id === 'ctr111')?.removable).toBe(false) // running + expect(items.find((i) => i.id === 'ctr222')?.removable).toBe(true) // exited + }) + + it('paused containers are in-use and not removable (docker rm refuses paused)', () => { + const paused = items.find((i) => i.id === 'ctr333') + expect(paused?.inUse).toBe(true) + expect(paused?.removable).toBe(false) + }) + + it('build-cache rows are never per-item removable', () => { + expect(items.find((i) => i.kind === 'buildcache')?.removable).toBe(false) + }) + + it('totals reclaimable = sum of removable item sizes per kind', () => { + const vol = totals.find((t) => t.kind === 'volume') + expect(vol?.reclaimableBytes).toBe(512 * 1000 * 1000) // only scratch + expect(vol?.count).toBe(2) + }) +}) diff --git a/src/main/docker/docker-parse.ts b/src/main/docker/docker-parse.ts new file mode 100644 index 0000000..e86880d --- /dev/null +++ b/src/main/docker/docker-parse.ts @@ -0,0 +1,154 @@ +import type { DockerCategoryTotal, DockerItem, DockerItemKind } from '@shared/docker.types' + +// docker prints decimal (SI) sizes: 1kB = 1000 B. +const UNIT: Record = { + B: 1, + KB: 1e3, + MB: 1e6, + GB: 1e9, + TB: 1e12, + KIB: 1024, + MIB: 1024 ** 2, + GIB: 1024 ** 3, +} + +/** "1.1GB" | "512MB" | "0B" β†’ bytes. Unknown/empty β†’ 0. */ +export function parseSize(s: string): number { + const m = /^([\d.]+)\s*([A-Za-z]+)$/.exec((s ?? '').trim()) + if (!m) return 0 + const n = Number(m[1]) + const mult = UNIT[m[2].toUpperCase()] ?? 0 + return Number.isFinite(n) ? Math.round(n * mult) : 0 +} + +/** "2026-01-02 10:00:00 +0000 UTC" | "2026-01-02 10:00:00 -0500 EST" β†’ ms epoch (0 if unparseable). */ +export function parseDate(s: string): number { + if (!s) return 0 + // Docker prints Go's time.Time.String(): "2006-01-02 15:04:05[.frac] -0700 MST". + const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s+([+-]\d{4}))?/.exec(s.trim()) + if (!m) return 0 + const offset = m[3] ? `${m[3].slice(0, 3)}:${m[3].slice(3)}` : 'Z' + const t = Date.parse(`${m[1]}T${m[2]}${offset}`) + return Number.isFinite(t) ? t : 0 +} + +export interface DfImage { + ID: string + Repository: string + Tag: string + CreatedAt: string + Size: string + Containers: string +} +export interface DfVolume { + Name: string + Size: string + Links: string +} +export interface DfContainer { + ID: string + Names: string + State: string + Image: string + CreatedAt: string + Size: string +} +export interface DfCache { + ID: string + Size: string + CreatedAt: string + InUse: string +} +export interface DfParsed { + images: DfImage[] + volumes: DfVolume[] + containers: DfContainer[] + buildCache: DfCache[] +} + +export function parseDf(json: string): DfParsed { + let o: Record = {} + try { + o = JSON.parse(json) as Record + } catch { + // leave empty + } + return { + images: (o.Images as DfImage[]) ?? [], + volumes: (o.Volumes as DfVolume[]) ?? [], + containers: (o.Containers as DfContainer[]) ?? [], + buildCache: (o.BuildCache as DfCache[]) ?? [], + } +} + +function totalsOf(items: DockerItem[]): DockerCategoryTotal[] { + const kinds: DockerItemKind[] = ['image', 'volume', 'container', 'buildcache'] + return kinds.map((kind) => { + const of = items.filter((i) => i.kind === kind) + return { + kind, + sizeBytes: of.reduce((s, i) => s + i.sizeBytes, 0), + reclaimableBytes: of.filter((i) => i.removable).reduce((s, i) => s + i.sizeBytes, 0), + count: of.length, + } + }) +} + +export function buildDockerItems(df: DfParsed): { items: DockerItem[]; totals: DockerCategoryTotal[] } { + const items: DockerItem[] = [] + + for (const img of df.images) { + const dangling = img.Repository === '' || img.Tag === '' + const inUse = Number(img.Containers) > 0 + const tag = dangling ? '' : `${img.Repository}:${img.Tag}` + items.push({ + id: img.ID, + kind: 'image', + name: tag, + sizeBytes: parseSize(img.Size), + createdAt: parseDate(img.CreatedAt), + inUse, + removable: !inUse, + }) + } + for (const vol of df.volumes) { + const inUse = Number(vol.Links) > 0 + items.push({ + id: vol.Name, + kind: 'volume', + name: vol.Name, + sizeBytes: parseSize(vol.Size), + createdAt: 0, + inUse, + removable: !inUse, + }) + } + for (const c of df.containers) { + // `docker rm` (no -f) only accepts truly stopped containers; it refuses + // running, paused, restarting, and removing states. + const inUse = c.State === 'running' || c.State === 'paused' + const removable = c.State === 'exited' || c.State === 'created' || c.State === 'dead' + items.push({ + id: c.ID, + kind: 'container', + name: c.Names || c.ID.slice(0, 12), + sizeBytes: parseSize(c.Size), + createdAt: parseDate(c.CreatedAt), + inUse, + removable, + }) + } + for (const cache of df.buildCache) { + items.push({ + id: cache.ID, + kind: 'buildcache', + name: cache.ID.slice(0, 12), + sizeBytes: parseSize(cache.Size), + createdAt: parseDate(cache.CreatedAt), + inUse: cache.InUse === 'true', + removable: false, // build cache is bulk-prune-only + }) + } + + return { items, totals: totalsOf(items) } +} diff --git a/src/main/docker/docker.test.ts b/src/main/docker/docker.test.ts new file mode 100644 index 0000000..6231af7 --- /dev/null +++ b/src/main/docker/docker.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tidydisk-test' } })) +vi.mock('./find-docker', () => ({ + findDocker: vi.fn(async (override?: string) => override ?? '/usr/local/bin/docker'), + dockerExecEnv: vi.fn(async () => ({})), +})) +vi.mock('../scanner/detect-kind', () => ({ detectKind: vi.fn(async () => 'node') })) +vi.mock('../scanner/find-project-icon', () => ({ findProjectIcon: vi.fn(async () => undefined) })) + +import { __setExecForTests, getDockerInfo, pruneDocker, removeDockerItem } from './docker' +import { findDocker } from './find-docker' + +const DF = JSON.stringify({ + Images: [{ ID: 'i1', Repository: '', Tag: '', CreatedAt: '', Size: '400MB', Containers: '0' }], + Volumes: [], + Containers: [], + BuildCache: [], +}) + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +describe('getDockerInfo', () => { + it('reports available with parsed items when the daemon responds', async () => { + __setExecForTests(async (_bin, args) => { + if (args[0] === 'version') return { stdout: '27.0.0', stderr: '' } + if (args[0] === 'system' && args[1] === 'df') return { stdout: DF, stderr: '' } + return { stdout: '', stderr: '' } + }) + const info = await getDockerInfo(true) + expect(info.available).toBe(true) + expect(info.items).toHaveLength(1) + expect(info.items[0].removable).toBe(true) + }) + + it('reports daemon not running when version fails', async () => { + __setExecForTests(async (_bin, args) => { + if (args[0] === 'version') throw new Error('Cannot connect to the Docker daemon') + return { stdout: '', stderr: '' } + }) + const info = await getDockerInfo(true) + expect(info.available).toBe(false) + expect(info.reason).toBe('daemon not running') + }) + + it('reports not installed when the binary cannot be found', async () => { + vi.mocked(findDocker).mockResolvedValueOnce(null) + const info = await getDockerInfo(true) + expect(info.available).toBe(false) + expect(info.reason).toBe('not installed') + }) + + it('does not alias in-flight requests for different binary paths', async () => { + const versionCallsByBin: Record = {} + __setExecForTests(async (bin, args) => { + if (args[0] === 'version') { + versionCallsByBin[bin] = (versionCallsByBin[bin] ?? 0) + 1 + await delay(10) + return { stdout: `${bin}-version`, stderr: '' } + } + if (args[0] === 'system' && args[1] === 'df') return { stdout: DF, stderr: '' } + return { stdout: '', stderr: '' } + }) + + const [infoA, infoB] = await Promise.all([ + getDockerInfo(true, { binaryPath: '/a/docker' }), + getDockerInfo(true, { binaryPath: '/b/docker' }), + ]) + + expect(infoA.available).toBe(true) + expect(infoB.available).toBe(true) + expect(versionCallsByBin['/a/docker']).toBe(1) + expect(versionCallsByBin['/b/docker']).toBe(1) + }) + + it('associates items with compose projects and attaches a logo from working_dir', async () => { + const df = JSON.stringify({ + Images: [{ ID: 'sha256:img1', Repository: 'node', Tag: '20', CreatedAt: '', Size: '400MB', Containers: '1' }], + Volumes: [], + Containers: [], + BuildCache: [], + }) + const containerInspect = JSON.stringify([ + { + Id: 'ctr1', + Image: 'sha256:img1', + Config: { + Image: 'node:20', + Labels: { + 'com.docker.compose.project': 'myapp', + 'com.docker.compose.project.working_dir': '/w/myapp', + }, + }, + Mounts: [], + }, + ]) + __setExecForTests(async (_bin, args) => { + if (args[0] === 'version') return { stdout: '27.0.0', stderr: '' } + if (args[0] === 'system' && args[1] === 'df') return { stdout: df, stderr: '' } + if (args[0] === 'ps') return { stdout: 'ctr1\n', stderr: '' } + if (args[0] === 'container' && args[1] === 'inspect') return { stdout: containerInspect, stderr: '' } + if (args[0] === 'volume' && args[1] === 'ls') return { stdout: '', stderr: '' } + return { stdout: '', stderr: '' } + }) + const info = await getDockerInfo(true) + expect(info.available).toBe(true) + expect(info.projects).toContainEqual({ + name: 'myapp', + workingDir: '/w/myapp', + kind: 'node', + iconDataUrl: undefined, + }) + const image = info.items.find((i) => i.kind === 'image') + expect(image?.project).toBe('myapp') + }) + + it('leaves items unassociated (fail-soft) when container inspect throws', async () => { + const df = JSON.stringify({ + Images: [{ ID: 'sha256:img1', Repository: 'node', Tag: '20', CreatedAt: '', Size: '400MB', Containers: '1' }], + Volumes: [], + Containers: [], + BuildCache: [], + }) + __setExecForTests(async (_bin, args) => { + if (args[0] === 'version') return { stdout: '27.0.0', stderr: '' } + if (args[0] === 'system' && args[1] === 'df') return { stdout: df, stderr: '' } + if (args[0] === 'ps') return { stdout: 'ctr1\n', stderr: '' } + if (args[0] === 'container' && args[1] === 'inspect') throw new Error('daemon unreachable') + return { stdout: '', stderr: '' } + }) + const info = await getDockerInfo(true) + expect(info.available).toBe(true) + expect(info.items.every((i) => i.project === undefined)).toBe(true) + expect(info.projects).toEqual([]) + }) + + it('still runs repository association when container inspect throws (regression: one inspect failure must not drop ALL grouping)', async () => { + const df = JSON.stringify({ + Images: [{ ID: 'sha256:img1', Repository: 'node', Tag: '20', CreatedAt: '', Size: '400MB', Containers: '1' }], + Volumes: [{ Name: 'pgdata', Links: '0', Size: '10MB' }], + Containers: [], + BuildCache: [], + }) + __setExecForTests(async (_bin, args) => { + if (args[0] === 'version') return { stdout: '27.0.0', stderr: '' } + if (args[0] === 'system' && args[1] === 'df') return { stdout: df, stderr: '' } + if (args[0] === 'ps') return { stdout: 'ctr1\n', stderr: '' } + if (args[0] === 'container' && args[1] === 'inspect') throw new Error('container removed between ps and inspect') + if (args[0] === 'volume' && args[1] === 'ls') return { stdout: '', stderr: '' } + return { stdout: '', stderr: '' } + }) + const info = await getDockerInfo(true) + expect(info.available).toBe(true) + const image = info.items.find((i) => i.kind === 'image') + expect(image?.repository).toBeTruthy() + expect(info.projects).toEqual([]) + }) + + it('associates a volume to its project via `docker volume ls` label output (regression: replaces fragile `volume inspect `)', async () => { + const df = JSON.stringify({ + Images: [], + Volumes: [ + { Name: 'supabase_db_lets-get-a-job', Links: '0', Size: '10MB' }, + { Name: 'some_anon_volume', Links: '0', Size: '1MB' }, + ], + Containers: [], + BuildCache: [], + }) + __setExecForTests(async (_bin, args) => { + if (args[0] === 'version') return { stdout: '27.0.0', stderr: '' } + if (args[0] === 'system' && args[1] === 'df') return { stdout: df, stderr: '' } + if (args[0] === 'ps') return { stdout: '', stderr: '' } + if (args[0] === 'volume' && args[1] === 'ls') { + return { + stdout: 'supabase_db_lets-get-a-job::lets-get-a-job\nsome_anon_volume::\n', + stderr: '', + } + } + return { stdout: '', stderr: '' } + }) + const info = await getDockerInfo(true) + expect(info.available).toBe(true) + expect(info.items.find((i) => i.name === 'supabase_db_lets-get-a-job')?.project).toBe('lets-get-a-job') + expect(info.items.find((i) => i.name === 'some_anon_volume')?.project).toBeUndefined() + }) + + it('leaves volumes unassociated (fail-soft) when `docker volume ls` throws, without a single stale volume name killing the whole batch', async () => { + const df = JSON.stringify({ + Images: [{ ID: 'sha256:img1', Repository: 'node', Tag: '20', CreatedAt: '', Size: '400MB', Containers: '0' }], + Volumes: [{ Name: 'pgdata', Links: '0', Size: '10MB' }], + Containers: [], + BuildCache: [], + }) + __setExecForTests(async (_bin, args) => { + if (args[0] === 'version') return { stdout: '27.0.0', stderr: '' } + if (args[0] === 'system' && args[1] === 'df') return { stdout: df, stderr: '' } + if (args[0] === 'ps') return { stdout: '', stderr: '' } + if (args[0] === 'volume' && args[1] === 'ls') throw new Error('exit status 1') + return { stdout: '', stderr: '' } + }) + const info = await getDockerInfo(true) + expect(info.available).toBe(true) + const volume = info.items.find((i) => i.kind === 'volume') + expect(volume?.project).toBeUndefined() + // image association still ran β€” one command failing doesn't stop the rest. + const image = info.items.find((i) => i.kind === 'image') + expect(image?.repository).toBe('node') + }) +}) + +it('removeDockerItem maps kindβ†’command and reports freed bytes from df delta', async () => { + const calls: string[][] = [] + let phase = 0 + __setExecForTests(async (_bin, args) => { + calls.push(args) + if (args[0] === 'system' && args[1] === 'df' && !args.includes('-v')) { + // grand-total df prints ONE JSON object per line; shrink after removal. + return { stdout: JSON.stringify({ Type: 'Images', Size: phase++ === 0 ? '1GB' : '0B' }), stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const r = await removeDockerItem('image', 'sha256:bbb') + expect(r.ok).toBe(true) + expect(calls.some((a) => a[0] === 'rmi' && a[1] === 'sha256:bbb')).toBe(true) +}) + +it('pruneDocker maps each target to the right prune command', async () => { + const seen: string[] = [] + __setExecForTests(async (_bin, args) => { + if (args[0] === 'system' && args[1] === 'df') return { stdout: '[]', stderr: '' } + seen.push(args.join(' ')) + return { stdout: '', stderr: '' } + }) + await pruneDocker('buildCache') + await pruneDocker('unusedVolumes') + expect(seen).toContain('builder prune -f') + expect(seen).toContain('volume prune -f') +}) + +it('pruneDocker maps ALL FIVE targets to the exact args', async () => { + const seen: string[] = [] + __setExecForTests(async (_bin, args) => { + if (args[0] === 'system' && args[1] === 'df') return { stdout: '[]', stderr: '' } + seen.push(args.join(' ')) + return { stdout: '', stderr: '' } + }) + await pruneDocker('danglingImages') + await pruneDocker('unusedImages') + await pruneDocker('stoppedContainers') + await pruneDocker('buildCache') + await pruneDocker('unusedVolumes') + expect(seen).toEqual([ + 'image prune -f', + 'image prune -a -f', + 'container prune -f', + 'builder prune -f', + 'volume prune -f', + ]) +}) + +it('removeDockerItem("volume", ...) issues volume rm with no -f/--force', async () => { + const calls: string[][] = [] + __setExecForTests(async (_bin, args) => { + if (args[0] === 'system' && args[1] === 'df') return { stdout: '[]', stderr: '' } + calls.push(args) + return { stdout: '', stderr: '' } + }) + const r = await removeDockerItem('volume', 'pgdata') + expect(r.ok).toBe(true) + expect(calls).toContainEqual(['volume', 'rm', 'pgdata']) + for (const call of calls) { + expect(call).not.toContain('-f') + expect(call).not.toContain('--force') + } +}) + +it('removeDockerItem("container", ...) issues rm with no -f/--force', async () => { + const calls: string[][] = [] + __setExecForTests(async (_bin, args) => { + if (args[0] === 'system' && args[1] === 'df') return { stdout: '[]', stderr: '' } + calls.push(args) + return { stdout: '', stderr: '' } + }) + const r = await removeDockerItem('container', 'ctr222') + expect(r.ok).toBe(true) + expect(calls).toContainEqual(['rm', 'ctr222']) + for (const call of calls) { + expect(call).not.toContain('-f') + expect(call).not.toContain('--force') + } +}) + +it('removeDockerItem("buildcache", ...) refuses without ever invoking a remove command', async () => { + const removeVerbCalls: string[][] = [] + __setExecForTests(async (_bin, args) => { + if (args[0] === 'rmi' || args[0] === 'rm' || (args[0] === 'volume' && args[1] === 'rm')) { + removeVerbCalls.push(args) + } + return { stdout: '[]', stderr: '' } + }) + const r = await removeDockerItem('buildcache', 'x') + expect(r).toEqual({ ok: false, freedBytes: 0 }) + expect(removeVerbCalls).toHaveLength(0) +}) + +it('removeDockerItem returns {ok:false, freedBytes:0} when the remove command itself throws', async () => { + __setExecForTests(async (_bin, args) => { + if (args[0] === 'system' && args[1] === 'df') return { stdout: '[]', stderr: '' } + if (args[0] === 'rmi') throw new Error('image is being used') + return { stdout: '', stderr: '' } + }) + const r = await removeDockerItem('image', 'sha256:aaa') + expect(r).toEqual({ ok: false, freedBytes: 0 }) +}) + +it('pruneDocker returns {ok:false, freedBytes:0} when the prune command itself throws', async () => { + __setExecForTests(async (_bin, args) => { + if (args[0] === 'system' && args[1] === 'df') return { stdout: '[]', stderr: '' } + if (args[0] === 'image' && args[1] === 'prune') throw new Error('prune failed') + return { stdout: '', stderr: '' } + }) + const r = await pruneDocker('danglingImages') + expect(r).toEqual({ ok: false, freedBytes: 0 }) +}) + +it('removeDockerItem does NOT fabricate freedBytes from the pre-existing total when the AFTER df read fails (regression for Fix 1)', async () => { + let dfCalls = 0 + __setExecForTests(async (_bin, args) => { + if (args[0] === 'system' && args[1] === 'df' && !args.includes('-v')) { + dfCalls++ + if (dfCalls === 1) return { stdout: JSON.stringify({ Type: 'Images', Size: '1GB' }), stderr: '' } + throw new Error('daemon unreachable') + } + return { stdout: '', stderr: '' } + }) + const r = await removeDockerItem('image', 'sha256:bbb') + expect(r).toEqual({ ok: true, freedBytes: 0 }) +}) + +it('pruneDocker does NOT fabricate freedBytes from the pre-existing total when the AFTER df read fails (regression for Fix 1)', async () => { + let dfCalls = 0 + __setExecForTests(async (_bin, args) => { + if (args[0] === 'system' && args[1] === 'df' && !args.includes('-v')) { + dfCalls++ + if (dfCalls === 1) return { stdout: JSON.stringify({ Type: 'Images', Size: '2GB' }), stderr: '' } + throw new Error('daemon unreachable') + } + return { stdout: '', stderr: '' } + }) + const r = await pruneDocker('danglingImages') + expect(r).toEqual({ ok: true, freedBytes: 0 }) +}) diff --git a/src/main/docker/docker.ts b/src/main/docker/docker.ts new file mode 100644 index 0000000..d1f4c3b --- /dev/null +++ b/src/main/docker/docker.ts @@ -0,0 +1,252 @@ +import { execFile } from 'node:child_process' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { promisify } from 'node:util' +import type { + DockerActionResult, + DockerInfo, + DockerItemKind, + DockerProject, + DockerPruneTarget, +} from '@shared/docker.types' +import { app } from 'electron' +import { detectKind } from '../scanner/detect-kind' +import { findProjectIcon } from '../scanner/find-project-icon' +import { associateProjects } from './docker-associate' +import { parseContainerInspect, parseVolumeLabels } from './docker-inspect' +import { buildDockerItems, parseDf, parseSize } from './docker-parse' +import { dockerExecEnv, findDocker } from './find-docker' + +const CLI_TIMEOUT_MS = 60_000 +const PRUNE_TIMEOUT_MS = 10 * 60_000 +const MAX_BUFFER = 32 * 1024 * 1024 + +type Exec = ( + bin: string, + args: string[], + opts: { timeout: number; env: NodeJS.ProcessEnv; maxBuffer: number }, +) => Promise<{ stdout: string; stderr: string }> + +let execImpl: Exec = promisify(execFile) as unknown as Exec +/** Test seam: swap the exec used by all docker calls. */ +export function __setExecForTests(fn: Exec): void { + execImpl = fn +} + +export interface DockerOpts { + binaryPath?: string +} + +let cached: DockerInfo | null = null +let cachedKey = '' +let diskLoaded = false +let inFlight: Promise | null = null +let inFlightKey = '' + +const cacheFile = (): string => join(app.getPath('userData'), 'docker-cache.json') + +function loadDiskCache(): void { + if (diskLoaded) return + diskLoaded = true + try { + const raw = JSON.parse(readFileSync(cacheFile(), 'utf8')) as { key: string; info: DockerInfo } + if (raw?.info) { + // A cache written before project enrichment existed has no `projects`; + // normalize so the renderer never iterates an undefined. + cached = { ...raw.info, projects: Array.isArray(raw.info.projects) ? raw.info.projects : [] } + cachedKey = raw.key ?? '' + } + } catch { + // no cache yet + } +} + +function saveDiskCache(): void { + if (!cached) return + try { + const file = cacheFile() + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, JSON.stringify({ key: cachedKey, info: cached })) + } catch (err) { + console.error('Failed to persist docker cache', err) + } +} + +async function run(bin: string, args: string[], timeout = CLI_TIMEOUT_MS): Promise { + const env = (await dockerExecEnv()) as NodeJS.ProcessEnv + const { stdout } = await execImpl(bin, args, { timeout, env, maxBuffer: MAX_BUFFER }) + return stdout +} + +async function readDockerInfo(opts: DockerOpts): Promise { + const now = Date.now() + const bin = await findDocker(opts.binaryPath) + if (!bin) return { available: false, reason: 'not installed', checkedAt: now, totals: [], items: [], projects: [] } + try { + await run(bin, ['version', '--format', '{{.Server.Version}}']) + } catch { + return { available: false, reason: 'daemon not running', checkedAt: now, totals: [], items: [], projects: [] } + } + let df = '' + try { + df = await run(bin, ['system', 'df', '-v', '--format', '{{json .}}']) + } catch { + return { available: false, reason: 'daemon not running', checkedAt: now, totals: [], items: [], projects: [] } + } + const { items, totals } = buildDockerItems(parseDf(df)) + + // Enrich with project association. Each Docker call below is fail-soft on its own: + // one inspect failing (e.g. a container removed between `ps` and `inspect`) must not + // stop `associateProjects` from running, since it also derives each image's + // `repository` (used for repository grouping) independent of any container/volume data. + let ids: string[] = [] + try { + ids = (await run(bin, ['ps', '-aq', '--no-trunc'])) + .split('\n') + .map((s) => s.trim()) + .filter(Boolean) + } catch (err) { + ids = [] + console.error('docker: ps failed', err) + } + + let containers: ReturnType = [] + if (ids.length) { + try { + containers = parseContainerInspect(await run(bin, ['container', 'inspect', ...ids])) + } catch (err) { + containers = [] + console.error('docker: container inspect failed', err) + } + } + + // `volume ls` (not `volume inspect `): a single call over all existing volumes, + // so a stale/anonymous volume name from `system df -v` can never 404 and kill the batch. + let volProjects = new Map() + try { + volProjects = parseVolumeLabels( + await run(bin, ['volume', 'ls', '--format', '{{.Name}}::{{.Label "com.docker.compose.project"}}']), + ) + } catch (err) { + volProjects = new Map() + console.error('docker: volume ls failed', err) + } + + const assoc = associateProjects(items, containers, volProjects) + let projects: DockerProject[] = assoc.projects + try { + projects = await withLogos(assoc.projects) + } catch (err) { + projects = assoc.projects + console.error('docker: withLogos failed', err) + } + return { available: true, checkedAt: now, totals, items: assoc.items, projects } +} + +/** Attach a project logo (framework kind + favicon) from each project's working_dir. + * Fail-soft per project: a missing/unreadable dir leaves kind/iconDataUrl undefined, + * and the renderer shows a generic Docker icon. */ +async function withLogos(projects: DockerProject[]): Promise { + return Promise.all( + projects.map(async (p) => { + if (!p.workingDir) return p + try { + const [kind, iconDataUrl] = await Promise.all([detectKind(p.workingDir), findProjectIcon(p.workingDir)]) + return { ...p, kind, iconDataUrl } + } catch { + return p + } + }), + ) +} + +export async function getDockerInfo(force = false, opts: DockerOpts = {}): Promise { + loadDiskCache() + const key = opts.binaryPath ?? '' + if (!force && cached && key === cachedKey) return cached + if (inFlight && inFlightKey === key) return inFlight + const pending = (async (): Promise => { + const info = await readDockerInfo(opts) + cached = info + cachedKey = key + saveDiskCache() + return info + })() + inFlight = pending + inFlightKey = key + void pending.finally(() => { + if (inFlight === pending) inFlight = null + }) + return pending +} + +/** Grand-total docker disk usage in bytes (sum of `docker system df` rows), or null if it could not be measured. */ +async function totalDiskBytes(bin: string): Promise { + try { + const out = await run(bin, ['system', 'df', '--format', '{{json .}}']) + // df grand total prints one JSON object per row (Type, Size, Reclaimable, ...) + return out + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .reduce((sum, line) => { + try { + return sum + parseSize((JSON.parse(line) as { Size: string }).Size) + } catch { + return sum + } + }, 0) + } catch { + return null + } +} + +const REMOVE_ARGS: Record, (id: string) => string[]> = { + image: (id) => ['rmi', id], + volume: (id) => ['volume', 'rm', id], + container: (id) => ['rm', id], +} + +export async function removeDockerItem( + kind: DockerItemKind, + id: string, + opts: DockerOpts = {}, +): Promise { + const bin = await findDocker(opts.binaryPath) + if (!bin || kind === 'buildcache') return { ok: false, freedBytes: 0 } + const before = await totalDiskBytes(bin) + try { + // in-use guard lives in the UI/validation; the CLI itself also refuses an + // in-use image/volume without -f (which we never pass), so this is safe. + await run(bin, REMOVE_ARGS[kind](id)) + } catch { + return { ok: false, freedBytes: 0 } + } + const after = await totalDiskBytes(bin) + const freedBytes = before !== null && after !== null ? Math.max(0, before - after) : 0 + return { ok: true, freedBytes } +} + +const PRUNE_ARGS: Record = { + danglingImages: ['image', 'prune', '-f'], + unusedImages: ['image', 'prune', '-a', '-f'], + stoppedContainers: ['container', 'prune', '-f'], + buildCache: ['builder', 'prune', '-f'], + unusedVolumes: ['volume', 'prune', '-f'], +} + +export async function pruneDocker(target: DockerPruneTarget, opts: DockerOpts = {}): Promise { + const bin = await findDocker(opts.binaryPath) + if (!bin) return { ok: false, freedBytes: 0 } + const before = await totalDiskBytes(bin) + try { + await run(bin, PRUNE_ARGS[target], PRUNE_TIMEOUT_MS) + } catch { + return { ok: false, freedBytes: 0 } + } + const after = await totalDiskBytes(bin) + const freedBytes = before !== null && after !== null ? Math.max(0, before - after) : 0 + return { ok: true, freedBytes } +} + +export { CLI_TIMEOUT_MS, PRUNE_TIMEOUT_MS, run } diff --git a/src/main/docker/find-docker.test.ts b/src/main/docker/find-docker.test.ts new file mode 100644 index 0000000..e7c102e --- /dev/null +++ b/src/main/docker/find-docker.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { dockerCandidates } from './find-docker' + +describe('dockerCandidates', () => { + it('puts an explicit override first and includes Docker Desktop + homebrew paths', () => { + const c = dockerCandidates({ PATH: '/usr/bin' }, '/Users/x', [], '/opt/homebrew/bin/docker') + expect(c[0]).toBe('/opt/homebrew/bin/docker') + expect(c).toContain('/usr/local/bin/docker') + expect(c).toContain('/Applications/Docker.app/Contents/Resources/bin/docker') + }) + + it('dedupes and works with no override', () => { + const c = dockerCandidates({ PATH: '/usr/local/bin' }, '/Users/x', []) + expect(new Set(c).size).toBe(c.length) + expect(c).toContain('/usr/local/bin/docker') + }) +}) diff --git a/src/main/docker/find-docker.ts b/src/main/docker/find-docker.ts new file mode 100644 index 0000000..1a7a179 --- /dev/null +++ b/src/main/docker/find-docker.ts @@ -0,0 +1,50 @@ +import { constants } from 'node:fs' +import { access } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { pnpmExecEnv } from '../pnpm-store/find-node' +import { nvmNodeBins, versionManagerBinDirs } from '../pnpm-store/runtime-bins' + +/** Finder-launched apps get a minimal PATH; reuse the pnpm spawn env (real PATH). */ +export const dockerExecEnv = pnpmExecEnv + +/** + * Candidate docker binary locations: an explicit override first, then every + * runtime bin dir, then the standard Docker Desktop / Homebrew / /usr/local + * install paths. Deduped, order-preserving. + */ +export function dockerCandidates( + env: NodeJS.ProcessEnv, + home: string, + nvmBins: string[] = [], + overrideBin?: string, +): string[] { + const fromBinDirs = versionManagerBinDirs(env, home, nvmBins).map((dir) => join(dir, 'docker')) + const standard = [ + '/usr/local/bin/docker', + '/opt/homebrew/bin/docker', + '/Applications/Docker.app/Contents/Resources/bin/docker', + ] + const all = overrideBin ? [overrideBin, ...fromBinDirs, ...standard] : [...fromBinDirs, ...standard] + return [...new Set(all)] +} + +let resolved: string | null | undefined + +/** Resolves the docker binary; null when not installed. Override bypasses the cache. */ +export async function findDocker(overrideBin?: string): Promise { + if (overrideBin === undefined && resolved !== undefined) return resolved + const home = homedir() + const candidates = dockerCandidates(process.env, home, await nvmNodeBins(home), overrideBin || undefined) + for (const candidate of candidates) { + try { + await access(candidate, constants.X_OK) + if (overrideBin === undefined) resolved = candidate + return candidate + } catch { + // keep looking + } + } + if (overrideBin === undefined) resolved = null + return null +} diff --git a/src/main/docker/validate-docker-arg.test.ts b/src/main/docker/validate-docker-arg.test.ts new file mode 100644 index 0000000..05ba5c2 --- /dev/null +++ b/src/main/docker/validate-docker-arg.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { coercePruneTarget, coerceRemoveArgs } from './validate-docker-arg' + +describe('docker arg validation', () => { + it('accepts valid remove kind/id and rejects junk', () => { + expect(coerceRemoveArgs('image', 'sha256:aaa')).toEqual({ kind: 'image', id: 'sha256:aaa' }) + expect(coerceRemoveArgs('buildcache', 'x')).toBeNull() // no per-item build-cache removal + expect(coerceRemoveArgs('image', '')).toBeNull() + expect(coerceRemoveArgs('bogus', 'x')).toBeNull() + expect(coerceRemoveArgs('image', 42)).toBeNull() + }) + it('accepts only known prune targets', () => { + expect(coercePruneTarget('buildCache')).toBe('buildCache') + expect(coercePruneTarget('rm -rf /')).toBeNull() + }) +}) diff --git a/src/main/docker/validate-docker-arg.ts b/src/main/docker/validate-docker-arg.ts new file mode 100644 index 0000000..54e820a --- /dev/null +++ b/src/main/docker/validate-docker-arg.ts @@ -0,0 +1,27 @@ +import type { DockerItemKind, DockerPruneTarget } from '@shared/docker.types' + +const REMOVABLE_KINDS: DockerItemKind[] = ['image', 'volume', 'container'] +const PRUNE_TARGETS: DockerPruneTarget[] = [ + 'danglingImages', + 'unusedImages', + 'stoppedContainers', + 'buildCache', + 'unusedVolumes', +] + +/** + * Validates an untrusted (kind, id) pair coming over IPC from the renderer. + * `buildcache` is deliberately excluded β€” there is no per-item build-cache removal. + */ +export function coerceRemoveArgs(kind: unknown, id: unknown): { kind: DockerItemKind; id: string } | null { + if (typeof kind !== 'string' || !(REMOVABLE_KINDS as string[]).includes(kind)) return null + if (typeof id !== 'string' || id.trim() === '') return null + return { kind: kind as DockerItemKind, id } +} + +/** Validates an untrusted prune target coming over IPC from the renderer. */ +export function coercePruneTarget(target: unknown): DockerPruneTarget | null { + return typeof target === 'string' && (PRUNE_TARGETS as string[]).includes(target) + ? (target as DockerPruneTarget) + : null +} diff --git a/src/main/ipc/register-ipc.test.ts b/src/main/ipc/register-ipc.test.ts index 48cb5fe..25d83b3 100644 --- a/src/main/ipc/register-ipc.test.ts +++ b/src/main/ipc/register-ipc.test.ts @@ -40,6 +40,13 @@ const detectLiveProjects = vi.fn(async (_dirs: string[]) => new Map()) vi.mock('../liveness/liveness', () => ({ detectLiveProjects: (dirs: string[]) => detectLiveProjects(dirs), })) +const removeDockerItem = vi.fn(async (_kind: unknown, _id: unknown) => ({ ok: true, freedBytes: 2048 })) +const pruneDocker = vi.fn(async (_target: unknown) => ({ ok: true, freedBytes: 4096 })) +vi.mock('../docker/docker', () => ({ + getDockerInfo: vi.fn(), + removeDockerItem: (kind: unknown, id: unknown) => removeDockerItem(kind, id), + pruneDocker: (target: unknown) => pruneDocker(target), +})) vi.mock('../actions/app-actions', () => ({ uninstallApp: vi.fn() })) vi.mock('../actions/pick-path', () => ({ pickPath: vi.fn() })) const copyCardToClipboard = vi.fn(async (_p: unknown) => true) @@ -72,6 +79,8 @@ function makeCtx(pro: boolean, extraProjects: Project[] = []) { deleteNodeModules.mockClear() guardExists.mockClear() prunePnpmStore.mockClear() + removeDockerItem.mockClear() + pruneDocker.mockClear() copyCardToClipboard.mockClear() detectLiveProjects.mockClear() detectLiveProjects.mockImplementation(async () => new Map()) @@ -208,4 +217,34 @@ describe('license enforcement in IPC handlers', () => { expect(res).toEqual({ ok: true }) expect(analytics.capture).toHaveBeenCalledWith('share_card_copied', { total_gb: 247.3, source: 'header' }) }) + + it('unlicensed docker:remove refuses without invoking the docker CLI', async () => { + const { analytics } = makeCtx(false) + expect(await invoke(IPC.removeDockerItem, 'image', 'abc123')).toEqual({ ok: false, freedBytes: 0 }) + expect(removeDockerItem).not.toHaveBeenCalled() + expect(analytics.capture).not.toHaveBeenCalledWith('clean_performed', expect.anything()) + }) + + it('unlicensed docker:prune refuses without invoking the docker CLI', async () => { + const { analytics } = makeCtx(false) + expect(await invoke(IPC.pruneDocker, 'danglingImages')).toEqual({ ok: false, freedBytes: 0 }) + expect(pruneDocker).not.toHaveBeenCalled() + expect(analytics.capture).not.toHaveBeenCalledWith('clean_performed', expect.anything()) + }) + + it('licensed docker:remove rejects a malformed payload before calling the docker CLI', async () => { + makeCtx(true) + expect(await invoke(IPC.removeDockerItem, 'buildcache', 'abc123')).toEqual({ ok: false, freedBytes: 0 }) + expect(await invoke(IPC.removeDockerItem, 'image', '')).toEqual({ ok: false, freedBytes: 0 }) + expect(await invoke(IPC.removeDockerItem, 'image', 42)).toEqual({ ok: false, freedBytes: 0 }) + expect(removeDockerItem).not.toHaveBeenCalled() + }) + + it('licensed docker:remove with a valid payload calls the docker CLI and captures clean_performed', async () => { + const { analytics } = makeCtx(true) + const res = await invoke(IPC.removeDockerItem, 'image', 'abc123') + expect(removeDockerItem).toHaveBeenCalledWith('image', 'abc123') + expect(res).toEqual({ ok: true, freedBytes: 2048 }) + expect(analytics.capture).toHaveBeenCalledWith('clean_performed', { kind: 'docker_image', freed_gb: 0 }) + }) }) diff --git a/src/main/ipc/register-ipc.ts b/src/main/ipc/register-ipc.ts index d08651c..c32cf17 100644 --- a/src/main/ipc/register-ipc.ts +++ b/src/main/ipc/register-ipc.ts @@ -12,6 +12,8 @@ import { deleteNodeModules, guardExists, openProject, revealInFinder } from '../ import type { AnalyticsEvent, AnalyticsProps } from '../analytics' import { RENDERER_EVENTS } from '../analytics' import type { AppContext } from '../app-context.types' +import { getDockerInfo, pruneDocker, removeDockerItem } from '../docker/docker' +import { coercePruneTarget, coerceRemoveArgs } from '../docker/validate-docker-arg' import { liveGuard } from '../liveness/guard-live' import { detectLiveProjects } from '../liveness/liveness' import { getPnpmStoreInfo, prunePnpmStore } from '../pnpm-store/pnpm-store' @@ -65,6 +67,40 @@ export function registerIpc(ctx: AppContext): void { const s = ctx.settings.get() return getPnpmStoreInfo(force, { storePath: s.pnpmStorePath, binaryPath: s.pnpmBinaryPath }) }) + ipcMain.handle(IPC.getDocker, (_e, force?: boolean) => { + const s = ctx.settings.get() + return getDockerInfo(force, { binaryPath: s.dockerBinaryPath }) + }) + ipcMain.handle(IPC.removeDockerItem, async (_e, kind: unknown, id: unknown) => { + // Free tier sees everything but mutates nothing β€” cleanup is the paid unlock. + if (!ctx.license.get().pro) return { ok: false, freedBytes: 0 } + const args = coerceRemoveArgs(kind, id) + if (!args) return { ok: false, freedBytes: 0 } + const s = ctx.settings.get() + const result = await removeDockerItem(args.kind, args.id, { binaryPath: s.dockerBinaryPath }) + if (result.ok) { + ctx.analytics.capture('clean_performed', { + kind: `docker_${args.kind}`, + freed_gb: Math.round((result.freedBytes / GB) * 10) / 10, + }) + } + return result + }) + ipcMain.handle(IPC.pruneDocker, async (_e, target: unknown) => { + // Free tier sees everything but mutates nothing β€” cleanup is the paid unlock. + if (!ctx.license.get().pro) return { ok: false, freedBytes: 0 } + const t = coercePruneTarget(target) + if (!t) return { ok: false, freedBytes: 0 } + const s = ctx.settings.get() + const result = await pruneDocker(t, { binaryPath: s.dockerBinaryPath }) + if (result.ok) { + ctx.analytics.capture('clean_performed', { + kind: 'docker_prune', + freed_gb: Math.round((result.freedBytes / GB) * 10) / 10, + }) + } + return result + }) ipcMain.handle(IPC.prunePnpmStore, async () => { // Free tier sees everything but mutates nothing β€” cleanup is the paid unlock. if (!ctx.license.get().pro) return { ok: false, freedBytes: 0 } diff --git a/src/main/settings/validate-setting.test.ts b/src/main/settings/validate-setting.test.ts index de1a5cc..33fd3c2 100644 --- a/src/main/settings/validate-setting.test.ts +++ b/src/main/settings/validate-setting.test.ts @@ -29,6 +29,18 @@ describe('coerceSetting', () => { expect(coerceSetting('thresholdGB', '5')).toBeNull() }) + it('clamps cacheThresholdGB and dockerThresholdGB and rejects bad values', () => { + expect(coerceSetting('cacheThresholdGB', 10)).toEqual({ key: 'cacheThresholdGB', value: 10 }) + expect(coerceSetting('cacheThresholdGB', 99999)).toEqual({ key: 'cacheThresholdGB', value: 1000 }) + expect(coerceSetting('cacheThresholdGB', -3)).toEqual({ key: 'cacheThresholdGB', value: 0.1 }) + expect(coerceSetting('cacheThresholdGB', Number.NaN)).toBeNull() + expect(coerceSetting('cacheThresholdGB', '10')).toBeNull() + + expect(coerceSetting('dockerThresholdGB', 20)).toEqual({ key: 'dockerThresholdGB', value: 20 }) + expect(coerceSetting('dockerThresholdGB', 99999)).toEqual({ key: 'dockerThresholdGB', value: 1000 }) + expect(coerceSetting('dockerThresholdGB', 0)).toEqual({ key: 'dockerThresholdGB', value: 0.1 }) + }) + it('validates notify as boolean', () => { expect(coerceSetting('notify', false)).toEqual({ key: 'notify', value: false }) expect(coerceSetting('notify', 'true')).toBeNull() @@ -69,6 +81,16 @@ describe('coerceSetting β€” pnpm overrides', () => { it('rejects a non-string override', () => { expect(coerceSetting('pnpmBinaryPath', 42)).toBeNull() }) + + it('validates the docker settings keys', () => { + expect(coerceSetting('docker', true)).toEqual({ key: 'docker', value: true }) + expect(coerceSetting('docker', 'yes')).toBeNull() + expect(coerceSetting('dockerBinaryPath', ' /usr/local/bin/docker ')).toEqual({ + key: 'dockerBinaryPath', + value: '/usr/local/bin/docker', + }) + expect(coerceSetting('dockerBinaryPath', 42)).toBeNull() + }) }) describe('scanRoots', () => { diff --git a/src/main/settings/validate-setting.ts b/src/main/settings/validate-setting.ts index e49b5ea..36e8372 100644 --- a/src/main/settings/validate-setting.ts +++ b/src/main/settings/validate-setting.ts @@ -38,13 +38,17 @@ export function coerceSetting( case 'onboarded': return typeof value === 'boolean' ? { key, value } : null case 'checkUpdates': + case 'docker': return typeof value === 'boolean' ? { key, value } : null case 'thresholdGB': + case 'cacheThresholdGB': + case 'dockerThresholdGB': return typeof value === 'number' && Number.isFinite(value) ? { key, value: Math.min(MAX_THRESHOLD_GB, Math.max(MIN_THRESHOLD_GB, value)) } : null case 'pnpmStorePath': case 'pnpmBinaryPath': + case 'dockerBinaryPath': return typeof value === 'string' ? { key, value: value.trim() } : null case 'scanRoots': return Array.isArray(value) && value.every((v) => typeof v === 'string' && isAbsolute(v)) diff --git a/src/preload/api.types.ts b/src/preload/api.types.ts index 248ac56..aa2adb0 100644 --- a/src/preload/api.types.ts +++ b/src/preload/api.types.ts @@ -1,4 +1,5 @@ import type { DeleteManyResult, DeleteResult } from '@shared/delete.types' +import type { DockerActionResult, DockerInfo, DockerItemKind, DockerPruneTarget } from '@shared/docker.types' import type { LauncherNavTarget } from '@shared/launcher-nav.types' import type { ActivateResult, LicenseState } from '@shared/license.types' import type { LiveInfo } from '@shared/liveness.types' @@ -14,6 +15,11 @@ export interface CleanApi { getLastScanTime(): Promise getPnpmStore(force?: boolean): Promise prunePnpmStore(): Promise + getDocker(force?: boolean): Promise + /** Removes a single image/volume/container. Gated: no-op (freedBytes 0) unless the license is pro. */ + removeDockerItem(kind: DockerItemKind, id: string): Promise + /** Prunes a whole docker category. Gated: no-op (freedBytes 0) unless the license is pro. */ + pruneDocker(target: DockerPruneTarget): Promise /** Cached package inventory, or null if never computed. */ getPackages(): Promise /** Compute (or, with force, recompute) the package inventory. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 489bf6e..2deeeee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,6 +14,9 @@ const api: CleanApi = { getProjects: () => ipcRenderer.invoke(IPC.getProjects), getLastScanTime: () => ipcRenderer.invoke(IPC.getLastScanTime), getPnpmStore: (force) => ipcRenderer.invoke(IPC.getPnpmStore, force), + getDocker: (force) => ipcRenderer.invoke(IPC.getDocker, force), + removeDockerItem: (kind, id) => ipcRenderer.invoke(IPC.removeDockerItem, kind, id), + pruneDocker: (target) => ipcRenderer.invoke(IPC.pruneDocker, target), prunePnpmStore: () => ipcRenderer.invoke(IPC.prunePnpmStore), getPackages: () => ipcRenderer.invoke(IPC.getPackages), computePackages: (force) => ipcRenderer.invoke(IPC.computePackages, force), diff --git a/src/renderer/src/components/Accordion/Accordion.constants.ts b/src/renderer/src/components/Accordion/Accordion.constants.ts new file mode 100644 index 0000000..8f6cbaa --- /dev/null +++ b/src/renderer/src/components/Accordion/Accordion.constants.ts @@ -0,0 +1,12 @@ +import type { CSSProperties } from 'react' + +/** When open with `card`, the header + body share one solid rounded surface (an accordion, + * not an appended box). Must match the Packages tab's previous inline wrapper exactly. */ +export const ACCORDION_OPEN_CARD: CSSProperties = { + position: 'relative', + zIndex: 1, + background: 'var(--surface-2)', + borderRadius: 10, + boxShadow: 'inset 0 0 0 1px var(--hairline)', + overflow: 'hidden', +} diff --git a/src/renderer/src/components/Accordion/Accordion.tsx b/src/renderer/src/components/Accordion/Accordion.tsx new file mode 100644 index 0000000..3ccba81 --- /dev/null +++ b/src/renderer/src/components/Accordion/Accordion.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react' +import { ACCORDION_OPEN_CARD } from './Accordion.constants' +import type { AccordionProps } from './Accordion.types' + +/** A header row with a body that shows only when `open`. With `card`, the open block becomes + * one solid rounded surface (Packages); without it, a plain static wrapper (Docker), so + * nested rows keep the outer list's positioned ancestor for the sliding highlight. */ +export function Accordion({ open, header, children, card = false }: AccordionProps): ReactNode { + return ( +
+ {header} + {open ? children : null} +
+ ) +} diff --git a/src/renderer/src/components/Accordion/Accordion.types.ts b/src/renderer/src/components/Accordion/Accordion.types.ts new file mode 100644 index 0000000..55b8494 --- /dev/null +++ b/src/renderer/src/components/Accordion/Accordion.types.ts @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' + +export interface AccordionProps { + open: boolean + /** The always-visible clickable header (the caller owns the click handler and chevron). */ + header: ReactNode + /** The body, rendered only when open. */ + children?: ReactNode + /** When true, the open block becomes one solid rounded card (header + body share a + * surface). Packages uses this; Docker omits it so its rows stay flat for the sliding + * highlight. */ + card?: boolean +} diff --git a/src/renderer/src/components/Accordion/index.ts b/src/renderer/src/components/Accordion/index.ts new file mode 100644 index 0000000..9844210 --- /dev/null +++ b/src/renderer/src/components/Accordion/index.ts @@ -0,0 +1,3 @@ +export { Accordion } from './Accordion' +export { ACCORDION_OPEN_CARD } from './Accordion.constants' +export type { AccordionProps } from './Accordion.types' diff --git a/src/renderer/src/components/CacheRow/CacheRow.tsx b/src/renderer/src/components/CacheRow/CacheRow.tsx index 0c188e2..2fdbbbc 100644 --- a/src/renderer/src/components/CacheRow/CacheRow.tsx +++ b/src/renderer/src/components/CacheRow/CacheRow.tsx @@ -6,12 +6,17 @@ import type { CacheRowProps } from './CacheRow.types' export function CacheRow({ icon, name, + badge, detail, size, selected = false, disabled = false, busy = false, actionLabel, + title, + busyLabel, + actionIcon, + danger = false, onAction, onSelect, }: CacheRowProps): ReactNode { @@ -46,7 +51,21 @@ export function CacheRow({ {icon({ size: 15 })}
-
{name}
+
+ + {name} + + {badge} +
- {busy ? 'Pruning…' : actionLabel} + {actionIcon?.({ size: 12 })} + {busy ? (busyLabel ?? 'Pruning…') : actionLabel} ) : disabled ? ( void /** Fired when the row itself is clicked (selects it). */ diff --git a/src/renderer/src/components/PixelMeter/PixelMeter.tsx b/src/renderer/src/components/PixelMeter/PixelMeter.tsx index 3e4d4ac..c42b442 100644 --- a/src/renderer/src/components/PixelMeter/PixelMeter.tsx +++ b/src/renderer/src/components/PixelMeter/PixelMeter.tsx @@ -8,7 +8,7 @@ export function PixelMeter({ usedGB, thresholdGB, trackMaxGB, accent, cells = 32 const limitCellIdx = Math.min(cells - 1, Math.max(0, Math.floor(limitPos * cells))) return ( -
+
{Array.from({ length: cells }).map((_, i) => { const p = ((i + 0.5) / cells) * trackMaxGB diff --git a/src/renderer/src/components/SeverityMeter/SeverityMeter.constants.test.ts b/src/renderer/src/components/SeverityMeter/SeverityMeter.constants.test.ts new file mode 100644 index 0000000..8ee49c4 --- /dev/null +++ b/src/renderer/src/components/SeverityMeter/SeverityMeter.constants.test.ts @@ -0,0 +1,39 @@ +import type { SeverityCounts } from '@renderer/lib/severity' +import { describe, expect, it } from 'vitest' +import { severityMeterTooltip, severitySegments } from './SeverityMeter.constants' + +const counts = (o: Partial): SeverityCounts => ({ + critical: 0, + high: 0, + moderate: 0, + low: 0, + vulnerable: 0, + outdated: 0, + ...o, +}) + +describe('severitySegments', () => { + it('returns severity-ordered segments with fractions of the vulnerable total', () => { + const segs = severitySegments(counts({ critical: 1, high: 3, vulnerable: 4 })) + expect(segs.map((s) => s.key)).toEqual(['critical', 'high']) + expect(segs[0].frac).toBeCloseTo(0.25) + expect(segs[1].frac).toBeCloseTo(0.75) + }) + + it('drops zero-count severities', () => { + const segs = severitySegments(counts({ high: 2, low: 1, vulnerable: 3 })) + expect(segs.map((s) => s.key)).toEqual(['high', 'low']) + }) + + it('returns an empty array when nothing is vulnerable', () => { + expect(severitySegments(counts({ outdated: 5 }))).toEqual([]) + }) +}) + +describe('severityMeterTooltip', () => { + it('lists every severity plus outdated and the total', () => { + expect( + severityMeterTooltip(counts({ critical: 2, high: 5, moderate: 3, low: 1, vulnerable: 11, outdated: 28 }), 142), + ).toBe('2 critical Β· 5 high Β· 3 moderate Β· 1 low Β· 28 outdated of 142 packages') + }) +}) diff --git a/src/renderer/src/components/SeverityMeter/SeverityMeter.constants.ts b/src/renderer/src/components/SeverityMeter/SeverityMeter.constants.ts new file mode 100644 index 0000000..e29894e --- /dev/null +++ b/src/renderer/src/components/SeverityMeter/SeverityMeter.constants.ts @@ -0,0 +1,29 @@ +import type { SeverityCounts, SeverityKey } from '@renderer/lib/severity' +import type { SeveritySegment } from './SeverityMeter.types' + +/** Worst-first, so the bar reads critical β†’ low left to right. */ +export const SEVERITY_ORDER: SeverityKey[] = ['critical', 'high', 'moderate', 'low'] + +export const SEVERITY_COLORS: Record = { + critical: '#ff453a', + high: '#ff9f0a', + moderate: '#ffd60a', + low: '#5e9eff', +} + +/** Non-empty severity buckets, in severity order, each with its share of the + * vulnerable total. Empty when nothing is vulnerable. */ +export function severitySegments(counts: SeverityCounts): SeveritySegment[] { + if (counts.vulnerable <= 0) return [] + return SEVERITY_ORDER.filter((k) => counts[k] > 0).map((k) => ({ + key: k, + count: counts[k], + color: SEVERITY_COLORS[k], + frac: counts[k] / counts.vulnerable, + })) +} + +/** Full breakdown for the header tooltip (the 132px slot has no room for a legend). */ +export function severityMeterTooltip(counts: SeverityCounts, total: number): string { + return `${counts.critical} critical Β· ${counts.high} high Β· ${counts.moderate} moderate Β· ${counts.low} low Β· ${counts.outdated} outdated of ${total} packages` +} diff --git a/src/renderer/src/components/SeverityMeter/SeverityMeter.tsx b/src/renderer/src/components/SeverityMeter/SeverityMeter.tsx new file mode 100644 index 0000000..102a2cc --- /dev/null +++ b/src/renderer/src/components/SeverityMeter/SeverityMeter.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react' +import { SEVERITY_COLORS, severityMeterTooltip, severitySegments } from './SeverityMeter.constants' +import type { SeverityMeterProps } from './SeverityMeter.types' + +/** Compact packages headline for the ~132px header slot: a stacked severity bar + * (critical β†’ low) with a vulnerable count and a trailing outdated count. The + * full breakdown lives in the tooltip. */ +export function SeverityMeter({ counts, total, computing = false }: SeverityMeterProps): ReactNode { + const segments = severitySegments(counts) + const tip = severityMeterTooltip(counts, total) + const clear = counts.vulnerable === 0 + + return ( +
+
+ {clear ? 'all clear' : `${counts.vulnerable} vuln`} +
+
+ {clear ? ( +
+ ) : ( + segments.map((s) => ( +
+ )) + )} + {computing && ( + + {counts.outdated > 0 && ( +
{counts.outdated} old
+ )} +
+ ) +} diff --git a/src/renderer/src/components/SeverityMeter/SeverityMeter.types.ts b/src/renderer/src/components/SeverityMeter/SeverityMeter.types.ts new file mode 100644 index 0000000..fced78c --- /dev/null +++ b/src/renderer/src/components/SeverityMeter/SeverityMeter.types.ts @@ -0,0 +1,17 @@ +import type { SeverityCounts, SeverityKey } from '@renderer/lib/severity' + +export interface SeveritySegment { + key: SeverityKey + count: number + color: string + /** Share of the vulnerable total (0–1). */ + frac: number +} + +export interface SeverityMeterProps { + counts: SeverityCounts + /** Total package count, for the tooltip. */ + total: number + /** Inventory still computing, show the ghost shimmer. */ + computing?: boolean +} diff --git a/src/renderer/src/components/SeverityMeter/index.ts b/src/renderer/src/components/SeverityMeter/index.ts new file mode 100644 index 0000000..db1f5d0 --- /dev/null +++ b/src/renderer/src/components/SeverityMeter/index.ts @@ -0,0 +1,2 @@ +export { SeverityMeter } from './SeverityMeter' +export type { SeverityMeterProps } from './SeverityMeter.types' diff --git a/src/renderer/src/components/TabHeadline/TabHeadline.tsx b/src/renderer/src/components/TabHeadline/TabHeadline.tsx new file mode 100644 index 0000000..b9be2c6 --- /dev/null +++ b/src/renderer/src/components/TabHeadline/TabHeadline.tsx @@ -0,0 +1,45 @@ +import { Gauge } from '@renderer/components/Gauge' +import { SeverityMeter } from '@renderer/components/SeverityMeter' +import { GB } from '@renderer/lib/format' +import type { ReactNode } from 'react' +import type { TabHeadlineProps } from './TabHeadline.types' + +/** The header data-viz slot, routed by active tab: a size-vs-limit Gauge for + * node_modules / caches / docker, a SeverityMeter for packages. Renders null + * when a size tab has no data to show (no pnpm store / docker unavailable). */ +export function TabHeadline(props: TabHeadlineProps): ReactNode { + const { tab, accent } = props + if (tab === 'packages') { + if (!props.packagesCheckEnabled) return null + if (props.packagesComputing) { + return + } + if (!props.packagesDataReady) return null + return + } + if (tab === 'caches') { + if (!props.cachesAvailable) return null + return ( + + ) + } + if (tab === 'docker') { + if (!props.dockerAvailable) return null + return + } + // projects + return ( + + ) +} diff --git a/src/renderer/src/components/TabHeadline/TabHeadline.types.ts b/src/renderer/src/components/TabHeadline/TabHeadline.types.ts new file mode 100644 index 0000000..1ed14bd --- /dev/null +++ b/src/renderer/src/components/TabHeadline/TabHeadline.types.ts @@ -0,0 +1,27 @@ +import type { LauncherTab } from '@renderer/launcher/LauncherApp/LauncherApp.types' +import type { SeverityCounts } from '@renderer/lib/severity' + +export interface TabHeadlineProps { + tab: LauncherTab + accent: string + // projects (node_modules) + projectsUsed: number + linkedBytes: number + projectsCalculating: boolean + thresholdGB: number + // caches (pnpm store) + cachesUsed: number + cachesAvailable: boolean + cachesCalculating: boolean + cacheThresholdGB: number + // docker + dockerUsed: number + dockerAvailable: boolean + dockerThresholdGB: number + // packages + severity: SeverityCounts + packagesTotal: number + packagesCheckEnabled: boolean + packagesComputing: boolean + packagesDataReady: boolean +} diff --git a/src/renderer/src/components/TabHeadline/index.ts b/src/renderer/src/components/TabHeadline/index.ts new file mode 100644 index 0000000..76e74e8 --- /dev/null +++ b/src/renderer/src/components/TabHeadline/index.ts @@ -0,0 +1,2 @@ +export { TabHeadline } from './TabHeadline' +export type { TabHeadlineProps } from './TabHeadline.types' diff --git a/src/renderer/src/components/TypeBadge/TypeBadge.tsx b/src/renderer/src/components/TypeBadge/TypeBadge.tsx new file mode 100644 index 0000000..7d5992f --- /dev/null +++ b/src/renderer/src/components/TypeBadge/TypeBadge.tsx @@ -0,0 +1,40 @@ +import type { DockerItemKind } from '@shared/docker.types' +import type { ReactNode } from 'react' + +const LABEL: Record = { + image: 'IMAGE', + volume: 'VOLUME', + container: 'CONTAINER', + buildcache: 'CACHE', +} + +/** Per-kind color, tuned for eye-scannability at a glance and legible on dark surfaces: + * image = blue, volume = violet, container = green, buildcache = amber. */ +const COLOR: Record = { + image: { bg: 'rgba(90,140,255,0.16)', fg: '#7ea6ff' }, + volume: { bg: 'rgba(168,120,255,0.16)', fg: '#b492ff' }, + container: { bg: 'rgba(80,190,120,0.16)', fg: '#6ddf9c' }, + buildcache: { bg: 'rgba(230,170,70,0.16)', fg: '#e6b45a' }, +} + +/** Tiny uppercase chip identifying a Docker item's kind (image/volume/container/build cache). */ +export function TypeBadge({ kind }: { kind: DockerItemKind }): ReactNode { + const { bg, fg } = COLOR[kind] + return ( + + {LABEL[kind]} + + ) +} diff --git a/src/renderer/src/components/TypeBadge/index.ts b/src/renderer/src/components/TypeBadge/index.ts new file mode 100644 index 0000000..d61b7e7 --- /dev/null +++ b/src/renderer/src/components/TypeBadge/index.ts @@ -0,0 +1 @@ +export { TypeBadge } from './TypeBadge' diff --git a/src/renderer/src/hooks/useDocker.ts b/src/renderer/src/hooks/useDocker.ts new file mode 100644 index 0000000..b09692d --- /dev/null +++ b/src/renderer/src/hooks/useDocker.ts @@ -0,0 +1,72 @@ +import type { DockerActionResult, DockerInfo, DockerItemKind, DockerPruneTarget } from '@shared/docker.types' +import { useCallback, useEffect, useState } from 'react' + +interface UseDocker { + info: DockerInfo | null + /** True until Docker has been probed at least once. */ + loading: boolean + /** id of the item being removed, or `prune:` while a prune runs; null when idle. */ + busyId: string | null + refresh: () => Promise + remove: (kind: DockerItemKind, id: string) => Promise + prune: (target: DockerPruneTarget) => Promise +} + +/** Docker images/volumes/containers/build-cache info, synced with the main process. */ +export function useDocker(): UseDocker { + const [info, setInfo] = useState(null) + const [loading, setLoading] = useState(true) + const [busyId, setBusyId] = useState(null) + + useEffect(() => { + let alive = true + void window.clean.getDocker().then((i) => { + if (alive) { + setInfo(i) + setLoading(false) + } + }) + return () => { + alive = false + } + }, []) + + const refresh = useCallback(async (): Promise => { + setLoading(true) + try { + setInfo(await window.clean.getDocker(true)) + } finally { + setLoading(false) + } + }, []) + + const remove = useCallback( + async (kind: DockerItemKind, id: string): Promise => { + setBusyId(id) + try { + const r = await window.clean.removeDockerItem(kind, id) + await refresh() + return r + } finally { + setBusyId(null) + } + }, + [refresh], + ) + + const prune = useCallback( + async (target: DockerPruneTarget): Promise => { + setBusyId(`prune:${target}`) + try { + const r = await window.clean.pruneDocker(target) + await refresh() + return r + } finally { + setBusyId(null) + } + }, + [refresh], + ) + + return { info, loading, busyId, refresh, remove, prune } +} diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index 33d84d1..f3f4cee 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -1,14 +1,16 @@ import { AppIcon } from '@renderer/components/AppIcon' -import { Gauge } from '@renderer/components/Gauge' import { Kbd } from '@renderer/components/Kbd' import { RescanHint } from '@renderer/components/RescanHint' import { ResultView } from '@renderer/components/ResultView' import { Row } from '@renderer/components/Row' +import type { SegmentedOption } from '@renderer/components/Segmented' import { Segmented } from '@renderer/components/Segmented' import { Spinner } from '@renderer/components/Spinner' +import { TabHeadline } from '@renderer/components/TabHeadline' import { UIIcon } from '@renderer/components/UIIcon' import { UnlockPrompt } from '@renderer/components/UnlockPrompt' import { useAutoHeight } from '@renderer/hooks/useAutoHeight' +import { useDocker } from '@renderer/hooks/useDocker' import { useLicense } from '@renderer/hooks/useLicense' import { useLiveProjects } from '@renderer/hooks/useLiveProjects' import { usePackagesTab } from '@renderer/hooks/usePackagesTab' @@ -19,17 +21,28 @@ import { useSettings } from '@renderer/hooks/useSettings' import { useToast } from '@renderer/hooks/useToast' import { mixColor, statusColor } from '@renderer/lib/colors' import { formatSizeStr, GB } from '@renderer/lib/format' +import { severityCounts } from '@renderer/lib/severity' +import { DOCKER_STALE_MS } from '@renderer/lib/staleness' +import type { DockerItem, DockerPruneTarget } from '@shared/docker.types' +import type { LauncherNavTarget } from '@shared/launcher-nav.types' import type { PackageEntry } from '@shared/package.types' import type { Project } from '@shared/project.types' import { type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { CachesView } from '../views/CachesView' +import { type LiveCache, selectedActionableCache } from '../views/CachesView.constants' +import { DockerView } from '../views/DockerView' +import type { DockerSortKey } from '../views/DockerView.constants' +import { dockerBuildCacheBytes, PRUNE_TARGET_LABEL, pruneEstimateBytes } from '../views/DockerView.constants' +import { confirmSatisfied, needsTypedConfirm, requiredConfirmText } from '../views/docker-confirm' import { EmptyView } from '../views/EmptyView' import { Onboarding } from '../views/Onboarding' import { PackagesView } from '../views/PackagesView' import { ScanningView } from '../views/ScanningView' import { SettingsView } from '../views/SettingsView' -import type { LauncherTab, LauncherToast, LauncherView, SortKey } from './LauncherApp.types' +import type { DockerConfirmState, LauncherTab, LauncherToast, LauncherView, SortKey } from './LauncherApp.types' +import { launcherNavState } from './launcherNav' import { SortTab } from './SortTab' +import { tabSummary } from './tabSummary' const NEXT_SCAN_LABEL: Record = { '6h': '6 hours', @@ -54,16 +67,30 @@ export function LauncherApp(): ReactNode { const [query, setQuery] = useState('') const [sortBy, setSortBy] = useState('used') + const [dockerSortBy, setDockerSortBy] = useState('size') const [sel, setSel] = useState(0) const [view, setView] = useState('list') const [tab, setTab] = useState('projects') const [deleting, setDeleting] = useState>(() => new Set()) const [confirm, setConfirm] = useState(null) + const [dockerConfirm, setDockerConfirm] = useState(null) + const [dockerTyped, setDockerTyped] = useState('') const [unlock, setUnlock] = useState<{ bytes?: number } | null>(null) const [reclaimed, setReclaimed] = useState(0) const [cardCopied, setCardCopied] = useState(false) const { toast, flashToast } = useToast() const { store, loading: storeLoading, pruning, prune, refresh } = usePnpmStore() + const dockerEnabled = settings.docker !== false + const tabOptions = useMemo[]>( + () => [ + { value: 'projects', label: 'Projects' }, + { value: 'caches', label: 'Caches' }, + { value: 'packages', label: 'Packages' }, + ...(dockerEnabled ? [{ value: 'docker', label: 'Docker' } as const] : []), + ], + [dockerEnabled], + ) + const docker = useDocker() const pkgActive = view === 'list' && tab === 'packages' const { inventory, @@ -90,17 +117,36 @@ export function LauncherApp(): ReactNode { wasScanning.current = scanning }, [scanning, refresh]) - // The menu-bar settings entry opens the launcher straight to Settings: pull any - // target queued for this (possibly fresh) window on mount, and listen for live - // navigation when the already-open window is reopened onto Settings. - useEffect(() => { - void window.clean.consumeLauncherNav().then((nav) => { - if (nav === 'settings') setView('settings') - }) - return window.clean.onLauncherNavigate((nav) => { - if (nav === 'settings') setView('settings') - }) + // A menu-bar click can open the launcher on any target (Settings, or a specific + // tab): pull any target queued for this (possibly fresh) window on mount, and + // listen for live navigation when an already-open window is re-targeted. + const applyNav = useCallback((nav: LauncherNavTarget | null): void => { + if (!nav) return + const next = launcherNavState(nav) + setView(next.view) + if (next.tab) setTab(next.tab) }, []) + useEffect(() => { + void window.clean.consumeLauncherNav().then(applyNav) + return window.clean.onLauncherNavigate(applyNav) + }, [applyNav]) + // If the user disables Docker while on that tab, fall back to Projects so the + // hidden tab can't stay selected via keyboard or stale state. + useEffect(() => { + if (!dockerEnabled && tab === 'docker') { + setTab('projects') + setSel(0) + } + }, [dockerEnabled, tab]) + + // Docker refreshes on its own cadence, decoupled from the disk rescan: when the + // Docker tab opens with missing or stale (>5 min) data, kick a background scan. + // Guarded on `docker.loading` and freshened `checkedAt` so it can't loop. + useEffect(() => { + if (view !== 'list' || (tab !== 'docker' && tab !== 'caches') || !dockerEnabled || docker.loading) return + const stale = !docker.info || Date.now() - docker.info.checkedAt > DOCKER_STALE_MS + if (stale) void docker.refresh() + }, [view, tab, dockerEnabled, docker.info, docker.loading, docker.refresh]) const inputRef = useRef(null) const listRef = useRef(null) @@ -121,6 +167,22 @@ export function LauncherApp(): ReactNode { ) const needsRescan = useMemo(() => projects.some((p) => p.uniqueSize === undefined), [projects]) const maxBytes = useMemo(() => Math.max(1, ...projects.map((p) => p.uniqueSize ?? p.size)), [projects]) + // Bar scale for the Docker rows' SizeViz. Excludes build-cache items: they no longer render + // in the Docker tab (they live in the Caches tab), so counting them here would scale every + // visible row against an off-screen max and silently compress the bars. + const dockerMaxBytes = useMemo( + () => Math.max(1, ...(docker.info?.items.filter((i) => i.kind !== 'buildcache').map((i) => i.sizeBytes) ?? [])), + [docker.info], + ) + const dockerTotal = useMemo(() => (docker.info?.totals ?? []).reduce((s, t) => s + t.sizeBytes, 0), [docker.info]) + const severity = useMemo(() => severityCounts(inventory?.packages ?? []), [inventory]) + const packagesDataReady = !!inventory && !inventory.enrichmentError + const cachesAvailable = !!store?.available + const dockerAvailable = !!docker.info?.available + const buildCacheBytes = useMemo( + () => (dockerAvailable ? dockerBuildCacheBytes(docker.info?.items ?? []) : 0), + [dockerAvailable, docker.info], + ) const ratio = totalUsed / threshold const status = statusColor(ratio, accent) @@ -232,6 +294,111 @@ export function LauncherApp(): ReactNode { [flashToast, license.pro], ) + const closeDockerConfirm = useCallback(() => { + setDockerConfirm(null) + setDockerTyped('') + }, []) + + // Disabling Docker mid-confirm must not leave a pending remove/prune dialog wired to a + // now-hidden feature. The tab reset above misses a build-cache prune requested from the + // Caches tab (tab !== 'docker'), so clear any pending Docker confirm whenever Docker is + // turned off, regardless of the active tab. + useEffect(() => { + if (!dockerEnabled) closeDockerConfirm() + }, [dockerEnabled, closeDockerConfirm]) + + // Free users hit the paywall before any confirm state is set β€” the destructive IPC + // is also gated pro-only in main, but the UI never even offers the affordance here. + const requestDockerRemove = useCallback( + (item: DockerItem) => { + if (!license.pro) { + setUnlock({ bytes: item.sizeBytes }) + window.clean.trackEvent('paywall_shown', { trigger: 'docker' }) + return + } + setDockerTyped('') + setDockerConfirm({ kind: 'remove', item }) + }, + [license.pro], + ) + + const requestDockerPrune = useCallback( + (target: DockerPruneTarget) => { + if (!license.pro) { + setUnlock({}) + window.clean.trackEvent('paywall_shown', { trigger: 'docker' }) + return + } + setDockerTyped('') + setDockerConfirm({ kind: 'prune', target, estimatedBytes: pruneEstimateBytes(docker.info?.items ?? [], target) }) + }, + [license.pro, docker.info], + ) + + const commitDockerRemove = useCallback( + (item: DockerItem) => { + closeDockerConfirm() + void docker.remove(item.kind, item.id).then((r) => { + if (r.ok) { + flashToast({ + icon: UIIcon.checkCircle, + text: `Reclaimed ${formatSizeStr(r.freedBytes || item.sizeBytes)} Β· ${item.name}`, + tone: 'good', + }) + } else { + flashToast({ + icon: UIIcon.alert, + text: `Couldn't remove ${item.name}. It may now be in use.`, + tone: 'neutral', + }) + } + }) + }, + [docker, flashToast, closeDockerConfirm], + ) + + const commitDockerPrune = useCallback( + (target: DockerPruneTarget, estimatedBytes: number) => { + closeDockerConfirm() + void docker.prune(target).then((r) => { + if (r.ok) { + flashToast({ + icon: UIIcon.checkCircle, + text: `Reclaimed ${formatSizeStr(r.freedBytes || estimatedBytes)} Β· ${PRUNE_TARGET_LABEL[target]}`, + tone: 'good', + }) + } else { + flashToast({ + icon: UIIcon.alert, + text: "Couldn't prune. Nothing was removed.", + tone: 'neutral', + }) + } + }) + }, + [docker, flashToast, closeDockerConfirm], + ) + + // Derived confirm-gate state for the docker confirm footer: only volumes (per-item or the + // bulk unusedVolumes prune) need the extra typed confirmation before Remove/↡ can fire. + const dockerNeedsTyped = dockerConfirm + ? dockerConfirm.kind === 'remove' + ? needsTypedConfirm({ kind: dockerConfirm.item.kind }) + : dockerConfirm.target === 'unusedVolumes' + : false + const dockerRequiredText = dockerConfirm + ? dockerConfirm.kind === 'remove' + ? requiredConfirmText({ kind: 'volume', name: dockerConfirm.item.name }) + : requiredConfirmText({ kind: 'prune' }) + : '' + const dockerConfirmBlocked = dockerNeedsTyped && !confirmSatisfied(dockerRequiredText, dockerTyped) + + const commitDockerConfirm = useCallback(() => { + if (!dockerConfirm || dockerConfirmBlocked) return + if (dockerConfirm.kind === 'remove') commitDockerRemove(dockerConfirm.item) + else commitDockerPrune(dockerConfirm.target, dockerConfirm.estimatedBytes) + }, [dockerConfirm, dockerConfirmBlocked, commitDockerRemove, commitDockerPrune]) + const rescan = useCallback(() => setView('scanning'), []) const copyCard = useCallback( @@ -272,6 +439,42 @@ export function LauncherApp(): ReactNode { } }, [prune, flashToast, license.pro]) + const liveCaches = useMemo(() => { + const list: LiveCache[] = [ + { + id: 'pnpm', + icon: UIIcon.hdd, + name: 'pnpm store', + detail: pruning + ? 'Pruning unreferenced packages…' + : store?.available + ? (store?.displayPath ?? '') + : (store?.reason ?? 'pnpm store not found'), + size: store?.available ? store?.sizeBytes : undefined, + disabled: !store?.available, + busy: pruning, + actionLabel: store?.canPrune ? 'Prune' : undefined, + onAction: handlePrune, + }, + ] + if (dockerEnabled && dockerAvailable && buildCacheBytes > 0) { + list.push({ + id: 'docker-buildcache', + icon: UIIcon.hdd, + name: 'Docker build cache', + detail: 'Docker layer build cache', + size: buildCacheBytes, + busy: docker.busyId === 'prune:buildCache', + actionLabel: 'Delete', + busyLabel: 'Deleting…', + danger: true, + title: 'Delete all Docker build cache. Permanent, not sent to the Trash.', + onAction: () => requestDockerPrune('buildCache'), + }) + } + return list + }, [store, pruning, handlePrune, dockerEnabled, dockerAvailable, buildCacheBytes, docker.busyId, requestDockerPrune]) + // keyboard useEffect(() => { const onKey = (e: KeyboardEvent): void => { @@ -280,20 +483,26 @@ export function LauncherApp(): ReactNode { e.preventDefault() setView((v) => (v === 'settings' ? 'list' : 'settings')) setConfirm(null) + closeDockerConfirm() setUnlock(null) return } if (meta && (e.key === 'r' || e.key === 'R')) { e.preventDefault() if (tab === 'packages') void refreshPackages() - else rescan() + else if (tab === 'docker') { + closeDockerConfirm() + void docker.refresh() + } else rescan() return } - if (meta && (e.key === '1' || e.key === '2' || e.key === '3')) { + if (meta && ['1', '2', '3', '4'].includes(e.key)) { + if (e.key === '4' && !dockerEnabled) return e.preventDefault() - setTab(e.key === '1' ? 'projects' : e.key === '2' ? 'caches' : 'packages') + setTab(e.key === '1' ? 'projects' : e.key === '2' ? 'caches' : e.key === '3' ? 'packages' : 'docker') setSel(0) setConfirm(null) + closeDockerConfirm() setUnlock(null) collapsePkg() return @@ -311,6 +520,10 @@ export function LauncherApp(): ReactNode { setConfirm(null) return } + if (dockerConfirm) { + closeDockerConfirm() + return + } if (view === 'list' && tab === 'packages' && expandedPkg) { collapsePkg() return @@ -333,9 +546,18 @@ export function LauncherApp(): ReactNode { } return } + if (dockerConfirm) { + // A blocked confirm (typed volume name not yet matching) still swallows Enter β€” + // an accidental keypress must never fall through to a destructive action. + if (e.key === 'Enter') { + e.preventDefault() + commitDockerConfirm() + } + return + } if (view !== 'list') return if (tab === 'caches') { - const cacheCount = store?.available ? 1 : 0 + const cacheCount = liveCaches.length if (e.key === 'ArrowDown') { e.preventDefault() setSel((s) => Math.min(cacheCount - 1, s + 1)) @@ -344,10 +566,19 @@ export function LauncherApp(): ReactNode { setSel((s) => Math.max(0, s - 1)) } else if (e.key === 'Enter') { e.preventDefault() - if (store?.available && !pruning) void handlePrune() + // Guard on visibility: `sel` indexes the full list, but a query may have filtered + // the selected row off screen β€” Enter must never fire a row the user can't see. + selectedActionableCache(liveCaches, query, sel)?.onAction?.() } return } + if (tab === 'docker') { + // Row-level remove/prune is mouse-driven (per-item and per-category buttons in + // DockerView); this just keeps Arrow/Enter/⌘⌫ from falling through to the + // projects-list logic below. The dockerConfirm branch above is what drives ↡/esc + // once a removal is pending. + return + } if (tab === 'packages') { if (e.key === 'ArrowDown') { e.preventDefault() @@ -388,20 +619,23 @@ export function LauncherApp(): ReactNode { view, tab, confirm, + dockerConfirm, + closeDockerConfirm, + commitDockerConfirm, unlock, query, commitDelete, doOpen, rescan, - store, - pruning, - handlePrune, + liveCaches, pkgFiltered, openNpm, refreshPackages, expandedPkg, togglePkgExpand, collapsePkg, + docker, + dockerEnabled, ]) // The window is hidden (not destroyed) on blur/esc, so it keeps its React @@ -411,6 +645,7 @@ export function LauncherApp(): ReactNode { setView('list') setTab('projects') setConfirm(null) + closeDockerConfirm() setUnlock(null) setQuery('') setSel(0) @@ -419,7 +654,7 @@ export function LauncherApp(): ReactNode { } window.addEventListener('focus', onFocus) return () => window.removeEventListener('focus', onFocus) - }, [collapsePkg]) + }, [collapsePkg, closeDockerConfirm]) // keep selected row in view (projects list only) useEffect(() => { @@ -436,7 +671,32 @@ export function LauncherApp(): ReactNode { }, [sel, view, tab, filtered]) const isEmpty = projects.length === 0 - const overBy = totalUsed - threshold + const summaryText = tabSummary({ + tab, + projectsUsed: totalUsed, + cachesUsed: storeBytes, + cachesAvailable, + dockerUsed: dockerTotal, + dockerAvailable, + thresholdGB: settings.thresholdGB, + cacheThresholdGB: settings.cacheThresholdGB, + dockerThresholdGB: settings.dockerThresholdGB, + severity, + packagesCheckEnabled: settings.checkUpdates, + packagesComputing: pkgComputing, + packagesDataReady, + }) + const summaryOver = + tab === 'projects' + ? totalUsed > threshold + : tab === 'caches' + ? cachesAvailable && storeBytes > settings.cacheThresholdGB * GB + : tab === 'docker' + ? dockerAvailable && dockerTotal > settings.dockerThresholdGB * GB + : severity.vulnerable > 0 + // The disk spinner only makes sense on the size tabs that background-calculate. + const showCalcSpinner = calculating && (tab === 'projects' || tab === 'caches') + const showSummary = !!summaryText && !(tab === 'projects' && isEmpty) return (
- {totalUsed > 0 && (
{tab === 'packages' ? ( void refreshPackages()} /> ) : tab === 'caches' ? ( - + ) : tab === 'docker' && dockerEnabled ? ( + void docker.refresh()} + busyId={docker.busyId} + onRemove={requestDockerRemove} + onPrune={requestDockerPrune} + accent={accent} + density={settings.density} + sizeStyle={settings.sizeStyle} + maxBytes={dockerMaxBytes} /> ) : isEmpty ? (
+ ) : dockerConfirm ? ( +
+
+ {UIIcon.trash({ size: 16 })} + + {dockerConfirm.kind === 'prune' ? 'Prune' : 'Remove'}{' '} + + {dockerConfirm.kind === 'remove' + ? dockerConfirm.item.name + : PRUNE_TARGET_LABEL[dockerConfirm.target]} + + ? Frees ~ + + {formatSizeStr( + dockerConfirm.kind === 'remove' ? dockerConfirm.item.sizeBytes : dockerConfirm.estimatedBytes, + )} + + . Permanent, not sent to the Trash. + +
+
+ {dockerNeedsTyped && ( + setDockerTyped(e.target.value)} + placeholder={`Type "${dockerRequiredText}"`} + spellCheck={false} + style={{ + fontSize: 12.5, + padding: '5px 9px', + borderRadius: 7, + border: '1px solid var(--surface-4)', + background: 'var(--surface-1)', + color: 'var(--text)', + width: 150, + fontFamily: 'var(--ui-font)', + }} + /> + )} + + +
+
) : (
{view === 'list' && - !isEmpty && - (calculating ? ( + (showCalcSpinner ? ( ) : ( - 1 ? mixColor('#fff', accent, 0.5) : 'var(--text-muted)', - fontWeight: 550, - }} - > - {ratio > 1 - ? `${formatSizeStr(overBy)} over your ${settings.thresholdGB} GB limit` - : `${(ratio * 100).toFixed(0)}% of your ${settings.thresholdGB} GB limit`} - + showSummary && ( + + {summaryText} + + ) ))}
@@ -804,7 +1156,7 @@ export function LauncherApp(): ReactNode {
)} - {view === 'list' && tab === 'caches' && store?.available && ( + {view === 'list' && tab === 'caches' && liveCaches.some((c) => !c.disabled) && (
{UIIcon.arrowUp({ size: 12 })} @@ -814,7 +1166,7 @@ export function LauncherApp(): ReactNode { {UIIcon.enter({ size: 12 })} {' '} - prune + {(liveCaches[sel]?.actionLabel ?? 'prune').toLowerCase()}
)} diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.types.ts b/src/renderer/src/launcher/LauncherApp/LauncherApp.types.ts index fb9e586..70c9955 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.types.ts +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.types.ts @@ -1,7 +1,8 @@ import type { IconRenderer } from '@renderer/components/UIIcon' +import type { DockerItem, DockerPruneTarget } from '@shared/docker.types' export type LauncherView = 'list' | 'scanning' | 'result' | 'settings' -export type LauncherTab = 'projects' | 'caches' | 'packages' +export type LauncherTab = 'projects' | 'caches' | 'packages' | 'docker' export type SortKey = 'used' | 'size' | 'name' export type PackageSortKey = 'used' | 'size' | 'name' | 'updates' @@ -10,3 +11,9 @@ export interface LauncherToast { text: string tone: 'neutral' | 'good' } + +/** A pending Docker removal/prune awaiting confirmation in the footer (Pro users only β€” + * free users hit the paywall before this state is ever set). */ +export type DockerConfirmState = + | { kind: 'remove'; item: DockerItem } + | { kind: 'prune'; target: DockerPruneTarget; estimatedBytes: number } diff --git a/src/renderer/src/launcher/LauncherApp/launcherNav.test.ts b/src/renderer/src/launcher/LauncherApp/launcherNav.test.ts new file mode 100644 index 0000000..afa82e3 --- /dev/null +++ b/src/renderer/src/launcher/LauncherApp/launcherNav.test.ts @@ -0,0 +1,20 @@ +import type { LauncherNavTarget } from '@shared/launcher-nav.types' +import { describe, expect, it } from 'vitest' +import { launcherNavState } from './launcherNav' + +describe('launcherNavState', () => { + it('settings opens the settings view, not a tab', () => { + expect(launcherNavState('settings')).toEqual({ view: 'settings' }) + }) + + it('each tab target opens the list view on that tab', () => { + expect(launcherNavState('projects')).toEqual({ view: 'list', tab: 'projects' }) + expect(launcherNavState('caches')).toEqual({ view: 'list', tab: 'caches' }) + expect(launcherNavState('packages')).toEqual({ view: 'list', tab: 'packages' }) + expect(launcherNavState('docker')).toEqual({ view: 'list', tab: 'docker' }) + }) + + it('ignores an unknown target rather than selecting a bogus tab (arrives over IPC)', () => { + expect(launcherNavState('nope' as LauncherNavTarget)).toEqual({ view: 'list' }) + }) +}) diff --git a/src/renderer/src/launcher/LauncherApp/launcherNav.ts b/src/renderer/src/launcher/LauncherApp/launcherNav.ts new file mode 100644 index 0000000..b340ffa --- /dev/null +++ b/src/renderer/src/launcher/LauncherApp/launcherNav.ts @@ -0,0 +1,18 @@ +import type { LauncherNavTarget } from '@shared/launcher-nav.types' +import type { LauncherTab, LauncherView } from './LauncherApp.types' + +export interface LauncherNavState { + view: LauncherView + /** Only set for tab targets; undefined leaves the current tab alone. */ + tab?: LauncherTab +} + +const TAB_TARGETS: readonly LauncherTab[] = ['projects', 'caches', 'packages', 'docker'] + +/** Where a nav target lands the launcher. The target crosses an IPC boundary, so an + * unrecognized value falls back to the list rather than selecting a bogus tab. */ +export function launcherNavState(target: LauncherNavTarget): LauncherNavState { + if (target === 'settings') return { view: 'settings' } + const tab = TAB_TARGETS.find((t) => t === target) + return tab ? { view: 'list', tab } : { view: 'list' } +} diff --git a/src/renderer/src/launcher/LauncherApp/tabSummary.test.ts b/src/renderer/src/launcher/LauncherApp/tabSummary.test.ts new file mode 100644 index 0000000..ea2c68e --- /dev/null +++ b/src/renderer/src/launcher/LauncherApp/tabSummary.test.ts @@ -0,0 +1,58 @@ +import { GB } from '@renderer/lib/format' +import type { SeverityCounts } from '@renderer/lib/severity' +import { describe, expect, it } from 'vitest' +import { type TabSummaryInput, tabSummary } from './tabSummary' + +const sev = (o: Partial = {}): SeverityCounts => ({ + critical: 0, + high: 0, + moderate: 0, + low: 0, + vulnerable: 0, + outdated: 0, + ...o, +}) +const base: TabSummaryInput = { + tab: 'projects', + projectsUsed: 0, + cachesUsed: 0, + cachesAvailable: true, + dockerUsed: 0, + dockerAvailable: true, + thresholdGB: 5, + cacheThresholdGB: 10, + dockerThresholdGB: 20, + severity: sev(), + packagesCheckEnabled: true, + packagesComputing: false, + packagesDataReady: true, +} + +describe('tabSummary', () => { + it('projects: percent under limit, over amount past it', () => { + expect(tabSummary({ ...base, tab: 'projects', projectsUsed: 2.5 * GB })).toBe('50% of your 5 GB limit') + expect(tabSummary({ ...base, tab: 'projects', projectsUsed: 6 * GB })).toBe('1.00 GB over your 5 GB limit') + }) + + it('caches: uses the cache limit and wording, null when unavailable', () => { + expect(tabSummary({ ...base, tab: 'caches', cachesUsed: 5 * GB })).toBe('50% of your 10 GB cache limit') + expect(tabSummary({ ...base, tab: 'caches', cachesAvailable: false })).toBeNull() + }) + + it('docker: uses the docker limit and wording, null when unavailable', () => { + expect(tabSummary({ ...base, tab: 'docker', dockerUsed: 10 * GB })).toBe('50% of your 20 GB Docker limit') + expect(tabSummary({ ...base, tab: 'docker', dockerAvailable: false })).toBeNull() + }) + + it('packages: vulnerable + outdated, all-clear, and null when unchecked/offline/computing', () => { + expect(tabSummary({ ...base, tab: 'packages', severity: sev({ vulnerable: 7, outdated: 28 }) })).toBe( + '7 vulnerable Β· 28 outdated', + ) + expect(tabSummary({ ...base, tab: 'packages', severity: sev({ outdated: 3 }) })).toBe('all clear Β· 3 outdated') + expect(tabSummary({ ...base, tab: 'packages', severity: sev() })).toBe('all clear') + // checks off, offline/no data, and computing all suppress the summary + expect(tabSummary({ ...base, tab: 'packages', packagesCheckEnabled: false })).toBeNull() + expect(tabSummary({ ...base, tab: 'packages', packagesDataReady: false })).toBeNull() + expect(tabSummary({ ...base, tab: 'packages', packagesComputing: true })).toBeNull() + }) +}) diff --git a/src/renderer/src/launcher/LauncherApp/tabSummary.ts b/src/renderer/src/launcher/LauncherApp/tabSummary.ts new file mode 100644 index 0000000..73d9a29 --- /dev/null +++ b/src/renderer/src/launcher/LauncherApp/tabSummary.ts @@ -0,0 +1,53 @@ +import { formatSizeStr, GB } from '@renderer/lib/format' +import type { SeverityCounts } from '@renderer/lib/severity' +import type { LauncherTab } from './LauncherApp.types' + +export interface TabSummaryInput { + tab: LauncherTab + projectsUsed: number + cachesUsed: number + cachesAvailable: boolean + dockerUsed: number + dockerAvailable: boolean + thresholdGB: number + cacheThresholdGB: number + dockerThresholdGB: number + severity: SeverityCounts + packagesCheckEnabled: boolean + packagesComputing: boolean + packagesDataReady: boolean +} + +/** "{pct}% of your {n} GB {qualifier}limit" under the cap, or + * "{over} over your {n} GB {qualifier}limit" past it. `qualifier` is '' + * for node_modules, 'cache ' for caches, 'Docker ' for docker. */ +function sizeLimitSummary(usedBytes: number, thresholdGB: number, qualifier: string): string { + const threshold = thresholdGB * GB + if (usedBytes > threshold) { + return `${formatSizeStr(usedBytes - threshold)} over your ${thresholdGB} GB ${qualifier}limit` + } + const pct = threshold > 0 ? (usedBytes / threshold) * 100 : 0 + return `${pct.toFixed(0)}% of your ${thresholdGB} GB ${qualifier}limit` +} + +/** The footer line for the active tab, or null when there is nothing to show. */ +export function tabSummary(input: TabSummaryInput): string | null { + switch (input.tab) { + case 'projects': + return sizeLimitSummary(input.projectsUsed, input.thresholdGB, '') + case 'caches': + return input.cachesAvailable ? sizeLimitSummary(input.cachesUsed, input.cacheThresholdGB, 'cache ') : null + case 'docker': + return input.dockerAvailable ? sizeLimitSummary(input.dockerUsed, input.dockerThresholdGB, 'Docker ') : null + case 'packages': { + if (!input.packagesCheckEnabled) return null + if (input.packagesComputing) return null + if (!input.packagesDataReady) return null + const { vulnerable, outdated } = input.severity + if (vulnerable === 0) return outdated > 0 ? `all clear Β· ${outdated} outdated` : 'all clear' + return `${vulnerable} vulnerable Β· ${outdated} outdated` + } + default: + return null + } +} diff --git a/src/renderer/src/launcher/views/CachesView.constants.test.ts b/src/renderer/src/launcher/views/CachesView.constants.test.ts index 05af5e2..3a8ba9b 100644 --- a/src/renderer/src/launcher/views/CachesView.constants.test.ts +++ b/src/renderer/src/launcher/views/CachesView.constants.test.ts @@ -1,5 +1,6 @@ +import type { IconRenderer } from '@renderer/components/UIIcon' import { describe, expect, it } from 'vitest' -import { CACHE_PLACEHOLDERS } from './CachesView.constants' +import { CACHE_PLACEHOLDERS, type LiveCache, selectedActionableCache, visibleCaches } from './CachesView.constants' describe('CACHE_PLACEHOLDERS', () => { it('lists the planned package-manager caches in order with unique ids', () => { @@ -14,3 +15,59 @@ describe('CACHE_PLACEHOLDERS', () => { } }) }) + +describe('visibleCaches', () => { + const stubIcon: IconRenderer = () => null + const cache = (name: string): LiveCache => ({ id: name, icon: stubIcon, name, detail: '' }) + const caches = [cache('pnpm store'), cache('Docker build cache')] + + it('returns every cache with its original index when the query is empty', () => { + expect(visibleCaches(caches, '')).toEqual([ + { cache: caches[0], index: 0 }, + { cache: caches[1], index: 1 }, + ]) + }) + + it('filters by name case-insensitively, preserving the original index', () => { + const r = visibleCaches(caches, 'DOCKER') + expect(r).toHaveLength(1) + expect(r[0]).toEqual({ cache: caches[1], index: 1 }) + }) + + it('returns nothing when no cache matches', () => { + expect(visibleCaches(caches, 'zzz')).toEqual([]) + }) +}) + +describe('selectedActionableCache', () => { + const stubIcon: IconRenderer = () => null + const mk = (name: string, extra: Partial = {}): LiveCache => ({ + id: name, + icon: stubIcon, + name, + detail: '', + ...extra, + }) + + it('returns the selected cache when it is visible and actionable', () => { + const caches = [mk('pnpm store'), mk('Docker build cache')] + expect(selectedActionableCache(caches, '', 1)).toBe(caches[1]) + }) + + it('returns null when the selected cache is filtered out of view by the query', () => { + const caches = [mk('pnpm store'), mk('Docker build cache')] + // sel=1 points at "Docker build cache", but the query only matches the pnpm row β€” + // so Enter must NOT fire the hidden Docker delete. + expect(selectedActionableCache(caches, 'pnpm', 1)).toBeNull() + }) + + it('returns null when the selected cache is disabled or busy', () => { + const caches = [mk('pnpm store', { disabled: true }), mk('Docker build cache', { busy: true })] + expect(selectedActionableCache(caches, '', 0)).toBeNull() + expect(selectedActionableCache(caches, '', 1)).toBeNull() + }) + + it('returns null when the selection is out of range', () => { + expect(selectedActionableCache([mk('pnpm store')], '', 5)).toBeNull() + }) +}) diff --git a/src/renderer/src/launcher/views/CachesView.constants.ts b/src/renderer/src/launcher/views/CachesView.constants.ts index fe2f45f..7d62250 100644 --- a/src/renderer/src/launcher/views/CachesView.constants.ts +++ b/src/renderer/src/launcher/views/CachesView.constants.ts @@ -1,3 +1,5 @@ +import type { IconRenderer } from '@renderer/components/UIIcon' + /** A package-manager cache the launcher will support but hasn't built yet. */ export interface CachePlaceholder { id: string @@ -5,9 +7,48 @@ export interface CachePlaceholder { detail: string } -/** npm/yarn/bun caches β€” shown disabled ("soon") until issue #2 M2 builds them. */ +/** npm/yarn/bun caches, shown disabled ("soon") until issue #2 M2 builds them. */ export const CACHE_PLACEHOLDERS: CachePlaceholder[] = [ - { id: 'npm', name: 'npm cache', detail: 'Global npm cache β€” coming soon' }, - { id: 'yarn', name: 'yarn cache', detail: 'Global yarn cache β€” coming soon' }, - { id: 'bun', name: 'bun cache', detail: 'Global bun cache β€” coming soon' }, + { id: 'npm', name: 'npm cache', detail: 'Global npm cache (coming soon)' }, + { id: 'yarn', name: 'yarn cache', detail: 'Global yarn cache (coming soon)' }, + { id: 'bun', name: 'bun cache', detail: 'Global bun cache (coming soon)' }, ] + +/** One live (present, actionable) cache shown at the top of the Caches tab, above the + * "coming soon" placeholders. `LauncherApp` builds these β€” the view stays presentational. */ +export interface LiveCache { + id: string + icon: IconRenderer + name: string + /** Secondary line: store path, a status, or a short description. */ + detail: string + size?: number + disabled?: boolean + busy?: boolean + actionLabel?: string + busyLabel?: string + title?: string + /** Render the action button as destructive (red) with a trash icon. */ + danger?: boolean + onAction?: () => void +} + +/** Query-filter live caches by name (case-insensitive substring), preserving each cache's + * original index so the keyboard-selected row stays correct while the list is filtered. */ +export function visibleCaches(caches: LiveCache[], query: string): { cache: LiveCache; index: number }[] { + const q = query.trim().toLowerCase() + return caches + .map((cache, index) => ({ cache, index })) + .filter(({ cache }) => !q || cache.name.toLowerCase().includes(q)) +} + +/** The live cache that the Enter key should act on for the current selection and query. + * `sel` indexes the full list, but only `visibleCaches` are on screen β€” so Enter returns + * the selected cache ONLY when it is still visible (not filtered out by the query) and + * actionable (not disabled or busy). Returns null otherwise, so Enter never fires the + * action of a row the query has hidden from view. */ +export function selectedActionableCache(caches: LiveCache[], query: string, sel: number): LiveCache | null { + const c = caches[sel] + if (!c || c.disabled || c.busy) return null + return visibleCaches(caches, query).some((v) => v.index === sel) ? c : null +} diff --git a/src/renderer/src/launcher/views/CachesView.tsx b/src/renderer/src/launcher/views/CachesView.tsx index e9285ff..17760b0 100644 --- a/src/renderer/src/launcher/views/CachesView.tsx +++ b/src/renderer/src/launcher/views/CachesView.tsx @@ -1,61 +1,51 @@ import { CacheRow } from '@renderer/components/CacheRow' import { UIIcon } from '@renderer/components/UIIcon' -import type { PnpmStoreInfo } from '@shared/pnpm-store.types' import type { ReactNode } from 'react' -import { CACHE_PLACEHOLDERS } from './CachesView.constants' +import { CACHE_PLACEHOLDERS, type LiveCache, visibleCaches } from './CachesView.constants' interface CachesViewProps { - store: PnpmStoreInfo | null - pruning: boolean - /** Index of the keyboard-selected enabled cache (0 = pnpm store). */ + /** Live caches (pnpm store, Docker build cache, …) in keyboard order. */ + caches: LiveCache[] + /** Index (into `caches`) of the keyboard-selected live cache. */ selectedIndex: number /** Search text from the shared header input; filters cache rows by name. */ query: string onSelectIndex: (i: number) => void - onPrune: () => void } -/** The launcher's "Caches" tab: global package-manager caches (pnpm now). */ -export function CachesView({ - store, - pruning, - selectedIndex, - query, - onSelectIndex, - onPrune, -}: CachesViewProps): ReactNode { +/** The launcher's "Caches" tab: live global caches (pnpm store, Docker build cache) above + * the not-yet-built placeholders (npm/yarn/bun). */ +export function CachesView({ caches, selectedIndex, query, onSelectIndex }: CachesViewProps): ReactNode { const q = query.trim().toLowerCase() - const storeAvailable = !!store?.available - const showStore = !q || 'pnpm store'.includes(q) + const rows = visibleCaches(caches, query) const placeholders = CACHE_PLACEHOLDERS.filter((c) => !q || c.name.toLowerCase().includes(q)) return (
- {showStore && ( + {rows.map(({ cache, index }) => ( onSelectIndex(0)} - onAction={onPrune} + key={cache.id} + icon={cache.icon} + name={cache.name} + detail={cache.detail} + size={cache.size} + selected={selectedIndex === index && !cache.disabled} + disabled={cache.disabled} + busy={cache.busy} + actionLabel={cache.actionLabel} + actionIcon={cache.danger ? UIIcon.trash : undefined} + danger={cache.danger} + title={cache.title} + busyLabel={cache.busyLabel} + onSelect={() => onSelectIndex(index)} + onAction={cache.onAction} /> - )} + ))} {placeholders.map((c) => ( ))} - {!showStore && placeholders.length === 0 && ( + {rows.length === 0 && placeholders.length === 0 && (
No caches match β€œ{query}”.
diff --git a/src/renderer/src/launcher/views/DockerView.constants.test.ts b/src/renderer/src/launcher/views/DockerView.constants.test.ts new file mode 100644 index 0000000..82c1b14 --- /dev/null +++ b/src/renderer/src/launcher/views/DockerView.constants.test.ts @@ -0,0 +1,255 @@ +import type { DockerInfo, DockerItem } from '@shared/docker.types' +import { describe, expect, it } from 'vitest' +import { + type DisplayGroup, + dockerBuildCacheBytes, + dockerGroupActive, + dockerItemDetail, + groupDockerForDisplay, + PRUNE_BUTTON_LABEL, + projectRowExpanded, + pruneEstimateBytes, + prunesForGroup, +} from './DockerView.constants' + +const item = (o: Partial & Pick): DockerItem => ({ + sizeBytes: 0, + createdAt: 0, + inUse: false, + removable: true, + ...o, +}) +const info = ( + items: DockerItem[], + projects = [{ name: 'myapp', workingDir: '/w', kind: 'node' as const }], +): DockerInfo => ({ + available: true, + checkedAt: 0, + totals: [], + items, + projects, +}) + +describe('groupDockerForDisplay', () => { + const items = [ + item({ id: 'c1', kind: 'container', name: 'myapp-web', project: 'myapp', sizeBytes: 10 }), + item({ id: 'i1', kind: 'image', name: 'node:20', project: 'myapp', repository: 'node', sizeBytes: 100 }), + item({ id: 'i2', kind: 'image', name: 'redis:7', repository: 'redis', sizeBytes: 500 }), + item({ id: 'bc', kind: 'buildcache', name: 'bc', removable: false, sizeBytes: 300 }), + item({ id: 'v9', kind: 'volume', name: 'orphan', sizeBytes: 5 }), + ] + + it('puts project groups first, then repository/unaffiliated under "Other"', () => { + const g = groupDockerForDisplay(info(items), { sortBy: 'size', typeFilter: 'all', query: '' }) + expect(g[0]).toMatchObject({ kind: 'project', project: { name: 'myapp' } }) + const kinds = g.map((x) => x.kind) + expect(kinds).toContain('repository') // redis + expect(kinds).toContain('unaffiliated') // orphan volume + // project group before any "Other" group + expect(kinds.indexOf('project')).toBeLessThan(kinds.indexOf('repository')) + }) + + it('never groups build-cache items (they moved to the Caches tab)', () => { + const g = groupDockerForDisplay(info(items), { sortBy: 'size', typeFilter: 'all', query: '' }) + expect(g.some((x) => x.kind === ('buildcache' as unknown))).toBe(false) + expect(g.every((x) => x.items.every((i) => i.kind !== 'buildcache'))).toBe(true) + }) + + it('sorts groups by total size when sortBy=size', () => { + const g = groupDockerForDisplay(info(items), { sortBy: 'size', typeFilter: 'all', query: '' }) + const other = g.filter((x) => x.kind !== 'project') + // redis repo (500) sorts before the orphan volume (5); build cache is excluded entirely + expect(other[0].kind === 'repository').toBe(true) + }) + + it('type filter keeps only matching items and drops empty groups', () => { + const g = groupDockerForDisplay(info(items), { sortBy: 'size', typeFilter: 'volume', query: '' }) + expect(g.every((x) => x.items.every((i) => i.kind === 'volume'))).toBe(true) + expect(g.some((x) => x.kind === 'repository')).toBe(false) // images filtered out + }) + + it('query filters by item name or project name', () => { + expect(groupDockerForDisplay(info(items), { sortBy: 'size', typeFilter: 'all', query: 'redis' }).length).toBe(1) + }) + + it('does not throw when info.projects is missing (stale cache from an older build)', () => { + // A docker-cache.json written before project enrichment has no `projects` field. + const stale = { available: true, checkedAt: 0, totals: [], items } as unknown as DockerInfo + const g = groupDockerForDisplay(stale, { sortBy: 'size', typeFilter: 'all', query: '' }) + // No project groups; every item falls into the "Other" section instead of crashing. + expect(g.some((x) => x.kind === 'project')).toBe(false) + expect(g.length).toBeGreaterThan(0) + }) +}) + +describe('dockerItemDetail', () => { + const NOW = Date.UTC(2026, 0, 10) + + it('shows a relative created date (in-use is shown as a dot, not text)', () => { + const item: DockerItem = { + id: 'x', + kind: 'image', + name: 'x', + sizeBytes: 1, + createdAt: NOW - 86400000, + inUse: true, + removable: false, + } + expect(dockerItemDetail(item, NOW)).toBe('yesterday') + }) + + it('shows "unknown date" when createdAt is 0', () => { + const item: DockerItem = { + id: 'x', + kind: 'image', + name: 'x', + sizeBytes: 1, + createdAt: 0, + inUse: false, + removable: true, + } + expect(dockerItemDetail(item, NOW)).toBe('unknown date') + }) +}) + +describe('pruneEstimateBytes', () => { + const pruneItems: DockerItem[] = [ + { id: 'i1', kind: 'image', name: '', sizeBytes: 1e9, createdAt: 0, inUse: false, removable: true }, + { id: 'i2', kind: 'image', name: 'old:tag', sizeBytes: 2e9, createdAt: 0, inUse: false, removable: true }, + { id: 'i3', kind: 'image', name: 'in-use:tag', sizeBytes: 5e9, createdAt: 0, inUse: true, removable: false }, + { id: 'v1', kind: 'volume', name: 'stray', sizeBytes: 3e8, createdAt: 0, inUse: false, removable: true }, + { id: 'v2', kind: 'volume', name: 'pgdata', sizeBytes: 4e8, createdAt: 0, inUse: true, removable: false }, + { id: 'c1', kind: 'container', name: 'stopped', sizeBytes: 5e7, createdAt: 0, inUse: false, removable: true }, + { id: 'c2', kind: 'container', name: 'running', sizeBytes: 6e7, createdAt: 0, inUse: true, removable: false }, + { id: 'b1', kind: 'buildcache', name: 'b1', sizeBytes: 7e6, createdAt: 0, inUse: false, removable: false }, + ] + + it('sums only untagged (dangling) removable images for danglingImages', () => { + expect(pruneEstimateBytes(pruneItems, 'danglingImages')).toBe(1e9) + }) + + it('sums all removable images (tagged and untagged) for unusedImages', () => { + expect(pruneEstimateBytes(pruneItems, 'unusedImages')).toBe(1e9 + 2e9) + }) + + it('sums removable containers for stoppedContainers', () => { + expect(pruneEstimateBytes(pruneItems, 'stoppedContainers')).toBe(5e7) + }) + + it('sums all build-cache items for buildCache', () => { + expect(pruneEstimateBytes(pruneItems, 'buildCache')).toBe(7e6) + }) + + it('sums removable volumes for unusedVolumes', () => { + expect(pruneEstimateBytes(pruneItems, 'unusedVolumes')).toBe(3e8) + }) +}) + +describe('prunesForGroup', () => { + const group = (o: Partial & Pick): DisplayGroup => + ({ id: 'g', label: 'g', ...o }) as DisplayGroup + + it('repository group with a removable tagged image offers unusedImages only', () => { + const g = group({ + kind: 'repository', + items: [item({ id: 'i1', kind: 'image', name: 'redis:7', removable: true })], + }) + expect(prunesForGroup(g)).toEqual(['unusedImages']) + }) + + it('repository group with a dangling image also offers danglingImages', () => { + const g = group({ + kind: 'repository', + items: [item({ id: 'i1', kind: 'image', name: '', removable: true })], + }) + expect(prunesForGroup(g)).toEqual(['danglingImages', 'unusedImages']) + }) + + it('unaffiliated group with a dangling image offers both danglingImages and unusedImages', () => { + const g = group({ + kind: 'unaffiliated', + items: [item({ id: 'i1', kind: 'image', name: '', removable: true })], + }) + expect(prunesForGroup(g)).toEqual(['danglingImages', 'unusedImages']) + }) + + it('unaffiliated group with a volume and a removable container offers unusedVolumes and stoppedContainers', () => { + const g = group({ + kind: 'unaffiliated', + items: [ + item({ id: 'v1', kind: 'volume', name: 'orphan', removable: true }), + item({ id: 'c1', kind: 'container', name: 'stopped', removable: true }), + ], + }) + expect(prunesForGroup(g)).toEqual(['unusedVolumes', 'stoppedContainers']) + }) + + it('project group offers no prune targets', () => { + const g = { + kind: 'project' as const, + id: 'p', + label: 'p', + project: { name: 'myapp' }, + items: [item({ id: 'i1', kind: 'image', name: 'x', removable: true })], + } + expect(prunesForGroup(g)).toEqual([]) + }) +}) + +describe('dockerGroupActive', () => { + it('is true when any item is in use', () => { + expect( + dockerGroupActive([ + item({ id: 'a', kind: 'image', name: 'a', inUse: false }), + item({ id: 'b', kind: 'volume', name: 'b', inUse: true }), + ]), + ).toBe(true) + }) + it('is false when no item is in use', () => { + expect(dockerGroupActive([item({ id: 'a', kind: 'image', name: 'a', inUse: false })])).toBe(false) + }) + it('is false for an empty group', () => { + expect(dockerGroupActive([])).toBe(false) + }) +}) + +describe('projectRowExpanded', () => { + it('a non-empty query expands every project regardless of the accordion id', () => { + expect(projectRowExpanded(true, null, 'project:x')).toBe(true) + expect(projectRowExpanded(true, 'project:y', 'project:x')).toBe(true) + }) + it('with no query, only the accordion-selected id is expanded', () => { + expect(projectRowExpanded(false, 'project:x', 'project:x')).toBe(true) + expect(projectRowExpanded(false, 'project:y', 'project:x')).toBe(false) + expect(projectRowExpanded(false, null, 'project:x')).toBe(false) + }) +}) + +describe('PRUNE_BUTTON_LABEL', () => { + it('leads every label with the word "Delete" so the button reads as destructive', () => { + for (const label of Object.values(PRUNE_BUTTON_LABEL)) { + expect(label.startsWith('Delete ')).toBe(true) + } + }) + + it('has no em dashes', () => { + for (const label of Object.values(PRUNE_BUTTON_LABEL)) { + expect(label.includes('β€”')).toBe(false) + } + }) +}) + +describe('dockerBuildCacheBytes', () => { + it('sums only build-cache items', () => { + const items: DockerItem[] = [ + { id: 'b1', kind: 'buildcache', name: 'b1', sizeBytes: 100, createdAt: 0, inUse: false, removable: false }, + { id: 'b2', kind: 'buildcache', name: 'b2', sizeBytes: 250, createdAt: 0, inUse: false, removable: false }, + { id: 'i1', kind: 'image', name: 'redis', sizeBytes: 999, createdAt: 0, inUse: false, removable: true }, + ] + expect(dockerBuildCacheBytes(items)).toBe(350) + }) + + it('is 0 when there are no build-cache items', () => { + expect(dockerBuildCacheBytes([])).toBe(0) + }) +}) diff --git a/src/renderer/src/launcher/views/DockerView.constants.ts b/src/renderer/src/launcher/views/DockerView.constants.ts new file mode 100644 index 0000000..2e4d23b --- /dev/null +++ b/src/renderer/src/launcher/views/DockerView.constants.ts @@ -0,0 +1,152 @@ +import { relativeTime } from '@renderer/lib/format' +import type { DockerInfo, DockerItem, DockerItemKind, DockerProject, DockerPruneTarget } from '@shared/docker.types' + +export type DockerSortKey = 'size' | 'name' | 'recent' +export type DockerTypeFilter = 'all' | DockerItemKind + +export type DisplayGroup = + | { kind: 'project'; id: string; label: string; project: DockerProject; items: DockerItem[] } + | { kind: 'repository'; id: string; label: string; items: DockerItem[] } + | { kind: 'unaffiliated'; id: string; label: string; items: DockerItem[] } + +const bytes = (items: DockerItem[]): number => items.reduce((s, i) => s + i.sizeBytes, 0) +const recent = (items: DockerItem[]): number => items.reduce((m, i) => Math.max(m, i.createdAt), 0) +const bySizeDesc = (a: DockerItem, b: DockerItem): number => b.sizeBytes - a.sizeBytes + +function sortGroups(groups: DisplayGroup[], sortBy: DockerSortKey): DisplayGroup[] { + return [...groups].sort((a, b) => { + if (sortBy === 'name') return a.label.localeCompare(b.label) + if (sortBy === 'recent') return recent(b.items) - recent(a.items) + return bytes(b.items) - bytes(a.items) + }) +} + +export function groupDockerForDisplay( + info: DockerInfo, + opts: { sortBy: DockerSortKey; typeFilter: DockerTypeFilter; query: string }, +): DisplayGroup[] { + const q = opts.query.trim().toLowerCase() + const items = info.items.filter((i) => { + if (opts.typeFilter !== 'all' && i.kind !== opts.typeFilter) return false + if (!q) return true + return i.name.toLowerCase().includes(q) || (i.project?.toLowerCase().includes(q) ?? false) + }) + + // Project groups. Guard `projects`: a cache written by an older build (or any + // DockerInfo that predates project enrichment) has no `projects` field, so it + // would be undefined here and blow up the iteration. + const projectGroups: DisplayGroup[] = [] + for (const p of Array.isArray(info.projects) ? info.projects : []) { + const of = items.filter((i) => i.project === p.name).sort(bySizeDesc) + if (of.length) + projectGroups.push({ kind: 'project', id: `project:${p.name}`, label: p.name, project: p, items: of }) + } + + // "Other": unaffiliated items β†’ repository groups (images) + unaffiliated (rest). Build-cache + // items are dropped here entirely β€” they live in the Caches tab now, not the Docker list. + const other = items.filter((i) => !i.project) + const repoGroups: DisplayGroup[] = [] + const repos = new Map() + for (const i of other) { + if (i.kind === 'image' && i.repository) { + if (!repos.has(i.repository)) repos.set(i.repository, []) + repos.get(i.repository)!.push(i) + } + } + for (const [repo, of] of repos) + repoGroups.push({ kind: 'repository', id: `repo:${repo}`, label: repo, items: of.sort(bySizeDesc) }) + + const rest = other.filter((i) => i.kind !== 'buildcache' && !(i.kind === 'image' && i.repository)).sort(bySizeDesc) + const unaffiliatedGroup: DisplayGroup[] = rest.length + ? [{ kind: 'unaffiliated', id: 'unaffiliated', label: 'Not linked to a project', items: rest }] + : [] + + return [...sortGroups(projectGroups, opts.sortBy), ...sortGroups([...repoGroups, ...unaffiliatedGroup], opts.sortBy)] +} + +/** Detail line for a Docker row: relative created date (or "unknown date"). In-use state is + * shown as a green dot next to the name instead (see `DockerItemRow`), not as text here. */ +export function dockerItemDetail(item: DockerItem, now = Date.now()): string { + return item.createdAt > 0 ? relativeTime(item.createdAt, now) : 'unknown date' +} + +/** Display label for each bulk-prune target, used on the per-category prune buttons and confirm footer. */ +export const PRUNE_TARGET_LABEL: Record = { + danglingImages: 'Dangling images', + unusedImages: 'Unused images', + stoppedContainers: 'Stopped containers', + buildCache: 'Build cache', + unusedVolumes: 'Unused volumes', +} + +/** Button labels for the bulk-prune actions. Unlike `PRUNE_TARGET_LABEL` (the noun used in + * the confirm footer + toast), these lead with a delete verb so the button reads as the + * destructive action it is. */ +export const PRUNE_BUTTON_LABEL: Record = { + danglingImages: 'Delete dangling images', + unusedImages: 'Delete unused images', + stoppedContainers: 'Delete stopped containers', + buildCache: 'Delete build cache', + unusedVolumes: 'Delete unused volumes', +} + +/** Prune commands are global (`docker prune -f` spans every project), so bulk-prune + * buttons only appear on the "Other" section headers β€” never under a project header, where + * they'd falsely imply a project-scoped prune. Targets are keyed by the item kinds present + * in that specific group. Dangling (``) images have no `repository`, so they can land + * in either a `repository` group (alongside tagged siblings of the same repo) or the + * `unaffiliated` group β€” check both for image targets. */ +export function prunesForGroup(group: DisplayGroup): DockerPruneTarget[] { + if (group.kind === 'repository' || group.kind === 'unaffiliated') { + const targets: DockerPruneTarget[] = [] + if (group.items.some((i) => i.kind === 'image' && i.name === '')) targets.push('danglingImages') + if (group.items.some((i) => i.kind === 'image' && i.removable)) targets.push('unusedImages') + if (group.kind === 'unaffiliated') { + if (group.items.some((i) => i.kind === 'volume')) targets.push('unusedVolumes') + if (group.items.some((i) => i.kind === 'container' && i.removable)) targets.push('stoppedContainers') + } + return targets + } + return [] +} + +/** Estimated bytes a bulk prune would free, from the currently-known item list (the real + * total is only known after the CLI runs). Dangling images are untagged (`name === ''`); + * unused images covers all removable images, tagged or not, matching `docker image prune -a`. */ +export function pruneEstimateBytes(items: DockerItem[], target: DockerPruneTarget): number { + const sum = (of: DockerItem[]): number => of.reduce((s, i) => s + i.sizeBytes, 0) + switch (target) { + case 'danglingImages': + return sum(items.filter((i) => i.kind === 'image' && i.removable && i.name === '')) + case 'unusedImages': + return sum(items.filter((i) => i.kind === 'image' && i.removable)) + case 'stoppedContainers': + return sum(items.filter((i) => i.kind === 'container' && i.removable)) + case 'buildCache': + return sum(items.filter((i) => i.kind === 'buildcache')) + case 'unusedVolumes': + return sum(items.filter((i) => i.kind === 'volume' && i.removable)) + default: + return 0 + } +} + +/** Total bytes of Docker build-cache items. Surfaced as a single row in the Caches tab β€” + * build cache is a global cache, not a per-project resource, so it lives with the other + * package-manager caches rather than in the project-grouped Docker list. */ +export function dockerBuildCacheBytes(items: DockerItem[]): number { + return items.filter((i) => i.kind === 'buildcache').reduce((s, i) => s + i.sizeBytes, 0) +} + +/** True when any resource in the group is in use by a container. Drives the green active + * dot on a collapsed project row. */ +export function dockerGroupActive(items: DockerItem[]): boolean { + return items.some((i) => i.inUse) +} + +/** Whether a project row renders expanded. A non-empty search expands every project (their + * items are already query-filtered, so results are never hidden behind a collapsed row); + * with no query, only the accordion-selected id is open. */ +export function projectRowExpanded(hasQuery: boolean, expandedId: string | null, groupId: string): boolean { + return hasQuery || expandedId === groupId +} diff --git a/src/renderer/src/launcher/views/DockerView.tsx b/src/renderer/src/launcher/views/DockerView.tsx new file mode 100644 index 0000000..c24abdf --- /dev/null +++ b/src/renderer/src/launcher/views/DockerView.tsx @@ -0,0 +1,557 @@ +import { Accordion } from '@renderer/components/Accordion' +import { FrameworkIcon } from '@renderer/components/FrameworkIcon' +import { RowAction } from '@renderer/components/Row' +import { SizeViz } from '@renderer/components/SizeViz' +import { TypeBadge } from '@renderer/components/TypeBadge' +import type { IconRenderer } from '@renderer/components/UIIcon' +import { UIIcon } from '@renderer/components/UIIcon' +import { formatSizeStr } from '@renderer/lib/format' +import type { DockerItem, DockerItemKind, DockerProject, DockerPruneTarget } from '@shared/docker.types' +import type { Density, SizeStyle } from '@shared/settings.types' +import { Fragment, type ReactNode, useState } from 'react' +import { + type DisplayGroup, + dockerGroupActive, + dockerItemDetail, + groupDockerForDisplay, + PRUNE_BUTTON_LABEL, + projectRowExpanded, + prunesForGroup, +} from './DockerView.constants' +import type { DockerViewProps } from './DockerView.types' + +const KIND_ICON: Record = { + image: UIIcon.box, + volume: UIIcon.hdd, + container: UIIcon.power, + buildcache: UIIcon.hdd, +} + +/** Generic header glyph for the non-project ("Other") groups. */ +const GROUP_ICON: Record<'repository' | 'unaffiliated', IconRenderer> = { + repository: UIIcon.box, + unaffiliated: UIIcon.hdd, +} + +/** The project's own favicon when available; its framework mark when known; a generic box + * glyph otherwise (e.g. a compose project with no detected framework). */ +function DockerProjectIcon({ project, size = 22 }: { project: DockerProject; size?: number }): ReactNode { + const [failed, setFailed] = useState(false) + + if (project.iconDataUrl && !failed) { + return ( +
+ setFailed(true)} + style={{ width: size * 0.72, height: size * 0.72, objectFit: 'contain', display: 'block' }} + /> +
+ ) + } + if (project.kind) return + return ( + + {UIIcon.box({ size: size * 0.6 })} + + ) +} + +function GroupHeader({ + group, + busyId, + onPrune, +}: { + group: Exclude + busyId?: string | null + onPrune?: (target: DockerPruneTarget) => void +}): ReactNode { + const totalBytes = group.items.reduce((s, i) => s + i.sizeBytes, 0) + const targets = prunesForGroup(group) + return ( +
+
+ + {GROUP_ICON[group.kind]({ size: 13 })} + +
+
+ {group.label} +
+
{formatSizeStr(totalBytes)}
+
+
+ {onPrune && targets.length > 0 && ( +
+ {targets.map((target) => { + const busy = busyId === `prune:${target}` + return ( + + ) + })} +
+ )} +
+ ) +} + +/** Same visual as `LiveDot` (the node_modules "running" indicator): an 8px green dot with a + * soft glow. Not the `LiveDot` component itself β€” that takes a `LiveInfo` (command/port) a + * Docker item doesn't have β€” just its exact style, with Docker-appropriate copy. */ +function InUseDot(): ReactNode { + return ( + + ) +} + +/** Collapsed, clickable header for a compose-project group: logo/icon, name, item count, + * total size, a green active dot when any resource is in use, and a chevron that rotates + * when expanded. The whole row toggles expansion and drives the shared .cc-hl highlight. */ +function DockerProjectRow({ + project, + itemCount, + totalBytes, + active, + expanded, + onToggle, + onHover, +}: { + project: DockerProject + itemCount: number + totalBytes: number + active: boolean + expanded: boolean + onToggle: () => void + onHover: (el: HTMLDivElement) => void +}): ReactNode { + return ( +
onHover(e.currentTarget)} + style={{ + position: 'relative', + zIndex: 1, + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '8px 12px', + borderRadius: 10, + cursor: 'pointer', + }} + > + +
+
+ {project.name} +
+
+ {itemCount} item{itemCount === 1 ? '' : 's'} +
+
+
+ + {formatSizeStr(totalBytes)} + + {active && } + + {UIIcon.chevronRight({ size: 14 })} + +
+
+ ) +} + +/** One Docker resource row. Mirrors `Row.tsx` (the node_modules row) so both lists read as + * the same list primitive: kind icon, name + colored `TypeBadge`, detail line, `SizeViz` + * for size, and a hover-revealed trash `RowAction` in place of a persistent Remove button. + * Non-removable items (anything the caller has no `onRemove` for) get a disabled trash with + * an explanatory title, and `onRemove` is never invoked for them. Build-cache items no longer + * reach this component (`groupDockerForDisplay` drops them; they live in the Caches tab), but + * the `buildcache` guards below are kept as cheap defense-in-depth on a permanent-delete path. */ +function DockerItemRow({ + item, + density, + sizeStyle, + maxBytes, + accent, + busy, + onRemove, + onHover, +}: { + item: DockerItem + density: Density + sizeStyle: SizeStyle + maxBytes: number + accent: string + busy: boolean + onRemove?: (item: DockerItem) => void + onHover: (el: HTMLDivElement) => void +}): ReactNode { + const [hover, setHover] = useState(false) + const roomy = density === 'roomy' + const canRemove = item.removable && item.kind !== 'buildcache' && !!onRemove + const showActions = hover + + const disabledReason = + item.kind === 'buildcache' + ? 'Build cache is removed via Prune, not individually' + : item.inUse + ? "Currently in use, can't remove" + : "Can't be removed individually" + + return ( +
{ + setHover(true) + onHover(e.currentTarget) + }} + onMouseLeave={() => setHover(false)} + style={{ + position: 'relative', + zIndex: 1, + display: 'flex', + alignItems: 'center', + gap: roomy ? 13 : 11, + padding: roomy ? '11px 14px' : '7px 14px', + borderRadius: 10, + }} + > + + {KIND_ICON[item.kind]({ size: roomy ? 16 : 14 })} + +
+ + + {item.name} + + {item.inUse && } + + + + {dockerItemDetail(item)} + +
+
+ +
+ {showActions && ( +
+ onRemove?.(item) : () => {}} + /> +
+ )} +
+ ) +} + +/** The launcher's "Docker" tab: resources grouped by compose project first, then an "Other" + * section (image repositories, build cache, unaffiliated resources) for anything unlinked. */ +export function DockerView({ + info, + loading, + query, + sortBy = 'size', + typeFilter = 'all', + busyId, + onRemove, + onPrune, + accent, + density, + sizeStyle, + maxBytes, +}: DockerViewProps): ReactNode { + // Shared sliding highlight behind the hovered row, matching the node_modules list (.cc-hl). + const [hl, setHl] = useState({ top: 0, height: 0, on: false }) + const [expandedProjectId, setExpandedProjectId] = useState(null) + + if (loading && !info) { + return ( +
+ Checking Docker… +
+ ) + } + + if (!info?.available) { + return ( +
+ {info?.reason ?? 'Docker not found'} +
+ ) + } + + const groups = groupDockerForDisplay(info, { sortBy, typeFilter, query }) + const firstOtherIndex = groups.findIndex((g) => g.kind !== 'project') + const hasQuery = query.trim().length > 0 + + return ( +
+
setHl((h) => ({ ...h, on: false }))} + > +
+ {groups.length === 0 ? ( +
+ {query ? `No Docker resources match β€œ${query}”.` : 'No Docker resources found.'} +
+ ) : ( + groups.map((g, idx) => { + if (g.kind === 'project') { + const expanded = projectRowExpanded(hasQuery, expandedProjectId, g.id) + return ( + s + i.sizeBytes, 0)} + active={dockerGroupActive(g.items)} + expanded={expanded} + onToggle={() => setExpandedProjectId((cur) => (cur === g.id ? null : g.id))} + onHover={(el) => setHl({ top: el.offsetTop, height: el.offsetHeight, on: true })} + /> + } + > +
+ {g.items.map((item: DockerItem) => ( + setHl({ top: el.offsetTop, height: el.offsetHeight, on: true })} + /> + ))} +
+
+ ) + } + return ( + + {idx === firstOtherIndex && firstOtherIndex > 0 && ( +
+ + Not linked to a project + +
+ )} +
+ + {g.items.map((item: DockerItem) => ( + setHl({ top: el.offsetTop, height: el.offsetHeight, on: true })} + /> + ))} +
+
+ ) + }) + )} +
+
+ ) +} diff --git a/src/renderer/src/launcher/views/DockerView.types.ts b/src/renderer/src/launcher/views/DockerView.types.ts new file mode 100644 index 0000000..1fc6459 --- /dev/null +++ b/src/renderer/src/launcher/views/DockerView.types.ts @@ -0,0 +1,31 @@ +import type { DockerInfo, DockerItem, DockerPruneTarget } from '@shared/docker.types' +import type { Density, SizeStyle } from '@shared/settings.types' +import type { DockerSortKey, DockerTypeFilter } from './DockerView.constants' + +export interface DockerViewProps { + info: DockerInfo | null + loading: boolean + /** Search text from the shared header input; filters rows by name. */ + query: string + /** Group/row sort order. Defaults to 'size' until the toolbar (Task 5) supplies it. */ + sortBy?: DockerSortKey + /** Item-kind filter. No UI control for this anymore (chips were removed); defaults to 'all'. */ + typeFilter?: DockerTypeFilter + onRefresh: () => void + /** Index of the keyboard-selected row. Unused for now (docker rows aren't keyboard-navigable yet). */ + selectedIndex?: number + onSelectIndex?: (i: number) => void + /** id of the item being removed, or `prune:` while a prune runs; null when idle. */ + busyId?: string | null + /** Requests removal of a single removable item; caller owns the confirm gate. */ + onRemove?: (item: DockerItem) => void + /** Requests a bulk prune for a category; caller owns the confirm gate. */ + onPrune?: (target: DockerPruneTarget) => void + /** Row density/size-display prefs, mirrored from settings so Docker rows match the node_modules list. */ + density: Density + sizeStyle: SizeStyle + accent: string + /** Largest sizeBytes among the items the Docker tab actually renders (build cache excluded, + * min 1), for SizeViz's bar scale. */ + maxBytes: number +} diff --git a/src/renderer/src/launcher/views/PackagesView.tsx b/src/renderer/src/launcher/views/PackagesView.tsx index 48d6519..8f1ba73 100644 --- a/src/renderer/src/launcher/views/PackagesView.tsx +++ b/src/renderer/src/launcher/views/PackagesView.tsx @@ -1,3 +1,4 @@ +import { Accordion } from '@renderer/components/Accordion' import { PackageDetails, PackageRow, PackageRowSkeleton } from '@renderer/components/PackageRow' import { Spinner } from '@renderer/components/Spinner' import { UIIcon } from '@renderer/components/UIIcon' @@ -192,39 +193,29 @@ export function PackagesView({ {items.map((entry, i) => { const open = entry.name === expandedName return ( -
{ + if (el) rowEls.current[entry.name] = el + }} + onSelect={() => onSelectIndex(i)} + onToggle={() => { + onSelectIndex(i) + onToggleExpand(entry.name) + }} + /> } > - { - if (el) rowEls.current[entry.name] = el - }} - onSelect={() => onSelectIndex(i)} - onToggle={() => { - onSelectIndex(i) - onToggleExpand(entry.name) - }} - /> - {open && onOpen(entry)} />} -
+ onOpen(entry)} /> + ) })}
diff --git a/src/renderer/src/launcher/views/SettingsView.tsx b/src/renderer/src/launcher/views/SettingsView.tsx index 437b866..7d84883 100644 --- a/src/renderer/src/launcher/views/SettingsView.tsx +++ b/src/renderer/src/launcher/views/SettingsView.tsx @@ -59,6 +59,36 @@ function SectionHeading({ title, hint }: { title: string; hint: string }): React ) } +function GbInput({ valueGB, onChange }: { valueGB: number; onChange: (v: number) => void }): ReactNode { + return ( +
+ { + const v = Number(e.target.value) + if (Number.isFinite(v)) onChange(v) + }} + style={{ + width: 74, + background: 'var(--surface-2)', + border: '1px solid var(--hairline)', + borderRadius: 7, + padding: '6px 9px', + fontSize: 12.5, + color: 'var(--text)', + outline: 'none', + fontVariantNumeric: 'tabular-nums', + textAlign: 'right', + }} + /> + GB +
+ ) +} + interface SettingsViewProps { settings: Settings setSetting: SetSetting @@ -192,6 +222,19 @@ export function SettingsView({ setSetting('notify', !settings.notify)} /> + + + + setSetting('cacheThresholdGB', v)} /> + + {(settings.docker ?? true) && ( + <> + + + setSetting('dockerThresholdGB', v)} /> + + + )} )} @@ -213,6 +256,54 @@ export function SettingsView({ hint="Override detection if the store or pnpm can't be found automatically" /> + + + setSetting('docker', !(settings.docker ?? true))} + /> + + {(settings.docker ?? true) && ( + <> + +
+
+ {settings.dockerBinaryPath || 'Auto-detected. Choose to override'} +
+ + {settings.dockerBinaryPath && ( + + )} +
+ + )} )} diff --git a/src/renderer/src/launcher/views/docker-confirm.test.ts b/src/renderer/src/launcher/views/docker-confirm.test.ts new file mode 100644 index 0000000..c27bc48 --- /dev/null +++ b/src/renderer/src/launcher/views/docker-confirm.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { confirmSatisfied, needsTypedConfirm, requiredConfirmText } from './docker-confirm' + +describe('needsTypedConfirm', () => { + it('is true for volumes', () => { + expect(needsTypedConfirm({ kind: 'volume' })).toBe(true) + }) + + it('is false for images, containers, and build cache', () => { + expect(needsTypedConfirm({ kind: 'image' })).toBe(false) + expect(needsTypedConfirm({ kind: 'container' })).toBe(false) + expect(needsTypedConfirm({ kind: 'buildcache' })).toBe(false) + }) +}) + +describe('requiredConfirmText', () => { + it('is the volume name for a per-item removal', () => { + expect(requiredConfirmText({ kind: 'volume', name: 'pgdata' })).toBe('pgdata') + }) + + it('is "prune" for a bulk volume prune', () => { + expect(requiredConfirmText({ kind: 'prune' })).toBe('prune') + }) +}) + +describe('confirmSatisfied', () => { + it('is true when the typed text matches exactly', () => { + expect(confirmSatisfied('pgdata', 'pgdata')).toBe(true) + }) + + it('is false on a partial match', () => { + expect(confirmSatisfied('pgdata', 'pg')).toBe(false) + }) + + it('trims surrounding whitespace from the typed text before comparing', () => { + expect(confirmSatisfied('pgdata', ' pgdata ')).toBe(true) + }) + + it('is false for an empty typed value', () => { + expect(confirmSatisfied('pgdata', '')).toBe(false) + }) + + it('is case-sensitive', () => { + expect(confirmSatisfied('pgdata', 'PGDATA')).toBe(false) + }) + + it('is false when the required text is empty, even if typed is also empty', () => { + expect(confirmSatisfied('', '')).toBe(false) + }) + + it('is true for a real, non-empty volume name typed exactly', () => { + expect(confirmSatisfied('pgdata', 'pgdata')).toBe(true) + }) +}) diff --git a/src/renderer/src/launcher/views/docker-confirm.ts b/src/renderer/src/launcher/views/docker-confirm.ts new file mode 100644 index 0000000..fba0e1d --- /dev/null +++ b/src/renderer/src/launcher/views/docker-confirm.ts @@ -0,0 +1,20 @@ +import type { DockerItem } from '@shared/docker.types' + +/** True when a pending removal needs the extra typed confirmation (all volumes). */ +export function needsTypedConfirm(item: { kind: DockerItem['kind'] }): boolean { + return item.kind === 'volume' +} + +/** The string the user must type: the volume name (per-item) or 'prune' (bulk volumes). */ +export function requiredConfirmText(target: { kind: 'volume'; name: string } | { kind: 'prune' }): string { + return target.kind === 'prune' ? 'prune' : target.name +} + +/** + * Whether the typed text satisfies the required confirmation text (trimmed, exact match). + * A blank `required` never satisfies β€” an empty required string must not let an empty + * typed value slip through the gate (defense-in-depth for an unexpectedly empty name). + */ +export function confirmSatisfied(required: string, typed: string): boolean { + return required.length > 0 && typed.trim() === required +} diff --git a/src/renderer/src/lib/severity/index.ts b/src/renderer/src/lib/severity/index.ts new file mode 100644 index 0000000..5c05b81 --- /dev/null +++ b/src/renderer/src/lib/severity/index.ts @@ -0,0 +1,2 @@ +export type { SeverityCounts, SeverityKey } from './severity' +export { severityCounts } from './severity' diff --git a/src/renderer/src/lib/severity/severity.test.ts b/src/renderer/src/lib/severity/severity.test.ts new file mode 100644 index 0000000..1a186a3 --- /dev/null +++ b/src/renderer/src/lib/severity/severity.test.ts @@ -0,0 +1,39 @@ +import type { PackageEntry } from '@shared/package.types' +import { describe, expect, it } from 'vitest' +import { severityCounts } from './severity' + +const pkg = (o: Partial & Pick): PackageEntry => ({ + usages: [], + projectCount: 1, + versions: [], + multipleVersions: false, + ...o, +}) + +describe('severityCounts', () => { + it('buckets each package by its advisory severity and sums vulnerable', () => { + const c = severityCounts([ + pkg({ name: 'a', advisory: { severity: 'critical', title: '', vulnerableVersions: '' } }), + pkg({ name: 'b', advisory: { severity: 'high', title: '', vulnerableVersions: '' } }), + pkg({ name: 'c', advisory: { severity: 'high', title: '', vulnerableVersions: '' } }), + pkg({ name: 'd', advisory: { severity: 'low', title: '', vulnerableVersions: '' } }), + pkg({ name: 'e' }), // no advisory + ]) + expect(c).toEqual({ critical: 1, high: 2, moderate: 0, low: 1, vulnerable: 4, outdated: 0 }) + }) + + it('counts outdated independently of advisories', () => { + const c = severityCounts([ + pkg({ name: 'a', outdated: true }), + pkg({ name: 'b', outdated: true, advisory: { severity: 'moderate', title: '', vulnerableVersions: '' } }), + pkg({ name: 'c', outdated: false }), + ]) + expect(c.outdated).toBe(2) + expect(c.moderate).toBe(1) + expect(c.vulnerable).toBe(1) + }) + + it('returns all zeros for an empty list', () => { + expect(severityCounts([])).toEqual({ critical: 0, high: 0, moderate: 0, low: 0, vulnerable: 0, outdated: 0 }) + }) +}) diff --git a/src/renderer/src/lib/severity/severity.ts b/src/renderer/src/lib/severity/severity.ts new file mode 100644 index 0000000..d300b09 --- /dev/null +++ b/src/renderer/src/lib/severity/severity.ts @@ -0,0 +1,29 @@ +import type { AdvisorySeverity, PackageEntry } from '@shared/package.types' + +export type SeverityKey = AdvisorySeverity + +export interface SeverityCounts { + critical: number + high: number + moderate: number + low: number + /** critical + high + moderate + low */ + vulnerable: number + outdated: number +} + +/** Tally packages by their worst-advisory severity (already stored on the entry), + * plus a separate count of packages behind `latest`. A package with no advisory + * contributes to no severity bucket; `outdated` is independent of advisories. */ +export function severityCounts(packages: PackageEntry[]): SeverityCounts { + const c: SeverityCounts = { critical: 0, high: 0, moderate: 0, low: 0, vulnerable: 0, outdated: 0 } + for (const p of packages) { + const sev = p.advisory?.severity + if (sev) { + c[sev] += 1 + c.vulnerable += 1 + } + if (p.outdated) c.outdated += 1 + } + return c +} diff --git a/src/renderer/src/lib/staleness/index.ts b/src/renderer/src/lib/staleness/index.ts new file mode 100644 index 0000000..92d2d8f --- /dev/null +++ b/src/renderer/src/lib/staleness/index.ts @@ -0,0 +1 @@ +export { DOCKER_STALE_MS } from './staleness' diff --git a/src/renderer/src/lib/staleness/staleness.ts b/src/renderer/src/lib/staleness/staleness.ts new file mode 100644 index 0000000..55e1f90 --- /dev/null +++ b/src/renderer/src/lib/staleness/staleness.ts @@ -0,0 +1,4 @@ +/** Re-probe Docker when a surface that shows Docker data opens and the cached + * info is older than this. `getDockerInfo` caches until forced, so without a + * staleness rule the numbers would never move. */ +export const DOCKER_STALE_MS = 5 * 60 * 1000 diff --git a/src/renderer/src/panel/PanelApp/AreaBar/AreaBar.tsx b/src/renderer/src/panel/PanelApp/AreaBar/AreaBar.tsx new file mode 100644 index 0000000..7f5ac36 --- /dev/null +++ b/src/renderer/src/panel/PanelApp/AreaBar/AreaBar.tsx @@ -0,0 +1,64 @@ +import { PixelMeter } from '@renderer/components/PixelMeter' +import { SeverityMeter } from '@renderer/components/SeverityMeter' +import { UIIcon } from '@renderer/components/UIIcon' +import { GB } from '@renderer/lib/format' +import type { ReactNode } from 'react' +import type { AreaBarProps } from './AreaBar.types' + +/** One area of the panel dashboard: label, its meter, its value, and a chevron. + * Clicking anywhere opens that area's tab in the main window. Read-only: the + * panel visualizes, the launcher acts. */ +export function AreaBar({ row, accent, onOpen }: AreaBarProps): ReactNode { + return ( + + ) +} diff --git a/src/renderer/src/panel/PanelApp/AreaBar/AreaBar.types.ts b/src/renderer/src/panel/PanelApp/AreaBar/AreaBar.types.ts new file mode 100644 index 0000000..c289339 --- /dev/null +++ b/src/renderer/src/panel/PanelApp/AreaBar/AreaBar.types.ts @@ -0,0 +1,8 @@ +import type { PanelAreaRow } from '../panelAreas' + +export interface AreaBarProps { + row: PanelAreaRow + accent: string + /** Opens this area's tab in the launcher. */ + onOpen: () => void +} diff --git a/src/renderer/src/panel/PanelApp/AreaBar/index.ts b/src/renderer/src/panel/PanelApp/AreaBar/index.ts new file mode 100644 index 0000000..8775024 --- /dev/null +++ b/src/renderer/src/panel/PanelApp/AreaBar/index.ts @@ -0,0 +1,2 @@ +export { AreaBar } from './AreaBar' +export type { AreaBarProps } from './AreaBar.types' diff --git a/src/renderer/src/panel/PanelApp/CleanStaleCta.tsx b/src/renderer/src/panel/PanelApp/CleanStaleCta.tsx deleted file mode 100644 index 395bc58..0000000 --- a/src/renderer/src/panel/PanelApp/CleanStaleCta.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { type ReactNode, useState } from 'react' - -interface CleanStaleCtaProps { - accent: string - sub?: string - children: ReactNode - onClick: () => void -} - -/** Accent call-to-action button (CTA2 in the design). */ -export function CleanStaleCta({ accent, sub, children, onClick }: CleanStaleCtaProps): ReactNode { - const [h, setH] = useState(false) - return ( - - ) -} diff --git a/src/renderer/src/panel/PanelApp/DiskSummary.tsx b/src/renderer/src/panel/PanelApp/DiskSummary.tsx deleted file mode 100644 index f9e0010..0000000 --- a/src/renderer/src/panel/PanelApp/DiskSummary.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { PixelMeter } from '@renderer/components/PixelMeter' -import { UIIcon } from '@renderer/components/UIIcon' -import { statusColor } from '@renderer/lib/colors' -import { formatSizeStr } from '@renderer/lib/format' -import type { ReactNode } from 'react' - -interface DiskSummaryProps { - totalUsed: number - /** threshold in bytes */ - threshold: number - thresholdGB: number - usedGB: number - trackMaxGB: number - accent: string - /** Bytes linked to the pnpm store across projects (shared, counted once). */ - linkedBytes?: number -} - -/** Panel header: total node_modules size, the limit + over/free lines, and the meter. */ -export function DiskSummary({ - totalUsed, - threshold, - thresholdGB, - usedGB, - trackMaxGB, - accent, - linkedBytes = 0, -}: DiskSummaryProps): ReactNode { - const over = totalUsed > threshold - const status = statusColor(totalUsed / threshold, accent) - - return ( -
-
- node_modules on disk -
-
- 0 - ? `Real disk used (packages counted once). A further ${formatSizeStr(linkedBytes)} is linked to the pnpm store and shared across projects.` - : undefined - } - style={{ - fontSize: 27, - fontWeight: 700, - color: '#fff', - letterSpacing: '-.01em', - fontVariantNumeric: 'tabular-nums', - whiteSpace: 'nowrap', - flex: '0 0 auto', - }} - > - {formatSizeStr(totalUsed)} - - {/* Two stacked lines, each no-wrap, so the narrow panel never reflows */} -
- - {UIIcon.hdd({ size: 12 })} - {thresholdGB} GB limit - - {over ? ( - - {UIIcon.alert({ size: 12 })} - {formatSizeStr(totalUsed - threshold)} over - - ) : ( - - {UIIcon.check({ size: 13 })} - {formatSizeStr(threshold - totalUsed)} free - - )} -
-
- {linkedBytes > 0 && ( -
- + {formatSizeStr(linkedBytes)} linked in pnpm store Β· shared -
- )} - -
- ) -} diff --git a/src/renderer/src/panel/PanelApp/PanelApp.constants.ts b/src/renderer/src/panel/PanelApp/PanelApp.constants.ts deleted file mode 100644 index 118c5ac..0000000 --- a/src/renderer/src/panel/PanelApp/PanelApp.constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** Projects untouched for longer than this are "stale" (design's STALE_DAYS). */ -export const STALE_DAYS = 100 - -/** How many reclaimable rows the dropdown shows. */ -export const VISIBLE_ROWS = 4 diff --git a/src/renderer/src/panel/PanelApp/PanelApp.tsx b/src/renderer/src/panel/PanelApp/PanelApp.tsx index 532931a..88adcb4 100644 --- a/src/renderer/src/panel/PanelApp/PanelApp.tsx +++ b/src/renderer/src/panel/PanelApp/PanelApp.tsx @@ -1,123 +1,75 @@ import { MItem } from '@renderer/components/MItem' -import { MiniRow } from '@renderer/components/MiniRow' -import { RescanHint } from '@renderer/components/RescanHint' import { UIIcon } from '@renderer/components/UIIcon' -import { UnlockPrompt } from '@renderer/components/UnlockPrompt' import { useAutoHeight } from '@renderer/hooks/useAutoHeight' -import { useLicense } from '@renderer/hooks/useLicense' -import { useLiveProjects } from '@renderer/hooks/useLiveProjects' +import { useDocker } from '@renderer/hooks/useDocker' +import { usePackages } from '@renderer/hooks/usePackages' import { usePnpmStore } from '@renderer/hooks/usePnpmStore' import { useProjects } from '@renderer/hooks/useProjects' import { useSettings } from '@renderer/hooks/useSettings' -import { useToast } from '@renderer/hooks/useToast' -import { DAY, formatSizeStr, GB } from '@renderer/lib/format' +import { DOCKER_STALE_MS } from '@renderer/lib/staleness' +import type { LauncherNavTarget } from '@shared/launcher-nav.types' import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { CleanStaleCta } from './CleanStaleCta' -import { DiskSummary } from './DiskSummary' -import { STALE_DAYS, VISIBLE_ROWS } from './PanelApp.constants' -import type { PanelToast, PanelView } from './PanelApp.types' +import { AreaBar } from './AreaBar' +import type { PanelView } from './PanelApp.types' import { PanelEmpty } from './PanelEmpty' -import { PnpmStoreRow } from './PnpmStoreRow' +import { panelAreas } from './panelAreas' import { ScanPanel } from './ScanPanel' import { Separator } from './Separator' +import { TrackedSummary } from './TrackedSummary' export function PanelApp(): ReactNode { const [settings, , settingsLoaded] = useSettings() - const projects = useProjects() - const liveById = useLiveProjects() const accent = settings.accent - const threshold = settings.thresholdGB * GB + + const projects = useProjects() + const { store, refresh: refreshStore } = usePnpmStore() + const docker = useDocker() + const { inventory } = usePackages() + const dockerEnabled = settings.docker !== false + + const areas = useMemo( + () => + panelAreas({ + projects, + store, + docker: docker.info, + dockerEnabled, + inventory, + checkUpdates: settings.checkUpdates, + thresholdGB: settings.thresholdGB, + cacheThresholdGB: settings.cacheThresholdGB, + dockerThresholdGB: settings.dockerThresholdGB, + }), + [ + projects, + store, + docker.info, + dockerEnabled, + inventory, + settings.checkUpdates, + settings.thresholdGB, + settings.cacheThresholdGB, + settings.dockerThresholdGB, + ], + ) const [view, setView] = useState('main') - const [deleting, setDeleting] = useState>(() => new Set()) - const [reclaimed, setReclaimed] = useState(0) const [lastScan, setLastScan] = useState(0) - const { toast, flashToast } = useToast() - const { store, pruning, prune } = usePnpmStore() - const { license, activate: activateLicense } = useLicense() - const [unlock, setUnlock] = useState<{ bytes?: number } | null>(null) const rootRef = useRef(null) useAutoHeight(rootRef) useEffect(() => { void window.clean.getLastScanTime().then(setLastScan) - }, [view, projects]) + }, [view]) - // "Real" disk: each project's own freeable bytes plus the pnpm store counted once. - const storeBytes = store?.available ? store.sizeBytes : 0 - const totalUsed = useMemo( - () => projects.reduce((a, p) => a + (p.uniqueSize ?? p.size), 0) + storeBytes, - [projects, storeBytes], - ) - const linkedTotal = useMemo( - () => projects.reduce((a, p) => a + (p.uniqueSize !== undefined ? p.size - p.uniqueSize : 0), 0), - [projects], - ) - const usedGB = totalUsed / GB - - // high-water mark keeps the meter scale stable while deleting (no render-time - // mutation: account for current usage now, persist the peak via state) - const [maxSeenGB, setMaxSeenGB] = useState(usedGB) - useEffect(() => setMaxSeenGB((m) => Math.max(m, usedGB)), [usedGB]) - const trackMaxGB = Math.max(settings.thresholdGB * 1.5, Math.max(maxSeenGB, usedGB) * 1.06) - - const needsRescan = useMemo(() => projects.some((p) => p.uniqueSize === undefined), [projects]) - const oldest = useMemo(() => [...projects].sort((a, b) => a.lastUsed - b.lastUsed), [projects]) - const visible = oldest.slice(0, VISIBLE_ROWS) - const staleSet = useMemo(() => projects.filter((p) => (Date.now() - p.lastUsed) / DAY > STALE_DAYS), [projects]) - const freeable = staleSet.reduce((a, p) => a + (p.uniqueSize ?? p.size), 0) - - const removeMany = useCallback( - async (ids: string[], label?: string) => { - if (!license.pro) { - const bytes = projects.filter((p) => ids.includes(p.id)).reduce((a, p) => a + (p.uniqueSize ?? p.size), 0) - setUnlock({ bytes }) - window.clean.trackEvent('paywall_shown', { - trigger: ids.length > 1 ? 'clean_stale' : 'delete', - teased_gb: Math.round((bytes / GB) * 10) / 10, - }) - return - } - setDeleting((s) => new Set([...s, ...ids])) - // One liveness check for the whole batch instead of one lsof spawn per id. - const { freed } = await window.clean.deleteManyNodeModules(ids) - setDeleting((s) => { - const n = new Set(s) - for (const i of ids) n.delete(i) - return n - }) - setReclaimed((r) => r + freed) - // Everything blocked/skipped and nothing freed β€” nothing worth flashing. - if (freed > 0) { - flashToast({ text: `Reclaimed ${formatSizeStr(freed)}${label ? ` Β· ${label}` : ''}`, good: true }) - } - }, - [flashToast, license.pro, projects], - ) - - const pruneStore = useCallback(async () => { - if (!license.pro) { - setUnlock({}) - window.clean.trackEvent('paywall_shown', { trigger: 'prune' }) - return - } - const result = await prune() - if (result?.ok) { - setReclaimed((r) => r + result.freedBytes) - flashToast({ text: `Reclaimed ${formatSizeStr(result.freedBytes)} Β· pnpm store`, good: true }) - } else if (result) { - flashToast({ text: 'pnpm store prune failed' }) - } - }, [prune, flashToast, license.pro]) - - const cleanStale = useCallback(() => { - if (staleSet.length) { - void removeMany( - staleSet.map((p) => p.id), - `${staleSet.length} stale folders`, - ) - } - }, [staleSet, removeMany]) + // Docker's cache never expires on its own, so re-probe it when the panel opens on + // missing or stale data. Guarded on `loading` and a freshened `checkedAt` so it + // cannot loop; the last-known numbers render instantly meanwhile. + useEffect(() => { + if (view !== 'main' || !dockerEnabled || docker.loading) return + const stale = !docker.info || Date.now() - docker.info.checkedAt > DOCKER_STALE_MS + if (stale) void docker.refresh() + }, [view, dockerEnabled, docker.info, docker.loading, docker.refresh]) // keyboard shortcuts: ⌘O full window, ⌘R scan, ⌘, settings, esc close useEffect(() => { @@ -136,14 +88,13 @@ export function PanelApp(): ReactNode { e.preventDefault() void window.clean.openLauncher('settings') } else if (e.key === 'Escape') { - if (unlock) setUnlock(null) - else if (view !== 'main') setView('main') + if (view !== 'main') setView('main') else void window.clean.closeWindow() } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [view, unlock]) + }, [view]) const nextScanLabel = settings.scanInterval === '6h' @@ -155,121 +106,44 @@ export function PanelApp(): ReactNode { : 'β€”' const lastScanLabel = lastScan ? `${Math.max(1, Math.round((Date.now() - lastScan) / 60000))} min ago` : 'never' + const openArea = useCallback((nav: LauncherNavTarget): void => { + void window.clean.openLauncher(nav) + }, []) + return (
- {view === 'scan' && setView('main')} />} + {view === 'scan' && ( + { + setView('main') + void refreshStore() + }} + /> + )} {view === 'main' && ( <> - - - {needsRescan && setView('scan')} />} - {projects.length === 0 ? ( - settingsLoaded ? ( - void window.clean.openLauncher()} - /> - ) : null - ) : ( - <> -
- - Reclaimable Β· oldest first - - {projects.length} total -
- {visible.map((p) => ( - void window.clean.openLauncher()} /> + ) : ( + <> + void removeMany([p.id], p.name)} - onReveal={() => { - void window.clean.revealInFinder(p.id) - flashToast({ text: `Revealing ${p.name} in Finder…` }) - }} /> - ))} - {staleSet.length > 0 ? ( - - Clean {staleSet.length} stale folders - - ) : ( -
- )} - - )} - {unlock && ( - <> - - setUnlock(null)} - needsReverify={license.needsReverify} - /> - - )} - {!license.pro && !unlock && projects.length > 0 && ( - - )} - {store?.available && ( - <> - - void pruneStore()} /> - - )} + +
+ {areas.rows.map((row) => ( + openArea(row.nav)} /> + ))} +
+ + ))}
)} - - {toast && ( -
- - {(toast.good ? UIIcon.checkCircle : UIIcon.finder)({ size: 15 })} - - {toast.text} -
- )}
) } diff --git a/src/renderer/src/panel/PanelApp/PanelApp.types.ts b/src/renderer/src/panel/PanelApp/PanelApp.types.ts index b4f3890..5c943dc 100644 --- a/src/renderer/src/panel/PanelApp/PanelApp.types.ts +++ b/src/renderer/src/panel/PanelApp/PanelApp.types.ts @@ -1,6 +1 @@ export type PanelView = 'main' | 'scan' - -export interface PanelToast { - text: string - good?: boolean -} diff --git a/src/renderer/src/panel/PanelApp/PanelEmpty.tsx b/src/renderer/src/panel/PanelApp/PanelEmpty.tsx index f9e4eec..2093aec 100644 --- a/src/renderer/src/panel/PanelApp/PanelEmpty.tsx +++ b/src/renderer/src/panel/PanelApp/PanelEmpty.tsx @@ -1,53 +1,40 @@ import { AppIcon } from '@renderer/components/AppIcon' -import { PingBadge } from '@renderer/components/PingBadge' -import { UIIcon } from '@renderer/components/UIIcon' -import { formatSizeStr } from '@renderer/lib/format' import type { ReactNode } from 'react' interface PanelEmptyProps { - onboarded: boolean - reclaimed: number accent: string onOpenSetup: () => void } -/** Panel body when there are no projects: first-run setup nudge, or all-clean. */ -export function PanelEmpty({ onboarded, reclaimed, accent, onOpenSetup }: PanelEmptyProps): ReactNode { - if (!onboarded) { - return ( -
- -
Finish setup
-
- Set your limit & run the first scan in the full window. -
- -
- ) - } +/** Panel body before the first scan: there is genuinely nothing to visualize yet. + * There is deliberately no "all clean" state β€” zero projects does not mean zero + * disk used, and a verdict about one area must never replace the whole dashboard + * (see the spec's D6b). */ +export function PanelEmpty({ accent, onOpenSetup }: PanelEmptyProps): ReactNode { return ( -
- -
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 }