Skip to content

feat(app): Docker tab grouped by project (logos + type badges + used-by association) - #42

Merged
AhmedFr merged 90 commits into
mainfrom
feat/docker-project-grouping
Jul 16, 2026
Merged

AhmedFr merged 90 commits into
mainfrom
feat/docker-project-grouping

Conversation

@AhmedFr

@AhmedFr AhmedFr commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Summary

Regroups the Docker tab by project instead of by category, so you can see which Docker resources belong to which project — the node_modules-style view you asked for. Stacked on #40 (Docker cleanup); review/merge that first. Implements docs/superpowers/specs/2026-07-08-docker-project-grouping-design.md, built subagent-driven with TDD.

What it does

  • Compose project groups at the top, each with the project's real logo (reusing the node_modules framework/favicon detection on the compose working_dir; generic Docker glyph when the folder is gone), name, and size/count. Rows are its containers, volumes, and images.
  • A "Not linked to a project" section: unassociated images sub-grouped by repository, a build-cache group, and orphan volumes/containers.
  • Type badges on every row (IMAGE / VOLUME / CONTAINER / CACHE), Size / Name / Recent sort tabs, and All/Images/Volumes/Containers/Cache filter chips — mirroring the Projects tab.

Association (honest but aggressive)

Project identity only exists for Docker Compose projects (labels). On top of that, "used-by" association pulls in resources an existing container references — an image a project's container runs, a volume it mounts. An item used by more than one 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, not a bug).

Under the hood

readDockerInfo adds batched docker container inspect + docker volume inspect behind the existing cache, all fail-soft (any inspect error → everything unassociated, tab still lists + cleans). Pure associateProjects + groupDockerForDisplay carry the logic (heavily unit-tested, no daemon).

Safety — unchanged

Presentation + enrichment only. Every row still flows its real DockerItem.id/kind; the typed-name volume-confirmation gate is byte-identical; buildcache is never per-item removable; bulk-prune buttons appear only on "Other" headers (prune is a global op, never shown as project-scoped). A whole-branch Opus review verified no wrongful-delete path.

Testing

257 tests green (typecheck/lint/build clean). The per-task + whole-branch review loop caught and fixed three real issues — the image id/ref match needing to be a union (not a fallback, else a two-project image mis-assigns), the "Dangling images" bulk prune being unreachable (danglings land in "Other"), and a fail-soft ordering gap that could silently hide items — each with a regression test. The docker CLI is mocked (no daemon in CI); a manual pass with a running compose project is in STATUS as a user action.

🤖 Generated with Claude Code

AhmedFr and others added 30 commits July 8, 2026 11:39
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
`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.
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.
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).
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.
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.
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.
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.
Add removeDockerItem (kind→CLI mapping, buildcache refused, never -f) and
pruneDocker (target→`docker <x> 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.
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_<kind> / docker_prune). Wires removeDockerItem/
pruneDocker onto the preload window.clean bridge.
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.
…rop 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.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
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).
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.
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 <noreply@anthropic.com>
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.
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 <noreply@anthropic.com>
AhmedFr and others added 6 commits July 14, 2026 13:35
…t in TDD-only tasks)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…w'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 <noreply@anthropic.com>
…holder 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 <noreply@anthropic.com>
…ons (M1h)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ng confirm)

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 <noreply@anthropic.com>
AhmedFr and others added 13 commits July 15, 2026 17:05
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ed once

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rounded literal

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…area

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tings 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
AhmedFr and others added 2 commits July 16, 2026 13:42
…oard

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@AhmedFr
AhmedFr changed the base branch from feat/docker-cleanup to main July 16, 2026 11:43
@AhmedFr
AhmedFr merged commit 7133c69 into main Jul 16, 2026
5 checks passed

This branch was successfully deployed

1 active deployment
Preview — 68ca47c7 Deployed Jul 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant