diff --git a/App/AppModel.swift b/App/AppModel.swift index 19ee6bf..88818bd 100644 --- a/App/AppModel.swift +++ b/App/AppModel.swift @@ -176,7 +176,7 @@ final class AppModel { if let container = containers.first(where: { $0.id == id }) { openConsole(container) } - case .runContainer, .containerLogs, .inspectContainer: + case .runContainer, .containerLogs, .inspectContainer, .cleanContainer: select(.containers) pendingIntent = intent case .pullImage, .buildImage, .runImage, .inspectImage: diff --git a/App/CommandPalette.swift b/App/CommandPalette.swift index 782eb48..85810cc 100644 --- a/App/CommandPalette.swift +++ b/App/CommandPalette.swift @@ -10,6 +10,7 @@ enum AppIntent: Equatable, Hashable, Sendable { // Containers case startContainer(id: String) case stopContainer(id: String) + case cleanContainer(id: String) case containerLogs(id: String) case inspectContainer(id: String) case openConsole(id: String) @@ -142,6 +143,13 @@ enum PaletteCatalog { systemImage: "stop.fill", category: .container, keywords: container.imageReference, intent: .stopContainer(id: container.id) )) + out.append(.init( + id: "container.clean.\(container.id)", title: "Clean \(container.name)", + subtitle: "Reclaim disk space from freed blocks", + systemImage: "sparkles", category: .container, + keywords: "\(container.imageReference) trim reclaim disk space", + intent: .cleanContainer(id: container.id) + )) } else { out.append(.init( id: "container.start.\(container.id)", title: "Start \(container.name)", subtitle: nil, diff --git a/Core/Services/ContainerService.swift b/Core/Services/ContainerService.swift index 0170a1a..51159cf 100644 --- a/Core/Services/ContainerService.swift +++ b/Core/Services/ContainerService.swift @@ -76,6 +76,24 @@ struct ContainerService: Sendable { ["prune"] } + /// `container clean` (1.4.1+): trims freed blocks on the root filesystem + /// and named volume mounts of *running* containers so the host reclaims + /// the space. Read-only roots and `ro` mounts are skipped by the CLI. + static func cleanArguments(ids: [String]) -> [String] { + ["clean"] + ids + } + + /// Shown when the CLI predates `clean` (added in container 1.4.1). + static let cleanUnsupportedMessage = "Cleaning containers requires Apple container 1.4.1 or later." + + /// An older CLI rejects the unknown `clean` subcommand with an + /// argument-parser usage error; surface that as a version requirement + /// instead of the raw parser text. Other errors describe themselves. + static func cleanErrorMessage(_ error: CLIError) -> String { + if case .usage = error { return cleanUnsupportedMessage } + return error.localizedDescription + } + static func runArguments(_ options: RunOptions) -> [String] { var args = ["run"] if options.detach { args.append("--detach") } @@ -160,4 +178,12 @@ struct ContainerService: Sendable { func prune() async throws -> String { try await cli.text(Self.pruneArguments()) } + + /// Reclaims host disk space from running containers by trimming freed + /// blocks on their root filesystems and named volumes. Nothing is deleted. + /// Fails for stopped containers; returns the cleaned ids, one per line. + @discardableResult + func clean(ids: [String]) async throws -> String { + try await cli.text(Self.cleanArguments(ids: ids)) + } } diff --git a/Features/Containers/ContainerDetailView.swift b/Features/Containers/ContainerDetailView.swift index 9d68837..587cc2f 100644 --- a/Features/Containers/ContainerDetailView.swift +++ b/Features/Containers/ContainerDetailView.swift @@ -6,6 +6,11 @@ struct ContainerDetailView: View { var stats: ContainerStats? /// Rolling CPU/memory series for the live chart (empty hides the chart). var statsPoints: [StatsPoint] = [] + /// True while a lifecycle action on this container is in flight. + var isBusy = false + /// Trims freed blocks to reclaim disk space (`container clean`, 1.4.1+). + /// Nil hides the button (e.g. hosts without a containers view model). + var onClean: (() -> Void)? = nil @Environment(AppModel.self) private var app @@ -76,6 +81,14 @@ struct ContainerDetailView: View { StatusBadge(state: container.state) if container.isRunning { Spacer(minLength: 0) + if let onClean { + PillButton { onClean() } label: { + if isBusy { ProgressView().controlSize(.small) } + else { Label("Clean", systemImage: "sparkles") } + } + .disabled(isBusy) + .help("Trim freed blocks so the host can reclaim disk space") + } PillButton(style: .accent) { app.openConsole(container) } label: { Label("Console", systemImage: "terminal") } diff --git a/Features/Containers/ContainerRow.swift b/Features/Containers/ContainerRow.swift index 5adebe7..8da8d07 100644 --- a/Features/Containers/ContainerRow.swift +++ b/Features/Containers/ContainerRow.swift @@ -13,6 +13,7 @@ struct ContainerRow: View { var onStart: () -> Void var onStop: () -> Void var onRestart: () -> Void + var onClean: () -> Void var onLogs: () -> Void var onKill: () -> Void var onDelete: () -> Void @@ -131,6 +132,9 @@ struct ContainerRow: View { Button("Stop", systemImage: "stop.fill", action: onStop) Button("Restart", systemImage: "arrow.clockwise", action: onRestart) Button("Kill", systemImage: "bolt.fill", action: onKill) + Divider() + Button("Clean", systemImage: "sparkles", action: onClean) + .help("Trim freed blocks so the host can reclaim disk space") } else { Button("Start", systemImage: "play.fill", action: onStart) } diff --git a/Features/Containers/ContainersScreen.swift b/Features/Containers/ContainersScreen.swift index 8d1adcc..b61cda1 100644 --- a/Features/Containers/ContainersScreen.swift +++ b/Features/Containers/ContainersScreen.swift @@ -89,6 +89,14 @@ struct ContainersScreen: View { return } model.selectedID = id + case .cleanContainer(let id): + // Routed here (not run globally) so the row's busy marker and the + // error banner give feedback, e.g. on a CLI that predates `clean`. + guard let container = model.containers.first(where: { $0.id == id }) else { + if listLoaded { app.clearIntent() } + return + } + Task { await model.clean(container) } default: return } @@ -154,6 +162,7 @@ struct ContainersScreen: View { onStart: { Task { await model.start(container) } }, onStop: { Task { await model.stop(container) } }, onRestart: { Task { await model.restart(container) } }, + onClean: { Task { await model.clean(container) } }, onLogs: { logsTarget = container }, onKill: { Task { await model.kill(container) } }, onDelete: { deleteTarget = container } @@ -172,7 +181,9 @@ struct ContainersScreen: View { ContainerDetailView( container: container, stats: model.statsByID[container.id], - statsPoints: model.history.points(for: container.id) + statsPoints: model.history.points(for: container.id), + isBusy: model.busyIDs.contains(container.id), + onClean: { Task { await model.clean(container) } } ) } else { VStack(spacing: 10) { diff --git a/Features/Containers/ContainersViewModel.swift b/Features/Containers/ContainersViewModel.swift index 3d5bbcc..6b7effc 100644 --- a/Features/Containers/ContainersViewModel.swift +++ b/Features/Containers/ContainersViewModel.swift @@ -86,26 +86,39 @@ final class ContainersViewModel { await perform(container.id) { _ = try await self.service.kill(ids: [container.id]) } } + /// Trims freed blocks so the host reclaims disk space (running containers only). + func clean(_ container: Container) async { + await perform(container.id, describing: ContainerService.cleanErrorMessage) { + _ = try await self.service.clean(ids: [container.id]) + } + } + func delete(_ container: Container, force: Bool) async { await perform(container.id) { _ = try await self.service.delete(ids: [container.id], force: force) } if selectedID == container.id { selectedID = nil } } - private func perform(_ id: String, _ action: @escaping () async throws -> Void) async { + /// Runs a per-container action with a busy marker, then reloads. + /// `describing` customizes the user-facing message for a `CLIError`. + private func perform( + _ id: String, + describing describe: (CLIError) -> String = { $0.localizedDescription }, + _ action: @escaping () async throws -> Void + ) async { busyIDs.insert(id) defer { busyIDs.remove(id) } do { try await action() await load() } catch let error as CLIError { - handle(error) + handle(error, message: describe(error)) } catch { errorMessage = error.localizedDescription } } - private func handle(_ error: CLIError) { - errorMessage = error.localizedDescription + private func handle(_ error: CLIError, message: String? = nil) { + errorMessage = message ?? error.localizedDescription if error.isBackendUnavailable { isDaemonDown = true } } } diff --git a/Features/System/SystemScreen.swift b/Features/System/SystemScreen.swift index 7b0b2fd..768ea54 100644 --- a/Features/System/SystemScreen.swift +++ b/Features/System/SystemScreen.swift @@ -7,10 +7,14 @@ struct SystemScreen: View { @State private var pruningContainers = false @State private var pruningImages = false + @State private var cleaningContainers = false @State private var showPruneContainers = false @State private var showPruneImages = false + @State private var showCleanContainers = false @State private var pruneError: String? + private var isReclaiming: Bool { pruningContainers || pruningImages || cleaningContainers } + init(service: SystemService) { _model = State(initialValue: SystemViewModel(service: service)) } @@ -62,6 +66,17 @@ struct SystemScreen: View { } message: { Text("This removes dangling and unreferenced images. Images used by a container are left untouched.") } + .confirmationDialog( + "Clean running containers?", + isPresented: $showCleanContainers + ) { + Button("Clean \(app.runningContainers.count) running") { + Task { await cleanRunningContainers() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This trims freed blocks on each running container’s root filesystem and named volumes so the host can reclaim the space. Nothing is deleted. Requires container 1.4.1 or later.") + } } @ViewBuilder private var content: some View { @@ -204,7 +219,7 @@ struct SystemScreen: View { Label("Prune containers", systemImage: "shippingbox") } } - .disabled(app.containerService == nil || pruningContainers || pruningImages) + .disabled(app.containerService == nil || isReclaiming) PillButton { showPruneImages = true } label: { if pruningImages { @@ -213,7 +228,17 @@ struct SystemScreen: View { Label("Prune images", systemImage: "square.stack.3d.up") } } - .disabled(app.imageService == nil || pruningContainers || pruningImages) + .disabled(app.imageService == nil || isReclaiming) + + PillButton { showCleanContainers = true } label: { + if cleaningContainers { + ProgressView().controlSize(.small) + } else { + Label("Clean running containers", systemImage: "sparkles") + } + } + .disabled(app.containerService == nil || app.runningContainers.isEmpty || isReclaiming) + .help("Trim freed blocks in every running container so the host can reclaim disk space (container 1.4.1+)") Spacer() } @@ -241,6 +266,26 @@ struct SystemScreen: View { } } + /// `container clean` on every running container (the monitor keeps + /// `app.runningContainers` fresh). Nothing is deleted; the CLI skips + /// read-only roots and `ro` mounts, and reports per-path failures at once. + private func cleanRunningContainers() async { + guard let containerService = app.containerService else { return } + let ids = app.runningContainers.map(\.id) + guard !ids.isEmpty else { return } + cleaningContainers = true + pruneError = nil + defer { cleaningContainers = false } + do { + _ = try await containerService.clean(ids: ids) + await model.load() + } catch let error as CLIError { + pruneError = ContainerService.cleanErrorMessage(error) + } catch { + pruneError = error.localizedDescription + } + } + private func pruneImages() async { guard let imageService = app.imageService else { return } pruningImages = true diff --git a/README.md b/README.md index 6df3410..e2ece34 100644 --- a/README.md +++ b/README.md @@ -115,11 +115,11 @@ SwiftUI Views ──▶ @Observable ViewModels ──▶ Services ──▶ | Area | Capabilities | |------|--------------| | **Compose projects** | Import a Compose file, preview services and compatibility issues, select profiles, start/rebuild a stack, stop the project, and open service logs/inspect/console. Requires the optional **Container-Compose 1.1.0** backend; see [setup, supported features, and limitations](docs/compose.md). | -| **Containers** | List (all/running), **live CPU/memory charts** with real CPU % derived from counter deltas, inspect, start/stop/restart/kill, delete, prune, streaming logs, exec, a full **Run** form (shell-style quoting supported), and a **one-click console** that opens an interactive shell in your terminal app (Terminal, iTerm2, Ghostty, Warp, kitty, Alacritty, WezTerm — pick in Settings). | +| **Containers** | List (all/running), **live CPU/memory charts** with real CPU % derived from counter deltas, inspect, start/stop/restart/kill, delete, prune, **clean** (trim freed blocks so the host reclaims disk space; container 1.4.1+), streaming logs, exec, a full **Run** form (shell-style quoting supported), and a **one-click console** that opens an interactive shell in your terminal app (Terminal, iTerm2, Ghostty, Warp, kitty, Alacritty, WezTerm — pick in Settings). | | **Images** | List, inspect (config/env/layers/platforms), **Pull** with streaming progress, **run a container straight from an image**, tag, delete, prune. | | **Volumes** | List, inspect, create (with size/format), delete, prune. | | **Networks** | List, inspect (subnets/gateway), create (NAT or host-only, custom subnets), delete, prune — with the built-in network protected. | -| **System** | Service status, versions, disk usage, reclaim-space pruning, and start/stop the system service. | +| **System** | Service status, versions, disk usage, reclaim-space pruning, one-click clean of every running container, and start/stop the system service. | | **Everywhere** | **⌘K command palette** (fuzzy search across every container, image, volume, network, and action), menu-bar quick controls, right-click context menus, and ⌘R refresh of the active screen. | The app auto-detects the `container` binary (and lets you set a custom path in **Settings**), and surfaces a clear state when the tool isn't installed or the service isn't running. diff --git a/Tests/ArgumentBuilderTests.swift b/Tests/ArgumentBuilderTests.swift index b45cad1..a46d795 100644 --- a/Tests/ArgumentBuilderTests.swift +++ b/Tests/ArgumentBuilderTests.swift @@ -65,6 +65,11 @@ struct ArgumentBuilderTests { #expect(ContainerService.pruneArguments() == ["prune"]) } + @Test func containerClean() { + #expect(ContainerService.cleanArguments(ids: ["web"]) == ["clean", "web"]) + #expect(ContainerService.cleanArguments(ids: ["web", "db"]) == ["clean", "web", "db"]) + } + @Test func images() { #expect(ImageService.listArguments() == ["image", "list", "--format", "json"]) #expect(ImageService.inspectArguments(reference: "alpine") == ["image", "inspect", "alpine"]) diff --git a/Tests/ServiceTests.swift b/Tests/ServiceTests.swift index 17d479c..c892c57 100644 --- a/Tests/ServiceTests.swift +++ b/Tests/ServiceTests.swift @@ -15,6 +15,30 @@ struct ServiceTests { #expect(mock.lastArguments == ["list", "--all", "--format", "json"]) } + @Test func cleanSendsIDsAndReturnsCleanedList() async throws { + // `container clean` prints each cleaned id on its own line. + let mock = MockCommandRunner(stdout: "web\ndb\n") + let service = ContainerService(cli: .mock(mock)) + + let output = try await service.clean(ids: ["web", "db"]) + + #expect(output == "web\ndb") + #expect(mock.lastArguments == ["clean", "web", "db"]) + } + + @Test func cleanOnOlderCLIExplainsVersionRequirement() async { + // A CLI older than 1.4.1 rejects the unknown subcommand with a + // usage error (exit 64); the UI should name the version, not the parser. + let mock = MockCommandRunner(stderr: "Error: Unexpected argument 'clean'", exitCode: 64) + let service = ContainerService(cli: .mock(mock)) + + await #expect(throws: CLIError.usage(message: "Error: Unexpected argument 'clean'")) { + try await service.clean(ids: ["web"]) + } + #expect(ContainerService.cleanErrorMessage(.usage(message: "x")) == ContainerService.cleanUnsupportedMessage) + #expect(ContainerService.cleanErrorMessage(.notFound(message: "no such container: web")) == "no such container: web") + } + @Test func statusDecodesDespiteNonZeroExit() async throws { // When the service is down, `system status` exits 1 but still prints JSON. let mock = MockCommandRunner(stdout: Fixtures.systemStatusUnregistered, exitCode: 1)