diff --git a/Sources/AppleDockerApp/App.swift b/Sources/AppleDockerApp/App.swift index 31829d1..df34b7a 100644 --- a/Sources/AppleDockerApp/App.swift +++ b/Sources/AppleDockerApp/App.swift @@ -100,11 +100,37 @@ private struct RootView: View { var body: some View { NavigationSplitView { - List(SidebarItem.allCases, selection: $selection) { item in - sidebarRow(item) - .tag(item) + List(selection: $selection) { + Section("Overview") { + sidebarRow(.dashboard) + .tag(SidebarItem.dashboard) + } + Section("Resources") { + sidebarRow(.containers) + .badge(state.containers.count) + .tag(SidebarItem.containers) + sidebarRow(.images) + .badge(state.images.count) + .tag(SidebarItem.images) + sidebarRow(.volumes) + .badge(state.volumes.count) + .tag(SidebarItem.volumes) + sidebarRow(.networks) + .badge(state.networks.count) + .tag(SidebarItem.networks) + sidebarRow(.compose) + .tag(SidebarItem.compose) + sidebarRow(.builds) + .tag(SidebarItem.builds) + } + Section("System") { + sidebarRow(.activity) + .tag(SidebarItem.activity) + sidebarRow(.settings) + .tag(SidebarItem.settings) + } } - .navigationTitle("Macker") .listStyle(.sidebar) + .listStyle(.sidebar) .navigationTitle("Macker") .navigationSplitViewColumnWidth(min: 240, ideal: 260, max: 360) .safeAreaInset(edge: .bottom) { diff --git a/Sources/AppleDockerApp/Views/ContainerListView.swift b/Sources/AppleDockerApp/Views/ContainerListView.swift index d990e63..cf22cc7 100644 --- a/Sources/AppleDockerApp/Views/ContainerListView.swift +++ b/Sources/AppleDockerApp/Views/ContainerListView.swift @@ -52,8 +52,8 @@ struct ContainerListView: View { private var sidebar: some View { VStack(spacing: 0) { - searchField - controls + searchRow + filterControl Divider() list } @@ -76,53 +76,53 @@ struct ContainerListView: View { } } .padding(6) - .background(Color(nsColor: .textBackgroundColor)) - .cornerRadius(6) - .padding(.horizontal, 8) - .padding(.top, 8) + .surfaceBackground(cornerRadius: 6) } - /// Filter + sort controls. Fixed height so it never grows when a filter - /// (e.g. "Stopped") is selected. - private var controls: some View { + /// Search and sort on one row, so the sort menu never competes with the + /// filter for width. + private var searchRow: some View { HStack(spacing: 6) { - filterControl + searchField sortControl } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .frame(height: 36) + .padding(.horizontal, 10) + .padding(.top, 10) } + /// Full-width segmented filter. Uses the native control so its states and + /// spacing follow macOS and fit the sidebar at its minimum width. private var filterControl: some View { - HStack(spacing: 2) { + Picker("Filter", selection: $filter) { ForEach(ContainerFilter.allCases) { f in - Button { - filter = f - } label: { - Text(f.rawValue) - .font(.caption.weight(.medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 4) - .background(filter == f ? Color.accentColor : Color.clear) - .foregroundStyle(filter == f ? .white : .primary) - .clipShape(RoundedRectangle(cornerRadius: 5)) - } - .buttonStyle(.plain) + Text(f.rawValue).tag(f) } } - .frame(height: 24) + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 10) + .padding(.vertical, 8) } + /// Icon-only menu next to the search field. The current sort is the + /// checked item, and the tooltip names the menu. private var sortControl: some View { - Picker("Sort", selection: $sort) { - ForEach(ContainerSort.allCases) { s in - Label(s.rawValue, systemImage: sortIcon(s)).tag(s) + Menu { + Picker("Sort by", selection: $sort) { + ForEach(ContainerSort.allCases) { s in + Label(s.rawValue, systemImage: sortIcon(s)).tag(s) + } } + .pickerStyle(.inline) + } label: { + Image(systemName: "arrow.up.arrow.down") + .foregroundStyle(.secondary) + .frame(width: 22, height: 22) } - .pickerStyle(.menu) - .labelsHidden() - .controlSize(.small) + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Sort containers") } private var resizer: some View { @@ -218,19 +218,21 @@ struct ContainerListView: View { // intrinsic size that, inside the sidebar VStack, would expand // and push the layout (the "grows to the middle of the screen" // bug when a filter like Stopped matches nothing). - VStack(spacing: 8) { + VStack(spacing: 10) { Image(systemName: "shippingbox") - .font(.title2) - .foregroundStyle(.secondary) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(Color.accentColor) + .frame(width: 44, height: 44) + .background(Color.accentColor.opacity(0.12), in: Circle()) Text("No containers") - .font(.callout.weight(.medium)) + .font(.callout.weight(.semibold)) Text("Run a container with `docker run`, or use `docker compose up`.") .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(16) + .padding(20) } else { List(selection: $selection) { ForEach(grouped, id: \.name) { group in @@ -399,13 +401,23 @@ struct ContainerRow: View { let container: ContainerSnapshot var body: some View { - HStack(spacing: 10) { + HStack(spacing: 12) { StatusDot(status: container.status) - VStack(alignment: .leading, spacing: 2) { - Text(container.id) - .font(.body.weight(.medium)) - .lineLimit(1) - .truncationMode(.middle) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(container.id) + .font(.body.weight(.semibold)) + .lineLimit(1) + .truncationMode(.middle) + ForEach(Array(container.configuration.publishedPorts.enumerated()), id: \.offset) { _, port in + Text("\(port.hostPort):\(port.containerPort)") + .font(.caption2.monospaced().weight(.medium)) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.accentColor.opacity(0.14), in: Capsule()) + } + } Text(container.configuration.image.reference) .font(.caption) .foregroundStyle(.secondary) @@ -413,34 +425,53 @@ struct ContainerRow: View { .truncationMode(.middle) } .frame(maxWidth: .infinity, alignment: .leading) - HStack(spacing: 6) { + if let memory = state.stats[container.id]?.memoryUsageBytes, container.status == .running { + VStack(alignment: .trailing, spacing: 1) { + Text("MEM") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + Text(ByteCountFormatter.string(fromByteCount: Int64(memory), countStyle: .memory)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + .frame(width: 70, alignment: .trailing) + } + HStack(spacing: 2) { if container.status == .running { - Button { - Task { @MainActor in await state.stopContainer(container.id) } - } label: { - Image(systemName: "stop.fill") + rowAction("stop.fill", help: "Stop") { + await state.stopContainer(container.id) } - .buttonStyle(.borderless) - .help("Stop") } else { - Button { - Task { @MainActor in await state.startContainer(container.id) } - } label: { - Image(systemName: "play.fill") + rowAction("play.fill", help: "Start") { + await state.startContainer(container.id) } - .buttonStyle(.borderless) - .help("Start") } - Button { - Task { @MainActor in await state.restartContainer(container.id) } - } label: { - Image(systemName: "arrow.clockwise") + rowAction("arrow.clockwise", help: "Restart", enabled: container.status == .running) { + await state.restartContainer(container.id) } - .buttonStyle(.borderless) - .disabled(container.status != .running) - .help("Restart") } } - .padding(.vertical, 2) + .padding(.vertical, 6) + } + + /// Small icon button with a fixed hit area, so the row actions line up. + private func rowAction( + _ symbol: String, + help: String, + enabled: Bool = true, + action: @escaping @MainActor () async -> Void + ) -> some View { + Button { + Task { @MainActor in await action() } + } label: { + Image(systemName: symbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(enabled ? .secondary : .tertiary) + .frame(width: 26, height: 26) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .disabled(!enabled) + .help(help) } } diff --git a/Sources/AppleDockerCLI/DockerShim/DockerCommand.swift b/Sources/AppleDockerCLI/DockerShim/DockerCommand.swift index ae55e60..d35b20e 100644 --- a/Sources/AppleDockerCLI/DockerShim/DockerCommand.swift +++ b/Sources/AppleDockerCLI/DockerShim/DockerCommand.swift @@ -95,7 +95,8 @@ public enum DockerCommandParser { "kill": ["signal", "s"], "images": ["filter", "f", "format", "since", "before", "label", "l"], "pull": ["platform", "arch", "a", "os", "scheme", "progress", "max-concurrent-downloads"], - "network": ["driver", "d", "subnet", "gateway", "ip-range", "label", "l", "opt", "o", "config-from", "driver-opt", "scope"], + "commit": ["author", "a", "change", "c", "message", "m"], + "network": ["driver", "d", "subnet", "gateway", "ip-range", "label", "l", "opt", "o", "config-from", "driver-opt", "scope", "alias"], "volume": ["driver", "d", "label", "l", "opt", "o", "name", "n", "type", "device", "volume-driver", "volume-opt"], "system": ["filter", "f", "format"], "inspect": ["format", "f", "size", "s", "type", "t"], diff --git a/Sources/AppleDockerCLI/DockerShim/DockerTranslator.swift b/Sources/AppleDockerCLI/DockerShim/DockerTranslator.swift index f7383a2..b6a6b8a 100644 --- a/Sources/AppleDockerCLI/DockerShim/DockerTranslator.swift +++ b/Sources/AppleDockerCLI/DockerShim/DockerTranslator.swift @@ -83,6 +83,8 @@ public struct DockerTranslator: Sendable { try await prune(command) case "tag": try await tag(command) + case "commit": + try await commit(command) case "login": try await login(command) case "logout": @@ -343,7 +345,7 @@ public struct DockerTranslator: Sendable { labels: ["com.macker.shim": "true"], networks: [AttachmentConfiguration( network: ContainerAPIClient.defaultNetworkName, - options: AttachmentOptions(hostname: name, mtu: 1280) + options: AttachmentOptions(hostname: name, mtu: 1280, aliases: Self.networkAliases(command, flag: "network-alias")) )], resources: .init() ) @@ -601,6 +603,31 @@ public struct DockerTranslator: Sendable { try await runCLI(["image", "tag", command.arguments[0], command.arguments[1]]) } + /// `docker commit CONTAINER REPOSITORY[:TAG]`. `container commit` takes the + /// same two arguments but has no equivalent of -a, -c or -m, so those are refused. + private func commit(_ command: DockerCommand) async throws { + guard let containerID = command.arguments.first else { + throw DockerShimError.missingArgument("CONTAINER") + } + for flag in ["author", "a", "change", "c", "message", "m"] where command.value(flag) != nil { + throw DockerShimError.unsupportedCommand("commit --\(flag)") + } + guard command.arguments.count >= 2 else { + throw DockerShimError.missingArgument("REPOSITORY[:TAG]") + } + let resolved = try await resolveContainerID(containerID) + try await runCLI(["commit", resolved, command.arguments[1]]) + } + + /// `--network-alias` (run) or `--alias` (network connect) values. Comma-separated, + /// because the parser keeps a single value per flag. + private static func networkAliases(_ command: DockerCommand, flag: String) -> [String] { + (command.value(flag) ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + /// `docker push` — push an image to a registry. private func push(_ command: DockerCommand) async throws { guard let ref = command.arguments.first else { @@ -745,7 +772,7 @@ public struct DockerTranslator: Sendable { if !already { config.networks.append(AttachmentConfiguration( network: network, - options: AttachmentOptions(hostname: config.id, mtu: 1280) + options: AttachmentOptions(hostname: config.id, mtu: 1280, aliases: Self.networkAliases(command, flag: "alias")) )) } try await recreateContainer(id: resolved, configuration: config) diff --git a/Sources/ContainerBackend/Models/NetworkModels.swift b/Sources/ContainerBackend/Models/NetworkModels.swift index fe3c41a..4578d35 100644 --- a/Sources/ContainerBackend/Models/NetworkModels.swift +++ b/Sources/ContainerBackend/Models/NetworkModels.swift @@ -267,10 +267,37 @@ public struct AttachmentOptions: Codable, Sendable { public let macAddress: MACAddress? /// The MTU for the network interface. public let mtu: UInt32? + /// Extra DNS names that resolve to this attachment (`--network-alias`). + public let aliases: [String] - public init(hostname: String = "", macAddress: MACAddress? = nil, mtu: UInt32? = nil) { + public init(hostname: String = "", macAddress: MACAddress? = nil, mtu: UInt32? = nil, aliases: [String] = []) { self.hostname = hostname self.macAddress = macAddress self.mtu = mtu + self.aliases = aliases + } + + private enum CodingKeys: String, CodingKey { + case hostname, macAddress, mtu, aliases + } + + // Configurations stored before aliases existed have no `aliases` key. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.hostname = try container.decode(String.self, forKey: .hostname) + self.macAddress = try container.decodeIfPresent(MACAddress.self, forKey: .macAddress) + self.mtu = try container.decodeIfPresent(UInt32.self, forKey: .mtu) + self.aliases = try container.decodeIfPresent([String].self, forKey: .aliases) ?? [] + } + + // The key is left out when empty, the same as the daemon's own encoding. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(hostname, forKey: .hostname) + try container.encodeIfPresent(macAddress, forKey: .macAddress) + try container.encodeIfPresent(mtu, forKey: .mtu) + if !aliases.isEmpty { + try container.encode(aliases, forKey: .aliases) + } } } diff --git a/Tests/ContainerBackendTests/RuntimeVersionTests.swift b/Tests/ContainerBackendTests/RuntimeVersionTests.swift index 327bc25..5144f6a 100644 --- a/Tests/ContainerBackendTests/RuntimeVersionTests.swift +++ b/Tests/ContainerBackendTests/RuntimeVersionTests.swift @@ -75,3 +75,24 @@ final class LaunchdStatusParsingTests: XCTestCase { XCTAssertEqual(LaunchdManager.parseStatus("", fallback: ""), .unknown("unreadable system status output")) } } + +final class AttachmentAliasCodingTests: XCTestCase { + func testAliasesOmittedFromJSONWhenEmpty() throws { + let data = try JSONEncoder().encode(AttachmentOptions(hostname: "web", mtu: 1280)) + let json = String(decoding: data, as: UTF8.self) + XCTAssertFalse(json.contains("aliases")) + } + + func testAliasesRoundTrip() throws { + let original = AttachmentOptions(hostname: "web", mtu: 1280, aliases: ["api", "web-api"]) + let decoded = try JSONDecoder().decode(AttachmentOptions.self, from: JSONEncoder().encode(original)) + XCTAssertEqual(decoded.aliases, ["api", "web-api"]) + XCTAssertEqual(decoded.hostname, "web") + } + + func testLegacyConfigWithoutAliasesDecodes() throws { + let legacy = #"{"hostname":"web","mtu":1280}"# + let decoded = try JSONDecoder().decode(AttachmentOptions.self, from: Data(legacy.utf8)) + XCTAssertEqual(decoded.aliases, []) + } +} diff --git a/Tests/DockerShimTests/DockerShimTests.swift b/Tests/DockerShimTests/DockerShimTests.swift index 5b9a9e9..f591523 100644 --- a/Tests/DockerShimTests/DockerShimTests.swift +++ b/Tests/DockerShimTests/DockerShimTests.swift @@ -140,6 +140,26 @@ final class DockerShimTests: XCTestCase { } } +final class CommitAndAliasParsingTests: XCTestCase { + func testCommitTakesContainerAndReference() throws { + let cmd = try DockerCommandParser.parse(["commit", "web", "myimage:1"]) + XCTAssertEqual(cmd.arguments, ["web", "myimage:1"]) + } + + func testCommitMessageFlagIsParsedAsValue() throws { + // The value must not leak into the positional arguments. + let cmd = try DockerCommandParser.parse(["commit", "-m", "note", "web", "myimage:1"]) + XCTAssertEqual(cmd.arguments, ["web", "myimage:1"]) + XCTAssertEqual(cmd.value("m"), "note") + } + + func testRunNetworkAliasIsParsedAsValue() throws { + let cmd = try DockerCommandParser.parse(["run", "--network-alias", "api,web-api", "-d", "nginx"]) + XCTAssertEqual(cmd.value("network-alias"), "api,web-api") + XCTAssertEqual(cmd.arguments, ["nginx"]) + } +} + final class ComposeScaleFlagTests: XCTestCase { func testScaleFlagsParseIntoCounts() throws { let cmd = try DockerCommandParser.parse(["compose", "up", "-d", "--scale", "web=3", "--scale", "worker=0"])