From feec6b049ac2abbc3e7d740f234327692c250558 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Tue, 7 Jul 2026 23:13:47 +0200 Subject: [PATCH 01/89] =?UTF-8?q?docs:=20design=20spec=20=E2=80=94=20Docke?= =?UTF-8?q?r=20images/volumes/containers/build-cache=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../specs/2026-07-07-docker-cleanup-design.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-07-docker-cleanup-design.md 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. From d0d5e1c15cf30ef12a52ce5e8cebb614e1b26d67 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Tue, 7 Jul 2026 23:53:36 +0200 Subject: [PATCH 02/89] =?UTF-8?q?docs:=20implementation=20plan=20=E2=80=94?= =?UTF-8?q?=20Docker=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-07-docker-cleanup.md | 1069 +++++++++++++++++ 1 file changed, 1069 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-docker-cleanup.md 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. From 1c26e5f2a46f78798cb68828cf385d73136134ef Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 00:01:54 +0200 Subject: [PATCH 03/89] feat(app): Docker shared model, IPC channels, settings keys --- src/main/settings/validate-setting.test.ts | 10 ++++++ src/main/settings/validate-setting.ts | 2 ++ src/shared/docker.types.ts | 40 ++++++++++++++++++++++ src/shared/ipc.constants.ts | 3 ++ src/shared/settings.constants.ts | 1 + src/shared/settings.types.ts | 4 +++ 6 files changed, 60 insertions(+) create mode 100644 src/shared/docker.types.ts diff --git a/src/main/settings/validate-setting.test.ts b/src/main/settings/validate-setting.test.ts index de1a5cc..224e787 100644 --- a/src/main/settings/validate-setting.test.ts +++ b/src/main/settings/validate-setting.test.ts @@ -69,6 +69,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..9b9237e 100644 --- a/src/main/settings/validate-setting.ts +++ b/src/main/settings/validate-setting.ts @@ -38,6 +38,7 @@ 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': return typeof value === 'number' && Number.isFinite(value) @@ -45,6 +46,7 @@ export function coerceSetting( : 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/shared/docker.types.ts b/src/shared/docker.types.ts new file mode 100644 index 0000000..dd4a0a1 --- /dev/null +++ b/src/shared/docker.types.ts @@ -0,0 +1,40 @@ +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 +} 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/settings.constants.ts b/src/shared/settings.constants.ts index 7dc088b..3e4315e 100644 --- a/src/shared/settings.constants.ts +++ b/src/shared/settings.constants.ts @@ -11,6 +11,7 @@ export const DEFAULT_SETTINGS: Settings = { 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..e50e14e 100644 --- a/src/shared/settings.types.ts +++ b/src/shared/settings.types.ts @@ -20,4 +20,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 } From ae27b203cd275411fddc48c29eb5ed7d15bed291 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 00:04:46 +0200 Subject: [PATCH 04/89] feat(app): find-docker binary resolution --- src/main/docker/find-docker.test.ts | 17 ++++++++++ src/main/docker/find-docker.ts | 50 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/main/docker/find-docker.test.ts create mode 100644 src/main/docker/find-docker.ts 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 +} From ad45ac94c30da53742ba6f8e715bc69ff00240dd Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 00:08:43 +0200 Subject: [PATCH 05/89] feat(app): docker df/ps parsers Pure parseSize/parseDate/parseDf/parseContainers/buildDockerItems functions turning `docker system df -v` and `docker ps -a` JSON output into DockerItem[]/DockerCategoryTotal[] with in-use/removable derivation. No exec, no daemon calls; fully unit-tested. --- src/main/docker/docker-parse.test.ts | 90 ++++++++++++++ src/main/docker/docker-parse.ts | 172 +++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/main/docker/docker-parse.test.ts create mode 100644 src/main/docker/docker-parse.ts diff --git a/src/main/docker/docker-parse.test.ts b/src/main/docker/docker-parse.test.ts new file mode 100644 index 0000000..2c6e069 --- /dev/null +++ b/src/main/docker/docker-parse.test.ts @@ -0,0 +1,90 @@ +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) + }) +}) diff --git a/src/main/docker/docker-parse.ts b/src/main/docker/docker-parse.ts new file mode 100644 index 0000000..11159d4 --- /dev/null +++ b/src/main/docker/docker-parse.ts @@ -0,0 +1,172 @@ +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) } +} From c2abca6e009e967e4a4d9e5d9c2cb762f9c586d6 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 00:14:31 +0200 Subject: [PATCH 06/89] fix(app): docker container removable states (exited/created/dead only) `docker rm` (no -f) refuses running, paused, restarting, and removing containers. Container inUse/removable derivation now matches that: inUse for running|paused, removable only for exited|created|dead. Also make parseDate robust to any numeric timezone offset (not just +0000) instead of silently returning 0. --- src/main/docker/docker-parse.test.ts | 14 ++++++++++++++ src/main/docker/docker-parse.ts | 18 +++++++++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/main/docker/docker-parse.test.ts b/src/main/docker/docker-parse.test.ts index 2c6e069..06a3251 100644 --- a/src/main/docker/docker-parse.test.ts +++ b/src/main/docker/docker-parse.test.ts @@ -41,6 +41,14 @@ const DF = JSON.stringify({ 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' }], }) @@ -78,6 +86,12 @@ describe('buildDockerItems', () => { 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) }) diff --git a/src/main/docker/docker-parse.ts b/src/main/docker/docker-parse.ts index 11159d4..0fc2250 100644 --- a/src/main/docker/docker-parse.ts +++ b/src/main/docker/docker-parse.ts @@ -21,10 +21,15 @@ export function parseSize(s: string): number { return Number.isFinite(n) ? Math.round(n * mult) : 0 } -/** "2026-01-02 10:00:00 +0000 UTC" → ms epoch (0 if unparseable). */ +/** "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 - const t = Date.parse(s.replace(' UTC', '').replace(' +0000', 'Z').replace(' ', 'T')) + // Strip a trailing zone word (e.g. "UTC", "EST") and normalize a numeric + // "+HHMM"/"-HHMM" offset into the "+HH:MM" form Date.parse understands, so + // any docker-reported timezone (not just +0000) parses correctly. + const withoutZoneWord = s.replace(/\s+[A-Za-z]+$/, '') + const isoish = withoutZoneWord.replace(' ', 'T').replace(/([+-])(\d{2})(\d{2})$/, '$1$2:$3') + const t = Date.parse(isoish) return Number.isFinite(t) ? t : 0 } @@ -145,15 +150,18 @@ export function buildDockerItems( }) } for (const c of df.containers) { - const running = c.State === 'running' + // `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: running, - removable: !running, + inUse, + removable, }) } for (const cache of df.buildCache) { From 9e3489738ae869b642a3c928c893083f3ff2a6ef Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 00:18:16 +0200 Subject: [PATCH 07/89] fix(app): parseDate handles docker timestamp format correctly Date.parse rejected the previous ISO-ish string because a space was left before the timezone offset, silently returning NaN -> 0 for every docker timestamp. Switch to an explicit regex extraction of date/time/offset and rebuild a proper ISO string. Add exact-epoch assertions (not just booleans) covering the standard "+0000 UTC" form, fractional seconds, and a non-UTC numeric offset. --- src/main/docker/docker-parse.test.ts | 15 ++++++++++++++- src/main/docker/docker-parse.ts | 11 +++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/main/docker/docker-parse.test.ts b/src/main/docker/docker-parse.test.ts index 06a3251..9f7b080 100644 --- a/src/main/docker/docker-parse.test.ts +++ b/src/main/docker/docker-parse.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildDockerItems, parseContainers, parseDf, parseSize } from './docker-parse' +import { buildDockerItems, parseContainers, parseDate, parseDf, parseSize } from './docker-parse' const DF = JSON.stringify({ Images: [ @@ -62,6 +62,19 @@ describe('parseSize', () => { }) }) +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 ps = parseContainers('') // df already carries containers; ps not needed for this fixture diff --git a/src/main/docker/docker-parse.ts b/src/main/docker/docker-parse.ts index 0fc2250..69a1c32 100644 --- a/src/main/docker/docker-parse.ts +++ b/src/main/docker/docker-parse.ts @@ -24,12 +24,11 @@ export function parseSize(s: string): number { /** "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 - // Strip a trailing zone word (e.g. "UTC", "EST") and normalize a numeric - // "+HHMM"/"-HHMM" offset into the "+HH:MM" form Date.parse understands, so - // any docker-reported timezone (not just +0000) parses correctly. - const withoutZoneWord = s.replace(/\s+[A-Za-z]+$/, '') - const isoish = withoutZoneWord.replace(' ', 'T').replace(/([+-])(\d{2})(\d{2})$/, '$1$2:$3') - const t = Date.parse(isoish) + // 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 } From aa59cb340778529ed95114c275d92236cb930e2f Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 00:21:23 +0200 Subject: [PATCH 08/89] feat(app): getDockerInfo with availability probe + cache Mirrors pnpm-store.ts's cache-and-refresh pattern for Docker: probes binary presence via findDocker, checks daemon reachability with `docker version`, then parses `system df` + `ps -a` into DockerInfo via docker-parse's buildDockerItems. Exposes __setExecForTests so the test suite needs no live daemon, plus run/CLI_TIMEOUT_MS/PRUNE_TIMEOUT_MS for reuse by the prune actions (Task 8). --- src/main/docker/docker.test.ts | 40 ++++++++++++ src/main/docker/docker.ts | 108 +++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 src/main/docker/docker.test.ts create mode 100644 src/main/docker/docker.ts diff --git a/src/main/docker/docker.test.ts b/src/main/docker/docker.test.ts new file mode 100644 index 0000000..d6cb843 --- /dev/null +++ b/src/main/docker/docker.test.ts @@ -0,0 +1,40 @@ +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') + }) +}) diff --git a/src/main/docker/docker.ts b/src/main/docker/docker.ts new file mode 100644 index 0000000..c2ac7cc --- /dev/null +++ b/src/main/docker/docker.ts @@ -0,0 +1,108 @@ +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 } From 16a3b29f030f51502808e58dd1cb931eefbe0459 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 00:26:22 +0200 Subject: [PATCH 09/89] fix(app): key-aware getDockerInfo coalescing + tests getDockerInfo's inFlight coalescing ignored opts.binaryPath, so a concurrent call with a different binary path silently received the other path's pending result. Mirror pnpm-store.ts's inFlightKey pattern: only reuse the pending promise when its key matches. Add regression coverage: a "not installed" case and a concurrency test proving two different binaryPaths each run their own version probe instead of aliasing. --- src/main/docker/docker.test.ts | 35 +++++++++++++++++++++++++++++++++- src/main/docker/docker.ts | 4 +++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/main/docker/docker.test.ts b/src/main/docker/docker.test.ts index d6cb843..f5c2cae 100644 --- a/src/main/docker/docker.test.ts +++ b/src/main/docker/docker.test.ts @@ -2,11 +2,12 @@ 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'), + findDocker: vi.fn(async (override?: string) => override ?? '/usr/local/bin/docker'), dockerExecEnv: vi.fn(async () => ({})), })) import { __setExecForTests, getDockerInfo } from './docker' +import { findDocker } from './find-docker' const DF = JSON.stringify({ Images: [{ ID: 'i1', Repository: '', Tag: '', CreatedAt: '', Size: '400MB', Containers: '0' }], @@ -15,6 +16,8 @@ const DF = JSON.stringify({ 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) => { @@ -37,4 +40,34 @@ describe('getDockerInfo', () => { 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) + }) }) diff --git a/src/main/docker/docker.ts b/src/main/docker/docker.ts index c2ac7cc..f8e9e32 100644 --- a/src/main/docker/docker.ts +++ b/src/main/docker/docker.ts @@ -31,6 +31,7 @@ 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') @@ -90,7 +91,7 @@ export async function getDockerInfo(force = false, opts: DockerOpts = {}): Promi loadDiskCache() const key = opts.binaryPath ?? '' if (!force && cached && key === cachedKey) return cached - if (inFlight) return inFlight + if (inFlight && inFlightKey === key) return inFlight const pending = (async (): Promise => { const info = await readDockerInfo(opts) cached = info @@ -99,6 +100,7 @@ export async function getDockerInfo(force = false, opts: DockerOpts = {}): Promi return info })() inFlight = pending + inFlightKey = key void pending.finally(() => { if (inFlight === pending) inFlight = null }) From a763cef6dcadc615cd4d0365ea0e5c50028ae9c6 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 00:28:31 +0200 Subject: [PATCH 10/89] feat(app): docker:get IPC + preload --- src/main/ipc/register-ipc.ts | 5 +++++ src/preload/api.types.ts | 2 ++ src/preload/index.ts | 1 + 3 files changed, 8 insertions(+) diff --git a/src/main/ipc/register-ipc.ts b/src/main/ipc/register-ipc.ts index d08651c..6e94d67 100644 --- a/src/main/ipc/register-ipc.ts +++ b/src/main/ipc/register-ipc.ts @@ -14,6 +14,7 @@ import { RENDERER_EVENTS } from '../analytics' import type { AppContext } from '../app-context.types' import { liveGuard } from '../liveness/guard-live' import { detectLiveProjects } from '../liveness/liveness' +import { getDockerInfo } from '../docker/docker' import { getPnpmStoreInfo, prunePnpmStore } from '../pnpm-store/pnpm-store' import { coerceSetting } from '../settings/validate-setting' import { coerceCardPayload, copyCardToClipboard } from '../share' @@ -65,6 +66,10 @@ 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.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/preload/api.types.ts b/src/preload/api.types.ts index 248ac56..625628b 100644 --- a/src/preload/api.types.ts +++ b/src/preload/api.types.ts @@ -1,5 +1,6 @@ import type { DeleteManyResult, DeleteResult } from '@shared/delete.types' import type { LauncherNavTarget } from '@shared/launcher-nav.types' +import type { DockerInfo } from '@shared/docker.types' import type { ActivateResult, LicenseState } from '@shared/license.types' import type { LiveInfo } from '@shared/liveness.types' import type { PackageInventory } from '@shared/package.types' @@ -14,6 +15,7 @@ export interface CleanApi { getLastScanTime(): Promise getPnpmStore(force?: boolean): Promise prunePnpmStore(): Promise + getDocker(force?: boolean): 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..a1da5fb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,6 +14,7 @@ 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), prunePnpmStore: () => ipcRenderer.invoke(IPC.prunePnpmStore), getPackages: () => ipcRenderer.invoke(IPC.getPackages), computePackages: (force) => ipcRenderer.invoke(IPC.computePackages, force), From 9d591d422f86fa248926dbb8fcbab1bfaab91cda Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 00:32:46 +0200 Subject: [PATCH 11/89] feat(app): Docker tab (read-only listing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds useDocker (Phase-A subset: info/loading/refresh) and DockerView, grouping images/volumes/containers/build-cache into sections with CacheRow, filtered by the shared search query. Wires the 'docker' tab into LauncherApp: Segmented option, ⌘4 shortcut, ⌘R refresh, body switch. No renderer JSX test harness exists yet (no @testing-library/react, no prior .test.tsx files), so grouping/filtering/detail-line logic was extracted into DockerView.constants.ts and unit-tested with vitest; DockerView.tsx itself stays a thin consumer of that logic. --- src/renderer/src/hooks/useDocker.ts | 40 +++++++++++ .../src/launcher/LauncherApp/LauncherApp.tsx | 21 +++++- .../launcher/LauncherApp/LauncherApp.types.ts | 2 +- .../views/DockerView.constants.test.ts | 62 +++++++++++++++++ .../launcher/views/DockerView.constants.ts | 34 ++++++++++ .../src/launcher/views/DockerView.tsx | 66 +++++++++++++++++++ .../src/launcher/views/DockerView.types.ts | 15 +++++ 7 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/hooks/useDocker.ts create mode 100644 src/renderer/src/launcher/views/DockerView.constants.test.ts create mode 100644 src/renderer/src/launcher/views/DockerView.constants.ts create mode 100644 src/renderer/src/launcher/views/DockerView.tsx create mode 100644 src/renderer/src/launcher/views/DockerView.types.ts diff --git a/src/renderer/src/hooks/useDocker.ts b/src/renderer/src/hooks/useDocker.ts new file mode 100644 index 0000000..95dc888 --- /dev/null +++ b/src/renderer/src/hooks/useDocker.ts @@ -0,0 +1,40 @@ +import type { DockerInfo } 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 + refresh: () => Promise +} + +/** Docker images/volumes/containers/build-cache info, synced with the main process. + * Phase-A subset (read-only); remove/prune land in a later task. */ +export function useDocker(): UseDocker { + 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 } +} diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index d3df791..9c9387b 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -9,6 +9,7 @@ import { Spinner } from '@renderer/components/Spinner' 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' @@ -23,6 +24,7 @@ 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 { DockerView } from '../views/DockerView' import { EmptyView } from '../views/EmptyView' import { Onboarding } from '../views/Onboarding' import { PackagesView } from '../views/PackagesView' @@ -64,6 +66,7 @@ export function LauncherApp(): ReactNode { const [cardCopied, setCardCopied] = useState(false) const { toast, flashToast } = useToast() const { store, loading: storeLoading, pruning, prune, refresh } = usePnpmStore() + const docker = useDocker() const pkgActive = view === 'list' && tab === 'packages' const { inventory, @@ -286,12 +289,13 @@ export function LauncherApp(): ReactNode { if (meta && (e.key === 'r' || e.key === 'R')) { e.preventDefault() if (tab === 'packages') void refreshPackages() + else if (tab === 'docker') 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)) { 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) setUnlock(null) @@ -402,6 +406,7 @@ export function LauncherApp(): ReactNode { expandedPkg, togglePkgExpand, collapsePkg, + docker, ]) // The window is hidden (not destroyed) on blur/esc, so it keeps its React @@ -478,7 +483,9 @@ export function LauncherApp(): ReactNode { ? 'Search node_modules by project or path…' : tab === 'packages' ? 'Search packages…' - : 'Search caches…' + : tab === 'docker' + ? 'Search Docker…' + : 'Search caches…' } /> {tab === 'projects' && !isEmpty && ( @@ -613,6 +621,13 @@ export function LauncherApp(): ReactNode { onSelectIndex={setSel} onPrune={handlePrune} /> + ) : tab === 'docker' ? ( + void docker.refresh()} + /> ) : isEmpty ? ( { + it('groups items by kind in Images/Volumes/Containers/Build cache order, omitting empty groups', () => { + const groups = groupDockerItems(items, '') + expect(groups.map((g) => g.label)).toEqual(['Images', 'Volumes', 'Containers']) + expect(groups.find((g) => g.label === 'Images')?.items.map((i) => i.name)).toEqual(['node:20']) + }) + + it('filters items by name, case-insensitively', () => { + const groups = groupDockerItems(items, 'NODE') + expect(groups).toHaveLength(1) + expect(groups[0]?.label).toBe('Images') + expect(groups[0]?.items.map((i) => i.name)).toEqual(['node:20']) + }) + + it('returns no groups when nothing matches the query', () => { + expect(groupDockerItems(items, 'zzz')).toEqual([]) + }) + + it('returns no groups for an empty item list', () => { + expect(groupDockerItems([], '')).toEqual([]) + }) +}) + +describe('dockerItemDetail', () => { + const NOW = Date.UTC(2026, 0, 10) + + it('shows a relative created date and the in-use badge', () => { + const item: DockerItem = { + id: 'x', + kind: 'image', + name: 'x', + sizeBytes: 1, + createdAt: NOW - 86400000, + inUse: true, + removable: false, + } + expect(dockerItemDetail(item, NOW)).toBe('yesterday · in use') + }) + + it('shows "unknown date" when createdAt is 0, and the unused badge', () => { + const item: DockerItem = { + id: 'x', + kind: 'image', + name: 'x', + sizeBytes: 1, + createdAt: 0, + inUse: false, + removable: true, + } + expect(dockerItemDetail(item, NOW)).toBe('unknown date · unused') + }) +}) 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..35eab12 --- /dev/null +++ b/src/renderer/src/launcher/views/DockerView.constants.ts @@ -0,0 +1,34 @@ +import { relativeTime } from '@renderer/lib/format' +import type { DockerItem, DockerItemKind } from '@shared/docker.types' + +export interface DockerGroup { + kind: DockerItemKind + label: string + items: DockerItem[] +} + +/** Fixed display order for the Docker tab's sections. */ +const GROUP_ORDER: Array<{ kind: DockerItemKind; label: string }> = [ + { kind: 'image', label: 'Images' }, + { kind: 'volume', label: 'Volumes' }, + { kind: 'container', label: 'Containers' }, + { kind: 'buildcache', label: 'Build cache' }, +] + +/** Filters items by name (case-insensitive substring) and groups them by kind, in a fixed + * display order. Groups with no matching items are omitted entirely. */ +export function groupDockerItems(items: DockerItem[], query: string): DockerGroup[] { + const q = query.trim().toLowerCase() + const filtered = q ? items.filter((i) => i.name.toLowerCase().includes(q)) : items + return GROUP_ORDER.map(({ kind, label }) => ({ + kind, + label, + items: filtered.filter((i) => i.kind === kind), + })).filter((g) => g.items.length > 0) +} + +/** Detail line for a Docker row: relative created date (or "unknown date") plus in-use badge. */ +export function dockerItemDetail(item: DockerItem, now = Date.now()): string { + const created = item.createdAt > 0 ? relativeTime(item.createdAt, now) : 'unknown date' + return `${created} · ${item.inUse ? 'in use' : 'unused'}` +} diff --git a/src/renderer/src/launcher/views/DockerView.tsx b/src/renderer/src/launcher/views/DockerView.tsx new file mode 100644 index 0000000..92b0861 --- /dev/null +++ b/src/renderer/src/launcher/views/DockerView.tsx @@ -0,0 +1,66 @@ +import { CacheRow } from '@renderer/components/CacheRow' +import type { IconRenderer } from '@renderer/components/UIIcon' +import { UIIcon } from '@renderer/components/UIIcon' +import type { DockerItemKind } from '@shared/docker.types' +import type { ReactNode } from 'react' +import { dockerItemDetail, groupDockerItems } 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, +} + +/** The launcher's "Docker" tab: images/volumes/containers/build cache, grouped by kind. + * Phase A is read-only — no remove/prune actions yet (Task 9 adds them). */ +export function DockerView({ info, query }: DockerViewProps): ReactNode { + if (!info?.available) { + return ( +
+ {info?.reason ?? 'Docker not found'} +
+ ) + } + + const groups = groupDockerItems(info.items, query) + + return ( +
+
+ {groups.length === 0 ? ( +
+ {query ? `No Docker resources match “${query}”.` : 'No Docker resources found.'} +
+ ) : ( + groups.map((g) => ( +
+
+ {g.label} +
+ {g.items.map((item) => ( + + ))} +
+ )) + )} +
+
+ ) +} 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..fbb9b79 --- /dev/null +++ b/src/renderer/src/launcher/views/DockerView.types.ts @@ -0,0 +1,15 @@ +import type { DockerActionResult, DockerInfo } from '@shared/docker.types' + +export interface DockerViewProps { + info: DockerInfo | null + loading: boolean + /** Search text from the shared header input; filters rows by name. */ + query: string + onRefresh: () => void + /** Index of the keyboard-selected row. Unused in Phase A (read-only). */ + selectedIndex?: number + onSelectIndex?: (i: number) => void + /** Phase B actions — unused in Phase A. */ + onRemove?: (id: string) => Promise + onPrune?: () => Promise +} From 47bcbb2281e478b90533d9d3a944c772d152f210 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 00:38:18 +0200 Subject: [PATCH 12/89] fix(app): docker tab keyboard guard + cold-load state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker tab had no keydown guard, so arrow/Enter/⌘⌫ fell through to the projects-list handlers (doOpen, setConfirm) while browsing Docker resources. Add a tab === 'docker' early-return alongside the existing caches/packages blocks; Phase A has no row actions, so it just swallows the keys. DockerView also ignored its loading prop, rendering the "Docker not found" state during the initial fetch before info resolves. Render a neutral "Checking Docker…" state while loading && !info instead. --- src/renderer/src/launcher/LauncherApp/LauncherApp.tsx | 5 +++++ src/renderer/src/launcher/views/DockerView.tsx | 10 +++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index 9c9387b..c2c31f0 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -352,6 +352,11 @@ export function LauncherApp(): ReactNode { } return } + if (tab === 'docker') { + // Phase A is read-only: no row actions yet, so just don't let + // Arrow/Enter/⌘⌫ fall through to the projects-list logic below. + return + } if (tab === 'packages') { if (e.key === 'ArrowDown') { e.preventDefault() diff --git a/src/renderer/src/launcher/views/DockerView.tsx b/src/renderer/src/launcher/views/DockerView.tsx index 92b0861..3ff45bb 100644 --- a/src/renderer/src/launcher/views/DockerView.tsx +++ b/src/renderer/src/launcher/views/DockerView.tsx @@ -15,7 +15,15 @@ const KIND_ICON: Record = { /** The launcher's "Docker" tab: images/volumes/containers/build cache, grouped by kind. * Phase A is read-only — no remove/prune actions yet (Task 9 adds them). */ -export function DockerView({ info, query }: DockerViewProps): ReactNode { +export function DockerView({ info, loading, query }: DockerViewProps): ReactNode { + if (loading && !info) { + return ( +
+ Checking Docker… +
+ ) + } + if (!info?.available) { return (
From 94569b8e1bfc6381ea8013ac5190fc7210acf013 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 00:42:44 +0200 Subject: [PATCH 13/89] feat(app): Docker settings toggle + binary override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Settings toggle for settings.docker (default on) with an optional docker binary path override, and gates the Docker tab's Segmented option and its ⌘4 shortcut on the setting so disabling it hides the tab and can't be reached via keyboard. --- .../src/launcher/LauncherApp/LauncherApp.tsx | 30 +++++++++--- .../src/launcher/views/SettingsView.tsx | 48 +++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index c2c31f0..12e1ea4 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -4,6 +4,7 @@ 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 { UIIcon } from '@renderer/components/UIIcon' @@ -66,6 +67,16 @@ export function LauncherApp(): ReactNode { 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 { @@ -104,6 +115,14 @@ export function LauncherApp(): ReactNode { if (nav === 'settings') setView('settings') }) }, []) + // 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]) const inputRef = useRef(null) const listRef = useRef(null) @@ -294,6 +313,7 @@ export function LauncherApp(): ReactNode { return } 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' : e.key === '3' ? 'packages' : 'docker') setSel(0) @@ -412,6 +432,7 @@ export function LauncherApp(): ReactNode { togglePkgExpand, collapsePkg, docker, + dockerEnabled, ]) // The window is hidden (not destroyed) on blur/esc, so it keeps its React @@ -576,12 +597,7 @@ export function LauncherApp(): ReactNode { setUnlock(null) collapsePkg() }} - options={[ - { value: 'projects', label: 'Projects' }, - { value: 'caches', label: 'Caches' }, - { value: 'packages', label: 'Packages' }, - { value: 'docker', label: 'Docker' }, - ]} + options={tabOptions} /> {tab === 'projects' && !isEmpty && (
@@ -626,7 +642,7 @@ export function LauncherApp(): ReactNode { onSelectIndex={setSel} onPrune={handlePrune} /> - ) : tab === 'docker' ? ( + ) : tab === 'docker' && dockerEnabled ? ( + + + setSetting('docker', !(settings.docker ?? true))} + /> + + {(settings.docker ?? true) && ( + <> + +
+
+ {settings.dockerBinaryPath || 'Auto-detected. Choose to override'} +
+ + {settings.dockerBinaryPath && ( + + )} +
+ + )} )} From 8ef51404d3a17ff5ffcfaade13c4d944b09c83db Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 08:36:28 +0200 Subject: [PATCH 14/89] feat(app): docker remove + prune with freed-bytes diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add removeDockerItem (kind→CLI mapping, buildcache refused, never -f) and pruneDocker (target→`docker prune -f`) to docker.ts. Freed bytes come from a grand-total `docker system df` before/after diff, parsed as one JSON object per line via parseSize. --- src/main/docker/docker.test.ts | 31 ++++++++++++++- src/main/docker/docker.ts | 70 +++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/main/docker/docker.test.ts b/src/main/docker/docker.test.ts index f5c2cae..0652d92 100644 --- a/src/main/docker/docker.test.ts +++ b/src/main/docker/docker.test.ts @@ -6,7 +6,7 @@ vi.mock('./find-docker', () => ({ dockerExecEnv: vi.fn(async () => ({})), })) -import { __setExecForTests, getDockerInfo } from './docker' +import { __setExecForTests, getDockerInfo, pruneDocker, removeDockerItem } from './docker' import { findDocker } from './find-docker' const DF = JSON.stringify({ @@ -71,3 +71,32 @@ describe('getDockerInfo', () => { expect(versionCallsByBin['/b/docker']).toBe(1) }) }) + +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') +}) diff --git a/src/main/docker/docker.ts b/src/main/docker/docker.ts index f8e9e32..5646edf 100644 --- a/src/main/docker/docker.ts +++ b/src/main/docker/docker.ts @@ -2,7 +2,7 @@ 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 type { DockerActionResult, DockerInfo, DockerItemKind, DockerPruneTarget } from '@shared/docker.types' import { app } from 'electron' import { buildDockerItems, parseContainers, parseDf } from './docker-parse' import { dockerExecEnv, findDocker } from './find-docker' @@ -107,4 +107,72 @@ export async function getDockerInfo(force = false, opts: DockerOpts = {}): Promi return pending } +/** 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) } +} + export { CLI_TIMEOUT_MS, PRUNE_TIMEOUT_MS, run } From 0ba4460a5c1d5305de3088891729c5b8b4100e2d Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 08:41:57 +0200 Subject: [PATCH 15/89] fix(app): docker freed-bytes measurement failure handling + safety tests --- src/main/docker/docker.test.ts | 114 +++++++++++++++++++++++++++++++++ src/main/docker/docker.ts | 15 +++-- 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/src/main/docker/docker.test.ts b/src/main/docker/docker.test.ts index 0652d92..cc4e15e 100644 --- a/src/main/docker/docker.test.ts +++ b/src/main/docker/docker.test.ts @@ -100,3 +100,117 @@ it('pruneDocker maps each target to the right prune command', async () => { 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 index 5646edf..84d9e25 100644 --- a/src/main/docker/docker.ts +++ b/src/main/docker/docker.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { promisify } from 'node:util' import type { DockerActionResult, DockerInfo, DockerItemKind, DockerPruneTarget } from '@shared/docker.types' import { app } from 'electron' -import { buildDockerItems, parseContainers, parseDf } from './docker-parse' +import { buildDockerItems, parseContainers, parseDf, parseSize } from './docker-parse' import { dockerExecEnv, findDocker } from './find-docker' const CLI_TIMEOUT_MS = 60_000 @@ -107,12 +107,11 @@ export async function getDockerInfo(force = false, opts: DockerOpts = {}): Promi return pending } -/** Grand-total docker disk usage in bytes (sum of `docker system df` rows). */ -async function totalDiskBytes(bin: string): Promise { +/** 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, ...) - const { parseSize } = await import('./docker-parse') return out .split('\n') .map((l) => l.trim()) @@ -125,7 +124,7 @@ async function totalDiskBytes(bin: string): Promise { } }, 0) } catch { - return 0 + return null } } @@ -151,7 +150,8 @@ export async function removeDockerItem( return { ok: false, freedBytes: 0 } } const after = await totalDiskBytes(bin) - return { ok: true, freedBytes: Math.max(0, before - after) } + const freedBytes = before !== null && after !== null ? Math.max(0, before - after) : 0 + return { ok: true, freedBytes } } const PRUNE_ARGS: Record = { @@ -172,7 +172,8 @@ export async function pruneDocker(target: DockerPruneTarget, opts: DockerOpts = return { ok: false, freedBytes: 0 } } const after = await totalDiskBytes(bin) - return { ok: true, freedBytes: Math.max(0, before - after) } + const freedBytes = before !== null && after !== null ? Math.max(0, before - after) : 0 + return { ok: true, freedBytes } } export { CLI_TIMEOUT_MS, PRUNE_TIMEOUT_MS, run } From efe9f828ef6dc7923c8c94ed27de2cc7f3c052a3 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 08:46:02 +0200 Subject: [PATCH 16/89] feat(app): gated docker remove/prune IPC + validation Exposes Task 8's removeDockerItem/pruneDocker over IPC behind the license gate, matching the prunePnpmStore/deleteNodeModules pattern: free tier sees everything but mutates nothing. Untrusted renderer payloads are validated with coerceRemoveArgs/coercePruneTarget before ever reaching the docker CLI (buildcache rejected for per-item removal; only the five known prune targets accepted). Successful mutations capture a clean_performed analytics event (kind: docker_ / docker_prune). Wires removeDockerItem/ pruneDocker onto the preload window.clean bridge. --- src/main/docker/validate-docker-arg.test.ts | 16 ++++++++++ src/main/docker/validate-docker-arg.ts | 27 +++++++++++++++++ src/main/ipc/register-ipc.ts | 33 ++++++++++++++++++++- src/preload/api.types.ts | 6 +++- src/preload/index.ts | 2 ++ 5 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/main/docker/validate-docker-arg.test.ts create mode 100644 src/main/docker/validate-docker-arg.ts 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.ts b/src/main/ipc/register-ipc.ts index 6e94d67..ede9205 100644 --- a/src/main/ipc/register-ipc.ts +++ b/src/main/ipc/register-ipc.ts @@ -14,7 +14,8 @@ import { RENDERER_EVENTS } from '../analytics' import type { AppContext } from '../app-context.types' import { liveGuard } from '../liveness/guard-live' import { detectLiveProjects } from '../liveness/liveness' -import { getDockerInfo } from '../docker/docker' +import { getDockerInfo, pruneDocker, removeDockerItem } from '../docker/docker' +import { coercePruneTarget, coerceRemoveArgs } from '../docker/validate-docker-arg' import { getPnpmStoreInfo, prunePnpmStore } from '../pnpm-store/pnpm-store' import { coerceSetting } from '../settings/validate-setting' import { coerceCardPayload, copyCardToClipboard } from '../share' @@ -70,6 +71,36 @@ export function registerIpc(ctx: AppContext): void { 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/preload/api.types.ts b/src/preload/api.types.ts index 625628b..8281a94 100644 --- a/src/preload/api.types.ts +++ b/src/preload/api.types.ts @@ -1,6 +1,6 @@ import type { DeleteManyResult, DeleteResult } from '@shared/delete.types' import type { LauncherNavTarget } from '@shared/launcher-nav.types' -import type { DockerInfo } from '@shared/docker.types' +import type { DockerActionResult, DockerInfo, DockerItemKind, DockerPruneTarget } from '@shared/docker.types' import type { ActivateResult, LicenseState } from '@shared/license.types' import type { LiveInfo } from '@shared/liveness.types' import type { PackageInventory } from '@shared/package.types' @@ -16,6 +16,10 @@ export interface CleanApi { 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 a1da5fb..2deeeee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,6 +15,8 @@ const api: CleanApi = { 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), From 95ae79222524e501a48f31e6cdbfa20a9088ff56 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 08:50:24 +0200 Subject: [PATCH 17/89] test(app): license-gate + payload-rejection coverage for docker IPC handlers --- src/main/ipc/register-ipc.test.ts | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) 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 }) + }) }) From ceb86f860c2e0e5dd0ff841ff9bf677401a54e4c Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 09:01:04 +0200 Subject: [PATCH 18/89] feat(app): docker cleanup actions + tiered confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the read-only Docker tab into an actionable cleanup surface: per-item remove and per-category prune (dangling/unused images, stopped containers, build cache, unused volumes), gated by a two-tier confirm — free users hit the paywall, Pro users get a single-confirm footer for image/container/build-cache actions and an additional typed-name gate for any volume removal (per-item or bulk) so an accidental Enter can never delete a volume. - useDocker gains remove(kind,id)/prune(target) + busyId, mirroring the pnpm-store hook's refresh-after-mutate pattern. - docker-confirm.ts is the TDD'd pure gate (needsTypedConfirm/ requiredConfirmText/confirmSatisfied) driving whether the footer's Remove/↵ is enabled. - DockerView adds per-category prune buttons and shows the CacheRow remove action only when item.removable; in-use/build-cache rows stay display-only. - LauncherApp wires both tiers into the existing confirm/unlock footer (no new modal) and threads Escape/Enter through the existing keyboard handler. --- src/renderer/src/hooks/useDocker.ts | 40 +++- .../src/launcher/LauncherApp/LauncherApp.tsx | 184 +++++++++++++++++- .../launcher/LauncherApp/LauncherApp.types.ts | 7 + .../views/DockerView.constants.test.ts | 35 +++- .../launcher/views/DockerView.constants.ts | 32 ++- .../src/launcher/views/DockerView.tsx | 69 +++++-- .../src/launcher/views/DockerView.types.ts | 13 +- .../src/launcher/views/docker-confirm.test.ts | 46 +++++ .../src/launcher/views/docker-confirm.ts | 16 ++ 9 files changed, 416 insertions(+), 26 deletions(-) create mode 100644 src/renderer/src/launcher/views/docker-confirm.test.ts create mode 100644 src/renderer/src/launcher/views/docker-confirm.ts diff --git a/src/renderer/src/hooks/useDocker.ts b/src/renderer/src/hooks/useDocker.ts index 95dc888..b09692d 100644 --- a/src/renderer/src/hooks/useDocker.ts +++ b/src/renderer/src/hooks/useDocker.ts @@ -1,18 +1,22 @@ -import type { DockerInfo } from '@shared/docker.types' +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. - * Phase-A subset (read-only); remove/prune land in a later task. */ +/** 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 @@ -36,5 +40,33 @@ export function useDocker(): UseDocker { } }, []) - return { info, loading, refresh } + 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 12e1ea4..acc7f36 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -21,17 +21,20 @@ 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 type { DockerItem, DockerPruneTarget } from '@shared/docker.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 { DockerView } from '../views/DockerView' +import { 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 { SortTab } from './SortTab' const NEXT_SCAN_LABEL: Record = { @@ -62,6 +65,8 @@ export function LauncherApp(): ReactNode { 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) @@ -254,6 +259,91 @@ export function LauncherApp(): ReactNode { [flashToast, license.pro], ) + const closeDockerConfirm = useCallback(() => { + setDockerConfirm(null) + setDockerTyped('') + }, []) + + // 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', + }) + } + }) + }, + [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', + }) + } + }) + }, + [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( @@ -302,6 +392,7 @@ export function LauncherApp(): ReactNode { e.preventDefault() setView((v) => (v === 'settings' ? 'list' : 'settings')) setConfirm(null) + closeDockerConfirm() setUnlock(null) return } @@ -318,6 +409,7 @@ export function LauncherApp(): ReactNode { setTab(e.key === '1' ? 'projects' : e.key === '2' ? 'caches' : e.key === '3' ? 'packages' : 'docker') setSel(0) setConfirm(null) + closeDockerConfirm() setUnlock(null) collapsePkg() return @@ -335,6 +427,10 @@ export function LauncherApp(): ReactNode { setConfirm(null) return } + if (dockerConfirm) { + closeDockerConfirm() + return + } if (view === 'list' && tab === 'packages' && expandedPkg) { collapsePkg() return @@ -357,6 +453,15 @@ 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 @@ -373,8 +478,10 @@ export function LauncherApp(): ReactNode { return } if (tab === 'docker') { - // Phase A is read-only: no row actions yet, so just don't let - // Arrow/Enter/⌘⌫ fall through to the projects-list logic below. + // 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') { @@ -417,6 +524,9 @@ export function LauncherApp(): ReactNode { view, tab, confirm, + dockerConfirm, + closeDockerConfirm, + commitDockerConfirm, unlock, query, commitDelete, @@ -442,6 +552,7 @@ export function LauncherApp(): ReactNode { setView('list') setTab('projects') setConfirm(null) + closeDockerConfirm() setUnlock(null) setQuery('') setSel(0) @@ -450,7 +561,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(() => { @@ -594,6 +705,7 @@ export function LauncherApp(): ReactNode { setTab(t) setSel(0) setConfirm(null) + closeDockerConfirm() setUnlock(null) collapsePkg() }} @@ -648,6 +760,9 @@ export function LauncherApp(): ReactNode { loading={docker.loading} query={query} onRefresh={() => void docker.refresh()} + busyId={docker.busyId} + onRemove={requestDockerRemove} + onPrune={requestDockerPrune} /> ) : 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)', + }} + /> + )} + + +
+
) : (
diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.types.ts b/src/renderer/src/launcher/LauncherApp/LauncherApp.types.ts index 07306e5..70c9955 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.types.ts +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.types.ts @@ -1,4 +1,5 @@ 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' | 'docker' @@ -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/views/DockerView.constants.test.ts b/src/renderer/src/launcher/views/DockerView.constants.test.ts index 7ac54cd..cd027e8 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.test.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.test.ts @@ -1,6 +1,6 @@ import type { DockerItem } from '@shared/docker.types' import { describe, expect, it } from 'vitest' -import { dockerItemDetail, groupDockerItems } from './DockerView.constants' +import { dockerItemDetail, groupDockerItems, pruneEstimateBytes } from './DockerView.constants' const items: DockerItem[] = [ { id: 'i1', kind: 'image', name: 'node:20', sizeBytes: 1e9, createdAt: 0, inUse: false, removable: true }, @@ -60,3 +60,36 @@ describe('dockerItemDetail', () => { expect(dockerItemDetail(item, NOW)).toBe('unknown date · unused') }) }) + +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) + }) +}) diff --git a/src/renderer/src/launcher/views/DockerView.constants.ts b/src/renderer/src/launcher/views/DockerView.constants.ts index 35eab12..56e05ca 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.ts @@ -1,5 +1,5 @@ import { relativeTime } from '@renderer/lib/format' -import type { DockerItem, DockerItemKind } from '@shared/docker.types' +import type { DockerItem, DockerItemKind, DockerPruneTarget } from '@shared/docker.types' export interface DockerGroup { kind: DockerItemKind @@ -32,3 +32,33 @@ export function dockerItemDetail(item: DockerItem, now = Date.now()): string { const created = item.createdAt > 0 ? relativeTime(item.createdAt, now) : 'unknown date' return `${created} · ${item.inUse ? 'in use' : 'unused'}` } + +/** 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', +} + +/** 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 + } +} diff --git a/src/renderer/src/launcher/views/DockerView.tsx b/src/renderer/src/launcher/views/DockerView.tsx index 3ff45bb..836c889 100644 --- a/src/renderer/src/launcher/views/DockerView.tsx +++ b/src/renderer/src/launcher/views/DockerView.tsx @@ -1,9 +1,9 @@ import { CacheRow } from '@renderer/components/CacheRow' import type { IconRenderer } from '@renderer/components/UIIcon' import { UIIcon } from '@renderer/components/UIIcon' -import type { DockerItemKind } from '@shared/docker.types' +import type { DockerItemKind, DockerPruneTarget } from '@shared/docker.types' import type { ReactNode } from 'react' -import { dockerItemDetail, groupDockerItems } from './DockerView.constants' +import { dockerItemDetail, groupDockerItems, PRUNE_TARGET_LABEL } from './DockerView.constants' import type { DockerViewProps } from './DockerView.types' const KIND_ICON: Record = { @@ -13,9 +13,16 @@ const KIND_ICON: Record = { buildcache: UIIcon.hdd, } -/** The launcher's "Docker" tab: images/volumes/containers/build cache, grouped by kind. - * Phase A is read-only — no remove/prune actions yet (Task 9 adds them). */ -export function DockerView({ info, loading, query }: DockerViewProps): ReactNode { +/** Bulk-prune targets offered under each group's header, in display order. */ +const GROUP_PRUNE_TARGETS: Record = { + image: ['danglingImages', 'unusedImages'], + volume: ['unusedVolumes'], + container: ['stoppedContainers'], + buildcache: ['buildCache'], +} + +/** The launcher's "Docker" tab: images/volumes/containers/build cache, grouped by kind. */ +export function DockerView({ info, loading, query, busyId, onRemove, onPrune }: DockerViewProps): ReactNode { if (loading && !info) { return (
@@ -46,15 +53,52 @@ export function DockerView({ info, loading, query }: DockerViewProps): ReactNode
- {g.label} +
+ {g.label} +
+ {onPrune && ( +
+ {GROUP_PRUNE_TARGETS[g.kind].map((target) => { + const busy = busyId === `prune:${target}` + return ( + + ) + })} +
+ )}
{g.items.map((item) => ( onRemove(item) : undefined} /> ))}
diff --git a/src/renderer/src/launcher/views/DockerView.types.ts b/src/renderer/src/launcher/views/DockerView.types.ts index fbb9b79..749cbb3 100644 --- a/src/renderer/src/launcher/views/DockerView.types.ts +++ b/src/renderer/src/launcher/views/DockerView.types.ts @@ -1,4 +1,4 @@ -import type { DockerActionResult, DockerInfo } from '@shared/docker.types' +import type { DockerInfo, DockerItem, DockerPruneTarget } from '@shared/docker.types' export interface DockerViewProps { info: DockerInfo | null @@ -6,10 +6,13 @@ export interface DockerViewProps { /** Search text from the shared header input; filters rows by name. */ query: string onRefresh: () => void - /** Index of the keyboard-selected row. Unused in Phase A (read-only). */ + /** Index of the keyboard-selected row. Unused for now (docker rows aren't keyboard-navigable yet). */ selectedIndex?: number onSelectIndex?: (i: number) => void - /** Phase B actions — unused in Phase A. */ - onRemove?: (id: string) => Promise - onPrune?: () => Promise + /** 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 } 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..3bd4863 --- /dev/null +++ b/src/renderer/src/launcher/views/docker-confirm.test.ts @@ -0,0 +1,46 @@ +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) + }) +}) 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..32e3448 --- /dev/null +++ b/src/renderer/src/launcher/views/docker-confirm.ts @@ -0,0 +1,16 @@ +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). */ +export function confirmSatisfied(required: string, typed: string): boolean { + return typed.trim() === required +} From 5bea7ab8b6c12273cc4e7b22023ba5e12816af74 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 09:07:20 +0200 Subject: [PATCH 19/89] fix(app): docker remove button copy + volume gate hardening + build-cache guard --- .../src/components/CacheRow/CacheRow.tsx | 6 ++-- .../src/components/CacheRow/CacheRow.types.ts | 4 +++ .../src/launcher/views/DockerView.tsx | 29 +++++++++++-------- .../src/launcher/views/docker-confirm.test.ts | 8 +++++ .../src/launcher/views/docker-confirm.ts | 8 +++-- 5 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/CacheRow/CacheRow.tsx b/src/renderer/src/components/CacheRow/CacheRow.tsx index 0c188e2..7425446 100644 --- a/src/renderer/src/components/CacheRow/CacheRow.tsx +++ b/src/renderer/src/components/CacheRow/CacheRow.tsx @@ -12,6 +12,8 @@ export function CacheRow({ disabled = false, busy = false, actionLabel, + title, + busyLabel, onAction, onSelect, }: CacheRowProps): ReactNode { @@ -80,7 +82,7 @@ export function CacheRow({ onAction?.() }} disabled={busy} - title="Remove packages no project references (pnpm store prune)" + title={title ?? 'Remove packages no project references (pnpm store prune)'} style={{ border: '1px solid var(--surface-4)', cursor: busy ? 'default' : 'pointer', @@ -93,7 +95,7 @@ export function CacheRow({ flex: 'none', }} > - {busy ? 'Pruning…' : actionLabel} + {busy ? (busyLabel ?? 'Pruning…') : actionLabel} ) : disabled ? ( void /** Fired when the row itself is clicked (selects it). */ diff --git a/src/renderer/src/launcher/views/DockerView.tsx b/src/renderer/src/launcher/views/DockerView.tsx index 836c889..2b83d2d 100644 --- a/src/renderer/src/launcher/views/DockerView.tsx +++ b/src/renderer/src/launcher/views/DockerView.tsx @@ -100,18 +100,23 @@ export function DockerView({ info, loading, query, busyId, onRemove, onPrune }:
)}
- {g.items.map((item) => ( - onRemove(item) : undefined} - /> - ))} + {g.items.map((item) => { + const canRemove = item.removable && item.kind !== 'buildcache' && !!onRemove + return ( + onRemove?.(item) : undefined} + /> + ) + })}
)) )} diff --git a/src/renderer/src/launcher/views/docker-confirm.test.ts b/src/renderer/src/launcher/views/docker-confirm.test.ts index 3bd4863..c27bc48 100644 --- a/src/renderer/src/launcher/views/docker-confirm.test.ts +++ b/src/renderer/src/launcher/views/docker-confirm.test.ts @@ -43,4 +43,12 @@ describe('confirmSatisfied', () => { 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 index 32e3448..fba0e1d 100644 --- a/src/renderer/src/launcher/views/docker-confirm.ts +++ b/src/renderer/src/launcher/views/docker-confirm.ts @@ -10,7 +10,11 @@ export function requiredConfirmText(target: { kind: 'volume'; name: string } | { return target.kind === 'prune' ? 'prune' : target.name } -/** Whether the typed text satisfies the required confirmation text (trimmed, exact match). */ +/** + * 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 typed.trim() === required + return required.length > 0 && typed.trim() === required } From 4ba976f6f564ec7ac449c9af30afe3b0c7a20c92 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 09:16:16 +0200 Subject: [PATCH 20/89] fix(app): docker removal failure toast + close-confirm-on-refresh + drop unused ps call Docker remove/prune failures now surface an error toast instead of closing the footer silently on an irreversible-delete surface. Cmd+R on the Docker tab now also closes a stale pending confirm before refreshing. Dropped the dead `docker ps` round-trip in readDockerInfo since buildDockerItems never used its second argument. --- src/main/docker/docker-parse.test.ts | 5 ++-- src/main/docker/docker-parse.ts | 27 +------------------ src/main/docker/docker.ts | 6 ++--- .../src/launcher/LauncherApp/LauncherApp.tsx | 18 +++++++++++-- 4 files changed, 21 insertions(+), 35 deletions(-) diff --git a/src/main/docker/docker-parse.test.ts b/src/main/docker/docker-parse.test.ts index 9f7b080..4934e16 100644 --- a/src/main/docker/docker-parse.test.ts +++ b/src/main/docker/docker-parse.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildDockerItems, parseContainers, parseDate, parseDf, parseSize } from './docker-parse' +import { buildDockerItems, parseDate, parseDf, parseSize } from './docker-parse' const DF = JSON.stringify({ Images: [ @@ -77,8 +77,7 @@ describe('parseDate', () => { 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) + 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') diff --git a/src/main/docker/docker-parse.ts b/src/main/docker/docker-parse.ts index 69a1c32..e86880d 100644 --- a/src/main/docker/docker-parse.ts +++ b/src/main/docker/docker-parse.ts @@ -81,28 +81,6 @@ export function parseDf(json: string): DfParsed { } } -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) => { @@ -116,10 +94,7 @@ function totalsOf(items: DockerItem[]): DockerCategoryTotal[] { }) } -export function buildDockerItems( - df: DfParsed, - _ps: PsContainer[], -): { items: DockerItem[]; totals: DockerCategoryTotal[] } { +export function buildDockerItems(df: DfParsed): { items: DockerItem[]; totals: DockerCategoryTotal[] } { const items: DockerItem[] = [] for (const img of df.images) { diff --git a/src/main/docker/docker.ts b/src/main/docker/docker.ts index 84d9e25..89c629c 100644 --- a/src/main/docker/docker.ts +++ b/src/main/docker/docker.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { promisify } from 'node:util' import type { DockerActionResult, DockerInfo, DockerItemKind, DockerPruneTarget } from '@shared/docker.types' import { app } from 'electron' -import { buildDockerItems, parseContainers, parseDf, parseSize } from './docker-parse' +import { buildDockerItems, parseDf, parseSize } from './docker-parse' import { dockerExecEnv, findDocker } from './find-docker' const CLI_TIMEOUT_MS = 60_000 @@ -76,14 +76,12 @@ async function readDockerInfo(opts: DockerOpts): Promise { 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)) + const { items, totals } = buildDockerItems(parseDf(df)) return { available: true, checkedAt: now, totals, items } } diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index acc7f36..e52cf8a 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -302,6 +302,12 @@ export function LauncherApp(): ReactNode { 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', + }) } }) }, @@ -318,6 +324,12 @@ export function LauncherApp(): ReactNode { 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', + }) } }) }, @@ -399,8 +411,10 @@ export function LauncherApp(): ReactNode { if (meta && (e.key === 'r' || e.key === 'R')) { e.preventDefault() if (tab === 'packages') void refreshPackages() - else if (tab === 'docker') void docker.refresh() - else rescan() + else if (tab === 'docker') { + closeDockerConfirm() + void docker.refresh() + } else rescan() return } if (meta && ['1', '2', '3', '4'].includes(e.key)) { From 1c37c9a94cb5f194e7816b290876713e3d5aae67 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 09:19:07 +0200 Subject: [PATCH 21/89] =?UTF-8?q?docs:=20STATUS=20=E2=80=94=20Docker=20cle?= =?UTF-8?q?anup=20built=20(pending=20PR=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- STATUS.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/STATUS.html b/STATUS.html index 91c819e..eec2ec8 100644 --- a/STATUS.html +++ b/STATUS.html @@ -67,6 +67,7 @@ { label: "M1 — pnpm store size + prune in the panel", detail: "Merged (was PR #3): Caches section, safe 'pnpm store prune' (never deletes the store). Now actually works from a Finder launch — fix/pnpm-path-detection resolves node for the pnpm shebang so detection no longer silently reports the store unavailable", status: "done" }, { label: "M1b — pnpm store surfaced in the launcher (Caches tab)", detail: "New Projects / Caches segmented tab in the launcher (⌘1/⌘2): pnpm store size + safe Prune (↵), with npm/yarn/bun as disabled 'soon' rows. The launcher home for the rest of issue #2. Branch feat/launcher-caches-tab", status: "done" }, { label: "M1c — robust pnpm store detection + manual override + rescan hint", detail: "Layered resolution: manual store/binary override > run pnpm (node-prepended) > infer the store dir from disk (PNPM_HOME/~/Library/pnpm/~/.local/share/pnpm, newest v) > none-with-reason. Runtime-bin discovery now finds npm-global/corepack pnpm under nvm/volta/asdf. PnpmStoreInfo gained source/reason/canPrune — sizing survives a broken pnpm; prune stays honest. Settings 'pnpm store' section with native folder/file pickers + a live status line. Stop back-filling uniqueSize=size for pre-split caches (now undefined=unknown); panel + launcher show a one-click 'rescan to compute' hint. Branch feat/pnpm-store-robustness", status: "done" }, + { label: "M1d — Docker cleanup (images / volumes / containers / build cache)", detail: "BUILT on feat/docker-cleanup (spec + plan in docs/superpowers/). A new launcher Docker tab (⌘4) lists Docker's four disk categories with size, created date, and in-use/unused status, sorted like projects. Cleanup via the docker CLI: per-item remove (unused images/volumes/stopped containers) + bulk prune (dangling/unused images, stopped containers, build cache, unused volumes). SAFETY (docker removals are PERMANENT, no Trash): only unused items are removable (in-use are display-only); every action is labeled 'permanent, not sent to the Trash'; VOLUMES require typing the exact volume name (or 'prune') before the delete fires — verified by an Opus review as a single gated choke point with no Enter/click/shortcut bypass. Listing is free (another shareable 'shocking number'); cleanup is Pro-gated like the rest. Built subagent-driven with TDD: ~65 docker tests incl. parsers, freed-bytes-honesty, license-gate, payload validation, and the typed-confirm gate; find-docker mirrors find-pnpm; execFile array args, never -f on per-item removals. Pending your review + manual daemon test", status: "done" }, { label: "M2 — npm / yarn / bun global caches", detail: "Same Caches section, after #3 lands", status: "planned" }, { label: "M3 — per-project build outputs (.next, dist, …)", detail: "Behind a settings toggle", status: "planned" }, { label: "M4 — beyond JS (venv, target/, DerivedData)", detail: "Only if scope grows", status: "planned" } @@ -129,6 +130,8 @@ /* Things ONLY the user can do. Claude removes/updates these as they get done. */ userActions: [ + { id: "review-docker-pr", category: "review", label: "Review the Docker cleanup PR", detail: "feat/docker-cleanup — new ⌘4 Docker tab: list + per-item remove + bulk prune of images/volumes/containers/build-cache, Pro-gated, with a typed-name confirmation for volumes (permanent, no Trash). Whole-branch Opus review found no wrongful-delete path. Skim the tiered confirmation UX and merge when happy." }, + { id: "test-docker-daemon", category: "testing", label: "Manual pass of Docker cleanup against a real daemon", detail: "The unit tests mock the docker CLI (no daemon in CI), so the real behavior needs your eyes: with Docker running, open the launcher → ⌘4. Confirm images/volumes/containers/build-cache list with real sizes + in-use/unused badges; as a free user a remove shows the paywall; as Pro, removing an unused image works and the number updates; a VOLUME removal makes you type its name first; in-use items have no Remove button; the per-category Prune buttons work. Also check the 'Docker not running' state (quit Docker Desktop) and the Settings toggle hides the tab." }, { id: "proofread-translations", category: "review", label: "Native-speaker proofread of the fr/es/de/pt copy", detail: "The 4 locales (landing + 40 blog articles) are high-quality machine translations, structurally validated (typecheck + parity test + em-dash sweep) but not human-proofread. Before promoting the locale URLs in Search Console, have a native speaker skim each locale's homepage + a couple of articles for tone/idiom. Content lives in site/lib/i18n/dictionaries/.tsx and site/content/blog//." }, { id: "lighthouse-verify", category: "testing", label: "Run Lighthouse on the deployed i18n preview", detail: "Once feat/site-i18n-seo has a Vercel preview: run Lighthouse (desktop) on /, /blog, and one article and confirm the scores. Fonts are now self-hosted (no render-blocking CDN requests) and the a11y/hreflang work is done, so the 90s-100s should be reachable; report anything that isn't so it can be tuned." }, { id: "vercel-primary-domain", category: "external", label: "Flip the Vercel primary domain to tidydisk.app", detail: "Today tidydisk.app 308-redirects to www.tidydisk.app, but the site's canonical/OG/sitemap URLs all say https://tidydisk.app — conflicting signals for Google. In Vercel: Project → Settings → Domains → set tidydisk.app as the primary domain (www.tidydisk.app then redirects to it). No code change needed." }, @@ -209,6 +212,7 @@ { date: "2026-07-08", text: "Three UX refinements on feat/scan-coverage-and-liveness (PR #39). (1) Disabled-delete tooltip: fixed a real bug where pointerEvents:none on the disabled trash button suppressed its own tooltip, and set the copy to 'Project currently in use, can't delete.' in both Row and MiniRow (cursor now not-allowed, click still guarded). (2) Full-window settings now tabbed: SettingsView split into Scanning / Packages / Privacy / License tabs (Segmented control) so it's no longer one long cramped column. (3) Menu-bar settings opens the full window: removed the panel's separate inline settings (deleted PanelSettings), and both the 'Settings…' menu item and ⌘, now open the launcher straight to its Settings view. New nav plumbing: openLauncher(nav?) + a one-shot consumeLauncherNav the renderer pulls on mount (race-free for a fresh window) plus an onLauncherNavigate event for the already-open case. Verified end-to-end in the running app: menu-bar settings lands on the Settings view with all four tabs rendering. 186 tests, typecheck/biome/build green." }, { date: "2026-07-08", text: "Killed the intermittent 'can't open the full window in dev' for good (root cause was duplicate/orphaned instances, since Electron isn't single-instance by default). Added app.requestSingleInstanceLock() in main: a second launch (double-click, or a stray pnpm dev) is denied the lock, quits itself instead of spawning a duplicate tray + windows, and the first instance surfaces its full window via the second-instance handler. Verified with two real built instances: A got the lock and ran; B was denied and exited; A received second-instance and opened the launcher. Also added a pnpm dev:clean script (scripts/dev-clean.sh) that kills stray electron-vite/Electron, quits the installed v1.0.0 app, clears node_modules/.vite + out, then starts a fresh dev server. typecheck/biome/186 tests/build green." }, { date: "2026-07-08", text: "Merged PR #39 to main (external drives + live indicator + the session's UI/dev fixes): the launcher focus/blur fix, the dev port-collision fix (strictPort 5199) + root ErrorBoundary, tabbed settings, menu-bar settings opening the full window, the disabled-delete tooltip, and app.requestSingleInstanceLock() + pnpm dev:clean. All CI green (check + package-macos + CodeRabbit + Vercel); branch deleted. These ship to the real app on the next release. Still yours: a manual pass with a physical external drive + a running dev server (userActions test-external-drives / test-live-indicator), and cutting a new signed release so users get the launcher + single-instance fixes." } + { date: "2026-07-08", text: "Built Docker cleanup (feat/docker-cleanup) — the app's next big disk layer after the pnpm store. Brainstormed the design with you (four decisions: tiered-by-recoverability safety, all four docker categories, honest created-date + in-use status instead of a faked 'last used', per-item + bulk prune) and settled the crux: docker removals are permanent with no Trash, so the safety model carries the weight — in-use items are read-only, and volumes need a typed-name confirmation. Executed subagent-driven with TDD in two phases (read-only listing, then Pro-gated cleanup), pausing at the Phase A boundary for your go-ahead. The review loop earned its keep: it caught three latent bugs in my own plan's code (parseDate returning 0 for every timestamp; the info cache coalescing ignoring the binary-path key; the Docker tab inheriting the Projects tab's Enter/⌘⌫ so it could open the delete-node_modules dialog), plus a freed-bytes miscount that could report the whole disk as 'freed' on a measurement failure, all fixed with regression tests. A whole-branch Opus review confirmed no wrongful-delete path (the typed-volume gate is a single choke point with no bypass) and I applied its non-blocking polish (failure toast, close-confirm-on-refresh, dropped a dead docker-ps call). ~65 docker tests, 210 total green; find-docker mirrors find-pnpm; execFile array args, never -f on per-item removals; listing free, cleanup Pro-gated. PR opened; your court: review + a manual pass against a real daemon (CI can't run one). One aside: a subagent flagged a prompt-injection attempt embedded in its tool inputs and correctly ignored it." } ] }; From ec7c2a9fe6c232e8d2baf124b4fc9e419867a2fd Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 11:46:46 +0200 Subject: [PATCH 22/89] chore(app): sort docker imports after rebase onto main Co-Authored-By: Claude Fable 5 --- src/main/ipc/register-ipc.ts | 4 ++-- src/preload/api.types.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/register-ipc.ts b/src/main/ipc/register-ipc.ts index ede9205..c32cf17 100644 --- a/src/main/ipc/register-ipc.ts +++ b/src/main/ipc/register-ipc.ts @@ -12,10 +12,10 @@ 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 { liveGuard } from '../liveness/guard-live' -import { detectLiveProjects } from '../liveness/liveness' 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' import { coerceSetting } from '../settings/validate-setting' import { coerceCardPayload, copyCardToClipboard } from '../share' diff --git a/src/preload/api.types.ts b/src/preload/api.types.ts index 8281a94..aa2adb0 100644 --- a/src/preload/api.types.ts +++ b/src/preload/api.types.ts @@ -1,6 +1,6 @@ import type { DeleteManyResult, DeleteResult } from '@shared/delete.types' -import type { LauncherNavTarget } from '@shared/launcher-nav.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' import type { PackageInventory } from '@shared/package.types' From 9df5292c13755f6e421d78dd6cec300926f2b3eb Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 16:26:12 +0200 Subject: [PATCH 23/89] =?UTF-8?q?docs:=20design=20spec=20=E2=80=94=20Docke?= =?UTF-8?q?r=20project-grouped=20display=20+=20used-by=20association?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...26-07-08-docker-project-grouping-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-08-docker-project-grouping-design.md 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. From 0ed55b84d569054d4cb6ae1f0b8c08e4ea2451cd Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 16:42:22 +0200 Subject: [PATCH 24/89] =?UTF-8?q?docs:=20implementation=20plan=20=E2=80=94?= =?UTF-8?q?=20Docker=20project-grouping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-07-08-docker-project-grouping.md | 547 ++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-docker-project-grouping.md 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. From c3a408a14a63da598bf7931ae1852f1dfceb3f60 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 17:03:10 +0200 Subject: [PATCH 25/89] feat(app): docker inspect parsers (compose labels + refs) Add DockerItem.project/repository, DockerProject, and DockerInfo.projects to the shared docker types, plus pure parseContainerInspect/parseVolumeInspect for `docker container/volume inspect` JSON. Add projects: [] to the four early-return DockerInfo objects in docker.ts to keep typecheck green. --- src/main/docker/docker-inspect.test.ts | 39 +++++++++++++++++++ src/main/docker/docker-inspect.ts | 54 ++++++++++++++++++++++++++ src/main/docker/docker.ts | 8 ++-- src/shared/docker.types.ts | 14 +++++++ 4 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 src/main/docker/docker-inspect.test.ts create mode 100644 src/main/docker/docker-inspect.ts diff --git a/src/main/docker/docker-inspect.test.ts b/src/main/docker/docker-inspect.test.ts new file mode 100644 index 0000000..661fd3a --- /dev/null +++ b/src/main/docker/docker-inspect.test.ts @@ -0,0 +1,39 @@ +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) + }) +}) diff --git a/src/main/docker/docker-inspect.ts b/src/main/docker/docker-inspect.ts new file mode 100644 index 0000000..3a7e491 --- /dev/null +++ b/src/main/docker/docker-inspect.ts @@ -0,0 +1,54 @@ +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 +} diff --git a/src/main/docker/docker.ts b/src/main/docker/docker.ts index 89c629c..9a287f3 100644 --- a/src/main/docker/docker.ts +++ b/src/main/docker/docker.ts @@ -69,20 +69,20 @@ async function run(bin: string, args: string[], timeout = CLI_TIMEOUT_MS): Promi 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: [] } + 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: [] } + 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: [] } + return { available: false, reason: 'daemon not running', checkedAt: now, totals: [], items: [], projects: [] } } const { items, totals } = buildDockerItems(parseDf(df)) - return { available: true, checkedAt: now, totals, items } + return { available: true, checkedAt: now, totals, items, projects: [] } } export async function getDockerInfo(force = false, opts: DockerOpts = {}): Promise { diff --git a/src/shared/docker.types.ts b/src/shared/docker.types.ts index dd4a0a1..469d689 100644 --- a/src/shared/docker.types.ts +++ b/src/shared/docker.types.ts @@ -1,3 +1,5 @@ +import type { FrameworkKind } from './project.types' + export type DockerItemKind = 'image' | 'volume' | 'container' | 'buildcache' export type DockerPruneTarget = 'danglingImages' | 'unusedImages' | 'stoppedContainers' | 'buildCache' | 'unusedVolumes' @@ -15,6 +17,17 @@ export interface DockerItem { 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 { @@ -32,6 +45,7 @@ export interface DockerInfo { checkedAt: number totals: DockerCategoryTotal[] items: DockerItem[] + projects: DockerProject[] } export interface DockerActionResult { From 79b5c484d2e29a98543f8483132ba296c014d714 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 17:04:48 +0200 Subject: [PATCH 26/89] feat(app): associateProjects (compose + used-by) with types Pure engine that groups images/volumes/containers into their Compose project via compose labels and used-by container references. Items referenced by more than one project (or none) stay unaffiliated so totals are never double-counted. Also reformats docker-inspect.test.ts per biome (formatting only, no logic change). --- src/main/docker/docker-associate.test.ts | 80 ++++++++++++++++++++++++ src/main/docker/docker-associate.ts | 64 +++++++++++++++++++ src/main/docker/docker-inspect.test.ts | 19 ++++-- 3 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 src/main/docker/docker-associate.test.ts create mode 100644 src/main/docker/docker-associate.ts diff --git a/src/main/docker/docker-associate.test.ts b/src/main/docker/docker-associate.test.ts new file mode 100644 index 0000000..f8d3888 --- /dev/null +++ b/src/main/docker/docker-associate.test.ts @@ -0,0 +1,80 @@ +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 + }) +}) diff --git a/src/main/docker/docker-associate.ts b/src/main/docker/docker-associate.ts new file mode 100644 index 0000000..456ba5e --- /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(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 } +} diff --git a/src/main/docker/docker-inspect.test.ts b/src/main/docker/docker-inspect.test.ts index 661fd3a..46e5885 100644 --- a/src/main/docker/docker-inspect.test.ts +++ b/src/main/docker/docker-inspect.test.ts @@ -5,10 +5,13 @@ 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', - } }, + 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: [] }, @@ -17,7 +20,13 @@ const CONTAINERS = JSON.stringify([ 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[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([]) }) From acff5590d9498ff2e18407c4d13937775434f659 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 17:13:14 +0200 Subject: [PATCH 27/89] fix(app): union imageId/imageRef project matches (no misassignment) The image association used a fallback (id, else name) rather than a union, so an image referenced by imageId in one compose project and by imageRef in a different project got wrongly assigned to the id-matched project instead of being flagged as used by 2 projects. --- src/main/docker/docker-associate.test.ts | 10 ++++++++++ src/main/docker/docker-associate.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/docker/docker-associate.test.ts b/src/main/docker/docker-associate.test.ts index f8d3888..d38f4a5 100644 --- a/src/main/docker/docker-associate.test.ts +++ b/src/main/docker/docker-associate.test.ts @@ -56,6 +56,16 @@ describe('associateProjects', () => { 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' }), diff --git a/src/main/docker/docker-associate.ts b/src/main/docker/docker-associate.ts index 456ba5e..51c1bbe 100644 --- a/src/main/docker/docker-associate.ts +++ b/src/main/docker/docker-associate.ts @@ -48,7 +48,7 @@ export function associateProjects( 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()) + 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()) } From bcd4e9d9bc503e62bfdee1d4a2279ce28cb260ae Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 18:23:35 +0200 Subject: [PATCH 28/89] feat(app): docker project enrichment (inspect + logos) in readDockerInfo Wire the association engine (parseContainerInspect/parseVolumeInspect/ associateProjects from Task 1) into readDockerInfo: after the df fetch, run `docker ps -aq` + `container inspect` + `volume inspect`, associate items to compose projects, and attach a per-project logo via the scanner's detectKind/findProjectIcon on each project's working_dir. Fail-soft throughout: any inspect/daemon error leaves items unassociated (projects: []) but availability stays true. Co-Authored-By: Claude Opus 4.8 --- src/main/docker/docker.test.ts | 63 ++++++++++++++++++++++++++++++++++ src/main/docker/docker.ts | 51 +++++++++++++++++++++++++-- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/main/docker/docker.test.ts b/src/main/docker/docker.test.ts index cc4e15e..038bd7b 100644 --- a/src/main/docker/docker.test.ts +++ b/src/main/docker/docker.test.ts @@ -5,6 +5,8 @@ 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' @@ -70,6 +72,67 @@ describe('getDockerInfo', () => { 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] === 'inspect') 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('removeDockerItem maps kind→command and reports freed bytes from df delta', async () => { diff --git a/src/main/docker/docker.ts b/src/main/docker/docker.ts index 9a287f3..e04945a 100644 --- a/src/main/docker/docker.ts +++ b/src/main/docker/docker.ts @@ -2,8 +2,18 @@ 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, DockerPruneTarget } from '@shared/docker.types' +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, parseVolumeInspect } from './docker-inspect' import { buildDockerItems, parseDf, parseSize } from './docker-parse' import { dockerExecEnv, findDocker } from './find-docker' @@ -82,7 +92,44 @@ async function readDockerInfo(opts: DockerOpts): Promise { return { available: false, reason: 'daemon not running', checkedAt: now, totals: [], items: [], projects: [] } } const { items, totals } = buildDockerItems(parseDf(df)) - return { available: true, checkedAt: now, totals, items, projects: [] } + + // 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 } +} + +/** 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 { From c5957e39fcbd9fa6f66710fd3c3cc227d7f10fad Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 18:31:50 +0200 Subject: [PATCH 29/89] feat(app): project-aware docker grouping (groupDockerForDisplay) Replace groupDockerItems/DockerGroup/GROUP_ORDER with groupDockerForDisplay(info, { sortBy, typeFilter, query }), producing project groups first, then repository/buildcache/unaffiliated groups under "Other". Supports size/name/recent sort, type filtering (drops non-matching items and resulting empty groups), and name/project query matching. dockerItemDetail, PRUNE_TARGET_LABEL, and pruneEstimateBytes are unchanged. DockerView.tsx still imports the removed groupDockerItems and will not typecheck/build until Task 4 updates it to consume DisplayGroup; unit tests (pnpm test) are green. --- .../views/DockerView.constants.test.ts | 69 ++++++++++----- .../launcher/views/DockerView.constants.ts | 87 ++++++++++++++----- 2 files changed, 113 insertions(+), 43 deletions(-) diff --git a/src/renderer/src/launcher/views/DockerView.constants.test.ts b/src/renderer/src/launcher/views/DockerView.constants.test.ts index cd027e8..9cccc85 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.test.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.test.ts @@ -1,33 +1,60 @@ -import type { DockerItem } from '@shared/docker.types' +import type { DockerInfo, DockerItem } from '@shared/docker.types' import { describe, expect, it } from 'vitest' -import { dockerItemDetail, groupDockerItems, pruneEstimateBytes } from './DockerView.constants' +import { dockerItemDetail, groupDockerForDisplay, pruneEstimateBytes } from './DockerView.constants' -const items: DockerItem[] = [ - { 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 }, - { id: 'c1', kind: 'container', name: 'app', sizeBytes: 3e8, createdAt: 0, inUse: true, removable: false }, -] +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 }), + ] -describe('groupDockerItems', () => { - it('groups items by kind in Images/Volumes/Containers/Build cache order, omitting empty groups', () => { - const groups = groupDockerItems(items, '') - expect(groups.map((g) => g.label)).toEqual(['Images', 'Volumes', 'Containers']) - expect(groups.find((g) => g.label === 'Images')?.items.map((i) => i.name)).toEqual(['node:20']) + 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('filters items by name, case-insensitively', () => { - const groups = groupDockerItems(items, 'NODE') - expect(groups).toHaveLength(1) - expect(groups[0]?.label).toBe('Images') - expect(groups[0]?.items.map((i) => i.name)).toEqual(['node:20']) + 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('returns no groups when nothing matches the query', () => { - expect(groupDockerItems(items, 'zzz')).toEqual([]) + 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('returns no groups for an empty item list', () => { - expect(groupDockerItems([], '')).toEqual([]) + it('query filters by item name or project name', () => { + expect(groupDockerForDisplay(info(items), { sortBy: 'size', typeFilter: 'all', query: 'redis' }).length).toBe(1) }) }) diff --git a/src/renderer/src/launcher/views/DockerView.constants.ts b/src/renderer/src/launcher/views/DockerView.constants.ts index 56e05ca..9d468e8 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.ts @@ -1,30 +1,73 @@ import { relativeTime } from '@renderer/lib/format' -import type { DockerItem, DockerItemKind, DockerPruneTarget } from '@shared/docker.types' +import type { DockerInfo, DockerItem, DockerItemKind, DockerProject, DockerPruneTarget } from '@shared/docker.types' -export interface DockerGroup { - kind: DockerItemKind - label: string - items: DockerItem[] +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) + }) } -/** Fixed display order for the Docker tab's sections. */ -const GROUP_ORDER: Array<{ kind: DockerItemKind; label: string }> = [ - { kind: 'image', label: 'Images' }, - { kind: 'volume', label: 'Volumes' }, - { kind: 'container', label: 'Containers' }, - { kind: 'buildcache', label: 'Build cache' }, -] +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 }] + : [] -/** Filters items by name (case-insensitive substring) and groups them by kind, in a fixed - * display order. Groups with no matching items are omitted entirely. */ -export function groupDockerItems(items: DockerItem[], query: string): DockerGroup[] { - const q = query.trim().toLowerCase() - const filtered = q ? items.filter((i) => i.name.toLowerCase().includes(q)) : items - return GROUP_ORDER.map(({ kind, label }) => ({ - kind, - label, - items: filtered.filter((i) => i.kind === kind), - })).filter((g) => g.items.length > 0) + return [ + ...sortGroups(projectGroups, opts.sortBy), + ...sortGroups([...repoGroups, ...buildcacheGroup, ...unaffiliatedGroup], opts.sortBy), + ] } /** Detail line for a Docker row: relative created date (or "unknown date") plus in-use badge. */ From d3e3b7e25ceef3765c3554e2bd651d160b1e7f48 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 19:04:24 +0200 Subject: [PATCH 30/89] feat(app): DockerView project-grouped rendering + type badges Renders groupDockerForDisplay's DisplayGroup[] instead of the removed groupDockerItems: project groups get a logo/framework-icon header with total size + item count, "Other" groups (repository/buildcache/ unaffiliated) get a generic-icon header, and a "Not linked to a project" divider marks the start of the Other section. Bulk-prune buttons move off project headers (prune commands are global, so a per-project button would misleadingly imply a scoped prune) onto the Other headers only, keyed by the item kinds each group actually contains. Adds a one-prop TypeBadge chip (image/volume/container/ cache) rendered on every row via a new optional CacheRow `badge` slot. Per-row remove wiring (onRemove(item), canRemove gating) is unchanged. sortBy/typeFilter are optional on DockerViewProps with defaults so LauncherApp (wired in Task 5) still typechecks untouched. Co-Authored-By: Claude Opus 4.8 --- .../src/components/CacheRow/CacheRow.tsx | 17 +- .../src/components/CacheRow/CacheRow.types.ts | 3 + .../src/components/TypeBadge/TypeBadge.tsx | 30 ++ .../src/components/TypeBadge/index.ts | 1 + .../src/launcher/views/DockerView.tsx | 292 +++++++++++++----- .../src/launcher/views/DockerView.types.ts | 5 + 6 files changed, 275 insertions(+), 73 deletions(-) create mode 100644 src/renderer/src/components/TypeBadge/TypeBadge.tsx create mode 100644 src/renderer/src/components/TypeBadge/index.ts diff --git a/src/renderer/src/components/CacheRow/CacheRow.tsx b/src/renderer/src/components/CacheRow/CacheRow.tsx index 7425446..c04a8de 100644 --- a/src/renderer/src/components/CacheRow/CacheRow.tsx +++ b/src/renderer/src/components/CacheRow/CacheRow.tsx @@ -6,6 +6,7 @@ import type { CacheRowProps } from './CacheRow.types' export function CacheRow({ icon, name, + badge, detail, size, selected = false, @@ -48,7 +49,21 @@ export function CacheRow({ {icon({ size: 15 })}
-
{name}
+
+ + {name} + + {badge} +
= { + image: 'IMAGE', + volume: 'VOLUME', + container: 'CONTAINER', + buildcache: 'CACHE', +} + +/** Tiny uppercase chip identifying a Docker item's kind (image/volume/container/build cache). */ +export function TypeBadge({ kind }: { kind: DockerItemKind }): ReactNode { + 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/launcher/views/DockerView.tsx b/src/renderer/src/launcher/views/DockerView.tsx index 2b83d2d..42330ab 100644 --- a/src/renderer/src/launcher/views/DockerView.tsx +++ b/src/renderer/src/launcher/views/DockerView.tsx @@ -1,9 +1,12 @@ import { CacheRow } from '@renderer/components/CacheRow' +import { FrameworkIcon } from '@renderer/components/FrameworkIcon' +import { TypeBadge } from '@renderer/components/TypeBadge' import type { IconRenderer } from '@renderer/components/UIIcon' import { UIIcon } from '@renderer/components/UIIcon' -import type { DockerItemKind, DockerPruneTarget } from '@shared/docker.types' -import type { ReactNode } from 'react' -import { dockerItemDetail, groupDockerItems, PRUNE_TARGET_LABEL } from './DockerView.constants' +import { formatSizeStr } from '@renderer/lib/format' +import type { DockerItem, DockerItemKind, DockerProject, DockerPruneTarget } from '@shared/docker.types' +import { Fragment, type ReactNode } from 'react' +import { type DisplayGroup, dockerItemDetail, groupDockerForDisplay, PRUNE_TARGET_LABEL } from './DockerView.constants' import type { DockerViewProps } from './DockerView.types' const KIND_ICON: Record = { @@ -13,16 +16,186 @@ const KIND_ICON: Record = { buildcache: UIIcon.hdd, } -/** Bulk-prune targets offered under each group's header, in display order. */ -const GROUP_PRUNE_TARGETS: Record = { - image: ['danglingImages', 'unusedImages'], - volume: ['unusedVolumes'], - container: ['stoppedContainers'], - buildcache: ['buildCache'], +/** Generic header glyph for the non-project ("Other") groups. */ +const GROUP_ICON: Record<'repository' | 'buildcache' | 'unaffiliated', IconRenderer> = { + repository: UIIcon.box, + buildcache: UIIcon.hdd, + unaffiliated: UIIcon.hdd, +} + +/** 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. */ +function prunesForGroup(group: DisplayGroup): DockerPruneTarget[] { + if (group.kind === 'repository') { + 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') + return targets + } + if (group.kind === 'buildcache') return ['buildCache'] + if (group.kind === 'unaffiliated') { + const targets: DockerPruneTarget[] = [] + 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 [] } -/** The launcher's "Docker" tab: images/volumes/containers/build cache, grouped by kind. */ -export function DockerView({ info, loading, query, busyId, onRemove, onPrune }: DockerViewProps): ReactNode { +/** 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 { + if (project.iconDataUrl) { + return ( +
+ +
+ ) + } + if (project.kind) return + return ( + + {UIIcon.box({ size: size * 0.6 })} + + ) +} + +function GroupHeader({ + group, + busyId, + onPrune, +}: { + group: DisplayGroup + 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.kind === 'project' ? ( + + ) : ( + + {GROUP_ICON[group.kind]({ size: 13 })} + + )} +
+
+ {group.label} +
+
+ {formatSizeStr(totalBytes)} + {group.kind === 'project' ? ` · ${group.items.length} item${group.items.length === 1 ? '' : 's'}` : ''} +
+
+
+ {onPrune && targets.length > 0 && ( +
+ {targets.map((target) => { + const busy = busyId === `prune:${target}` + return ( + + ) + })} +
+ )} +
+ ) +} + +/** 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, +}: DockerViewProps): ReactNode { if (loading && !info) { return (
@@ -39,7 +212,8 @@ export function DockerView({ info, loading, query, busyId, onRemove, onPrune }: ) } - const groups = groupDockerItems(info.items, query) + const groups = groupDockerForDisplay(info, { sortBy, typeFilter, query }) + const firstOtherIndex = groups.findIndex((g) => g.kind !== 'project') return (
@@ -49,75 +223,49 @@ export function DockerView({ info, loading, query, busyId, onRemove, onPrune }: {query ? `No Docker resources match “${query}”.` : 'No Docker resources found.'}
) : ( - groups.map((g) => ( -
-
+ groups.map((g, idx) => ( + + {idx === firstOtherIndex && firstOtherIndex > 0 && (
- {g.label} + + Not linked to a project +
- {onPrune && ( -
- {GROUP_PRUNE_TARGETS[g.kind].map((target) => { - const busy = busyId === `prune:${target}` - return ( - - ) - })} -
- )} + )} +
+ + {g.items.map((item: DockerItem) => { + const canRemove = item.removable && item.kind !== 'buildcache' && !!onRemove + return ( + } + detail={dockerItemDetail(item)} + size={item.sizeBytes} + busy={busyId === item.id} + actionLabel={canRemove ? 'Remove' : undefined} + title={canRemove ? `Remove ${item.name} permanently — not sent to the Trash` : undefined} + busyLabel="Removing…" + onAction={canRemove ? () => onRemove?.(item) : undefined} + /> + ) + })}
- {g.items.map((item) => { - const canRemove = item.removable && item.kind !== 'buildcache' && !!onRemove - return ( - onRemove?.(item) : undefined} - /> - ) - })} -
+ )) )}
diff --git a/src/renderer/src/launcher/views/DockerView.types.ts b/src/renderer/src/launcher/views/DockerView.types.ts index 749cbb3..3666231 100644 --- a/src/renderer/src/launcher/views/DockerView.types.ts +++ b/src/renderer/src/launcher/views/DockerView.types.ts @@ -1,10 +1,15 @@ import type { DockerInfo, DockerItem, DockerPruneTarget } from '@shared/docker.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. Defaults to 'all' until the toolbar (Task 5) supplies it. */ + typeFilter?: DockerTypeFilter onRefresh: () => void /** Index of the keyboard-selected row. Unused for now (docker rows aren't keyboard-navigable yet). */ selectedIndex?: number From c9fe2d34a1709cab9d37439697ce2e8f686fcb25 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 19:11:47 +0200 Subject: [PATCH 31/89] fix(app): dangling-image bulk prune + favicon onError fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract prunesForGroup into DockerView.constants.ts as a pure, tested function. Dangling images have no `repository` (see docker-associate.ts repositoryOf) so they land in the `unaffiliated` group, not a `repository` group — the old inline version only checked image kinds under `repository`, leaving dangling images with no bulk- prune button. Both `repository` and `unaffiliated` groups now check their actual image items for danglingImages/unusedImages targets. - DockerProjectIcon gains the same useState-based onError fallback as ProjectIcon: a broken/stale iconDataUrl degrades to FrameworkIcon (or the generic box glyph) instead of a broken-image icon. --- .../views/DockerView.constants.test.ts | 64 ++++++++++++++++++- .../launcher/views/DockerView.constants.ts | 21 ++++++ .../src/launcher/views/DockerView.tsx | 36 ++++------- 3 files changed, 96 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/launcher/views/DockerView.constants.test.ts b/src/renderer/src/launcher/views/DockerView.constants.test.ts index 9cccc85..43ab47a 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.test.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.test.ts @@ -1,6 +1,12 @@ import type { DockerInfo, DockerItem } from '@shared/docker.types' import { describe, expect, it } from 'vitest' -import { dockerItemDetail, groupDockerForDisplay, pruneEstimateBytes } from './DockerView.constants' +import { + type DisplayGroup, + dockerItemDetail, + groupDockerForDisplay, + pruneEstimateBytes, + prunesForGroup, +} from './DockerView.constants' const item = (o: Partial & Pick): DockerItem => ({ sizeBytes: 0, @@ -120,3 +126,59 @@ describe('pruneEstimateBytes', () => { 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('buildcache group offers buildCache', () => { + const g = group({ kind: 'buildcache', items: [item({ id: 'b1', kind: 'buildcache', name: 'b1' })] }) + expect(prunesForGroup(g)).toEqual(['buildCache']) + }) + + 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([]) + }) +}) diff --git a/src/renderer/src/launcher/views/DockerView.constants.ts b/src/renderer/src/launcher/views/DockerView.constants.ts index 9d468e8..f120903 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.ts @@ -85,6 +85,27 @@ export const PRUNE_TARGET_LABEL: Record = { unusedVolumes: '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 + } + if (group.kind === 'buildcache') return ['buildCache'] + 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`. */ diff --git a/src/renderer/src/launcher/views/DockerView.tsx b/src/renderer/src/launcher/views/DockerView.tsx index 42330ab..cd061ff 100644 --- a/src/renderer/src/launcher/views/DockerView.tsx +++ b/src/renderer/src/launcher/views/DockerView.tsx @@ -5,8 +5,14 @@ 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 { Fragment, type ReactNode } from 'react' -import { type DisplayGroup, dockerItemDetail, groupDockerForDisplay, PRUNE_TARGET_LABEL } from './DockerView.constants' +import { Fragment, type ReactNode, useState } from 'react' +import { + type DisplayGroup, + dockerItemDetail, + groupDockerForDisplay, + PRUNE_TARGET_LABEL, + prunesForGroup, +} from './DockerView.constants' import type { DockerViewProps } from './DockerView.types' const KIND_ICON: Record = { @@ -23,31 +29,12 @@ const GROUP_ICON: Record<'repository' | 'buildcache' | 'unaffiliated', IconRende unaffiliated: UIIcon.hdd, } -/** 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. */ -function prunesForGroup(group: DisplayGroup): DockerPruneTarget[] { - if (group.kind === 'repository') { - 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') - return targets - } - if (group.kind === 'buildcache') return ['buildCache'] - if (group.kind === 'unaffiliated') { - const targets: DockerPruneTarget[] = [] - 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 [] -} - /** 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 { - if (project.iconDataUrl) { + const [failed, setFailed] = useState(false) + + if (project.iconDataUrl && !failed) { return ( From a3effa23a0c79003654ff6f74898185ad1300835 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 19:14:40 +0200 Subject: [PATCH 32/89] feat(app): docker sort tabs + type-filter chips Wire dockerSortBy/dockerTypeFilter state into LauncherApp and pass them to DockerView, mirroring the existing packages sort-tab markup. Confirm/prune plumbing untouched. --- .../src/launcher/LauncherApp/LauncherApp.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index e52cf8a..27d03ab 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -27,6 +27,7 @@ import type { Project } from '@shared/project.types' import { type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { CachesView } from '../views/CachesView' import { DockerView } from '../views/DockerView' +import type { DockerSortKey, DockerTypeFilter } from '../views/DockerView.constants' import { PRUNE_TARGET_LABEL, pruneEstimateBytes } from '../views/DockerView.constants' import { confirmSatisfied, needsTypedConfirm, requiredConfirmText } from '../views/docker-confirm' import { EmptyView } from '../views/EmptyView' @@ -60,6 +61,8 @@ export function LauncherApp(): ReactNode { const [query, setQuery] = useState('') const [sortBy, setSortBy] = useState('used') + const [dockerSortBy, setDockerSortBy] = useState('size') + const [dockerTypeFilter, setDockerTypeFilter] = useState('all') const [sel, setSel] = useState(0) const [view, setView] = useState('list') const [tab, setTab] = useState('projects') @@ -742,6 +745,44 @@ export function LauncherApp(): ReactNode { setPkgSortBy('updates')} />
)} + {tab === 'docker' && ( +
+ Sort + setDockerSortBy('size')} /> + setDockerSortBy('name')} /> + setDockerSortBy('recent')} + /> + Show + setDockerTypeFilter('all')} + /> + setDockerTypeFilter('image')} + /> + setDockerTypeFilter('volume')} + /> + setDockerTypeFilter('container')} + /> + setDockerTypeFilter('buildcache')} + /> +
+ )}
{tab === 'packages' ? ( void docker.refresh()} busyId={docker.busyId} onRemove={requestDockerRemove} From 963d01421a2261d073f99953741d6f46f1846fcb Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 19:22:10 +0200 Subject: [PATCH 33/89] fix(app): make readDockerInfo enrichment fail-soft atomic (no silent-hide) --- src/main/docker/docker.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/docker/docker.ts b/src/main/docker/docker.ts index e04945a..7a37fc8 100644 --- a/src/main/docker/docker.ts +++ b/src/main/docker/docker.ts @@ -107,8 +107,9 @@ async function readDockerInfo(opts: DockerOpts): Promise { ? parseVolumeInspect(await run(bin, ['volume', 'inspect', ...volNames])) : new Map() const assoc = associateProjects(items, containers, volProjects) + const projectsWithLogos = await withLogos(assoc.projects) enriched = assoc.items - projects = await withLogos(assoc.projects) + projects = projectsWithLogos } catch { // leave items unassociated } From 28864c018175c3e0f73d532ed1a01ddd067c3159 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 8 Jul 2026 19:24:10 +0200 Subject: [PATCH 34/89] =?UTF-8?q?docs:=20STATUS=20=E2=80=94=20Docker=20tab?= =?UTF-8?q?=20regrouped=20by=20project=20(M1e,=20pending=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- STATUS.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/STATUS.html b/STATUS.html index eec2ec8..e2561fa 100644 --- a/STATUS.html +++ b/STATUS.html @@ -68,6 +68,7 @@ { label: "M1b — pnpm store surfaced in the launcher (Caches tab)", detail: "New Projects / Caches segmented tab in the launcher (⌘1/⌘2): pnpm store size + safe Prune (↵), with npm/yarn/bun as disabled 'soon' rows. The launcher home for the rest of issue #2. Branch feat/launcher-caches-tab", status: "done" }, { label: "M1c — robust pnpm store detection + manual override + rescan hint", detail: "Layered resolution: manual store/binary override > run pnpm (node-prepended) > infer the store dir from disk (PNPM_HOME/~/Library/pnpm/~/.local/share/pnpm, newest v) > none-with-reason. Runtime-bin discovery now finds npm-global/corepack pnpm under nvm/volta/asdf. PnpmStoreInfo gained source/reason/canPrune — sizing survives a broken pnpm; prune stays honest. Settings 'pnpm store' section with native folder/file pickers + a live status line. Stop back-filling uniqueSize=size for pre-split caches (now undefined=unknown); panel + launcher show a one-click 'rescan to compute' hint. Branch feat/pnpm-store-robustness", status: "done" }, { label: "M1d — Docker cleanup (images / volumes / containers / build cache)", detail: "BUILT on feat/docker-cleanup (spec + plan in docs/superpowers/). A new launcher Docker tab (⌘4) lists Docker's four disk categories with size, created date, and in-use/unused status, sorted like projects. Cleanup via the docker CLI: per-item remove (unused images/volumes/stopped containers) + bulk prune (dangling/unused images, stopped containers, build cache, unused volumes). SAFETY (docker removals are PERMANENT, no Trash): only unused items are removable (in-use are display-only); every action is labeled 'permanent, not sent to the Trash'; VOLUMES require typing the exact volume name (or 'prune') before the delete fires — verified by an Opus review as a single gated choke point with no Enter/click/shortcut bypass. Listing is free (another shareable 'shocking number'); cleanup is Pro-gated like the rest. Built subagent-driven with TDD: ~65 docker tests incl. parsers, freed-bytes-honesty, license-gate, payload validation, and the typed-confirm gate; find-docker mirrors find-pnpm; execFile array args, never -f on per-item removals. Pending your review + manual daemon test", status: "done" }, + { label: "M1e — Docker tab regrouped BY PROJECT (logos + type badges + used-by association)", detail: "BUILT on feat/docker-project-grouping (stacked on the M1d PR #40; spec + plan in docs/superpowers/). Replaces the by-category Docker sections with a project-grouped list: Compose projects group at the top with their REAL logo (reusing the node_modules framework/favicon detection on the compose working_dir, generic Docker glyph when the folder is gone), then a 'Not linked to a project' section (unassociated images sub-grouped by repository, a build-cache group, and orphan volumes/containers). Each row carries a type badge (IMAGE / VOLUME / CONTAINER / CACHE), and there are Size / Name / Recent sort tabs + All/Images/Volumes/Containers/Cache filter chips mirroring the Projects tab. Association is aggressive but honest: Compose labels PLUS 'used-by' (an image a project's container runs, a volume it mounts) — an item used by >1 project stays unassociated so the size sort never double-counts; pure 'docker run' setups with no compose fall to 'Other' (a stated data-limitation ceiling). Main process adds batched docker container/volume inspect behind the existing cache, all fail-soft (any inspect error → everything unassociated, tab still lists + cleans). SAFETY UNCHANGED: presentation + enrichment only — rows still flow the real id/kind, and the typed-name volume-confirm gate is byte-identical (Opus whole-branch review verified no wrongful-delete path). Bulk-prune buttons live only on the 'Other' headers (prune is a global op, never shown as project-scoped). Built subagent-driven with TDD; the review loop caught + fixed 3 real issues (image id/ref match must be a union not a fallback; dangling-image bulk prune was unreachable; fail-soft ordering could silently hide items). Pending your review + manual daemon test", status: "done" }, { label: "M2 — npm / yarn / bun global caches", detail: "Same Caches section, after #3 lands", status: "planned" }, { label: "M3 — per-project build outputs (.next, dist, …)", detail: "Behind a settings toggle", status: "planned" }, { label: "M4 — beyond JS (venv, target/, DerivedData)", detail: "Only if scope grows", status: "planned" } @@ -131,7 +132,7 @@ /* Things ONLY the user can do. Claude removes/updates these as they get done. */ userActions: [ { id: "review-docker-pr", category: "review", label: "Review the Docker cleanup PR", detail: "feat/docker-cleanup — new ⌘4 Docker tab: list + per-item remove + bulk prune of images/volumes/containers/build-cache, Pro-gated, with a typed-name confirmation for volumes (permanent, no Trash). Whole-branch Opus review found no wrongful-delete path. Skim the tiered confirmation UX and merge when happy." }, - { id: "test-docker-daemon", category: "testing", label: "Manual pass of Docker cleanup against a real daemon", detail: "The unit tests mock the docker CLI (no daemon in CI), so the real behavior needs your eyes: with Docker running, open the launcher → ⌘4. Confirm images/volumes/containers/build-cache list with real sizes + in-use/unused badges; as a free user a remove shows the paywall; as Pro, removing an unused image works and the number updates; a VOLUME removal makes you type its name first; in-use items have no Remove button; the per-category Prune buttons work. Also check the 'Docker not running' state (quit Docker Desktop) and the Settings toggle hides the tab." }, + { id: "test-docker-daemon", category: "testing", label: "Manual pass of Docker cleanup against a real daemon", detail: "The unit tests mock the docker CLI (no daemon in CI), so the real behavior needs your eyes: with Docker running, open the launcher → ⌘4. Confirm images/volumes/containers/build-cache list with real sizes + in-use/unused badges; as a free user a remove shows the paywall; as Pro, removing an unused image works and the number updates; a VOLUME removal makes you type its name first; in-use items have no Remove button; the per-category Prune buttons work. Also check the 'Docker not running' state (quit Docker Desktop) and the Settings toggle hides the tab. NEW (project-grouping, PR stacked on #40): with a running docker-compose project, confirm its resources group under one header with the project's real logo, that an image its container uses appears under that project, the type badges + Size/Name/Recent sort tabs + filter chips work, and the 'Not linked to a project' section holds pulled images (by repo) + build cache." }, { id: "proofread-translations", category: "review", label: "Native-speaker proofread of the fr/es/de/pt copy", detail: "The 4 locales (landing + 40 blog articles) are high-quality machine translations, structurally validated (typecheck + parity test + em-dash sweep) but not human-proofread. Before promoting the locale URLs in Search Console, have a native speaker skim each locale's homepage + a couple of articles for tone/idiom. Content lives in site/lib/i18n/dictionaries/.tsx and site/content/blog//." }, { id: "lighthouse-verify", category: "testing", label: "Run Lighthouse on the deployed i18n preview", detail: "Once feat/site-i18n-seo has a Vercel preview: run Lighthouse (desktop) on /, /blog, and one article and confirm the scores. Fonts are now self-hosted (no render-blocking CDN requests) and the a11y/hreflang work is done, so the 90s-100s should be reachable; report anything that isn't so it can be tuned." }, { id: "vercel-primary-domain", category: "external", label: "Flip the Vercel primary domain to tidydisk.app", detail: "Today tidydisk.app 308-redirects to www.tidydisk.app, but the site's canonical/OG/sitemap URLs all say https://tidydisk.app — conflicting signals for Google. In Vercel: Project → Settings → Domains → set tidydisk.app as the primary domain (www.tidydisk.app then redirects to it). No code change needed." }, @@ -212,7 +213,8 @@ { date: "2026-07-08", text: "Three UX refinements on feat/scan-coverage-and-liveness (PR #39). (1) Disabled-delete tooltip: fixed a real bug where pointerEvents:none on the disabled trash button suppressed its own tooltip, and set the copy to 'Project currently in use, can't delete.' in both Row and MiniRow (cursor now not-allowed, click still guarded). (2) Full-window settings now tabbed: SettingsView split into Scanning / Packages / Privacy / License tabs (Segmented control) so it's no longer one long cramped column. (3) Menu-bar settings opens the full window: removed the panel's separate inline settings (deleted PanelSettings), and both the 'Settings…' menu item and ⌘, now open the launcher straight to its Settings view. New nav plumbing: openLauncher(nav?) + a one-shot consumeLauncherNav the renderer pulls on mount (race-free for a fresh window) plus an onLauncherNavigate event for the already-open case. Verified end-to-end in the running app: menu-bar settings lands on the Settings view with all four tabs rendering. 186 tests, typecheck/biome/build green." }, { date: "2026-07-08", text: "Killed the intermittent 'can't open the full window in dev' for good (root cause was duplicate/orphaned instances, since Electron isn't single-instance by default). Added app.requestSingleInstanceLock() in main: a second launch (double-click, or a stray pnpm dev) is denied the lock, quits itself instead of spawning a duplicate tray + windows, and the first instance surfaces its full window via the second-instance handler. Verified with two real built instances: A got the lock and ran; B was denied and exited; A received second-instance and opened the launcher. Also added a pnpm dev:clean script (scripts/dev-clean.sh) that kills stray electron-vite/Electron, quits the installed v1.0.0 app, clears node_modules/.vite + out, then starts a fresh dev server. typecheck/biome/186 tests/build green." }, { date: "2026-07-08", text: "Merged PR #39 to main (external drives + live indicator + the session's UI/dev fixes): the launcher focus/blur fix, the dev port-collision fix (strictPort 5199) + root ErrorBoundary, tabbed settings, menu-bar settings opening the full window, the disabled-delete tooltip, and app.requestSingleInstanceLock() + pnpm dev:clean. All CI green (check + package-macos + CodeRabbit + Vercel); branch deleted. These ship to the real app on the next release. Still yours: a manual pass with a physical external drive + a running dev server (userActions test-external-drives / test-live-indicator), and cutting a new signed release so users get the launcher + single-instance fixes." } - { date: "2026-07-08", text: "Built Docker cleanup (feat/docker-cleanup) — the app's next big disk layer after the pnpm store. Brainstormed the design with you (four decisions: tiered-by-recoverability safety, all four docker categories, honest created-date + in-use status instead of a faked 'last used', per-item + bulk prune) and settled the crux: docker removals are permanent with no Trash, so the safety model carries the weight — in-use items are read-only, and volumes need a typed-name confirmation. Executed subagent-driven with TDD in two phases (read-only listing, then Pro-gated cleanup), pausing at the Phase A boundary for your go-ahead. The review loop earned its keep: it caught three latent bugs in my own plan's code (parseDate returning 0 for every timestamp; the info cache coalescing ignoring the binary-path key; the Docker tab inheriting the Projects tab's Enter/⌘⌫ so it could open the delete-node_modules dialog), plus a freed-bytes miscount that could report the whole disk as 'freed' on a measurement failure, all fixed with regression tests. A whole-branch Opus review confirmed no wrongful-delete path (the typed-volume gate is a single choke point with no bypass) and I applied its non-blocking polish (failure toast, close-confirm-on-refresh, dropped a dead docker-ps call). ~65 docker tests, 210 total green; find-docker mirrors find-pnpm; execFile array args, never -f on per-item removals; listing free, cleanup Pro-gated. PR opened; your court: review + a manual pass against a real daemon (CI can't run one). One aside: a subagent flagged a prompt-injection attempt embedded in its tool inputs and correctly ignored it." } + { date: "2026-07-08", text: "Built Docker cleanup (feat/docker-cleanup) — the app's next big disk layer after the pnpm store. Brainstormed the design with you (four decisions: tiered-by-recoverability safety, all four docker categories, honest created-date + in-use status instead of a faked 'last used', per-item + bulk prune) and settled the crux: docker removals are permanent with no Trash, so the safety model carries the weight — in-use items are read-only, and volumes need a typed-name confirmation. Executed subagent-driven with TDD in two phases (read-only listing, then Pro-gated cleanup), pausing at the Phase A boundary for your go-ahead. The review loop earned its keep: it caught three latent bugs in my own plan's code (parseDate returning 0 for every timestamp; the info cache coalescing ignoring the binary-path key; the Docker tab inheriting the Projects tab's Enter/⌘⌫ so it could open the delete-node_modules dialog), plus a freed-bytes miscount that could report the whole disk as 'freed' on a measurement failure, all fixed with regression tests. A whole-branch Opus review confirmed no wrongful-delete path (the typed-volume gate is a single choke point with no bypass) and I applied its non-blocking polish (failure toast, close-confirm-on-refresh, dropped a dead docker-ps call). ~65 docker tests, 210 total green; find-docker mirrors find-pnpm; execFile array args, never -f on per-item removals; listing free, cleanup Pro-gated. PR opened; your court: review + a manual pass against a real daemon (CI can't run one). One aside: a subagent flagged a prompt-injection attempt embedded in its tool inputs and correctly ignored it." }, + { date: "2026-07-08", text: "Redesigned the Docker tab to group BY PROJECT (feat/docker-project-grouping, stacked on the #40 branch) — you asked to see which Docker stuff belongs to which project, like the node_modules view. Brainstormed the honest constraint up front: Docker resources mostly aren't tied to a folder, so only Docker Compose resources carry a project identity; we settled on a project-grouped list + type-filter chips (not category tabs, which fight the goal), and 'used-by' association (an image a project's container runs, a volume it mounts) on top of Compose labels to pull most in-use resources under their project, with the honest remainder — pulled images (by repo), build cache, orphans — in an 'Other' section. Compose groups show the project's REAL logo by reusing the node_modules framework/favicon detection on the compose working_dir. Built subagent-driven with TDD (6 tasks, fresh implementer + independent review each, final Opus whole-branch review). The review loop again earned its keep: it caught the image id/ref match being a fallback instead of a union (an image used by two projects could be misassigned), the 'Dangling images' bulk-prune button being unreachable (danglings land in 'Other', not a repo group), and a fail-soft ordering gap that could silently hide items if logo-detection ever threw — all fixed with regression tests. Whole-branch Opus review APPROVED with no wrongful-delete path: this is presentation + enrichment only, rows still flow the real id/kind and the typed-name volume-confirm gate is byte-identical. Main process adds batched docker container/volume inspect behind the existing cache, fully fail-soft. 257 tests green; typecheck/lint/build clean. PR stacked on #40; your court: review + a manual pass with a running compose project (CI has no daemon)." } ] }; From 4ef579fc6279aeda65769feac6860567f04cc8a0 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 00:35:27 +0200 Subject: [PATCH 35/89] fix(app): guard docker info.projects against stale/older cache shape A docker-cache.json written by a build before project enrichment has no 'projects' field; groupDockerForDisplay iterated it directly and threw 'info.projects is not iterable' on the instant last-known render. Defend the render path (treat missing projects as []) and normalize the loaded disk cache. Co-Authored-By: Claude Fable 5 --- src/main/docker/docker.ts | 4 +++- .../src/launcher/views/DockerView.constants.test.ts | 9 +++++++++ src/renderer/src/launcher/views/DockerView.constants.ts | 6 ++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/docker/docker.ts b/src/main/docker/docker.ts index 7a37fc8..b6f0caf 100644 --- a/src/main/docker/docker.ts +++ b/src/main/docker/docker.ts @@ -51,7 +51,9 @@ function loadDiskCache(): void { try { const raw = JSON.parse(readFileSync(cacheFile(), 'utf8')) as { key: string; info: DockerInfo } if (raw?.info) { - cached = 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 { diff --git a/src/renderer/src/launcher/views/DockerView.constants.test.ts b/src/renderer/src/launcher/views/DockerView.constants.test.ts index 43ab47a..a79b64a 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.test.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.test.ts @@ -62,6 +62,15 @@ describe('groupDockerForDisplay', () => { 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', () => { diff --git a/src/renderer/src/launcher/views/DockerView.constants.ts b/src/renderer/src/launcher/views/DockerView.constants.ts index f120903..57bba3a 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.ts @@ -33,9 +33,11 @@ export function groupDockerForDisplay( return i.name.toLowerCase().includes(q) || (i.project?.toLowerCase().includes(q) ?? false) }) - // Project groups + // 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 info.projects) { + 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 }) From 3344c383055798971a8ec3e4f28338362d6f1837 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 10:06:30 +0200 Subject: [PATCH 36/89] fix(app): make docker enrichment resilient so repository grouping always runs Split the single try/catch around docker ps/inspect/associateProjects/withLogos into per-call try/catches so a container or volume inspect failure (e.g. a container removed between ps and inspect) no longer skips associateProjects entirely. associateProjects derives each image's repository independent of container/volume data, so it must always run for repository grouping to survive a partial daemon failure. Each failure point now logs via console.error instead of failing silently. --- src/main/docker/docker.test.ts | 22 +++++++++++++ src/main/docker/docker.ts | 56 ++++++++++++++++++++++++---------- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/main/docker/docker.test.ts b/src/main/docker/docker.test.ts index 038bd7b..097e2a3 100644 --- a/src/main/docker/docker.test.ts +++ b/src/main/docker/docker.test.ts @@ -133,6 +133,28 @@ describe('getDockerInfo', () => { 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] === 'inspect') 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('removeDockerItem maps kind→command and reports freed bytes from df delta', async () => { diff --git a/src/main/docker/docker.ts b/src/main/docker/docker.ts index b6f0caf..ddefe2d 100644 --- a/src/main/docker/docker.ts +++ b/src/main/docker/docker.ts @@ -95,27 +95,51 @@ async function readDockerInfo(opts: DockerOpts): Promise { } const { items, totals } = buildDockerItems(parseDf(df)) - // Enrich with project association (fail-soft: any inspect failure → everything in "Other"). - let enriched = items - let projects: DockerProject[] = [] + // 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 { - const ids = (await run(bin, ['ps', '-aq', '--no-trunc'])) + 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) - const projectsWithLogos = await withLogos(assoc.projects) - enriched = assoc.items - projects = projectsWithLogos - } catch { - // leave items unassociated + } 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) + } + } + + const volNames = items.filter((i) => i.kind === 'volume').map((i) => i.id) + let volProjects = new Map() + if (volNames.length) { + try { + volProjects = parseVolumeInspect(await run(bin, ['volume', 'inspect', ...volNames])) + } catch (err) { + volProjects = new Map() + console.error('docker: volume inspect 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: enriched, projects } + return { available: true, checkedAt: now, totals, items: assoc.items, projects } } /** Attach a project logo (framework kind + favicon) from each project's working_dir. From e2075931feb1380a8495a13adf743e9ca4001b07 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 10:12:03 +0200 Subject: [PATCH 37/89] feat(app): standardize Docker rows on Row primitives (hover trash, SizeViz, colored badges); drop type filter --- .../src/components/TypeBadge/TypeBadge.tsx | 14 +- .../src/launcher/LauncherApp/LauncherApp.tsx | 38 +--- .../src/launcher/views/DockerView.tsx | 175 ++++++++++++++++-- .../src/launcher/views/DockerView.types.ts | 9 +- 4 files changed, 185 insertions(+), 51 deletions(-) diff --git a/src/renderer/src/components/TypeBadge/TypeBadge.tsx b/src/renderer/src/components/TypeBadge/TypeBadge.tsx index 33fd7d5..7d5992f 100644 --- a/src/renderer/src/components/TypeBadge/TypeBadge.tsx +++ b/src/renderer/src/components/TypeBadge/TypeBadge.tsx @@ -8,16 +8,26 @@ const LABEL: Record = { 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 ( ('used') const [dockerSortBy, setDockerSortBy] = useState('size') - const [dockerTypeFilter, setDockerTypeFilter] = useState('all') const [sel, setSel] = useState(0) const [view, setView] = useState('list') const [tab, setTab] = useState('projects') @@ -151,6 +150,10 @@ 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]) + const dockerMaxBytes = useMemo( + () => Math.max(1, ...(docker.info?.items.map((i) => i.sizeBytes) ?? [])), + [docker.info], + ) const ratio = totalUsed / threshold const status = statusColor(ratio, accent) @@ -755,32 +758,6 @@ export function LauncherApp(): ReactNode { active={dockerSortBy === 'recent'} onClick={() => setDockerSortBy('recent')} /> - Show - setDockerTypeFilter('all')} - /> - setDockerTypeFilter('image')} - /> - setDockerTypeFilter('volume')} - /> - setDockerTypeFilter('container')} - /> - setDockerTypeFilter('buildcache')} - />
)} @@ -815,11 +792,14 @@ export function LauncherApp(): ReactNode { loading={docker.loading} query={query} sortBy={dockerSortBy} - typeFilter={dockerTypeFilter} onRefresh={() => void docker.refresh()} busyId={docker.busyId} onRemove={requestDockerRemove} onPrune={requestDockerPrune} + accent={accent} + density={settings.density} + sizeStyle={settings.sizeStyle} + maxBytes={dockerMaxBytes} /> ) : isEmpty ? ( 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)} + onMouseLeave={() => setHover(false)} + style={{ + 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} + + + + + {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({ @@ -183,6 +322,10 @@ export function DockerView({ busyId, onRemove, onPrune, + accent, + density, + sizeStyle, + maxBytes, }: DockerViewProps): ReactNode { if (loading && !info) { return ( @@ -234,24 +377,18 @@ export function DockerView({ )}
- {g.items.map((item: DockerItem) => { - const canRemove = item.removable && item.kind !== 'buildcache' && !!onRemove - return ( - } - detail={dockerItemDetail(item)} - size={item.sizeBytes} - busy={busyId === item.id} - actionLabel={canRemove ? 'Remove' : undefined} - title={canRemove ? `Remove ${item.name} permanently — not sent to the Trash` : undefined} - busyLabel="Removing…" - onAction={canRemove ? () => onRemove?.(item) : undefined} - /> - ) - })} + {g.items.map((item: DockerItem) => ( + + ))}
)) diff --git a/src/renderer/src/launcher/views/DockerView.types.ts b/src/renderer/src/launcher/views/DockerView.types.ts index 3666231..81a1915 100644 --- a/src/renderer/src/launcher/views/DockerView.types.ts +++ b/src/renderer/src/launcher/views/DockerView.types.ts @@ -1,4 +1,5 @@ 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 { @@ -8,7 +9,7 @@ export interface DockerViewProps { query: string /** Group/row sort order. Defaults to 'size' until the toolbar (Task 5) supplies it. */ sortBy?: DockerSortKey - /** Item-kind filter. Defaults to 'all' until the toolbar (Task 5) supplies it. */ + /** 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). */ @@ -20,4 +21,10 @@ export interface DockerViewProps { 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 single item's sizeBytes across the whole Docker inventory (min 1), for SizeViz's bar scale. */ + maxBytes: number } From f41374b6657bb3c9373282c5860f2fa2b7aef333 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 10:16:00 +0200 Subject: [PATCH 38/89] =?UTF-8?q?docs:=20STATUS=20=E2=80=94=20Docker=20gro?= =?UTF-8?q?uping=20refinements=20from=20real-daemon=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- STATUS.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/STATUS.html b/STATUS.html index e2561fa..d167f88 100644 --- a/STATUS.html +++ b/STATUS.html @@ -15,7 +15,7 @@ const STATUS = { app: "TidyDisk", version: "1.0.0", - updated: "2026-07-08", + updated: "2026-07-09", phase: "🚀 v1.0.0 — TidyDisk is LIVE-ready. All three PRs merged to main (landing refresh #22, license gate + analytics #30, rename + honest copy #31); the release gate is lifted; tidydisk.app serves the new site with the €19 Polar checkout wired. The v1.0.0 tag fires the signed-release pipeline (first tidydisk-arm64.dmg). Your last clicks before selling: publish the draft release when the workflow finishes, then run the launch playbook (#27 scan screenshot, #28 assets, #29 the wave).", summary: "Two new app features shipped to review on feat/scan-coverage-and-liveness (PR #39), each brainstormed → spec'd → planned → built via subagent-driven TDD (15 tasks, fresh implementer + independent review each), then a whole-branch review on the strongest tier (READY TO MERGE). (1) External-drive / scan-location coverage — the scanner was hard-wired to ~; now a Settings 'Scan locations' section lists mounted external volumes (detected under /Volumes, boot disk excluded by device id) as toggles plus an 'Add folder…' picker, choices persist in a new scanRoots setting, and resolveScanRoots feeds home + opted-in roots to the scanner each scan (mounted-only, deduped, nesting-dropped) — so node_modules on an external SSD or any folder is finally scannable and cleanable; an unplugged drive's projects just drop off next scan and return on replug, and a delete-time guard refuses trashing a path that vanished. (2) Live/running indicator — a green dot marks any project with a process running from inside it (one lsof cwd sweep per refresh, best-effort port label, soft-fails to no-dots on any error), its delete is disabled and main hard-refuses it re-checked at the instant of delete, so you cannot trash a running app's node_modules; polls every 45s while a window is open. Shared: unified deleteNodeModules → { freed, blocked? }; a batch deleteMany IPC keeps 'Clean N stale' to one liveness check (the final review's one Important finding). 183 tests, typecheck/biome/build green; the real lsof flags smoke-tested on macOS. Awaiting your review/merge + manual pass (needs a physical external drive + a running dev server). Previous session context: SEO + content session (site only, no app code). Audited tidydisk.app's SEO: robots.txt and sitemap.xml were 404, no canonical tags, no structured data, and the apex→www redirect direction contradicts the code's canonical base (fix is one Vercel dashboard click on your side). Built the fixes plus a self-publishing blog on feat/blog-seo (PR #34): markdown articles in site/content/blog/ parsed by a tested lib/blog pipeline; every surface (listing, article pages, sitemap, RSS) revalidates hourly and filters on publish date, so the 10 pre-written keyword-targeted articles release themselves one per week (2026-07-04 → 2026-09-05) with zero deploys. Also: SoftwareApplication + BlogPosting JSON-LD, RSS feed, Blog in nav/footer. 17 unit tests, build green, rendering verified via Playwright. Earlier context: decided to reposition the product as TidyDisk (from 'Clean my node_modules'), broadening the pitch from node_modules to all developer disk waste (caches, builds, later Docker/Xcode), and to monetize via free-scan / paid-clean rather than a free/OSS-only model: the free scan stays the marketing engine (its shocking number is the shareable artifact) and the one-click Clean action becomes a paid, lifetime unlock (€19 founding → €29). Produced two artifacts: TIDYDISK-LAUNCH.html — an interactive, on-brand launch dashboard (live price→sales calculator, pricing ladder, offer stack, competitor table, Show HN / Reddit drafts, and a persistent 2–4 week launch checklist; rendering + interactivity verified via Playwright) — and docs/superpowers/specs/2026-07-01-tidydisk-gtm-and-monetization-design.md, the GTM + monetization spec, whose one implementation slice is a license gate on the destructive actions (new src/main/license/*, offline-verifiable keys, enforced inside the destructive IPC handlers, Merchant-of-Record checkout via Lemon Squeezy/Paddle). Nothing renamed or gated in code yet — awaiting your review of the spec and your go-ahead. Still open from the prior session: publish the v0.2.0 draft release and deploy site/ to Vercel.", @@ -214,7 +214,8 @@ { date: "2026-07-08", text: "Killed the intermittent 'can't open the full window in dev' for good (root cause was duplicate/orphaned instances, since Electron isn't single-instance by default). Added app.requestSingleInstanceLock() in main: a second launch (double-click, or a stray pnpm dev) is denied the lock, quits itself instead of spawning a duplicate tray + windows, and the first instance surfaces its full window via the second-instance handler. Verified with two real built instances: A got the lock and ran; B was denied and exited; A received second-instance and opened the launcher. Also added a pnpm dev:clean script (scripts/dev-clean.sh) that kills stray electron-vite/Electron, quits the installed v1.0.0 app, clears node_modules/.vite + out, then starts a fresh dev server. typecheck/biome/186 tests/build green." }, { date: "2026-07-08", text: "Merged PR #39 to main (external drives + live indicator + the session's UI/dev fixes): the launcher focus/blur fix, the dev port-collision fix (strictPort 5199) + root ErrorBoundary, tabbed settings, menu-bar settings opening the full window, the disabled-delete tooltip, and app.requestSingleInstanceLock() + pnpm dev:clean. All CI green (check + package-macos + CodeRabbit + Vercel); branch deleted. These ship to the real app on the next release. Still yours: a manual pass with a physical external drive + a running dev server (userActions test-external-drives / test-live-indicator), and cutting a new signed release so users get the launcher + single-instance fixes." } { date: "2026-07-08", text: "Built Docker cleanup (feat/docker-cleanup) — the app's next big disk layer after the pnpm store. Brainstormed the design with you (four decisions: tiered-by-recoverability safety, all four docker categories, honest created-date + in-use status instead of a faked 'last used', per-item + bulk prune) and settled the crux: docker removals are permanent with no Trash, so the safety model carries the weight — in-use items are read-only, and volumes need a typed-name confirmation. Executed subagent-driven with TDD in two phases (read-only listing, then Pro-gated cleanup), pausing at the Phase A boundary for your go-ahead. The review loop earned its keep: it caught three latent bugs in my own plan's code (parseDate returning 0 for every timestamp; the info cache coalescing ignoring the binary-path key; the Docker tab inheriting the Projects tab's Enter/⌘⌫ so it could open the delete-node_modules dialog), plus a freed-bytes miscount that could report the whole disk as 'freed' on a measurement failure, all fixed with regression tests. A whole-branch Opus review confirmed no wrongful-delete path (the typed-volume gate is a single choke point with no bypass) and I applied its non-blocking polish (failure toast, close-confirm-on-refresh, dropped a dead docker-ps call). ~65 docker tests, 210 total green; find-docker mirrors find-pnpm; execFile array args, never -f on per-item removals; listing free, cleanup Pro-gated. PR opened; your court: review + a manual pass against a real daemon (CI can't run one). One aside: a subagent flagged a prompt-injection attempt embedded in its tool inputs and correctly ignored it." }, - { date: "2026-07-08", text: "Redesigned the Docker tab to group BY PROJECT (feat/docker-project-grouping, stacked on the #40 branch) — you asked to see which Docker stuff belongs to which project, like the node_modules view. Brainstormed the honest constraint up front: Docker resources mostly aren't tied to a folder, so only Docker Compose resources carry a project identity; we settled on a project-grouped list + type-filter chips (not category tabs, which fight the goal), and 'used-by' association (an image a project's container runs, a volume it mounts) on top of Compose labels to pull most in-use resources under their project, with the honest remainder — pulled images (by repo), build cache, orphans — in an 'Other' section. Compose groups show the project's REAL logo by reusing the node_modules framework/favicon detection on the compose working_dir. Built subagent-driven with TDD (6 tasks, fresh implementer + independent review each, final Opus whole-branch review). The review loop again earned its keep: it caught the image id/ref match being a fallback instead of a union (an image used by two projects could be misassigned), the 'Dangling images' bulk-prune button being unreachable (danglings land in 'Other', not a repo group), and a fail-soft ordering gap that could silently hide items if logo-detection ever threw — all fixed with regression tests. Whole-branch Opus review APPROVED with no wrongful-delete path: this is presentation + enrichment only, rows still flow the real id/kind and the typed-name volume-confirm gate is byte-identical. Main process adds batched docker container/volume inspect behind the existing cache, fully fail-soft. 257 tests green; typecheck/lint/build clean. PR stacked on #40; your court: review + a manual pass with a running compose project (CI has no daemon)." } + { date: "2026-07-08", text: "Redesigned the Docker tab to group BY PROJECT (feat/docker-project-grouping, stacked on the #40 branch) — you asked to see which Docker stuff belongs to which project, like the node_modules view. Brainstormed the honest constraint up front: Docker resources mostly aren't tied to a folder, so only Docker Compose resources carry a project identity; we settled on a project-grouped list + type-filter chips (not category tabs, which fight the goal), and 'used-by' association (an image a project's container runs, a volume it mounts) on top of Compose labels to pull most in-use resources under their project, with the honest remainder — pulled images (by repo), build cache, orphans — in an 'Other' section. Compose groups show the project's REAL logo by reusing the node_modules framework/favicon detection on the compose working_dir. Built subagent-driven with TDD (6 tasks, fresh implementer + independent review each, final Opus whole-branch review). The review loop again earned its keep: it caught the image id/ref match being a fallback instead of a union (an image used by two projects could be misassigned), the 'Dangling images' bulk-prune button being unreachable (danglings land in 'Other', not a repo group), and a fail-soft ordering gap that could silently hide items if logo-detection ever threw — all fixed with regression tests. Whole-branch Opus review APPROVED with no wrongful-delete path: this is presentation + enrichment only, rows still flow the real id/kind and the typed-name volume-confirm gate is byte-identical. Main process adds batched docker container/volume inspect behind the existing cache, fully fail-soft. 257 tests green; typecheck/lint/build clean. PR stacked on #40; your court: review + a manual pass with a running compose project (CI has no daemon)." }, + { date: "2026-07-09", text: "Iterated on the Docker project-grouping from your real-daemon test (screenshot showed everything flat under 'Not linked to a project'). Fixed the root cause first: a crash ('info.projects is not iterable') from a docker-cache.json written by the older build with no projects field — guarded the render path + normalized the loaded cache. Then the real linking bug: the whole enrichment sat in one try/catch, so a single failing docker inspect silently dropped ALL grouping, including the repository grouping that's independent of Compose — split each docker call into its own fail-soft try with console.error logging so associateProjects always runs and repository groups always appear (the logging will also reveal whether your Supabase/custom containers carry Compose labels or their own namespace, which is what decides real project grouping). Also acted on your UI feedback: removed the type-filter chips (kept Sort), gave each type badge a distinct color, and standardized the Docker rows on the node_modules Row primitives — hover-revealed trash RowAction instead of a persistent Remove button, SizeViz for the size text — so we reuse components instead of rebuilding each screen. Each change TDD'd + independently reviewed (safety gate + buildcache-non-removable verified intact); 259 tests green. Pushed to PR #42." } ] }; From a244e287b0f39a8f953926e2524d278c66192b3f Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 10:38:37 +0200 Subject: [PATCH 39/89] fix(app): robust volume labels via 'volume ls'; in-use green dot + node_modules row hover Real-daemon testing showed `docker volume inspect ` exits 1 (killing the whole batch) when `system df -v` lists a stale/anonymous volume name. Replace it with a single `docker volume ls --format '{{.Name}}::{{.Label ...}}'` call, which only lists volumes that currently exist and can't 404. Also swap the Docker row's "in use/unused" text for the same green LiveDot-style indicator node_modules rows use, and give Docker rows the same surface-2/hairline hover treatment as the node_modules list. --- src/main/docker/docker-inspect.test.ts | 50 +++++++++++++---- src/main/docker/docker-inspect.ts | 25 +++++---- src/main/docker/docker.test.ts | 55 ++++++++++++++++++- src/main/docker/docker.ts | 19 ++++--- .../views/DockerView.constants.test.ts | 8 +-- .../launcher/views/DockerView.constants.ts | 6 +- .../src/launcher/views/DockerView.tsx | 25 +++++++++ 7 files changed, 148 insertions(+), 40 deletions(-) diff --git a/src/main/docker/docker-inspect.test.ts b/src/main/docker/docker-inspect.test.ts index 46e5885..576c30c 100644 --- a/src/main/docker/docker-inspect.test.ts +++ b/src/main/docker/docker-inspect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseContainerInspect, parseVolumeInspect } from './docker-inspect' +import { parseContainerInspect, parseVolumeLabels } from './docker-inspect' const CONTAINERS = JSON.stringify([ { @@ -35,14 +35,44 @@ describe('parseContainerInspect', () => { }) }) -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) +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 index 3a7e491..95b60a2 100644 --- a/src/main/docker/docker-inspect.ts +++ b/src/main/docker/docker-inspect.ts @@ -36,19 +36,20 @@ export function parseContainerInspect(json: string): InspectedContainer[] { }) } -export function parseVolumeInspect(json: string): Map { +/** 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() - 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) + 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.test.ts b/src/main/docker/docker.test.ts index 097e2a3..6231af7 100644 --- a/src/main/docker/docker.test.ts +++ b/src/main/docker/docker.test.ts @@ -99,7 +99,7 @@ describe('getDockerInfo', () => { 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] === 'inspect') return { stdout: '[]', stderr: '' } + if (args[0] === 'volume' && args[1] === 'ls') return { stdout: '', stderr: '' } return { stdout: '', stderr: '' } }) const info = await getDockerInfo(true) @@ -146,7 +146,7 @@ describe('getDockerInfo', () => { 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] === 'inspect') return { stdout: '[]', stderr: '' } + if (args[0] === 'volume' && args[1] === 'ls') return { stdout: '', stderr: '' } return { stdout: '', stderr: '' } }) const info = await getDockerInfo(true) @@ -155,6 +155,57 @@ describe('getDockerInfo', () => { 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 () => { diff --git a/src/main/docker/docker.ts b/src/main/docker/docker.ts index ddefe2d..d1f4c3b 100644 --- a/src/main/docker/docker.ts +++ b/src/main/docker/docker.ts @@ -13,7 +13,7 @@ import { app } from 'electron' import { detectKind } from '../scanner/detect-kind' import { findProjectIcon } from '../scanner/find-project-icon' import { associateProjects } from './docker-associate' -import { parseContainerInspect, parseVolumeInspect } from './docker-inspect' +import { parseContainerInspect, parseVolumeLabels } from './docker-inspect' import { buildDockerItems, parseDf, parseSize } from './docker-parse' import { dockerExecEnv, findDocker } from './find-docker' @@ -120,15 +120,16 @@ async function readDockerInfo(opts: DockerOpts): Promise { } } - const volNames = items.filter((i) => i.kind === 'volume').map((i) => i.id) + // `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() - if (volNames.length) { - try { - volProjects = parseVolumeInspect(await run(bin, ['volume', 'inspect', ...volNames])) - } catch (err) { - volProjects = new Map() - console.error('docker: volume inspect failed', err) - } + 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) diff --git a/src/renderer/src/launcher/views/DockerView.constants.test.ts b/src/renderer/src/launcher/views/DockerView.constants.test.ts index a79b64a..d3bf1ab 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.test.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.test.ts @@ -76,7 +76,7 @@ describe('groupDockerForDisplay', () => { describe('dockerItemDetail', () => { const NOW = Date.UTC(2026, 0, 10) - it('shows a relative created date and the in-use badge', () => { + it('shows a relative created date (in-use is shown as a dot, not text)', () => { const item: DockerItem = { id: 'x', kind: 'image', @@ -86,10 +86,10 @@ describe('dockerItemDetail', () => { inUse: true, removable: false, } - expect(dockerItemDetail(item, NOW)).toBe('yesterday · in use') + expect(dockerItemDetail(item, NOW)).toBe('yesterday') }) - it('shows "unknown date" when createdAt is 0, and the unused badge', () => { + it('shows "unknown date" when createdAt is 0', () => { const item: DockerItem = { id: 'x', kind: 'image', @@ -99,7 +99,7 @@ describe('dockerItemDetail', () => { inUse: false, removable: true, } - expect(dockerItemDetail(item, NOW)).toBe('unknown date · unused') + expect(dockerItemDetail(item, NOW)).toBe('unknown date') }) }) diff --git a/src/renderer/src/launcher/views/DockerView.constants.ts b/src/renderer/src/launcher/views/DockerView.constants.ts index 57bba3a..205c83d 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.ts @@ -72,10 +72,10 @@ export function groupDockerForDisplay( ] } -/** Detail line for a Docker row: relative created date (or "unknown date") plus in-use badge. */ +/** 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 { - const created = item.createdAt > 0 ? relativeTime(item.createdAt, now) : 'unknown date' - return `${created} · ${item.inUse ? 'in use' : 'unused'}` + 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. */ diff --git a/src/renderer/src/launcher/views/DockerView.tsx b/src/renderer/src/launcher/views/DockerView.tsx index 2941f47..0bb928d 100644 --- a/src/renderer/src/launcher/views/DockerView.tsx +++ b/src/renderer/src/launcher/views/DockerView.tsx @@ -174,6 +174,27 @@ function GroupHeader({ ) } +/** 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 ( + + ) +} + /** 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. @@ -218,6 +239,9 @@ function DockerItemRow({ gap: roomy ? 13 : 11, padding: roomy ? '11px 14px' : '7px 14px', borderRadius: 10, + background: hover ? 'var(--surface-2)' : 'transparent', + boxShadow: hover ? 'inset 0 0 0 1px var(--hairline)' : 'none', + transition: 'background .12s ease, box-shadow .12s ease', }} > {item.name} + {item.inUse && } Date: Thu, 9 Jul 2026 10:41:13 +0200 Subject: [PATCH 40/89] =?UTF-8?q?docs:=20STATUS=20=E2=80=94=20real-daemon?= =?UTF-8?q?=20diagnosis=20(volume=20ls=20fix,=203=20projects=20found)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- STATUS.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/STATUS.html b/STATUS.html index d167f88..3f9578e 100644 --- a/STATUS.html +++ b/STATUS.html @@ -215,7 +215,8 @@ { date: "2026-07-08", text: "Merged PR #39 to main (external drives + live indicator + the session's UI/dev fixes): the launcher focus/blur fix, the dev port-collision fix (strictPort 5199) + root ErrorBoundary, tabbed settings, menu-bar settings opening the full window, the disabled-delete tooltip, and app.requestSingleInstanceLock() + pnpm dev:clean. All CI green (check + package-macos + CodeRabbit + Vercel); branch deleted. These ship to the real app on the next release. Still yours: a manual pass with a physical external drive + a running dev server (userActions test-external-drives / test-live-indicator), and cutting a new signed release so users get the launcher + single-instance fixes." } { date: "2026-07-08", text: "Built Docker cleanup (feat/docker-cleanup) — the app's next big disk layer after the pnpm store. Brainstormed the design with you (four decisions: tiered-by-recoverability safety, all four docker categories, honest created-date + in-use status instead of a faked 'last used', per-item + bulk prune) and settled the crux: docker removals are permanent with no Trash, so the safety model carries the weight — in-use items are read-only, and volumes need a typed-name confirmation. Executed subagent-driven with TDD in two phases (read-only listing, then Pro-gated cleanup), pausing at the Phase A boundary for your go-ahead. The review loop earned its keep: it caught three latent bugs in my own plan's code (parseDate returning 0 for every timestamp; the info cache coalescing ignoring the binary-path key; the Docker tab inheriting the Projects tab's Enter/⌘⌫ so it could open the delete-node_modules dialog), plus a freed-bytes miscount that could report the whole disk as 'freed' on a measurement failure, all fixed with regression tests. A whole-branch Opus review confirmed no wrongful-delete path (the typed-volume gate is a single choke point with no bypass) and I applied its non-blocking polish (failure toast, close-confirm-on-refresh, dropped a dead docker-ps call). ~65 docker tests, 210 total green; find-docker mirrors find-pnpm; execFile array args, never -f on per-item removals; listing free, cleanup Pro-gated. PR opened; your court: review + a manual pass against a real daemon (CI can't run one). One aside: a subagent flagged a prompt-injection attempt embedded in its tool inputs and correctly ignored it." }, { date: "2026-07-08", text: "Redesigned the Docker tab to group BY PROJECT (feat/docker-project-grouping, stacked on the #40 branch) — you asked to see which Docker stuff belongs to which project, like the node_modules view. Brainstormed the honest constraint up front: Docker resources mostly aren't tied to a folder, so only Docker Compose resources carry a project identity; we settled on a project-grouped list + type-filter chips (not category tabs, which fight the goal), and 'used-by' association (an image a project's container runs, a volume it mounts) on top of Compose labels to pull most in-use resources under their project, with the honest remainder — pulled images (by repo), build cache, orphans — in an 'Other' section. Compose groups show the project's REAL logo by reusing the node_modules framework/favicon detection on the compose working_dir. Built subagent-driven with TDD (6 tasks, fresh implementer + independent review each, final Opus whole-branch review). The review loop again earned its keep: it caught the image id/ref match being a fallback instead of a union (an image used by two projects could be misassigned), the 'Dangling images' bulk-prune button being unreachable (danglings land in 'Other', not a repo group), and a fail-soft ordering gap that could silently hide items if logo-detection ever threw — all fixed with regression tests. Whole-branch Opus review APPROVED with no wrongful-delete path: this is presentation + enrichment only, rows still flow the real id/kind and the typed-name volume-confirm gate is byte-identical. Main process adds batched docker container/volume inspect behind the existing cache, fully fail-soft. 257 tests green; typecheck/lint/build clean. PR stacked on #40; your court: review + a manual pass with a running compose project (CI has no daemon)." }, - { date: "2026-07-09", text: "Iterated on the Docker project-grouping from your real-daemon test (screenshot showed everything flat under 'Not linked to a project'). Fixed the root cause first: a crash ('info.projects is not iterable') from a docker-cache.json written by the older build with no projects field — guarded the render path + normalized the loaded cache. Then the real linking bug: the whole enrichment sat in one try/catch, so a single failing docker inspect silently dropped ALL grouping, including the repository grouping that's independent of Compose — split each docker call into its own fail-soft try with console.error logging so associateProjects always runs and repository groups always appear (the logging will also reveal whether your Supabase/custom containers carry Compose labels or their own namespace, which is what decides real project grouping). Also acted on your UI feedback: removed the type-filter chips (kept Sort), gave each type badge a distinct color, and standardized the Docker rows on the node_modules Row primitives — hover-revealed trash RowAction instead of a persistent Remove button, SizeViz for the size text — so we reuse components instead of rebuilding each screen. Each change TDD'd + independently reviewed (safety gate + buildcache-non-removable verified intact); 259 tests green. Pushed to PR #42." } + { date: "2026-07-09", text: "Iterated on the Docker project-grouping from your real-daemon test (screenshot showed everything flat under 'Not linked to a project'). Fixed the root cause first: a crash ('info.projects is not iterable') from a docker-cache.json written by the older build with no projects field — guarded the render path + normalized the loaded cache. Then the real linking bug: the whole enrichment sat in one try/catch, so a single failing docker inspect silently dropped ALL grouping, including the repository grouping that's independent of Compose — split each docker call into its own fail-soft try with console.error logging so associateProjects always runs and repository groups always appear (the logging will also reveal whether your Supabase/custom containers carry Compose labels or their own namespace, which is what decides real project grouping). Also acted on your UI feedback: removed the type-filter chips (kept Sort), gave each type badge a distinct color, and standardized the Docker rows on the node_modules Row primitives — hover-revealed trash RowAction instead of a persistent Remove button, SizeViz for the size text — so we reuse components instead of rebuilding each screen. Each change TDD'd + independently reviewed (safety gate + buildcache-non-removable verified intact); 259 tests green. Pushed to PR #42." }, + { date: "2026-07-09", text: "Debugged the 'still no project groups' report by running your REAL docker data through the app's association logic. Found it: `docker volume inspect ` exits 1 whenever `docker system df -v` lists an anonymous/stale volume (one bad name fails the whole batch) — and before the resilience fix that single failure took down ALL grouping. Your containers DO carry Compose labels, so the association correctly yields three projects (lets-get-a-job, orchestrator, aprocy — aprocy has a working_dir so it gets a real logo; the two Supabase ones have empty working_dirs so they show a generic Docker icon + name). Switched volume label lookup to a single robust `docker volume ls --format` call (can't 404 on a stale volume). Also per your feedback: in-use items now show the same green LiveDot as running node_modules projects (dropped the in-use/unused text), and Docker rows got the node_modules row hover style. The main-process fixes need a FULL app restart to load (pnpm dev hot-reloads only the renderer). 265 tests green; reviewed, safety gate intact. Pushed to PR #42." } ] }; From 7a975a3b680d45b0141fce82d54f47e3da1fee05 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 11:08:16 +0200 Subject: [PATCH 41/89] fix(app): docker rows use the node_modules sliding highlight (.cc-hl) on hover Replace the per-row hover background with the shared sliding highlight the projects list uses: a single .cc-hl rectangle positioned at the hovered row's offsetTop/offsetHeight, so it animates between rows identically. Co-Authored-By: Claude Opus 4.8 --- .../src/launcher/views/DockerView.tsx | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/launcher/views/DockerView.tsx b/src/renderer/src/launcher/views/DockerView.tsx index 0bb928d..4f73162 100644 --- a/src/renderer/src/launcher/views/DockerView.tsx +++ b/src/renderer/src/launcher/views/DockerView.tsx @@ -208,6 +208,7 @@ function DockerItemRow({ accent, busy, onRemove, + onHover, }: { item: DockerItem density: Density @@ -216,6 +217,7 @@ function DockerItemRow({ accent: string busy: boolean onRemove?: (item: DockerItem) => void + onHover: (el: HTMLDivElement) => void }): ReactNode { const [hover, setHover] = useState(false) const roomy = density === 'roomy' @@ -231,17 +233,19 @@ function DockerItemRow({ return (
setHover(true)} + onMouseEnter={(e) => { + 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, - background: hover ? 'var(--surface-2)' : 'transparent', - boxShadow: hover ? 'inset 0 0 0 1px var(--hairline)' : 'none', - transition: 'background .12s ease, box-shadow .12s ease', }} > @@ -373,7 +380,20 @@ export function DockerView({ return (
-
+
setHl((h) => ({ ...h, on: false }))} + > +
{groups.length === 0 ? (
{query ? `No Docker resources match “${query}”.` : 'No Docker resources found.'} @@ -412,6 +432,7 @@ export function DockerView({ accent={accent} busy={busyId === item.id} onRemove={onRemove} + onHover={(el) => setHl({ top: el.offsetTop, height: el.offsetHeight, on: true })} /> ))}
From 707384ac27731403311ac691b50e0100caa0abd5 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 14:56:00 +0200 Subject: [PATCH 42/89] docs: spec for per-tab headline data-viz + docker auto-scan Co-Authored-By: Claude Opus 4.8 --- ...6-07-09-per-tab-headline-dataviz-design.md | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-09-per-tab-headline-dataviz-design.md 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`). From 85470afb3a04b465cc12739748d5aef4458e650f Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 15:08:11 +0200 Subject: [PATCH 43/89] docs: plan for per-tab headline data-viz + docker auto-scan Co-Authored-By: Claude Opus 4.8 --- .../2026-07-09-per-tab-headline-dataviz.md | 1040 +++++++++++++++++ 1 file changed, 1040 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-per-tab-headline-dataviz.md 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. From 3071c1e51ec952f58986c20c90afea7f0ea559a6 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 15:10:00 +0200 Subject: [PATCH 44/89] feat(settings): add cacheThresholdGB and dockerThresholdGB limits --- src/main/settings/validate-setting.test.ts | 12 ++++++++++++ src/main/settings/validate-setting.ts | 2 ++ src/shared/settings.constants.ts | 2 ++ src/shared/settings.types.ts | 4 ++++ 4 files changed, 20 insertions(+) diff --git a/src/main/settings/validate-setting.test.ts b/src/main/settings/validate-setting.test.ts index 224e787..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() diff --git a/src/main/settings/validate-setting.ts b/src/main/settings/validate-setting.ts index 9b9237e..36e8372 100644 --- a/src/main/settings/validate-setting.ts +++ b/src/main/settings/validate-setting.ts @@ -41,6 +41,8 @@ export function coerceSetting( 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 diff --git a/src/shared/settings.constants.ts b/src/shared/settings.constants.ts index 3e4315e..bdc4650 100644 --- a/src/shared/settings.constants.ts +++ b/src/shared/settings.constants.ts @@ -5,6 +5,8 @@ export const DEFAULT_SETTINGS: Settings = { sizeStyle: 'plain', density: 'roomy', thresholdGB: 5, + cacheThresholdGB: 10, + dockerThresholdGB: 20, scanInterval: 'daily', notify: true, onboarded: false, diff --git a/src/shared/settings.types.ts b/src/shared/settings.types.ts index e50e14e..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 From 7b2dac0f74ce386f001529d9b01a9721f2949b48 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 15:13:42 +0200 Subject: [PATCH 45/89] feat(app): severityCounts helper for the packages headline Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/lib/severity/index.ts | 2 + .../src/lib/severity/severity.test.ts | 39 +++++++++++++++++++ src/renderer/src/lib/severity/severity.ts | 29 ++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 src/renderer/src/lib/severity/index.ts create mode 100644 src/renderer/src/lib/severity/severity.test.ts create mode 100644 src/renderer/src/lib/severity/severity.ts 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 +} From 9d3750a5dc56eac8deb8beac1c3368f314f5aa48 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 15:17:37 +0200 Subject: [PATCH 46/89] =?UTF-8?q?feat(app):=20SeverityMeter=20=E2=80=94=20?= =?UTF-8?q?compact=20packages=20severity=20headline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SeverityMeter.constants.test.ts | 39 ++++++++++++++ .../SeverityMeter/SeverityMeter.constants.ts | 29 +++++++++++ .../SeverityMeter/SeverityMeter.tsx | 51 +++++++++++++++++++ .../SeverityMeter/SeverityMeter.types.ts | 17 +++++++ .../src/components/SeverityMeter/index.ts | 2 + 5 files changed, 138 insertions(+) create mode 100644 src/renderer/src/components/SeverityMeter/SeverityMeter.constants.test.ts create mode 100644 src/renderer/src/components/SeverityMeter/SeverityMeter.constants.ts create mode 100644 src/renderer/src/components/SeverityMeter/SeverityMeter.tsx create mode 100644 src/renderer/src/components/SeverityMeter/SeverityMeter.types.ts create mode 100644 src/renderer/src/components/SeverityMeter/index.ts 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..91423c3 --- /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' From 0b8ac31e48e37c0ce93377098139bf5acf29ec35 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 15:21:17 +0200 Subject: [PATCH 47/89] =?UTF-8?q?feat(app):=20tabSummary=20=E2=80=94=20per?= =?UTF-8?q?-tab=20footer=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Haiku 4.5 --- .../launcher/LauncherApp/tabSummary.test.ts | 42 ++++++++++++++++ .../src/launcher/LauncherApp/tabSummary.ts | 50 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/renderer/src/launcher/LauncherApp/tabSummary.test.ts create mode 100644 src/renderer/src/launcher/LauncherApp/tabSummary.ts 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..ef6d839 --- /dev/null +++ b/src/renderer/src/launcher/LauncherApp/tabSummary.test.ts @@ -0,0 +1,42 @@ +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() + }) +}) diff --git a/src/renderer/src/launcher/LauncherApp/tabSummary.ts b/src/renderer/src/launcher/LauncherApp/tabSummary.ts new file mode 100644 index 0000000..5bcdc07 --- /dev/null +++ b/src/renderer/src/launcher/LauncherApp/tabSummary.ts @@ -0,0 +1,50 @@ +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 + } +} From 78a796e653eea301c58f29e17738ab40a8ec88f2 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 15:21:37 +0200 Subject: [PATCH 48/89] =?UTF-8?q?style:=20tabSummary.test.ts=20=E2=80=94?= =?UTF-8?q?=20biome=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../launcher/LauncherApp/tabSummary.test.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/launcher/LauncherApp/tabSummary.test.ts b/src/renderer/src/launcher/LauncherApp/tabSummary.test.ts index ef6d839..81b5128 100644 --- a/src/renderer/src/launcher/LauncherApp/tabSummary.test.ts +++ b/src/renderer/src/launcher/LauncherApp/tabSummary.test.ts @@ -1,18 +1,30 @@ -import type { SeverityCounts } from '@renderer/lib/severity' 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, + 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, + cachesUsed: 0, + cachesAvailable: true, + dockerUsed: 0, + dockerAvailable: true, + thresholdGB: 5, + cacheThresholdGB: 10, + dockerThresholdGB: 20, + severity: sev(), + packagesComputing: false, + packagesHasData: true, } describe('tabSummary', () => { From e17530733a6c1fe17a3a2d88c6b6712ef310361f Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 15:25:46 +0200 Subject: [PATCH 49/89] =?UTF-8?q?feat(app):=20TabHeadline=20=E2=80=94=20pe?= =?UTF-8?q?r-tab=20header=20data-viz=20switcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/TabHeadline/TabHeadline.tsx | 40 +++++++++++++++++++ .../TabHeadline/TabHeadline.types.ts | 25 ++++++++++++ .../src/components/TabHeadline/index.ts | 2 + 3 files changed, 67 insertions(+) create mode 100644 src/renderer/src/components/TabHeadline/TabHeadline.tsx create mode 100644 src/renderer/src/components/TabHeadline/TabHeadline.types.ts create mode 100644 src/renderer/src/components/TabHeadline/index.ts diff --git a/src/renderer/src/components/TabHeadline/TabHeadline.tsx b/src/renderer/src/components/TabHeadline/TabHeadline.tsx new file mode 100644 index 0000000..2ad038c --- /dev/null +++ b/src/renderer/src/components/TabHeadline/TabHeadline.tsx @@ -0,0 +1,40 @@ +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 ( + + ) +} 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..8e1e332 --- /dev/null +++ b/src/renderer/src/components/TabHeadline/TabHeadline.types.ts @@ -0,0 +1,25 @@ +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 + packagesComputing: 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' From dbd706f50ce34b774551f2698110e30ec363b3f4 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 9 Jul 2026 15:30:13 +0200 Subject: [PATCH 50/89] feat(app): per-tab header data-viz and footer summary in LauncherApp Swaps the header's hardwired node_modules Gauge for the TabHeadline switcher and the footer's node_modules-only over/under text for the per-tab tabSummary line, so Caches/Docker/Packages tabs get correct header and footer readouts instead of always showing projects data. --- .../src/launcher/LauncherApp/LauncherApp.tsx | 79 ++++++++++++++----- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index d7717a1..f0b9710 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -1,5 +1,4 @@ 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' @@ -7,6 +6,7 @@ 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' @@ -21,6 +21,7 @@ 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 type { DockerItem, DockerPruneTarget } from '@shared/docker.types' import type { PackageEntry } from '@shared/package.types' import type { Project } from '@shared/project.types' @@ -37,6 +38,7 @@ import { ScanningView } from '../views/ScanningView' import { SettingsView } from '../views/SettingsView' import type { DockerConfirmState, LauncherTab, LauncherToast, LauncherView, SortKey } from './LauncherApp.types' import { SortTab } from './SortTab' +import { tabSummary } from './tabSummary' const NEXT_SCAN_LABEL: Record = { '6h': '6 hours', @@ -154,6 +156,11 @@ export function LauncherApp(): ReactNode { () => Math.max(1, ...(docker.info?.items.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 packagesHasData = (inventory?.packages.length ?? 0) > 0 + const cachesAvailable = !!store?.available + const dockerAvailable = !!docker.info?.available const ratio = totalUsed / threshold const status = statusColor(ratio, accent) @@ -598,7 +605,31 @@ 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, + 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) return (
- {totalUsed > 0 && ( + ) + })} +``` + +- [ ] **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). From 6385636f666dea244e54a003cc6a5a9902fc0795 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Tue, 14 Jul 2026 13:13:31 +0200 Subject: [PATCH 64/89] refactor(app): drop Docker build-cache group; add delete-verb labels + size helper Co-Authored-By: Claude Opus 4.8 --- .../views/DockerView.constants.test.ts | 45 ++++++++++++++++--- .../launcher/views/DockerView.constants.ts | 27 +++++++---- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/launcher/views/DockerView.constants.test.ts b/src/renderer/src/launcher/views/DockerView.constants.test.ts index 013c3dd..d0e2251 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.test.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.test.ts @@ -2,9 +2,11 @@ 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, @@ -37,17 +39,22 @@ describe('groupDockerForDisplay', () => { item({ id: 'v9', kind: 'volume', name: 'orphan', sizeBytes: 5 }), ] - it('puts project groups first, then repository/buildcache/unaffiliated under "Other"', () => { + 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('buildcache') 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') @@ -177,11 +184,6 @@ describe('prunesForGroup', () => { expect(prunesForGroup(g)).toEqual(['unusedVolumes', 'stoppedContainers']) }) - it('buildcache group offers buildCache', () => { - const g = group({ kind: 'buildcache', items: [item({ id: 'b1', kind: 'buildcache', name: 'b1' })] }) - expect(prunesForGroup(g)).toEqual(['buildCache']) - }) - it('project group offers no prune targets', () => { const g = { kind: 'project' as const, @@ -222,3 +224,32 @@ describe('projectRowExpanded', () => { 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 index c0bc97a..032fe7f 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.ts @@ -7,7 +7,6 @@ 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) @@ -56,11 +55,6 @@ export function groupDockerForDisplay( 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 }] @@ -68,7 +62,7 @@ export function groupDockerForDisplay( return [ ...sortGroups(projectGroups, opts.sortBy), - ...sortGroups([...repoGroups, ...buildcacheGroup, ...unaffiliatedGroup], opts.sortBy), + ...sortGroups([...repoGroups, ...unaffiliatedGroup], opts.sortBy), ] } @@ -87,6 +81,17 @@ export const PRUNE_TARGET_LABEL: Record = { 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 @@ -104,7 +109,6 @@ export function prunesForGroup(group: DisplayGroup): DockerPruneTarget[] { } return targets } - if (group.kind === 'buildcache') return ['buildCache'] return [] } @@ -129,6 +133,13 @@ export function pruneEstimateBytes(items: DockerItem[], target: DockerPruneTarge } } +/** 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 { From e5dfe2e656b02617009e53ae79e79f2dcb6db692 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Tue, 14 Jul 2026 13:17:25 +0200 Subject: [PATCH 65/89] docs(app): refresh stale build-cache comments in Docker constants Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/launcher/views/DockerView.constants.test.ts | 2 +- src/renderer/src/launcher/views/DockerView.constants.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/launcher/views/DockerView.constants.test.ts b/src/renderer/src/launcher/views/DockerView.constants.test.ts index d0e2251..82c1b14 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.test.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.test.ts @@ -58,7 +58,7 @@ describe('groupDockerForDisplay', () => { 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) + // redis repo (500) sorts before the orphan volume (5); build cache is excluded entirely expect(other[0].kind === 'repository').toBe(true) }) diff --git a/src/renderer/src/launcher/views/DockerView.constants.ts b/src/renderer/src/launcher/views/DockerView.constants.ts index 032fe7f..1d350ff 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.ts @@ -42,7 +42,8 @@ export function groupDockerForDisplay( projectGroups.push({ kind: 'project', id: `project:${p.name}`, label: p.name, project: p, items: of }) } - // "Other": unaffiliated items → repository groups (images) + buildcache + unaffiliated (rest) + // "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() From 36abe2ece1f3c1a4bdc00e28a4179bfb45b3a883 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Tue, 14 Jul 2026 13:18:52 +0200 Subject: [PATCH 66/89] feat(app): Docker prune buttons read as deletes (verb + trash icon + danger) Co-Authored-By: Claude Opus 4.8 --- .../src/launcher/views/DockerView.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/launcher/views/DockerView.tsx b/src/renderer/src/launcher/views/DockerView.tsx index 18b18c4..4143396 100644 --- a/src/renderer/src/launcher/views/DockerView.tsx +++ b/src/renderer/src/launcher/views/DockerView.tsx @@ -14,7 +14,7 @@ import { dockerGroupActive, dockerItemDetail, groupDockerForDisplay, - PRUNE_TARGET_LABEL, + PRUNE_BUTTON_LABEL, projectRowExpanded, prunesForGroup, } from './DockerView.constants' @@ -28,9 +28,8 @@ const KIND_ICON: Record = { } /** Generic header glyph for the non-project ("Other") groups. */ -const GROUP_ICON: Record<'repository' | 'buildcache' | 'unaffiliated', IconRenderer> = { +const GROUP_ICON: Record<'repository' | 'unaffiliated', IconRenderer> = { repository: UIIcon.box, - buildcache: UIIcon.hdd, unaffiliated: UIIcon.hdd, } @@ -148,19 +147,23 @@ function GroupHeader({ type="button" onClick={() => onPrune(target)} disabled={busy} - title={`Prune ${PRUNE_TARGET_LABEL[target].toLowerCase()}. Permanent, not sent to the Trash.`} + title={`${PRUNE_BUTTON_LABEL[target]}. Permanent, not sent to the Trash.`} style={{ - border: '1px solid var(--surface-4)', + display: 'flex', + alignItems: 'center', + gap: 5, + border: '1px solid rgba(255,99,99,0.4)', cursor: busy ? 'default' : 'pointer', padding: '3px 9px', borderRadius: 7, - background: 'var(--surface-1)', - color: busy ? 'var(--text-dim)' : 'var(--text-2)', + background: 'rgba(255,99,99,0.10)', + color: busy ? 'var(--text-dim)' : 'rgba(255,99,99,0.95)', fontSize: 11, fontWeight: 600, }} > - {busy ? 'Pruning…' : PRUNE_TARGET_LABEL[target]} + {UIIcon.trash({ size: 11 })} + {busy ? 'Deleting…' : PRUNE_BUTTON_LABEL[target]} ) })} From 681bb481bf3614fe15397c4783cd014704fbf949 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Tue, 14 Jul 2026 13:21:43 +0200 Subject: [PATCH 67/89] feat(app): CacheRow supports a danger action button with an optional icon --- src/renderer/src/components/CacheRow/CacheRow.tsx | 12 +++++++++--- .../src/components/CacheRow/CacheRow.types.ts | 4 ++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/CacheRow/CacheRow.tsx b/src/renderer/src/components/CacheRow/CacheRow.tsx index c04a8de..2fdbbbc 100644 --- a/src/renderer/src/components/CacheRow/CacheRow.tsx +++ b/src/renderer/src/components/CacheRow/CacheRow.tsx @@ -15,6 +15,8 @@ export function CacheRow({ actionLabel, title, busyLabel, + actionIcon, + danger = false, onAction, onSelect, }: CacheRowProps): ReactNode { @@ -99,17 +101,21 @@ export function CacheRow({ disabled={busy} title={title ?? 'Remove packages no project references (pnpm store prune)'} style={{ - border: '1px solid var(--surface-4)', + display: 'flex', + alignItems: 'center', + gap: 5, + border: `1px solid ${danger ? 'rgba(255,99,99,0.4)' : 'var(--surface-4)'}`, cursor: busy ? 'default' : 'pointer', padding: '5px 11px', borderRadius: 8, - background: 'var(--surface-1)', - color: busy ? 'var(--text-dim)' : 'var(--text-2)', + background: danger ? 'rgba(255,99,99,0.10)' : 'var(--surface-1)', + color: busy ? 'var(--text-dim)' : danger ? 'rgba(255,99,99,0.95)' : 'var(--text-2)', fontSize: 12, fontWeight: 600, flex: 'none', }} > + {actionIcon?.({ size: 12 })} {busy ? (busyLabel ?? 'Pruning…') : actionLabel} ) : disabled ? ( diff --git a/src/renderer/src/components/CacheRow/CacheRow.types.ts b/src/renderer/src/components/CacheRow/CacheRow.types.ts index 82d76e9..378d2d2 100644 --- a/src/renderer/src/components/CacheRow/CacheRow.types.ts +++ b/src/renderer/src/components/CacheRow/CacheRow.types.ts @@ -24,6 +24,10 @@ export interface CacheRowProps { title?: string /** Label shown on the action button while `busy` is true; defaults to "Pruning…". */ busyLabel?: string + /** 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 /** Fired when the action button is pressed. */ onAction?: () => void /** Fired when the row itself is clicked (selects it). */ From 1bf13eafd663d9dc82a7ef0956c2d12b8c4699e4 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Tue, 14 Jul 2026 13:35:45 +0200 Subject: [PATCH 68/89] feat(app): Caches tab renders a live-cache list (pnpm + future caches) --- .../views/CachesView.constants.test.ts | 26 +++++++- .../launcher/views/CachesView.constants.ts | 30 +++++++++ .../src/launcher/views/CachesView.tsx | 62 ++++++++----------- 3 files changed, 81 insertions(+), 37 deletions(-) diff --git a/src/renderer/src/launcher/views/CachesView.constants.test.ts b/src/renderer/src/launcher/views/CachesView.constants.test.ts index 05af5e2..762083d 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 { describe, expect, it } from 'vitest' -import { CACHE_PLACEHOLDERS } from './CachesView.constants' +import { CACHE_PLACEHOLDERS, type LiveCache, visibleCaches } from './CachesView.constants' +import type { IconRenderer } from '@renderer/components/UIIcon' describe('CACHE_PLACEHOLDERS', () => { it('lists the planned package-manager caches in order with unique ids', () => { @@ -14,3 +15,26 @@ 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([]) + }) +}) diff --git a/src/renderer/src/launcher/views/CachesView.constants.ts b/src/renderer/src/launcher/views/CachesView.constants.ts index fe2f45f..7593e88 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 @@ -11,3 +13,31 @@ export const CACHE_PLACEHOLDERS: CachePlaceholder[] = [ { 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)) +} 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}”.
From 6e9dfe6d6bca72b1d5475fb95d0201b2141a4e10 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Tue, 14 Jul 2026 13:44:55 +0200 Subject: [PATCH 69/89] feat(app): Docker build cache lives in the Caches tab (delete + auto-scan + keyboard nav) --- .../src/launcher/LauncherApp/LauncherApp.tsx | 63 ++++++++++++++----- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index 6892c90..ea217eb 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -27,9 +27,10 @@ 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 } from '../views/CachesView.constants' import { DockerView } from '../views/DockerView' import type { DockerSortKey } from '../views/DockerView.constants' -import { PRUNE_TARGET_LABEL, pruneEstimateBytes } 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' @@ -139,7 +140,7 @@ export function LauncherApp(): ReactNode { // 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 + 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]) @@ -172,6 +173,10 @@ export function LauncherApp(): ReactNode { 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) @@ -420,6 +425,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 => { @@ -502,7 +543,7 @@ export function LauncherApp(): ReactNode { } 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)) @@ -511,7 +552,8 @@ export function LauncherApp(): ReactNode { setSel((s) => Math.max(0, s - 1)) } else if (e.key === 'Enter') { e.preventDefault() - if (store?.available && !pruning) void handlePrune() + const c = liveCaches[sel] + if (c && !c.disabled && !c.busy) c.onAction?.() } return } @@ -570,9 +612,7 @@ export function LauncherApp(): ReactNode { commitDelete, doOpen, rescan, - store, - pruning, - handlePrune, + liveCaches, pkgFiltered, openNpm, refreshPackages, @@ -834,14 +874,7 @@ export function LauncherApp(): ReactNode { onRefresh={() => void refreshPackages()} /> ) : tab === 'caches' ? ( - + ) : tab === 'docker' && dockerEnabled ? ( Date: Tue, 14 Jul 2026 13:48:06 +0200 Subject: [PATCH 70/89] style(app): biome format fixes in Docker/Caches constants (missed lint in TDD-only tasks) Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/launcher/views/CachesView.constants.test.ts | 2 +- src/renderer/src/launcher/views/DockerView.constants.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/launcher/views/CachesView.constants.test.ts b/src/renderer/src/launcher/views/CachesView.constants.test.ts index 762083d..626dccd 100644 --- a/src/renderer/src/launcher/views/CachesView.constants.test.ts +++ b/src/renderer/src/launcher/views/CachesView.constants.test.ts @@ -1,6 +1,6 @@ +import type { IconRenderer } from '@renderer/components/UIIcon' import { describe, expect, it } from 'vitest' import { CACHE_PLACEHOLDERS, type LiveCache, visibleCaches } from './CachesView.constants' -import type { IconRenderer } from '@renderer/components/UIIcon' describe('CACHE_PLACEHOLDERS', () => { it('lists the planned package-manager caches in order with unique ids', () => { diff --git a/src/renderer/src/launcher/views/DockerView.constants.ts b/src/renderer/src/launcher/views/DockerView.constants.ts index 1d350ff..2e4d23b 100644 --- a/src/renderer/src/launcher/views/DockerView.constants.ts +++ b/src/renderer/src/launcher/views/DockerView.constants.ts @@ -61,10 +61,7 @@ export function groupDockerForDisplay( ? [{ kind: 'unaffiliated', id: 'unaffiliated', label: 'Not linked to a project', items: rest }] : [] - return [ - ...sortGroups(projectGroups, opts.sortBy), - ...sortGroups([...repoGroups, ...unaffiliatedGroup], opts.sortBy), - ] + 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 From c75b258b9c1d146f7df9aecce78d446007cc1f54 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Tue, 14 Jul 2026 13:53:42 +0200 Subject: [PATCH 71/89] fix(app): Caches footer hint tracks the live-cache list + selected row's verb Was gated on the pnpm store alone and hardcoded 'prune'; now shows whenever any live cache is present and labels Enter with the selected row's action (Prune/Delete). Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/launcher/LauncherApp/LauncherApp.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index ea217eb..03a9407 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -1138,7 +1138,7 @@ export function LauncherApp(): ReactNode {
)} - {view === 'list' && tab === 'caches' && store?.available && ( + {view === 'list' && tab === 'caches' && liveCaches.length > 0 && (
{UIIcon.arrowUp({ size: 12 })} @@ -1148,7 +1148,7 @@ export function LauncherApp(): ReactNode { {UIIcon.enter({ size: 12 })} {' '} - prune + {(liveCaches[sel]?.actionLabel ?? 'prune').toLowerCase()}
)} From 916e497c0656b59bde1804d113ec90828050aa45 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Tue, 14 Jul 2026 14:01:35 +0200 Subject: [PATCH 72/89] fix(app): Caches Enter never fires a query-hidden row; footer + placeholder polish Final-review fixes: - Important: Enter on the Caches tab indexed the full liveCaches list while only the query-filtered rows were on screen, so Enter could trigger a hidden row's action. New tested selectedActionableCache() guard returns the selected cache only when it is visible and actionable; Enter no-ops otherwise. - Minor: footer hint now requires at least one non-disabled cache (was any cache, so a disabled-only pnpm row still showed the hint). - Minor: drop em dashes from the npm/yarn/bun placeholder copy (no-em-dash rule). Co-Authored-By: Claude Opus 4.8 --- .../src/launcher/LauncherApp/LauncherApp.tsx | 9 ++--- .../views/CachesView.constants.test.ts | 35 ++++++++++++++++++- .../launcher/views/CachesView.constants.ts | 19 +++++++--- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index 03a9407..b70a1b8 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -27,7 +27,7 @@ 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 } from '../views/CachesView.constants' +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' @@ -552,8 +552,9 @@ export function LauncherApp(): ReactNode { 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?.() + // 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 } @@ -1138,7 +1139,7 @@ export function LauncherApp(): ReactNode {
)} - {view === 'list' && tab === 'caches' && liveCaches.length > 0 && ( + {view === 'list' && tab === 'caches' && liveCaches.some((c) => !c.disabled) && (
{UIIcon.arrowUp({ size: 12 })} diff --git a/src/renderer/src/launcher/views/CachesView.constants.test.ts b/src/renderer/src/launcher/views/CachesView.constants.test.ts index 626dccd..3a8ba9b 100644 --- a/src/renderer/src/launcher/views/CachesView.constants.test.ts +++ b/src/renderer/src/launcher/views/CachesView.constants.test.ts @@ -1,6 +1,6 @@ import type { IconRenderer } from '@renderer/components/UIIcon' import { describe, expect, it } from 'vitest' -import { CACHE_PLACEHOLDERS, type LiveCache, visibleCaches } 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', () => { @@ -38,3 +38,36 @@ describe('visibleCaches', () => { 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 7593e88..7d62250 100644 --- a/src/renderer/src/launcher/views/CachesView.constants.ts +++ b/src/renderer/src/launcher/views/CachesView.constants.ts @@ -7,11 +7,11 @@ 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 @@ -41,3 +41,14 @@ export function visibleCaches(caches: LiveCache[], query: string): { cache: Live .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 +} From 9575a5eff0e68222cd2af3695fcdb3de4cd5d607 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Tue, 14 Jul 2026 14:04:01 +0200 Subject: [PATCH 73/89] =?UTF-8?q?docs:=20STATUS=20=E2=80=94=20Docker=20bui?= =?UTF-8?q?ld=20cache=20to=20Caches=20tab=20+=20clearer=20delete=20buttons?= =?UTF-8?q?=20(M1h)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- STATUS.html | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/STATUS.html b/STATUS.html index c31652d..473dca0 100644 --- a/STATUS.html +++ b/STATUS.html @@ -15,9 +15,9 @@ const STATUS = { app: "TidyDisk", version: "1.0.0", - updated: "2026-07-09", + updated: "2026-07-14", phase: "🚀 v1.0.0 — TidyDisk is LIVE-ready. All three PRs merged to main (landing refresh #22, license gate + analytics #30, rename + honest copy #31); the release gate is lifted; tidydisk.app serves the new site with the €19 Polar checkout wired. The v1.0.0 tag fires the signed-release pipeline (first tidydisk-arm64.dmg). Your last clicks before selling: publish the draft release when the workflow finishes, then run the launch playbook (#27 scan screenshot, #28 assets, #29 the wave).", - summary: "This session (2026-07-09): per-tab headline data-viz + Docker auto-scan, BUILT on feat/docker-project-grouping (spec + plan in docs/superpowers/2026-07-09-*, subagent-driven TDD, 8 tasks + a final-review fix). Each launcher tab's header slot and footer line now reflect THAT tab's core metric instead of always node_modules: Projects/Caches/Docker each fill a size-vs-limit Gauge against their OWN configurable GB limit (new cacheThresholdGB=10, dockerThresholdGB=20 in Settings > Scanning > Storage limits, clamped 0.1-1000 like the node_modules threshold); Packages shows a compact SeverityMeter (a stacked critical/high/moderate/low advisory bar + outdated count, full breakdown in the tooltip). A new TabHeadline switches the header slot; a new pure tabSummary() switches the footer line; both null-gate honestly (no gauge when a store/daemon is absent). The Docker tab auto-scans on open when its data is missing or >5 min stale (loop-guarded, decoupled from the disk rescan; manual Cmd-R/refresh kept). Whole-branch Opus review returned one Important finding, fixed: the Packages headline no longer shows a false 'all clear' when advisories were never actually checked (checks off / offline / before enrichment now render nothing) - the same fix removes the first-open flash. 277 tests, typecheck/lint/build green; Projects tab confirmed unregressed and the Docker typed-name confirm safety gate untouched. In your court: manual QA (needs a running app + Docker daemon) and whether this rides the still-pending Docker-grouping PR #42 or gets its own branch/PR. Prior sessions: Two new app features shipped on feat/scan-coverage-and-liveness (PR #39), each brainstormed → spec'd → planned → built via subagent-driven TDD (15 tasks, fresh implementer + independent review each), then a whole-branch review on the strongest tier (READY TO MERGE). (1) External-drive / scan-location coverage — the scanner was hard-wired to ~; now a Settings 'Scan locations' section lists mounted external volumes (detected under /Volumes, boot disk excluded by device id) as toggles plus an 'Add folder…' picker, choices persist in a new scanRoots setting, and resolveScanRoots feeds home + opted-in roots to the scanner each scan (mounted-only, deduped, nesting-dropped) — so node_modules on an external SSD or any folder is finally scannable and cleanable; an unplugged drive's projects just drop off next scan and return on replug, and a delete-time guard refuses trashing a path that vanished. (2) Live/running indicator — a green dot marks any project with a process running from inside it (one lsof cwd sweep per refresh, best-effort port label, soft-fails to no-dots on any error), its delete is disabled and main hard-refuses it re-checked at the instant of delete, so you cannot trash a running app's node_modules; polls every 45s while a window is open. Shared: unified deleteNodeModules → { freed, blocked? }; a batch deleteMany IPC keeps 'Clean N stale' to one liveness check (the final review's one Important finding). 183 tests, typecheck/biome/build green; the real lsof flags smoke-tested on macOS. Awaiting your review/merge + manual pass (needs a physical external drive + a running dev server). Previous session context: SEO + content session (site only, no app code). Audited tidydisk.app's SEO: robots.txt and sitemap.xml were 404, no canonical tags, no structured data, and the apex→www redirect direction contradicts the code's canonical base (fix is one Vercel dashboard click on your side). Built the fixes plus a self-publishing blog on feat/blog-seo (PR #34): markdown articles in site/content/blog/ parsed by a tested lib/blog pipeline; every surface (listing, article pages, sitemap, RSS) revalidates hourly and filters on publish date, so the 10 pre-written keyword-targeted articles release themselves one per week (2026-07-04 → 2026-09-05) with zero deploys. Also: SoftwareApplication + BlogPosting JSON-LD, RSS feed, Blog in nav/footer. 17 unit tests, build green, rendering verified via Playwright. Earlier context: decided to reposition the product as TidyDisk (from 'Clean my node_modules'), broadening the pitch from node_modules to all developer disk waste (caches, builds, later Docker/Xcode), and to monetize via free-scan / paid-clean rather than a free/OSS-only model: the free scan stays the marketing engine (its shocking number is the shareable artifact) and the one-click Clean action becomes a paid, lifetime unlock (€19 founding → €29). Produced two artifacts: TIDYDISK-LAUNCH.html — an interactive, on-brand launch dashboard (live price→sales calculator, pricing ladder, offer stack, competitor table, Show HN / Reddit drafts, and a persistent 2–4 week launch checklist; rendering + interactivity verified via Playwright) — and docs/superpowers/specs/2026-07-01-tidydisk-gtm-and-monetization-design.md, the GTM + monetization spec, whose one implementation slice is a license gate on the destructive actions (new src/main/license/*, offline-verifiable keys, enforced inside the destructive IPC handlers, Merchant-of-Record checkout via Lemon Squeezy/Paddle). Nothing renamed or gated in code yet — awaiting your review of the spec and your go-ahead. Still open from the prior session: publish the v0.2.0 draft release and deploy site/ to Vercel.", + summary: "This session (2026-07-14): finished the Docker feature on feat/docker-project-grouping (spec + plan docs/superpowers/2026-07-14-docker-buildcache-*, subagent-driven TDD, 6 tasks + a final-review fix wave). Two things you asked for. (1) Docker build cache no longer sits in the Docker tab: groupDockerForDisplay drops the build-cache group (items still count in the Docker header gauge, but the list only shows project/repo/orphan resources), and it now appears as a single aggregated 'Docker build cache' row in the CACHES tab next to the pnpm store. Its red Delete routes through the EXISTING requestDockerPrune('buildCache') — same license gate, same confirm, same toast, no new delete path — and the Caches tab was generalized from a hard-coded pnpm row into a list of 'live caches' (keyboard nav + Enter across them; the tab also auto-scans Docker on open when data is missing/stale, mirroring the Docker tab). (2) The deletion buttons now unmistakably read as deletions: the Docker bulk-prune buttons became 'Delete unused images / dangling images / stopped containers / unused volumes' with a trash icon and red danger styling; the build-cache row's Delete is likewise danger-styled (CacheRow gained an opt-in danger + icon variant); the per-item unused-image/volume/container trash stays hover-revealed like the node_modules rows (already danger). SAFETY UNCHANGED: presentation + a delete-button relabel; the typed-name volume confirm gate is byte-identical and build cache is still never individually removable (3-layer block, verified). Whole-branch Opus review: 1 Important fixed — Caches Enter indexed the full cache list while the rows were query-filtered, so Enter could fire a row scrolled off by search; a new tested selectedActionableCache() guard makes Enter a no-op unless the selected row is visible + actionable (confirm-gated the whole time, never a silent delete). Also swept em dashes out of the npm/yarn/bun placeholder copy. 293 tests, typecheck/lint/build green. In your court: manual QA (needs a running app + Docker daemon) and push/PR (rides PR #42 or its own branch, your call). Prior session (2026-07-09): per-tab headline data-viz + Docker auto-scan, BUILT on feat/docker-project-grouping (spec + plan in docs/superpowers/2026-07-09-*, subagent-driven TDD, 8 tasks + a final-review fix). Each launcher tab's header slot and footer line now reflect THAT tab's core metric instead of always node_modules: Projects/Caches/Docker each fill a size-vs-limit Gauge against their OWN configurable GB limit (new cacheThresholdGB=10, dockerThresholdGB=20 in Settings > Scanning > Storage limits, clamped 0.1-1000 like the node_modules threshold); Packages shows a compact SeverityMeter (a stacked critical/high/moderate/low advisory bar + outdated count, full breakdown in the tooltip). A new TabHeadline switches the header slot; a new pure tabSummary() switches the footer line; both null-gate honestly (no gauge when a store/daemon is absent). The Docker tab auto-scans on open when its data is missing or >5 min stale (loop-guarded, decoupled from the disk rescan; manual Cmd-R/refresh kept). Whole-branch Opus review returned one Important finding, fixed: the Packages headline no longer shows a false 'all clear' when advisories were never actually checked (checks off / offline / before enrichment now render nothing) - the same fix removes the first-open flash. 277 tests, typecheck/lint/build green; Projects tab confirmed unregressed and the Docker typed-name confirm safety gate untouched. In your court: manual QA (needs a running app + Docker daemon) and whether this rides the still-pending Docker-grouping PR #42 or gets its own branch/PR. Prior sessions: Two new app features shipped on feat/scan-coverage-and-liveness (PR #39), each brainstormed → spec'd → planned → built via subagent-driven TDD (15 tasks, fresh implementer + independent review each), then a whole-branch review on the strongest tier (READY TO MERGE). (1) External-drive / scan-location coverage — the scanner was hard-wired to ~; now a Settings 'Scan locations' section lists mounted external volumes (detected under /Volumes, boot disk excluded by device id) as toggles plus an 'Add folder…' picker, choices persist in a new scanRoots setting, and resolveScanRoots feeds home + opted-in roots to the scanner each scan (mounted-only, deduped, nesting-dropped) — so node_modules on an external SSD or any folder is finally scannable and cleanable; an unplugged drive's projects just drop off next scan and return on replug, and a delete-time guard refuses trashing a path that vanished. (2) Live/running indicator — a green dot marks any project with a process running from inside it (one lsof cwd sweep per refresh, best-effort port label, soft-fails to no-dots on any error), its delete is disabled and main hard-refuses it re-checked at the instant of delete, so you cannot trash a running app's node_modules; polls every 45s while a window is open. Shared: unified deleteNodeModules → { freed, blocked? }; a batch deleteMany IPC keeps 'Clean N stale' to one liveness check (the final review's one Important finding). 183 tests, typecheck/biome/build green; the real lsof flags smoke-tested on macOS. Awaiting your review/merge + manual pass (needs a physical external drive + a running dev server). Previous session context: SEO + content session (site only, no app code). Audited tidydisk.app's SEO: robots.txt and sitemap.xml were 404, no canonical tags, no structured data, and the apex→www redirect direction contradicts the code's canonical base (fix is one Vercel dashboard click on your side). Built the fixes plus a self-publishing blog on feat/blog-seo (PR #34): markdown articles in site/content/blog/ parsed by a tested lib/blog pipeline; every surface (listing, article pages, sitemap, RSS) revalidates hourly and filters on publish date, so the 10 pre-written keyword-targeted articles release themselves one per week (2026-07-04 → 2026-09-05) with zero deploys. Also: SoftwareApplication + BlogPosting JSON-LD, RSS feed, Blog in nav/footer. 17 unit tests, build green, rendering verified via Playwright. Earlier context: decided to reposition the product as TidyDisk (from 'Clean my node_modules'), broadening the pitch from node_modules to all developer disk waste (caches, builds, later Docker/Xcode), and to monetize via free-scan / paid-clean rather than a free/OSS-only model: the free scan stays the marketing engine (its shocking number is the shareable artifact) and the one-click Clean action becomes a paid, lifetime unlock (€19 founding → €29). Produced two artifacts: TIDYDISK-LAUNCH.html — an interactive, on-brand launch dashboard (live price→sales calculator, pricing ladder, offer stack, competitor table, Show HN / Reddit drafts, and a persistent 2–4 week launch checklist; rendering + interactivity verified via Playwright) — and docs/superpowers/specs/2026-07-01-tidydisk-gtm-and-monetization-design.md, the GTM + monetization spec, whose one implementation slice is a license gate on the destructive actions (new src/main/license/*, offline-verifiable keys, enforced inside the destructive IPC handlers, Merchant-of-Record checkout via Lemon Squeezy/Paddle). Nothing renamed or gated in code yet — awaiting your review of the spec and your go-ahead. Still open from the prior session: publish the v0.2.0 draft release and deploy site/ to Vercel.", milestones: [ { @@ -71,6 +71,7 @@ { label: "M1e — Docker tab regrouped BY PROJECT (logos + type badges + used-by association)", detail: "BUILT on feat/docker-project-grouping (stacked on the M1d PR #40; spec + plan in docs/superpowers/). Replaces the by-category Docker sections with a project-grouped list: Compose projects group at the top with their REAL logo (reusing the node_modules framework/favicon detection on the compose working_dir, generic Docker glyph when the folder is gone), then a 'Not linked to a project' section (unassociated images sub-grouped by repository, a build-cache group, and orphan volumes/containers). Each row carries a type badge (IMAGE / VOLUME / CONTAINER / CACHE), and there are Size / Name / Recent sort tabs + All/Images/Volumes/Containers/Cache filter chips mirroring the Projects tab. Association is aggressive but honest: Compose labels PLUS 'used-by' (an image a project's container runs, a volume it mounts) — an item used by >1 project stays unassociated so the size sort never double-counts; pure 'docker run' setups with no compose fall to 'Other' (a stated data-limitation ceiling). Main process adds batched docker container/volume inspect behind the existing cache, all fail-soft (any inspect error → everything unassociated, tab still lists + cleans). SAFETY UNCHANGED: presentation + enrichment only — rows still flow the real id/kind, and the typed-name volume-confirm gate is byte-identical (Opus whole-branch review verified no wrongful-delete path). Bulk-prune buttons live only on the 'Other' headers (prune is a global op, never shown as project-scoped). Built subagent-driven with TDD; the review loop caught + fixed 3 real issues (image id/ref match must be a union not a fallback; dangling-image bulk prune was unreachable; fail-soft ordering could silently hide items). Pending your review + manual daemon test", status: "done" }, { label: "M1f — Per-tab headline data-viz + Docker auto-scan", detail: "BUILT on feat/docker-project-grouping (spec + plan docs/superpowers/2026-07-09-*, subagent-driven TDD, 8 tasks + 1 final-review fix). Each launcher tab's header slot + footer line now reflect THAT tab's core metric instead of always node_modules: Projects/Caches/Docker each fill a size-vs-limit Gauge against their OWN configurable GB limit (new cacheThresholdGB=10 / dockerThresholdGB=20 in Settings > Scanning > Storage limits, validated + clamped 0.1-1000 like the node_modules threshold); Packages shows a new compact SeverityMeter - a stacked critical/high/moderate/low advisory bar + outdated count, full breakdown in the tooltip. New TabHeadline component switches the header slot per active tab; new pure tabSummary() switches the footer line; both null-gate honestly (no gauge when a store/daemon is absent, nothing on Packages when advisory checks are off/offline). Docker auto-scans on tab open when its data is missing or >5 min stale (loop-guarded via docker.loading + fresh checkedAt, decoupled from the disk rescan; manual Cmd-R/button kept). SAFETY UNCHANGED: presentation only - the Docker typed-name confirm gate is untouched and the Projects tab renders identically (both verified in the whole-branch review). Whole-branch Opus review: 1 Important fixed - the Packages headline no longer claims 'all clear' when advisories were never checked. 277 tests, typecheck/lint/build green. Pending your review + manual pass", status: "done" }, { label: "M1g — Docker tab: collapsible project rows (shared Accordion)", detail: "BUILT on feat/docker-project-grouping (spec + plan docs/superpowers/2026-07-09-docker-collapsible-*, subagent-driven TDD, 4 tasks + 1 final-review fix). The Docker tab was a long flat list (every group's header + all item rows always visible); now each compose-PROJECT group is a collapsed-by-default clickable row showing its logo/icon, name, item count, total size, and a green active dot when any resource is in use. Clicking reveals the project's items (accordion, one open at a time, like the Packages tab); typing in the search box expands all matching projects. The 'Other' groups (images-by-repo, build cache, orphans) are unchanged. Reuse done right: extracted a generic components/Accordion primitive (open-card wrapper, optional card prop) and retrofitted BOTH tabs onto it. Packages passes card (verified pixel-identical no-op); Docker omits it so its rows stay flat and the node_modules-style .cc-hl sliding highlight keeps working across headers + items. Two tested pure helpers (dockerGroupActive, projectRowExpanded). SAFETY UNCHANGED: presentation only, DockerItemRow + the typed-name volume confirm gate untouched. Whole-branch Opus review READY TO MERGE (0 Critical/Important; it traced the highlight offsetParent chain, the Packages no-op, and the safety gate); fixed 1 Minor (a residual em dash in the remove label). 282 tests green. Follow-up logged: an a11y pass to give the Row/PackageRow/DockerProjectRow family real aria-expanded disclosure + keyboard support. Pending your review + manual pass", status: "done" }, + { label: "M1h — Docker build cache → Caches tab + clearer deletion buttons", detail: "BUILT on feat/docker-project-grouping (spec + plan docs/superpowers/2026-07-14-docker-buildcache-*, subagent-driven TDD, 6 tasks + a final-review fix wave). You didn't like build caches listed inside the Docker tab, and the delete buttons didn't look like deletes. Fixed both. (1) groupDockerForDisplay no longer emits a build-cache group — build cache leaves the Docker list entirely (it still counts toward the Docker header gauge, since those bytes are real Docker disk) and shows up as one aggregated 'Docker build cache' row in the CACHES tab beside the pnpm store. Delete routes through the EXISTING requestDockerPrune('buildCache') (same license gate + confirm + toast; no new IPC/confirm path). The Caches tab was generalized from a hard-coded pnpm row into a list of 'live caches' with keyboard nav + Enter across them, and it auto-scans Docker on open when data is missing/stale (mirrors the Docker tab). (2) Docker bulk-prune buttons are now 'Delete unused images / dangling images / stopped containers / unused volumes' with a trash icon + red danger styling; the build-cache Delete is likewise danger-styled (CacheRow gained an opt-in danger + icon action variant); per-item unused-image/volume/container trash stays hover-revealed (already danger, like the node_modules rows). SAFETY UNCHANGED: relabel + relocation only — the typed-name volume confirm gate is byte-identical and build cache is still never individually removable (3-layer block verified). Whole-branch Opus review: 1 Important fixed — Caches Enter could fire a row that search had filtered off screen (now a tested selectedActionableCache() guard no-ops unless the selected row is visible + actionable; confirm-gated throughout, never a silent delete); also swept em dashes from the placeholder copy. 293 tests, typecheck/lint/build green. Pending your review + manual pass", status: "done" }, { label: "M2 — npm / yarn / bun global caches", detail: "Same Caches section, after #3 lands", status: "planned" }, { label: "M3 — per-project build outputs (.next, dist, …)", detail: "Behind a settings toggle", status: "planned" }, { label: "M4 — beyond JS (venv, target/, DerivedData)", detail: "Only if scope grows", status: "planned" } @@ -221,7 +222,8 @@ { date: "2026-07-09", text: "Iterated on the Docker project-grouping from your real-daemon test (screenshot showed everything flat under 'Not linked to a project'). Fixed the root cause first: a crash ('info.projects is not iterable') from a docker-cache.json written by the older build with no projects field — guarded the render path + normalized the loaded cache. Then the real linking bug: the whole enrichment sat in one try/catch, so a single failing docker inspect silently dropped ALL grouping, including the repository grouping that's independent of Compose — split each docker call into its own fail-soft try with console.error logging so associateProjects always runs and repository groups always appear (the logging will also reveal whether your Supabase/custom containers carry Compose labels or their own namespace, which is what decides real project grouping). Also acted on your UI feedback: removed the type-filter chips (kept Sort), gave each type badge a distinct color, and standardized the Docker rows on the node_modules Row primitives — hover-revealed trash RowAction instead of a persistent Remove button, SizeViz for the size text — so we reuse components instead of rebuilding each screen. Each change TDD'd + independently reviewed (safety gate + buildcache-non-removable verified intact); 259 tests green. Pushed to PR #42." }, { date: "2026-07-09", text: "Debugged the 'still no project groups' report by running your REAL docker data through the app's association logic. Found it: `docker volume inspect ` exits 1 whenever `docker system df -v` lists an anonymous/stale volume (one bad name fails the whole batch) — and before the resilience fix that single failure took down ALL grouping. Your containers DO carry Compose labels, so the association correctly yields three projects (lets-get-a-job, orchestrator, aprocy — aprocy has a working_dir so it gets a real logo; the two Supabase ones have empty working_dirs so they show a generic Docker icon + name). Switched volume label lookup to a single robust `docker volume ls --format` call (can't 404 on a stale volume). Also per your feedback: in-use items now show the same green LiveDot as running node_modules projects (dropped the in-use/unused text), and Docker rows got the node_modules row hover style. The main-process fixes need a FULL app restart to load (pnpm dev hot-reloads only the renderer). 265 tests green; reviewed, safety gate intact. Pushed to PR #42." }, { date: "2026-07-09", text: "Shipped per-tab headline data-viz + Docker auto-scan (feat/docker-project-grouping) after you asked for each tab to have its own core-data visualization like the node_modules limit gauge. Brainstormed the four decisions (own configurable limit per tab; Packages gets a severity bar + outdated count rather than a limit; reuse the compact 132px header slot per-tab rather than a new band; Docker auto-scans on open when stale), spec'd + planned, then executed subagent-driven with TDD: 8 tasks, fresh implementer + independent spec/quality review each, cheap models for the pure/transcription tasks and a standard tier for the LauncherApp integration. Result: TabHeadline swaps the header gauge per active tab (Projects/Caches/Docker fill a size-vs-limit Gauge against their own new cacheThresholdGB/dockerThresholdGB settings; Packages shows a new SeverityMeter), tabSummary swaps the footer line, and a loop-guarded effect auto-refreshes Docker when its tab opens with missing or >5-min-stale data. The whole-branch Opus review earned its keep again: it caught that severityCounts() returns all-zero whenever nothing has an advisory, so with npm checks off / offline / before enrichment the Packages headline falsely rendered a green 'all clear' contradicting the tab body - fixed by gating both the header and footer on packagesCheckEnabled + packagesDataReady so they render nothing until advisories are actually trustworthy (same fix removed a first-open flash). It also confirmed the Projects tab is byte-identical and the Docker typed-name confirm safety gate is untouched. 277 tests, typecheck/lint/build green. Not pushed yet: your call whether it rides the still-pending PR #42 or gets its own branch." }, - { date: "2026-07-09", text: "Made the Docker tab's project groups collapsible (feat/docker-project-grouping). You asked for clickable project rows that reveal their items on click, like the Packages tab, since a busy daemon makes the flat list long. Brainstormed the shape (accordion, one open at a time; only PROJECT groups collapse, the 'Other' groups stay as-is) and surfaced the honest finding that there was no reusable accordion COMPONENT (it was fused into the package-specific PackageRow), so you chose to extract a generic one and retrofit both tabs. Executed subagent-driven with TDD (4 tasks): a shared components/Accordion primitive (open-card wrapper with an optional card prop), the Packages tab retrofitted onto it as a verified pixel-identical no-op, two tested pure helpers (dockerGroupActive, projectRowExpanded), and DockerView rewired so projects render through Accordion with a new collapsible DockerProjectRow header (logo, name, N items, size, active dot, rotating chevron). The key reconciliation: Docker's Accordion omits card so its wrapper stays a static div, keeping the item rows' offsetParent the outer list container so the node_modules-style .cc-hl sliding highlight still tracks across headers and items. The Opus whole-branch review traced every ancestor chain and confirmed it, plus the Packages no-op and the untouched safety gate: READY TO MERGE, 0 Critical/Important. Fixed its one Minor (a residual em dash in the Docker remove label). 282 tests, typecheck/lint/build green. Rides PR #42 per your earlier call. Logged follow-up: a proper aria-expanded/keyboard a11y pass for the Row/PackageRow/DockerProjectRow disclosure family." } + { date: "2026-07-09", text: "Made the Docker tab's project groups collapsible (feat/docker-project-grouping). You asked for clickable project rows that reveal their items on click, like the Packages tab, since a busy daemon makes the flat list long. Brainstormed the shape (accordion, one open at a time; only PROJECT groups collapse, the 'Other' groups stay as-is) and surfaced the honest finding that there was no reusable accordion COMPONENT (it was fused into the package-specific PackageRow), so you chose to extract a generic one and retrofit both tabs. Executed subagent-driven with TDD (4 tasks): a shared components/Accordion primitive (open-card wrapper with an optional card prop), the Packages tab retrofitted onto it as a verified pixel-identical no-op, two tested pure helpers (dockerGroupActive, projectRowExpanded), and DockerView rewired so projects render through Accordion with a new collapsible DockerProjectRow header (logo, name, N items, size, active dot, rotating chevron). The key reconciliation: Docker's Accordion omits card so its wrapper stays a static div, keeping the item rows' offsetParent the outer list container so the node_modules-style .cc-hl sliding highlight still tracks across headers and items. The Opus whole-branch review traced every ancestor chain and confirmed it, plus the Packages no-op and the untouched safety gate: READY TO MERGE, 0 Critical/Important. Fixed its one Minor (a residual em dash in the Docker remove label). 282 tests, typecheck/lint/build green. Rides PR #42 per your earlier call. Logged follow-up: a proper aria-expanded/keyboard a11y pass for the Row/PackageRow/DockerProjectRow disclosure family." }, + { date: "2026-07-14", text: "Finished the Docker feature from your two asks: build caches shouldn't live in the Docker tab, and the delete buttons should look like deletes. Brainstormed the four decisions (single aggregated 'Docker build cache' row in the Caches tab reusing the existing prune confirm/gate; build cache stays counted in the Docker gauge since it's real Docker disk; verb+trash+danger relabel of the bulk-prune buttons; keep the per-item hover trash), spec'd + planned, then executed subagent-driven with TDD (6 tasks: cheap models for the transcription tasks, a standard tier for the CachesView rewrite and the LauncherApp integration, opus for the reviews). Result: groupDockerForDisplay drops the build-cache group; the Caches tab became a generalized 'live caches' list (pnpm + Docker build cache, keyboard-navigable, auto-scans Docker on open); Docker bulk buttons now say 'Delete unused images' etc. in red with a trash icon; CacheRow gained an opt-in danger+icon action variant. SAFETY stayed frozen — the build-cache Delete routes through the unchanged requestDockerPrune('buildCache'), the typed-name volume gate is byte-identical, and build cache is still un-deletable individually (canRemove + removeDockerItem + coerceRemoveArgs all block it). Two per-task reviews flagged missed lint in the TDD-only tasks (biome format) which I fixed. The whole-branch Opus review earned its keep: it caught that Caches Enter indexed the full cache list while the rows were query-filtered, so pressing Enter with an active search could trigger a row scrolled off screen (confirm-gated, so never a silent delete) — fixed with a tested selectedActionableCache() guard, plus a footer-hint gate and an em-dash sweep of the placeholder copy. 293 tests, typecheck/lint/build green. Not pushed — your call whether it rides PR #42 or its own branch; manual daemon QA still yours." } ] }; From 2c223f9d8d896e66acd6b3e5de0a83587c49f6b7 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Tue, 14 Jul 2026 14:32:01 +0200 Subject: [PATCH 74/89] fix(app): pre-merge review fixes (em dashes, docker bar scale, dangling confirm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whole-feature review (3 parallel reviewers) before merging the Docker stack to main: - Critical: drop em dashes from three user-facing strings (two remove/prune toasts + the Docker confirm footer 'Permanent, not sent to the Trash.') — the no-em-dash rule. - Important: dockerMaxBytes excluded build cache from the Docker rows' SizeViz bar scale; build cache left the Docker display but was still counted, compressing every visible bar. - Important: clear any pending Docker confirm when Docker is disabled, so disabling the feature mid-confirm (incl. a build-cache prune started from the Caches tab) can't leave a live destructive dialog floating over the wrong tab. - Refresh a stale doc comment; keep the buildcache remove-guards as defense-in-depth. Co-Authored-By: Claude Opus 4.8 --- .../src/launcher/LauncherApp/LauncherApp.tsx | 19 +++++++++++++++---- .../src/launcher/views/DockerView.tsx | 6 ++++-- .../src/launcher/views/DockerView.types.ts | 3 ++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index b70a1b8..7d99707 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -164,8 +164,11 @@ 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.map((i) => i.sizeBytes) ?? [])), + () => 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]) @@ -293,6 +296,14 @@ export function LauncherApp(): ReactNode { 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( @@ -334,7 +345,7 @@ export function LauncherApp(): ReactNode { } else { flashToast({ icon: UIIcon.alert, - text: `Couldn't remove ${item.name} — it may now be in use.`, + text: `Couldn't remove ${item.name}. It may now be in use.`, tone: 'neutral', }) } @@ -356,7 +367,7 @@ export function LauncherApp(): ReactNode { } else { flashToast({ icon: UIIcon.alert, - text: "Couldn't prune — nothing was removed.", + text: "Couldn't prune. Nothing was removed.", tone: 'neutral', }) } @@ -1038,7 +1049,7 @@ export function LauncherApp(): ReactNode { dockerConfirm.kind === 'remove' ? dockerConfirm.item.sizeBytes : dockerConfirm.estimatedBytes, )} - . Permanent — not sent to the Trash. + . Permanent, not sent to the Trash.
diff --git a/src/renderer/src/launcher/views/DockerView.tsx b/src/renderer/src/launcher/views/DockerView.tsx index 4143396..c24abdf 100644 --- a/src/renderer/src/launcher/views/DockerView.tsx +++ b/src/renderer/src/launcher/views/DockerView.tsx @@ -272,8 +272,10 @@ function DockerProjectRow({ /** 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 (build cache, or anything the caller has no `onRemove` for) get a - * disabled trash with an explanatory title, and `onRemove` is never invoked for them. */ + * 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, diff --git a/src/renderer/src/launcher/views/DockerView.types.ts b/src/renderer/src/launcher/views/DockerView.types.ts index 81a1915..1fc6459 100644 --- a/src/renderer/src/launcher/views/DockerView.types.ts +++ b/src/renderer/src/launcher/views/DockerView.types.ts @@ -25,6 +25,7 @@ export interface DockerViewProps { density: Density sizeStyle: SizeStyle accent: string - /** Largest single item's sizeBytes across the whole Docker inventory (min 1), for SizeViz's bar scale. */ + /** Largest sizeBytes among the items the Docker tab actually renders (build cache excluded, + * min 1), for SizeViz's bar scale. */ maxBytes: number } From 282ed3953a68ba1c45d17e493d6141e4525b7ee3 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 15 Jul 2026 17:05:43 +0200 Subject: [PATCH 75/89] docs: spec for the visualization-only menu bar panel The popover drops the reclaimable node_modules list and every destructive action, becoming 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. Records the non-obvious calls: the hero counts the pnpm store once and is therefore smaller than the four rows added up (rows mirror their tabs so click-through never lies); "tracked" not "reclaimable", because the store's prunable share is unknowable before a prune; the panel never computes the package inventory; and the panel's removal takes all three paywall_shown triggers with it, leaving the launcher as the only paywall surface. Co-Authored-By: Claude Opus 4.8 --- ...6-07-15-panel-visualization-only-design.md | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-panel-visualization-only-design.md 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..96c5cac --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-panel-visualization-only-design.md @@ -0,0 +1,250 @@ +# 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 + store:** 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:139-146`). `getDockerInfo` already caches to disk and dedupes + in-flight calls; `getPnpmStore` is a `du` that can take seconds, so its result must + never gate first paint. +- **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. From 8ae0089601a3b004e72489732f22b4e9e21686a2 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 15 Jul 2026 17:16:57 +0200 Subject: [PATCH 76/89] docs: plan for the visualization-only menu bar panel Nine TDD tasks: widen the nav target, share DOCKER_STALE_MS, build the pure panelAreas brain (hero math then row gating), AreaBar + TrackedSummary, strip the panel, wire the dashboard, update STATUS. Also corrects D7 in the spec. It had put the pnpm store on the same >5 min staleness rule as Docker, but getPnpmStoreInfo has no TTL, so that would force a multi-second du every time the popover opened past the TTL. The launcher already solves this properly: it re-sizes the store on scan-completion only, and its stale rule refreshes Docker alone. The panel now matches that. Co-Authored-By: Claude Opus 4.8 --- .../2026-07-15-panel-visualization-only.md | 1396 +++++++++++++++++ ...6-07-15-panel-visualization-only-design.md | 19 +- 2 files changed, 1410 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-15-panel-visualization-only.md 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-15-panel-visualization-only-design.md b/docs/superpowers/specs/2026-07-15-panel-visualization-only-design.md index 96c5cac..872e9e4 100644 --- a/docs/superpowers/specs/2026-07-15-panel-visualization-only-design.md +++ b/docs/superpowers/specs/2026-07-15-panel-visualization-only-design.md @@ -146,11 +146,20 @@ Therefore: The popover opens on every menu bar click and must never block. -- **Docker + store:** 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:139-146`). `getDockerInfo` already caches to disk and dedupes - in-flight calls; `getPnpmStore` is a `du` that can take seconds, so its result must - never gate first paint. +- **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. From d05a7ae3a07dd063f8b8131dd686371a549e515b Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 15 Jul 2026 17:23:38 +0200 Subject: [PATCH 77/89] feat(app): launcher can be told to open on any tab, not just settings Co-Authored-By: Claude Opus 4.8 --- .../src/launcher/LauncherApp/LauncherApp.tsx | 18 ++++++++++------- .../launcher/LauncherApp/launcherNav.test.ts | 20 +++++++++++++++++++ .../src/launcher/LauncherApp/launcherNav.ts | 18 +++++++++++++++++ src/shared/launcher-nav.types.ts | 4 ++-- 4 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/launcher/LauncherApp/launcherNav.test.ts create mode 100644 src/renderer/src/launcher/LauncherApp/launcherNav.ts diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index 7d99707..beee49d 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -23,6 +23,7 @@ import { mixColor, statusColor } from '@renderer/lib/colors' import { formatSizeStr, GB } from '@renderer/lib/format' import { severityCounts } from '@renderer/lib/severity' 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' @@ -38,6 +39,7 @@ import { PackagesView } from '../views/PackagesView' import { ScanningView } from '../views/ScanningView' import { SettingsView } from '../views/SettingsView' import type { DockerConfirmState, LauncherTab, LauncherToast, LauncherView, SortKey } from './LauncherApp.types' +import { launcherNavState } from './launcherNav' import { SortTab } from './SortTab' import { tabSummary } from './tabSummary' @@ -119,14 +121,16 @@ export function LauncherApp(): ReactNode { // 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') - }) + 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(() => { 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/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' From d5e806e70d856d7c7ca4e733908bbd5dfea101fa Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 15 Jul 2026 17:29:10 +0200 Subject: [PATCH 78/89] refactor(app): share DOCKER_STALE_MS so the panel can reuse the staleness rule --- src/renderer/src/launcher/LauncherApp/LauncherApp.tsx | 3 +-- src/renderer/src/lib/staleness/index.ts | 1 + src/renderer/src/lib/staleness/staleness.ts | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/lib/staleness/index.ts create mode 100644 src/renderer/src/lib/staleness/staleness.ts diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index beee49d..cc13287 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -22,6 +22,7 @@ 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' @@ -55,8 +56,6 @@ const VISIBLE_ROWS = 7 const ROW_GAP = 4 /** Vertical padding of the .cc-list container, in px (matches global.css). */ const LIST_PADDING = 6 -/** Re-scan Docker when its tab opens and the data is older than this. */ -const DOCKER_STALE_MS = 5 * 60 * 1000 export function LauncherApp(): ReactNode { const [settings, setSetting, settingsLoaded] = useSettings() 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 From 49a6bc4b34d364c7b708440a2e5604b0c1a52ea1 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Wed, 15 Jul 2026 17:39:13 +0200 Subject: [PATCH 79/89] feat(app): panelAreas computes the panel hero totals, each byte counted once Co-Authored-By: Claude Opus 4.8 --- .../src/panel/PanelApp/panelAreas.test.ts | 93 +++++++++++++++++++ src/renderer/src/panel/PanelApp/panelAreas.ts | 66 +++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/renderer/src/panel/PanelApp/panelAreas.test.ts create mode 100644 src/renderer/src/panel/PanelApp/panelAreas.ts 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..799790a --- /dev/null +++ b/src/renderer/src/panel/PanelApp/panelAreas.test.ts @@ -0,0 +1,93 @@ +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) + }) +}) diff --git a/src/renderer/src/panel/PanelApp/panelAreas.ts b/src/renderer/src/panel/PanelApp/panelAreas.ts new file mode 100644 index 0000000..7a070cd --- /dev/null +++ b/src/renderer/src/panel/PanelApp/panelAreas.ts @@ -0,0 +1,66 @@ +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: [], + } +} From 795dd5c8f18e4f6491c4633b0058766219f81f5f Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 16 Jul 2026 12:01:03 +0200 Subject: [PATCH 80/89] feat(app): panelAreas resolves the four panel rows with honest gating Co-Authored-By: Claude Opus 4.8 --- .../src/panel/PanelApp/panelAreas.test.ts | 96 ++++++++++++++++++- src/renderer/src/panel/PanelApp/panelAreas.ts | 80 +++++++++++++++- 2 files changed, 172 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/panel/PanelApp/panelAreas.test.ts b/src/renderer/src/panel/PanelApp/panelAreas.test.ts index 799790a..264cc7f 100644 --- a/src/renderer/src/panel/PanelApp/panelAreas.test.ts +++ b/src/renderer/src/panel/PanelApp/panelAreas.test.ts @@ -1,9 +1,10 @@ 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 PanelAreasInput, panelAreas } from './panelAreas' +import { type PanelAreaRow, type PanelAreasInput, panelAreas } from './panelAreas' const project = (uniqueSize: number): Project => ({ id: `p${uniqueSize}`, @@ -47,6 +48,29 @@ const base: PanelAreasInput = { 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 @@ -91,3 +115,73 @@ describe('panelAreas hero', () => { 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). + 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' }) + }) +}) diff --git a/src/renderer/src/panel/PanelApp/panelAreas.ts b/src/renderer/src/panel/PanelApp/panelAreas.ts index 7a070cd..1ac2000 100644 --- a/src/renderer/src/panel/PanelApp/panelAreas.ts +++ b/src/renderer/src/panel/PanelApp/panelAreas.ts @@ -1,5 +1,7 @@ -import { GB } from '@renderer/lib/format' +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' @@ -30,7 +32,38 @@ export interface PanelAreas { rows: PanelAreaRow[] } -export type PanelAreaRow = never +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. */ @@ -38,6 +71,42 @@ 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 @@ -56,11 +125,16 @@ export function panelAreas(input: PanelAreasInput): PanelAreas { 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: [], + rows, } } From 7707dc79c94a0e63f53369d162f2d4688872a15e Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 16 Jul 2026 12:03:45 +0200 Subject: [PATCH 81/89] fix(app): assert the projects row against its exact summation, not a rounded literal Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/panel/PanelApp/panelAreas.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/panel/PanelApp/panelAreas.test.ts b/src/renderer/src/panel/PanelApp/panelAreas.test.ts index 264cc7f..9a6ffe6 100644 --- a/src/renderer/src/panel/PanelApp/panelAreas.test.ts +++ b/src/renderer/src/panel/PanelApp/panelAreas.test.ts @@ -124,7 +124,9 @@ describe('panelAreas rows', () => { 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 }) + // 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 }) From b24fae8f0bda2c62070cdf34a4c4afb1ba859ba4 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 16 Jul 2026 12:08:09 +0200 Subject: [PATCH 82/89] feat(app): AreaBar renders one click-through area row for the panel Co-Authored-By: Claude Opus 4.8 --- .../src/panel/PanelApp/AreaBar/AreaBar.tsx | 64 +++++++++++++++++++ .../panel/PanelApp/AreaBar/AreaBar.types.ts | 8 +++ .../src/panel/PanelApp/AreaBar/index.ts | 2 + 3 files changed, 74 insertions(+) create mode 100644 src/renderer/src/panel/PanelApp/AreaBar/AreaBar.tsx create mode 100644 src/renderer/src/panel/PanelApp/AreaBar/AreaBar.types.ts create mode 100644 src/renderer/src/panel/PanelApp/AreaBar/index.ts 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' From c089c17353d877c8d97522533952c1898c53320e Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 16 Jul 2026 12:12:41 +0200 Subject: [PATCH 83/89] feat(app): TrackedSummary generalizes the panel hero beyond node_modules --- .../TrackedSummary/TrackedSummary.tsx | 93 +++++++++++++++++++ .../TrackedSummary/TrackedSummary.types.ts | 9 ++ .../panel/PanelApp/TrackedSummary/index.ts | 2 + 3 files changed, 104 insertions(+) create mode 100644 src/renderer/src/panel/PanelApp/TrackedSummary/TrackedSummary.tsx create mode 100644 src/renderer/src/panel/PanelApp/TrackedSummary/TrackedSummary.types.ts create mode 100644 src/renderer/src/panel/PanelApp/TrackedSummary/index.ts 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' From 338ca6afa47760775700ad7736d868a672f824fa Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 16 Jul 2026 12:20:01 +0200 Subject: [PATCH 84/89] refactor(app): strip the panel to read-only (no list, CTA, store prune or paywall) --- .../src/panel/PanelApp/CleanStaleCta.tsx | 39 --- .../src/panel/PanelApp/DiskSummary.tsx | 121 --------- .../panel/PanelApp/DiskSummaryPlaceholder.tsx | 6 + .../src/panel/PanelApp/PanelApp.constants.ts | 5 - src/renderer/src/panel/PanelApp/PanelApp.tsx | 229 +----------------- .../src/panel/PanelApp/PanelApp.types.ts | 5 - .../src/panel/PanelApp/PanelEmpty.tsx | 65 ++--- .../src/panel/PanelApp/PnpmStoreRow.tsx | 105 -------- src/renderer/src/panel/PanelApp/index.ts | 2 +- 9 files changed, 42 insertions(+), 535 deletions(-) delete mode 100644 src/renderer/src/panel/PanelApp/CleanStaleCta.tsx delete mode 100644 src/renderer/src/panel/PanelApp/DiskSummary.tsx create mode 100644 src/renderer/src/panel/PanelApp/DiskSummaryPlaceholder.tsx delete mode 100644 src/renderer/src/panel/PanelApp/PanelApp.constants.ts delete mode 100644 src/renderer/src/panel/PanelApp/PnpmStoreRow.tsx 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/DiskSummaryPlaceholder.tsx b/src/renderer/src/panel/PanelApp/DiskSummaryPlaceholder.tsx new file mode 100644 index 0000000..a456be8 --- /dev/null +++ b/src/renderer/src/panel/PanelApp/DiskSummaryPlaceholder.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from 'react' + +/** Temporary: replaced by TrackedSummary + AreaBar rows in Task 8. */ +export function DiskSummaryPlaceholder(): ReactNode { + return
Dashboard loading...
+} 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..9213a07 100644 --- a/src/renderer/src/panel/PanelApp/PanelApp.tsx +++ b/src/renderer/src/panel/PanelApp/PanelApp.tsx @@ -1,123 +1,26 @@ 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 { 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 { 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 { type ReactNode, useEffect, useRef, useState } from 'react' +import { DiskSummaryPlaceholder } from './DiskSummaryPlaceholder' +import type { PanelView } from './PanelApp.types' import { PanelEmpty } from './PanelEmpty' -import { PnpmStoreRow } from './PnpmStoreRow' import { ScanPanel } from './ScanPanel' import { Separator } from './Separator' export function PanelApp(): ReactNode { const [settings, , settingsLoaded] = useSettings() - const projects = useProjects() - const liveById = useLiveProjects() const accent = settings.accent - const threshold = settings.thresholdGB * GB 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]) - - // "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]) + }, [view]) // keyboard shortcuts: ⌘O full window, ⌘R scan, ⌘, settings, esc close useEffect(() => { @@ -136,14 +39,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' @@ -161,114 +63,10 @@ export function PanelApp(): ReactNode { {view === 'main' && ( <> - - - {needsRescan && setView('scan')} />} - {projects.length === 0 ? ( - settingsLoaded ? ( - void window.clean.openLauncher()} - /> - ) : null + {settingsLoaded && !settings.onboarded ? ( + void window.clean.openLauncher()} /> ) : ( - <> -
- - Reclaimable · oldest first - - {projects.length} total -
- {visible.map((p) => ( - 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()} /> - + )}
@@ -293,15 +91,6 @@ export function PanelApp(): ReactNode {
)} - - {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/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' From 62cc3a2f2b76ff7c0994c3f5f0eb5a29f781e11c Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 16 Jul 2026 12:26:15 +0200 Subject: [PATCH 85/89] feat(app): the menu bar panel is a read-only dashboard of every disk area Co-Authored-By: Claude Opus 4.8 --- .../panel/PanelApp/DiskSummaryPlaceholder.tsx | 6 -- src/renderer/src/panel/PanelApp/PanelApp.tsx | 83 ++++++++++++++++++- 2 files changed, 79 insertions(+), 10 deletions(-) delete mode 100644 src/renderer/src/panel/PanelApp/DiskSummaryPlaceholder.tsx diff --git a/src/renderer/src/panel/PanelApp/DiskSummaryPlaceholder.tsx b/src/renderer/src/panel/PanelApp/DiskSummaryPlaceholder.tsx deleted file mode 100644 index a456be8..0000000 --- a/src/renderer/src/panel/PanelApp/DiskSummaryPlaceholder.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react' - -/** Temporary: replaced by TrackedSummary + AreaBar rows in Task 8. */ -export function DiskSummaryPlaceholder(): ReactNode { - return
Dashboard loading...
-} diff --git a/src/renderer/src/panel/PanelApp/PanelApp.tsx b/src/renderer/src/panel/PanelApp/PanelApp.tsx index 9213a07..68666ee 100644 --- a/src/renderer/src/panel/PanelApp/PanelApp.tsx +++ b/src/renderer/src/panel/PanelApp/PanelApp.tsx @@ -1,18 +1,58 @@ 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 { type ReactNode, useEffect, useRef, useState } from 'react' -import { DiskSummaryPlaceholder } from './DiskSummaryPlaceholder' +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 { 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' export function PanelApp(): ReactNode { const [settings, , settingsLoaded] = useSettings() const accent = settings.accent + 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 [lastScan, setLastScan] = useState(0) const rootRef = useRef(null) @@ -22,6 +62,15 @@ export function PanelApp(): ReactNode { void window.clean.getLastScanTime().then(setLastScan) }, [view]) + // 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(() => { const onKey = (e: KeyboardEvent): void => { @@ -57,16 +106,42 @@ 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' && ( <> {settingsLoaded && !settings.onboarded ? ( void window.clean.openLauncher()} /> ) : ( - + <> + + +
+ {areas.rows.map((row) => ( + openArea(row.nav)} /> + ))} +
+ )}
From 38ec0f250ad53c3cccbf35014cccea4e4a34ce57 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 16 Jul 2026 12:31:42 +0200 Subject: [PATCH 86/89] =?UTF-8?q?docs:=20STATUS=20=E2=80=94=20the=20menu?= =?UTF-8?q?=20bar=20panel=20is=20now=20a=20read-only=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- STATUS.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/STATUS.html b/STATUS.html index 473dca0..d77c177 100644 --- a/STATUS.html +++ b/STATUS.html @@ -15,9 +15,9 @@ const STATUS = { app: "TidyDisk", version: "1.0.0", - updated: "2026-07-14", + updated: "2026-07-16", phase: "🚀 v1.0.0 — TidyDisk is LIVE-ready. All three PRs merged to main (landing refresh #22, license gate + analytics #30, rename + honest copy #31); the release gate is lifted; tidydisk.app serves the new site with the €19 Polar checkout wired. The v1.0.0 tag fires the signed-release pipeline (first tidydisk-arm64.dmg). Your last clicks before selling: publish the draft release when the workflow finishes, then run the launch playbook (#27 scan screenshot, #28 assets, #29 the wave).", - summary: "This session (2026-07-14): finished the Docker feature on feat/docker-project-grouping (spec + plan docs/superpowers/2026-07-14-docker-buildcache-*, subagent-driven TDD, 6 tasks + a final-review fix wave). Two things you asked for. (1) Docker build cache no longer sits in the Docker tab: groupDockerForDisplay drops the build-cache group (items still count in the Docker header gauge, but the list only shows project/repo/orphan resources), and it now appears as a single aggregated 'Docker build cache' row in the CACHES tab next to the pnpm store. Its red Delete routes through the EXISTING requestDockerPrune('buildCache') — same license gate, same confirm, same toast, no new delete path — and the Caches tab was generalized from a hard-coded pnpm row into a list of 'live caches' (keyboard nav + Enter across them; the tab also auto-scans Docker on open when data is missing/stale, mirroring the Docker tab). (2) The deletion buttons now unmistakably read as deletions: the Docker bulk-prune buttons became 'Delete unused images / dangling images / stopped containers / unused volumes' with a trash icon and red danger styling; the build-cache row's Delete is likewise danger-styled (CacheRow gained an opt-in danger + icon variant); the per-item unused-image/volume/container trash stays hover-revealed like the node_modules rows (already danger). SAFETY UNCHANGED: presentation + a delete-button relabel; the typed-name volume confirm gate is byte-identical and build cache is still never individually removable (3-layer block, verified). Whole-branch Opus review: 1 Important fixed — Caches Enter indexed the full cache list while the rows were query-filtered, so Enter could fire a row scrolled off by search; a new tested selectedActionableCache() guard makes Enter a no-op unless the selected row is visible + actionable (confirm-gated the whole time, never a silent delete). Also swept em dashes out of the npm/yarn/bun placeholder copy. 293 tests, typecheck/lint/build green. In your court: manual QA (needs a running app + Docker daemon) and push/PR (rides PR #42 or its own branch, your call). Prior session (2026-07-09): per-tab headline data-viz + Docker auto-scan, BUILT on feat/docker-project-grouping (spec + plan in docs/superpowers/2026-07-09-*, subagent-driven TDD, 8 tasks + a final-review fix). Each launcher tab's header slot and footer line now reflect THAT tab's core metric instead of always node_modules: Projects/Caches/Docker each fill a size-vs-limit Gauge against their OWN configurable GB limit (new cacheThresholdGB=10, dockerThresholdGB=20 in Settings > Scanning > Storage limits, clamped 0.1-1000 like the node_modules threshold); Packages shows a compact SeverityMeter (a stacked critical/high/moderate/low advisory bar + outdated count, full breakdown in the tooltip). A new TabHeadline switches the header slot; a new pure tabSummary() switches the footer line; both null-gate honestly (no gauge when a store/daemon is absent). The Docker tab auto-scans on open when its data is missing or >5 min stale (loop-guarded, decoupled from the disk rescan; manual Cmd-R/refresh kept). Whole-branch Opus review returned one Important finding, fixed: the Packages headline no longer shows a false 'all clear' when advisories were never actually checked (checks off / offline / before enrichment now render nothing) - the same fix removes the first-open flash. 277 tests, typecheck/lint/build green; Projects tab confirmed unregressed and the Docker typed-name confirm safety gate untouched. In your court: manual QA (needs a running app + Docker daemon) and whether this rides the still-pending Docker-grouping PR #42 or gets its own branch/PR. Prior sessions: Two new app features shipped on feat/scan-coverage-and-liveness (PR #39), each brainstormed → spec'd → planned → built via subagent-driven TDD (15 tasks, fresh implementer + independent review each), then a whole-branch review on the strongest tier (READY TO MERGE). (1) External-drive / scan-location coverage — the scanner was hard-wired to ~; now a Settings 'Scan locations' section lists mounted external volumes (detected under /Volumes, boot disk excluded by device id) as toggles plus an 'Add folder…' picker, choices persist in a new scanRoots setting, and resolveScanRoots feeds home + opted-in roots to the scanner each scan (mounted-only, deduped, nesting-dropped) — so node_modules on an external SSD or any folder is finally scannable and cleanable; an unplugged drive's projects just drop off next scan and return on replug, and a delete-time guard refuses trashing a path that vanished. (2) Live/running indicator — a green dot marks any project with a process running from inside it (one lsof cwd sweep per refresh, best-effort port label, soft-fails to no-dots on any error), its delete is disabled and main hard-refuses it re-checked at the instant of delete, so you cannot trash a running app's node_modules; polls every 45s while a window is open. Shared: unified deleteNodeModules → { freed, blocked? }; a batch deleteMany IPC keeps 'Clean N stale' to one liveness check (the final review's one Important finding). 183 tests, typecheck/biome/build green; the real lsof flags smoke-tested on macOS. Awaiting your review/merge + manual pass (needs a physical external drive + a running dev server). Previous session context: SEO + content session (site only, no app code). Audited tidydisk.app's SEO: robots.txt and sitemap.xml were 404, no canonical tags, no structured data, and the apex→www redirect direction contradicts the code's canonical base (fix is one Vercel dashboard click on your side). Built the fixes plus a self-publishing blog on feat/blog-seo (PR #34): markdown articles in site/content/blog/ parsed by a tested lib/blog pipeline; every surface (listing, article pages, sitemap, RSS) revalidates hourly and filters on publish date, so the 10 pre-written keyword-targeted articles release themselves one per week (2026-07-04 → 2026-09-05) with zero deploys. Also: SoftwareApplication + BlogPosting JSON-LD, RSS feed, Blog in nav/footer. 17 unit tests, build green, rendering verified via Playwright. Earlier context: decided to reposition the product as TidyDisk (from 'Clean my node_modules'), broadening the pitch from node_modules to all developer disk waste (caches, builds, later Docker/Xcode), and to monetize via free-scan / paid-clean rather than a free/OSS-only model: the free scan stays the marketing engine (its shocking number is the shareable artifact) and the one-click Clean action becomes a paid, lifetime unlock (€19 founding → €29). Produced two artifacts: TIDYDISK-LAUNCH.html — an interactive, on-brand launch dashboard (live price→sales calculator, pricing ladder, offer stack, competitor table, Show HN / Reddit drafts, and a persistent 2–4 week launch checklist; rendering + interactivity verified via Playwright) — and docs/superpowers/specs/2026-07-01-tidydisk-gtm-and-monetization-design.md, the GTM + monetization spec, whose one implementation slice is a license gate on the destructive actions (new src/main/license/*, offline-verifiable keys, enforced inside the destructive IPC handlers, Merchant-of-Record checkout via Lemon Squeezy/Paddle). Nothing renamed or gated in code yet — awaiting your review of the spec and your go-ahead. Still open from the prior session: publish the v0.2.0 draft release and deploy site/ to Vercel.", + summary: "This session (2026-07-16): 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. Built spec -> plan -> subagent-driven TDD (9 tasks, fresh implementer + independent review each); all arithmetic + gating lives in pure panelAreas.ts / launcherNav.ts so the .tsx stays thin. The hero counts the pnpm store once and is therefore smaller than the four rows added up (rows mirror their tabs so click-through never lies); Docker refreshes on open when stale, but the store re-sizes only on a scan (no du on every popover open), and the panel never computes the package inventory. 315 tests, typecheck/lint/build green. In your court: manual QA (needs a running app + Docker daemon) and whether the launcher needs its own paywall moment now the panel has none. Prior session (2026-07-14): finished the Docker feature on feat/docker-project-grouping (spec + plan docs/superpowers/2026-07-14-docker-buildcache-*, subagent-driven TDD, 6 tasks + a final-review fix wave). Two things you asked for. (1) Docker build cache no longer sits in the Docker tab: groupDockerForDisplay drops the build-cache group (items still count in the Docker header gauge, but the list only shows project/repo/orphan resources), and it now appears as a single aggregated 'Docker build cache' row in the CACHES tab next to the pnpm store. Its red Delete routes through the EXISTING requestDockerPrune('buildCache') — same license gate, same confirm, same toast, no new delete path — and the Caches tab was generalized from a hard-coded pnpm row into a list of 'live caches' (keyboard nav + Enter across them; the tab also auto-scans Docker on open when data is missing/stale, mirroring the Docker tab). (2) The deletion buttons now unmistakably read as deletions: the Docker bulk-prune buttons became 'Delete unused images / dangling images / stopped containers / unused volumes' with a trash icon and red danger styling; the build-cache row's Delete is likewise danger-styled (CacheRow gained an opt-in danger + icon variant); the per-item unused-image/volume/container trash stays hover-revealed like the node_modules rows (already danger). SAFETY UNCHANGED: presentation + a delete-button relabel; the typed-name volume confirm gate is byte-identical and build cache is still never individually removable (3-layer block, verified). Whole-branch Opus review: 1 Important fixed — Caches Enter indexed the full cache list while the rows were query-filtered, so Enter could fire a row scrolled off by search; a new tested selectedActionableCache() guard makes Enter a no-op unless the selected row is visible + actionable (confirm-gated the whole time, never a silent delete). Also swept em dashes out of the npm/yarn/bun placeholder copy. 293 tests, typecheck/lint/build green. In your court: manual QA (needs a running app + Docker daemon) and push/PR (rides PR #42 or its own branch, your call). Prior session (2026-07-09): per-tab headline data-viz + Docker auto-scan, BUILT on feat/docker-project-grouping (spec + plan in docs/superpowers/2026-07-09-*, subagent-driven TDD, 8 tasks + a final-review fix). Each launcher tab's header slot and footer line now reflect THAT tab's core metric instead of always node_modules: Projects/Caches/Docker each fill a size-vs-limit Gauge against their OWN configurable GB limit (new cacheThresholdGB=10, dockerThresholdGB=20 in Settings > Scanning > Storage limits, clamped 0.1-1000 like the node_modules threshold); Packages shows a compact SeverityMeter (a stacked critical/high/moderate/low advisory bar + outdated count, full breakdown in the tooltip). A new TabHeadline switches the header slot; a new pure tabSummary() switches the footer line; both null-gate honestly (no gauge when a store/daemon is absent). The Docker tab auto-scans on open when its data is missing or >5 min stale (loop-guarded, decoupled from the disk rescan; manual Cmd-R/refresh kept). Whole-branch Opus review returned one Important finding, fixed: the Packages headline no longer shows a false 'all clear' when advisories were never actually checked (checks off / offline / before enrichment now render nothing) - the same fix removes the first-open flash. 277 tests, typecheck/lint/build green; Projects tab confirmed unregressed and the Docker typed-name confirm safety gate untouched. In your court: manual QA (needs a running app + Docker daemon) and whether this rides the still-pending Docker-grouping PR #42 or gets its own branch/PR. Prior sessions: Two new app features shipped on feat/scan-coverage-and-liveness (PR #39), each brainstormed → spec'd → planned → built via subagent-driven TDD (15 tasks, fresh implementer + independent review each), then a whole-branch review on the strongest tier (READY TO MERGE). (1) External-drive / scan-location coverage — the scanner was hard-wired to ~; now a Settings 'Scan locations' section lists mounted external volumes (detected under /Volumes, boot disk excluded by device id) as toggles plus an 'Add folder…' picker, choices persist in a new scanRoots setting, and resolveScanRoots feeds home + opted-in roots to the scanner each scan (mounted-only, deduped, nesting-dropped) — so node_modules on an external SSD or any folder is finally scannable and cleanable; an unplugged drive's projects just drop off next scan and return on replug, and a delete-time guard refuses trashing a path that vanished. (2) Live/running indicator — a green dot marks any project with a process running from inside it (one lsof cwd sweep per refresh, best-effort port label, soft-fails to no-dots on any error), its delete is disabled and main hard-refuses it re-checked at the instant of delete, so you cannot trash a running app's node_modules; polls every 45s while a window is open. Shared: unified deleteNodeModules → { freed, blocked? }; a batch deleteMany IPC keeps 'Clean N stale' to one liveness check (the final review's one Important finding). 183 tests, typecheck/biome/build green; the real lsof flags smoke-tested on macOS. Awaiting your review/merge + manual pass (needs a physical external drive + a running dev server). Previous session context: SEO + content session (site only, no app code). Audited tidydisk.app's SEO: robots.txt and sitemap.xml were 404, no canonical tags, no structured data, and the apex→www redirect direction contradicts the code's canonical base (fix is one Vercel dashboard click on your side). Built the fixes plus a self-publishing blog on feat/blog-seo (PR #34): markdown articles in site/content/blog/ parsed by a tested lib/blog pipeline; every surface (listing, article pages, sitemap, RSS) revalidates hourly and filters on publish date, so the 10 pre-written keyword-targeted articles release themselves one per week (2026-07-04 → 2026-09-05) with zero deploys. Also: SoftwareApplication + BlogPosting JSON-LD, RSS feed, Blog in nav/footer. 17 unit tests, build green, rendering verified via Playwright. Earlier context: decided to reposition the product as TidyDisk (from 'Clean my node_modules'), broadening the pitch from node_modules to all developer disk waste (caches, builds, later Docker/Xcode), and to monetize via free-scan / paid-clean rather than a free/OSS-only model: the free scan stays the marketing engine (its shocking number is the shareable artifact) and the one-click Clean action becomes a paid, lifetime unlock (€19 founding → €29). Produced two artifacts: TIDYDISK-LAUNCH.html — an interactive, on-brand launch dashboard (live price→sales calculator, pricing ladder, offer stack, competitor table, Show HN / Reddit drafts, and a persistent 2–4 week launch checklist; rendering + interactivity verified via Playwright) — and docs/superpowers/specs/2026-07-01-tidydisk-gtm-and-monetization-design.md, the GTM + monetization spec, whose one implementation slice is a license gate on the destructive actions (new src/main/license/*, offline-verifiable keys, enforced inside the destructive IPC handlers, Merchant-of-Record checkout via Lemon Squeezy/Paddle). Nothing renamed or gated in code yet — awaiting your review of the spec and your go-ahead. Still open from the prior session: publish the v0.2.0 draft release and deploy site/ to Vercel.", milestones: [ { @@ -134,6 +134,8 @@ /* Things ONLY the user can do. Claude removes/updates these as they get done. */ userActions: [ + { 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 (Cmd-, 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 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." }, { id: "review-docker-pr", category: "review", label: "Review the Docker cleanup PR", detail: "feat/docker-cleanup — new ⌘4 Docker tab: list + per-item remove + bulk prune of images/volumes/containers/build-cache, Pro-gated, with a typed-name confirmation for volumes (permanent, no Trash). Whole-branch Opus review found no wrongful-delete path. Skim the tiered confirmation UX and merge when happy." }, { id: "test-docker-daemon", category: "testing", label: "Manual pass of Docker cleanup against a real daemon", detail: "The unit tests mock the docker CLI (no daemon in CI), so the real behavior needs your eyes: with Docker running, open the launcher → ⌘4. Confirm images/volumes/containers/build-cache list with real sizes + in-use/unused badges; as a free user a remove shows the paywall; as Pro, removing an unused image works and the number updates; a VOLUME removal makes you type its name first; in-use items have no Remove button; the per-category Prune buttons work. Also check the 'Docker not running' state (quit Docker Desktop) and the Settings toggle hides the tab. NEW (project-grouping, PR stacked on #40): with a running docker-compose project, confirm its resources group under one header with the project's real logo, that an image its container uses appears under that project, the type badges + Size/Name/Recent sort tabs + filter chips work, and the 'Not linked to a project' section holds pulled images (by repo) + build cache." }, { id: "proofread-translations", category: "review", label: "Native-speaker proofread of the fr/es/de/pt copy", detail: "The 4 locales (landing + 40 blog articles) are high-quality machine translations, structurally validated (typecheck + parity test + em-dash sweep) but not human-proofread. Before promoting the locale URLs in Search Console, have a native speaker skim each locale's homepage + a couple of articles for tone/idiom. Content lives in site/lib/i18n/dictionaries/.tsx and site/content/blog//." }, @@ -223,7 +225,8 @@ { date: "2026-07-09", text: "Debugged the 'still no project groups' report by running your REAL docker data through the app's association logic. Found it: `docker volume inspect ` exits 1 whenever `docker system df -v` lists an anonymous/stale volume (one bad name fails the whole batch) — and before the resilience fix that single failure took down ALL grouping. Your containers DO carry Compose labels, so the association correctly yields three projects (lets-get-a-job, orchestrator, aprocy — aprocy has a working_dir so it gets a real logo; the two Supabase ones have empty working_dirs so they show a generic Docker icon + name). Switched volume label lookup to a single robust `docker volume ls --format` call (can't 404 on a stale volume). Also per your feedback: in-use items now show the same green LiveDot as running node_modules projects (dropped the in-use/unused text), and Docker rows got the node_modules row hover style. The main-process fixes need a FULL app restart to load (pnpm dev hot-reloads only the renderer). 265 tests green; reviewed, safety gate intact. Pushed to PR #42." }, { date: "2026-07-09", text: "Shipped per-tab headline data-viz + Docker auto-scan (feat/docker-project-grouping) after you asked for each tab to have its own core-data visualization like the node_modules limit gauge. Brainstormed the four decisions (own configurable limit per tab; Packages gets a severity bar + outdated count rather than a limit; reuse the compact 132px header slot per-tab rather than a new band; Docker auto-scans on open when stale), spec'd + planned, then executed subagent-driven with TDD: 8 tasks, fresh implementer + independent spec/quality review each, cheap models for the pure/transcription tasks and a standard tier for the LauncherApp integration. Result: TabHeadline swaps the header gauge per active tab (Projects/Caches/Docker fill a size-vs-limit Gauge against their own new cacheThresholdGB/dockerThresholdGB settings; Packages shows a new SeverityMeter), tabSummary swaps the footer line, and a loop-guarded effect auto-refreshes Docker when its tab opens with missing or >5-min-stale data. The whole-branch Opus review earned its keep again: it caught that severityCounts() returns all-zero whenever nothing has an advisory, so with npm checks off / offline / before enrichment the Packages headline falsely rendered a green 'all clear' contradicting the tab body - fixed by gating both the header and footer on packagesCheckEnabled + packagesDataReady so they render nothing until advisories are actually trustworthy (same fix removed a first-open flash). It also confirmed the Projects tab is byte-identical and the Docker typed-name confirm safety gate is untouched. 277 tests, typecheck/lint/build green. Not pushed yet: your call whether it rides the still-pending PR #42 or gets its own branch." }, { date: "2026-07-09", text: "Made the Docker tab's project groups collapsible (feat/docker-project-grouping). You asked for clickable project rows that reveal their items on click, like the Packages tab, since a busy daemon makes the flat list long. Brainstormed the shape (accordion, one open at a time; only PROJECT groups collapse, the 'Other' groups stay as-is) and surfaced the honest finding that there was no reusable accordion COMPONENT (it was fused into the package-specific PackageRow), so you chose to extract a generic one and retrofit both tabs. Executed subagent-driven with TDD (4 tasks): a shared components/Accordion primitive (open-card wrapper with an optional card prop), the Packages tab retrofitted onto it as a verified pixel-identical no-op, two tested pure helpers (dockerGroupActive, projectRowExpanded), and DockerView rewired so projects render through Accordion with a new collapsible DockerProjectRow header (logo, name, N items, size, active dot, rotating chevron). The key reconciliation: Docker's Accordion omits card so its wrapper stays a static div, keeping the item rows' offsetParent the outer list container so the node_modules-style .cc-hl sliding highlight still tracks across headers and items. The Opus whole-branch review traced every ancestor chain and confirmed it, plus the Packages no-op and the untouched safety gate: READY TO MERGE, 0 Critical/Important. Fixed its one Minor (a residual em dash in the Docker remove label). 282 tests, typecheck/lint/build green. Rides PR #42 per your earlier call. Logged follow-up: a proper aria-expanded/keyboard a11y pass for the Row/PackageRow/DockerProjectRow disclosure family." }, - { date: "2026-07-14", text: "Finished the Docker feature from your two asks: build caches shouldn't live in the Docker tab, and the delete buttons should look like deletes. Brainstormed the four decisions (single aggregated 'Docker build cache' row in the Caches tab reusing the existing prune confirm/gate; build cache stays counted in the Docker gauge since it's real Docker disk; verb+trash+danger relabel of the bulk-prune buttons; keep the per-item hover trash), spec'd + planned, then executed subagent-driven with TDD (6 tasks: cheap models for the transcription tasks, a standard tier for the CachesView rewrite and the LauncherApp integration, opus for the reviews). Result: groupDockerForDisplay drops the build-cache group; the Caches tab became a generalized 'live caches' list (pnpm + Docker build cache, keyboard-navigable, auto-scans Docker on open); Docker bulk buttons now say 'Delete unused images' etc. in red with a trash icon; CacheRow gained an opt-in danger+icon action variant. SAFETY stayed frozen — the build-cache Delete routes through the unchanged requestDockerPrune('buildCache'), the typed-name volume gate is byte-identical, and build cache is still un-deletable individually (canRemove + removeDockerItem + coerceRemoveArgs all block it). Two per-task reviews flagged missed lint in the TDD-only tasks (biome format) which I fixed. The whole-branch Opus review earned its keep: it caught that Caches Enter indexed the full cache list while the rows were query-filtered, so pressing Enter with an active search could trigger a row scrolled off screen (confirm-gated, so never a silent delete) — fixed with a tested selectedActionableCache() guard, plus a footer-hint gate and an em-dash sweep of the placeholder copy. 293 tests, typecheck/lint/build green. Not pushed — your call whether it rides PR #42 or its own branch; manual daemon QA still yours." } + { date: "2026-07-14", text: "Finished the Docker feature from your two asks: build caches shouldn't live in the Docker tab, and the delete buttons should look like deletes. Brainstormed the four decisions (single aggregated 'Docker build cache' row in the Caches tab reusing the existing prune confirm/gate; build cache stays counted in the Docker gauge since it's real Docker disk; verb+trash+danger relabel of the bulk-prune buttons; keep the per-item hover trash), spec'd + planned, then executed subagent-driven with TDD (6 tasks: cheap models for the transcription tasks, a standard tier for the CachesView rewrite and the LauncherApp integration, opus for the reviews). Result: groupDockerForDisplay drops the build-cache group; the Caches tab became a generalized 'live caches' list (pnpm + Docker build cache, keyboard-navigable, auto-scans Docker on open); Docker bulk buttons now say 'Delete unused images' etc. in red with a trash icon; CacheRow gained an opt-in danger+icon action variant. SAFETY stayed frozen — the build-cache Delete routes through the unchanged requestDockerPrune('buildCache'), the typed-name volume gate is byte-identical, and build cache is still un-deletable individually (canRemove + removeDockerItem + coerceRemoveArgs all block it). Two per-task reviews flagged missed lint in the TDD-only tasks (biome format) which I fixed. The whole-branch Opus review earned its keep: it caught that Caches Enter indexed the full cache list while the rows were query-filtered, so pressing Enter with an active search could trigger a row scrolled off screen (confirm-gated, so never a silent delete) — fixed with a tested selectedActionableCache() guard, plus a footer-hint gate and an em-dash sweep of the placeholder copy. 293 tests, typecheck/lint/build green. Not pushed — your call whether it rides PR #42 or its own branch; manual daemon QA still yours." }, + { date: "2026-07-16", text: "Menu bar panel became a read-only dashboard: aggregate 'Tracked on disk' hero + four click-through rows (Projects / Caches / Packages / Docker) that open that tab in the main window; the panel's reclaimable list, Clean-stale CTA, pnpm prune row and paywall are all gone (so the launcher is now the only paid-conversion surface). Brainstormed with a visual companion (picked the aggregate-hero layout), spec'd the honest-number decisions (hero counts the pnpm store once so it is deliberately smaller than the four rows summed, since rows mirror their tabs and click-through must never lie; 'tracked' not 'reclaimable' because the store's prunable share is unknown until pruned; combined limit = sum of the three existing limits), then built subagent-driven with TDD across 9 tasks. All arithmetic + gating went into pure panelAreas.ts (hero + row gating) and launcherNav.ts (nav-target map), keeping the .tsx thin, per the pure-logic-only test setup. Freshness tuned to not regress popover open speed: Docker stale-refreshes on open (5 min, non-looping), the pnpm store re-sizes only when a scan finishes (getPnpmStoreInfo has no TTL, so a timer would du on every open), and the panel never computes the package inventory (cached read only, placeholder row clicks through to compute it in the tab). One plan-authored test bug caught + fixed (asserted 41.2 GB but the code sums 34.8+6.4, off at the IEEE 754 bit). 315 tests, typecheck/lint/build green. Not pushed; manual QA + the launcher-paywall question are yours." } ] }; From b3dede7a062745c9329076453d981331de7fa41d Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 16 Jul 2026 12:37:47 +0200 Subject: [PATCH 87/89] fix(app): accurate nav comment + no zeroed-dashboard flash before settings load - LauncherApp: updated stale comment reflecting that nav now routes all targets (Settings + 4 tabs) via launcherNavState, not just Settings - PanelApp: gate the hero/empty region on settingsLoaded to prevent dashboard rendering with zeroed values for one frame while settings load Co-Authored-By: Claude Opus 4.8 --- .../src/launcher/LauncherApp/LauncherApp.tsx | 6 +-- src/renderer/src/panel/PanelApp/PanelApp.tsx | 39 ++++++++++--------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx index cc13287..354d08a 100644 --- a/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx +++ b/src/renderer/src/launcher/LauncherApp/LauncherApp.tsx @@ -117,9 +117,9 @@ 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. + // 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) diff --git a/src/renderer/src/panel/PanelApp/PanelApp.tsx b/src/renderer/src/panel/PanelApp/PanelApp.tsx index 68666ee..88adcb4 100644 --- a/src/renderer/src/panel/PanelApp/PanelApp.tsx +++ b/src/renderer/src/panel/PanelApp/PanelApp.tsx @@ -124,25 +124,26 @@ export function PanelApp(): ReactNode { {view === 'main' && ( <> - {settingsLoaded && !settings.onboarded ? ( - void window.clean.openLauncher()} /> - ) : ( - <> - - -
- {areas.rows.map((row) => ( - openArea(row.nav)} /> - ))} -
- - )} + {settingsLoaded && + (!settings.onboarded ? ( + void window.clean.openLauncher()} /> + ) : ( + <> + + +
+ {areas.rows.map((row) => ( + openArea(row.nav)} /> + ))} +
+ + ))}
Date: Thu, 16 Jul 2026 12:49:56 +0200 Subject: [PATCH 88/89] fix(app): per-row PixelMeter bars render in the panel (fill container width) The panel's AreaBar puts the meter in a display:flex column, where PixelMeter's rootless-width div collapsed to zero (its cells are flex:1 with no basis), so the Projects/Caches/Docker bars showed only the GB number. Give the meter root width:100% so it fills its flex parent; a no-op in the block-context hero, where it already filled. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/components/PixelMeter/PixelMeter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 68ca47c7d9dc9c6144636ec730dadbdb2b6c7414 Mon Sep 17 00:00:00 2001 From: Ahmed ABOUELLEIL Date: Thu, 16 Jul 2026 13:43:17 +0200 Subject: [PATCH 89/89] =?UTF-8?q?chore(release):=20v1.1.0=20=E2=80=94=20Do?= =?UTF-8?q?cker=20cleanup=20stack=20+=20read-only=20panel=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",