Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions App/CommandPalette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions Core/Services/ContainerService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down Expand Up @@ -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))
}
}
13 changes: 13 additions & 0 deletions Features/Containers/ContainerDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
}
Expand Down
4 changes: 4 additions & 0 deletions Features/Containers/ContainerRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
13 changes: 12 additions & 1 deletion Features/Containers/ContainersScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 }
Expand All @@ -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) {
Expand Down
21 changes: 17 additions & 4 deletions Features/Containers/ContainersViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
49 changes: 47 additions & 2 deletions Features/System/SystemScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions Tests/ArgumentBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
24 changes: 24 additions & 0 deletions Tests/ServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading