diff --git a/App/AppModel.swift b/App/AppModel.swift index 8f6a67f..1f075bd 100644 --- a/App/AppModel.swift +++ b/App/AppModel.swift @@ -5,6 +5,7 @@ import Observation enum SidebarItem: String, CaseIterable, Identifiable, Hashable { case containers case images + case explore case volumes case networks case system @@ -15,6 +16,7 @@ enum SidebarItem: String, CaseIterable, Identifiable, Hashable { switch self { case .containers: return "Containers" case .images: return "Images" + case .explore: return "Explore" case .volumes: return "Volumes" case .networks: return "Networks" case .system: return "System" @@ -25,6 +27,7 @@ enum SidebarItem: String, CaseIterable, Identifiable, Hashable { switch self { case .containers: return "shippingbox.fill" case .images: return "square.stack.3d.up.fill" + case .explore: return "sparkle.magnifyingglass" case .volumes: return "externaldrive.fill" case .networks: return "point.3.filled.connected.trianglepath.dotted" case .system: return "gearshape.2.fill" @@ -86,6 +89,10 @@ final class AppModel { var volumeService: VolumeService? { cli.map(VolumeService.init) } var networkService: NetworkService? { cli.map(NetworkService.init) } + /// Docker Hub image search talks to the network directly (the CLI has no + /// registry-search command), so it's available regardless of the backend. + let dockerHubService = DockerHubService() + /// Monotonic counter bumped by the global Refresh command (⌘R). /// Screens observe it and reload their own lists. private(set) var refreshTick = 0 diff --git a/App/CommandPalette.swift b/App/CommandPalette.swift index 93a5725..782eb48 100644 --- a/App/CommandPalette.swift +++ b/App/CommandPalette.swift @@ -83,6 +83,11 @@ enum PaletteCatalog { id: "action.build", title: "Build an image…", subtitle: "Build from a Dockerfile", systemImage: "hammer.fill", category: .action, keywords: "dockerfile compile make", intent: .buildImage )) + out.append(.init( + id: "action.searchHub", title: "Search Docker Hub…", subtitle: "Find images to pull", + systemImage: "sparkle.magnifyingglass", category: .action, + keywords: "registry hub search explore find image download", intent: .navigate(.explore) + )) out.append(.init( id: "action.createVolume", title: "Create a volume…", subtitle: nil, systemImage: "externaldrive.fill.badge.plus", category: .action, keywords: "new storage", intent: .createVolume diff --git a/App/RootView.swift b/App/RootView.swift index 0512c68..a1ec4fa 100644 --- a/App/RootView.swift +++ b/App/RootView.swift @@ -66,6 +66,7 @@ private struct DetailColumn: View { switch app.selection ?? .containers { case .containers: ContainersScreen(service: containerService) case .images: ImagesScreen(service: imageService) + case .explore: ExploreScreen(service: app.dockerHubService) case .volumes: VolumesScreen(service: volumeService) case .networks: NetworksScreen(service: networkService) case .system: SystemScreen(service: systemService) diff --git a/App/Sidebar.swift b/App/Sidebar.swift index 6ddda39..a423490 100644 --- a/App/Sidebar.swift +++ b/App/Sidebar.swift @@ -4,10 +4,14 @@ import AppKit struct Sidebar: View { @Binding var selection: SidebarItem? + /// Core screens shown in the plain navigation list. Explore is pulled out + /// into its own standout button (see `header`). + private var listItems: [SidebarItem] { SidebarItem.allCases.filter { $0 != .explore } } + var body: some View { List(selection: $selection) { Section { - ForEach(SidebarItem.allCases) { item in + ForEach(listItems) { item in Label(item.title, systemImage: item.symbol) .tag(item) .padding(.vertical, 2) @@ -16,7 +20,19 @@ struct Sidebar: View { } .listStyle(.sidebar) .safeAreaInset(edge: .top, spacing: 0) { brand } - .safeAreaInset(edge: .bottom, spacing: 0) { BackendStatusPill().padding(10) } + .safeAreaInset(edge: .bottom, spacing: 0) { footer } + } + + /// Pinned to the sidebar bottom: the Explore button sits right above the + /// backend status pill. + private var footer: some View { + VStack(spacing: 8) { + ExploreSidebarButton(isSelected: selection == .explore) { + withAnimation(Theme.Motion.smooth) { selection = .explore } + } + BackendStatusPill() + } + .padding(10) } private var brand: some View { @@ -36,6 +52,78 @@ struct Sidebar: View { } } +/// The sidebar's standout entry point to Docker Hub search — a distinct accent +/// button rather than a plain navigation row, so discovery feels like its own thing. +private struct ExploreSidebarButton: View { + let isSelected: Bool + let action: () -> Void + + @State private var hovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: "sparkle.magnifyingglass") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background( + isSelected ? AnyShapeStyle(Color.white.opacity(0.22)) + : AnyShapeStyle(Theme.Palette.accentGradient), + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .shadow(color: Color.accentColor.opacity(isSelected ? 0 : 0.35), radius: 5, y: 2) + + VStack(alignment: .leading, spacing: 1) { + Text("Explore") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(isSelected ? Color.white : Color.primary) + Text("Search Docker Hub") + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(isSelected ? Color.white.opacity(0.85) : Color.secondary) + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(isSelected ? Color.white.opacity(0.7) : Color.secondary.opacity(0.55)) + } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous).fill(fill) + } + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(border, lineWidth: 1) + } + .shadow(color: isSelected ? Color.accentColor.opacity(0.30) : .clear, radius: 9, y: 3) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in withAnimation(Theme.Motion.snappy) { self.hovering = hovering } } + .animation(Theme.Motion.smooth, value: isSelected) + } + + private var fill: AnyShapeStyle { + if isSelected { return AnyShapeStyle(Theme.Palette.accentGradient) } + return AnyShapeStyle(Color.accentColor.opacity(hovering ? 0.15 : 0.08)) + } + + private var border: AnyShapeStyle { + if isSelected { return AnyShapeStyle(Color.clear) } + return AnyShapeStyle( + LinearGradient( + colors: [Color.accentColor.opacity(0.45), Color.accentColor.opacity(0.14)], + startPoint: .top, + endPoint: .bottom + ) + ) + } +} + /// Compact backend health readout pinned to the sidebar bottom; tapping it jumps /// to the System tab and re-probes. struct BackendStatusPill: View { diff --git a/Core/Models/DockerHub.swift b/Core/Models/DockerHub.swift new file mode 100644 index 0000000..5bd8ab7 --- /dev/null +++ b/Core/Models/DockerHub.swift @@ -0,0 +1,130 @@ +import Foundation + +/// Models for Docker Hub's public HTTP API. `container` has no registry-search +/// command, so these back the one feature that talks to the network directly. +/// +/// Docker Hub uses `snake_case` keys (mapped explicitly per model, matching the +/// house rule of never using a key-decoding strategy) and timestamps with +/// nanosecond fractional seconds — which break `.iso8601` — so every date is +/// decoded as a `String` and parsed lazily at the view layer (the same approach +/// `OCIImage.created` takes). + +/// One page of repository search results (`GET /v2/search/repositories/`). +struct HubSearchResponse: Codable, Hashable, Sendable { + var count: Int + var next: String? + var previous: String? + var results: [HubRepository] +} + +/// A repository in Docker Hub search results. +struct HubRepository: Codable, Hashable, Sendable, Identifiable { + /// `nginx` for official images, `owner/repo` for community images. + var repoName: String + var shortDescription: String? + var starCount: Int + var pullCount: Int + var repoOwner: String? + var isOfficial: Bool + var isAutomated: Bool + + /// `repoName` is globally unique on Hub, so it doubles as the identity. + var id: String { repoName } + + /// Namespace used by the tags API and the pull reference. Official images + /// live under `library`; community images carry their owner as the namespace. + var namespace: String { + guard let slash = repoName.firstIndex(of: "/") else { return "library" } + return String(repoName[.. String { + "docker.io/\(namespace)/\(repository):\(tag)" + } + + private enum CodingKeys: String, CodingKey { + case repoName = "repo_name" + case shortDescription = "short_description" + case starCount = "star_count" + case pullCount = "pull_count" + case repoOwner = "repo_owner" + case isOfficial = "is_official" + case isAutomated = "is_automated" + } +} + +/// One page of a repository's tags (`GET /v2/repositories/{ns}/{repo}/tags/`). +struct HubTagsResponse: Codable, Hashable, Sendable { + var count: Int + var next: String? + var results: [HubTag] +} + +/// A single tag and its per-platform manifests. +struct HubTag: Codable, Hashable, Sendable, Identifiable { + var name: String + var tagID: Int? + var lastUpdated: String? + var fullSize: Int? + var images: [HubTagImage] + + var id: String { name } + + /// Real OS/arch platforms, excluding attestation ("unknown") manifests. + var platforms: [HubTagImage] { + images.filter { ($0.os ?? "unknown") != "unknown" && $0.architecture != "unknown" } + } + + /// Compact "linux/arm64, linux/amd64" summary of the real platforms. + var platformSummary: String { + platforms.map(\.platformLabel).joined(separator: ", ") + } + + /// Best size to surface: prefer the host architecture, then the reported + /// total, then the largest platform manifest. + var displaySize: Int? { + let hostArch = ImageReference.hostArchitecture + if let match = platforms.first(where: { $0.architecture == hostArch }), let size = match.size { + return size + } + return fullSize ?? platforms.compactMap(\.size).max() + } + + private enum CodingKeys: String, CodingKey { + case name, images + case tagID = "id" + case lastUpdated = "last_updated" + case fullSize = "full_size" + } +} + +/// One platform manifest listed under a tag. +struct HubTagImage: Codable, Hashable, Sendable { + var architecture: String + var variant: String? + var digest: String? + var os: String? + var size: Int? + var status: String? + var lastPushed: String? + + /// `linux/arm64/v8`-style label from the OS, architecture, and variant. + var platformLabel: String { + var parts = [os ?? "", architecture].filter { !$0.isEmpty } + if let variant, !variant.isEmpty { parts.append(variant) } + return parts.joined(separator: "/") + } + + private enum CodingKeys: String, CodingKey { + case architecture, variant, digest, os, size, status + case lastPushed = "last_pushed" + } +} diff --git a/Core/Models/Formatting.swift b/Core/Models/Formatting.swift index 869ae13..b2f234e 100644 --- a/Core/Models/Formatting.swift +++ b/Core/Models/Formatting.swift @@ -50,4 +50,51 @@ enum Formatting { static func cpus(_ count: Int) -> String { "\(count) CPU\(count == 1 ? "" : "s")" } + + /// Compact human count for large tallies (stars, pulls): `999`, `21.3K`, + /// `1.2M`, `13.1B`. Trailing `.0` is dropped (`1.0K` → `1K`). + static func compactCount(_ value: Int) -> String { + // Thresholds sit just under each unit so a value that rounds up to + // "1000" within a band (e.g. 999_999_999) promotes to the next unit + // ("1B") rather than rendering "1000M". + let magnitude = abs(value) + switch magnitude { + case 999_950_000...: + return scaled(Double(value) / 1_000_000_000) + "B" + case 999_950...: + return scaled(Double(value) / 1_000_000) + "M" + case 1_000...: + return scaled(Double(value) / 1_000) + "K" + default: + return String(value) + } + } + + private static func scaled(_ value: Double) -> String { + let rounded = (value * 10).rounded() / 10 + if rounded == rounded.rounded() { return String(Int(rounded)) } + return String(format: "%.1f", rounded) + } + + private static let iso8601Fractional: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let iso8601Plain: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + /// Relative time from an ISO-8601 string that may or may not carry fractional + /// seconds (Docker Hub uses nanoseconds). Returns nil if it can't be parsed. + static func relativeISO8601(_ string: String?, now: Date = Date()) -> String? { + guard let string, !string.isEmpty else { return nil } + guard let date = iso8601Fractional.date(from: string) ?? iso8601Plain.date(from: string) else { + return nil + } + return relative(date, now: now) + } } diff --git a/Core/Net/HTTPClient.swift b/Core/Net/HTTPClient.swift new file mode 100644 index 0000000..8358986 --- /dev/null +++ b/Core/Net/HTTPClient.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Abstraction over HTTP GETs so registry/network services can be unit-tested +/// against recorded fixtures without a live connection — the network-side +/// counterpart to `CommandRunner`. +protocol HTTPClient: Sendable { + /// Performs a GET and returns the raw body plus the HTTP response. + /// Throws `CancellationError` if the task was cancelled, otherwise `HubError`. + func get(_ url: URL) async throws -> (Data, HTTPURLResponse) +} + +/// Typed failures from a Docker Hub request. +enum HubError: Error, Sendable, Equatable { + /// The request never reached Docker Hub (no network, DNS, timeout…). + case offline + /// Docker Hub replied with a non-2xx status. + case http(status: Int) + /// The body didn't decode into the expected shape. + case decodingFailed(message: String) + /// The response wasn't an HTTP response at all. + case invalidResponse +} + +extension HubError: LocalizedError { + var errorDescription: String? { + switch self { + case .offline: + return "Couldn’t reach Docker Hub. Check your internet connection and try again." + case .http(let status): + return "Docker Hub returned an error (HTTP \(status))." + case .decodingFailed: + return "Docker Hub sent a response ContainerUI couldn’t read." + case .invalidResponse: + return "Docker Hub returned an unexpected response." + } + } +} + +/// `HTTPClient` backed by `URLSession`, tuned for a responsive search box: +/// short timeout, fail-fast (no waiting for connectivity), and a descriptive +/// `User-Agent`. +struct URLSessionHTTPClient: HTTPClient { + private let session: URLSession + + init(session: URLSession? = nil) { + if let session { + self.session = session + } else { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + config.waitsForConnectivity = false + config.httpAdditionalHeaders = ["User-Agent": Self.userAgent] + self.session = URLSession(configuration: config) + } + } + + func get(_ url: URL) async throws -> (Data, HTTPURLResponse) { + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(from: url) + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch is CancellationError { + throw CancellationError() + } catch { + throw HubError.offline + } + guard let http = response as? HTTPURLResponse else { throw HubError.invalidResponse } + return (data, http) + } + + private static let userAgent: String = { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" + return "ContainerUI/\(version)" + }() +} diff --git a/Core/Services/DockerHubService.swift b/Core/Services/DockerHubService.swift new file mode 100644 index 0000000..bc53b10 --- /dev/null +++ b/Core/Services/DockerHubService.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Read-only access to Docker Hub's public API for image discovery. +/// +/// This is the one service that reaches the network directly instead of shelling +/// out to `container`, because the CLI has no registry-search command. It's +/// backed by an injectable `HTTPClient` so it can be unit-tested against recorded +/// fixtures, mirroring how the CLI services use `CommandRunner`. +struct DockerHubService: Sendable { + let client: HTTPClient + + init(client: HTTPClient = URLSessionHTTPClient()) { + self.client = client + } + + /// Docker Hub keys are mapped with explicit `CodingKeys` per model, so no key + /// strategy — matching the CLI decoder's house rule. + static let decoder = JSONDecoder() + + private static let host = "hub.docker.com" + + // MARK: URL builders (pure, unit-tested) + + static func searchURL(query: String, page: Int = 1, pageSize: Int = 25) -> URL { + var components = URLComponents() + components.scheme = "https" + components.host = host + components.path = "/v2/search/repositories/" + components.queryItems = [ + URLQueryItem(name: "query", value: query), + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "page_size", value: String(pageSize)), + ] + return components.url! + } + + static func tagsURL(namespace: String, repository: String, pageSize: Int = 50) -> URL { + var components = URLComponents() + components.scheme = "https" + components.host = host + components.path = "/v2/repositories/\(namespace)/\(repository)/tags/" + components.queryItems = [ + URLQueryItem(name: "page_size", value: String(pageSize)), + URLQueryItem(name: "ordering", value: "last_updated"), + ] + return components.url! + } + + // MARK: Operations + + /// Searches Docker Hub repositories, newest/most-relevant first (Hub's order). + func search(query: String, page: Int = 1, pageSize: Int = 25) async throws -> [HubRepository] { + let url = Self.searchURL(query: query, page: page, pageSize: pageSize) + return try await fetch(HubSearchResponse.self, from: url).results + } + + /// Lists a repository's tags, most-recently-updated first. + func tags(namespace: String, repository: String, pageSize: Int = 50) async throws -> [HubTag] { + let url = Self.tagsURL(namespace: namespace, repository: repository, pageSize: pageSize) + return try await fetch(HubTagsResponse.self, from: url).results + } + + // MARK: Fetch + decode + + private func fetch(_ type: T.Type, from url: URL) async throws -> T { + let (data, response) = try await client.get(url) + guard (200..<300).contains(response.statusCode) else { + throw HubError.http(status: response.statusCode) + } + do { + return try Self.decoder.decode(T.self, from: data) + } catch { + throw HubError.decodingFailed(message: String(describing: error)) + } + } +} diff --git a/Features/Explore/ExploreScreen.swift b/Features/Explore/ExploreScreen.swift new file mode 100644 index 0000000..91309ae --- /dev/null +++ b/Features/Explore/ExploreScreen.swift @@ -0,0 +1,124 @@ +import SwiftUI + +/// Docker Hub search: type a term, browse repositories, inspect a repo's tags, +/// and pull straight into the local `container` store. +struct ExploreScreen: View { + @State private var model: ExploreViewModel + @Environment(AppModel.self) private var app + + @State private var pullTarget: PullTarget? + + init(service: DockerHubService) { + _model = State(initialValue: ExploreViewModel(service: service)) + } + + /// Identifiable wrapper so `.sheet(item:)` can carry the reference to pull. + private struct PullTarget: Identifiable { + let id = UUID() + let reference: String + } + + var body: some View { + @Bindable var model = model + ScreenScaffold(title: "Explore", subtitle: model.subtitle) { + SearchField(text: $model.query, prompt: "Search Docker Hub", width: 260) + } content: { + content + } + .onChange(of: model.query) { model.queryChanged() } + .onDisappear { model.cancel() } + .inspector(isPresented: inspectorPresented) { + inspector.inspectorColumnWidth(min: 320, ideal: 380, max: 540) + } + .sheet(item: $pullTarget) { target in + if let imageService = app.imageService { + PullImageView(service: imageService, initialReference: target.reference) {} + } + } + } + + @ViewBuilder private var content: some View { + if model.isLoading && model.results.isEmpty { + LoadingView(label: "Searching Docker Hub…") + } else if let error = model.errorState, model.results.isEmpty { + errorState(error) + } else if model.results.isEmpty { + if model.hasSearched { + EmptyStateView( + systemImage: "magnifyingglass", + title: "No results", + message: "Nothing on Docker Hub matched “\(model.query.trimmingCharacters(in: .whitespaces))”." + ) + } else { + EmptyStateView( + systemImage: "sparkle.magnifyingglass", + title: "Search Docker Hub", + message: "Find images to pull — try “nginx”, “postgres”, or “redis”." + ) + } + } else { + list + } + } + + private var list: some View { + VStack(spacing: 0) { + if let error = model.errorState, !model.results.isEmpty { + InlineBanner(kind: .warning, title: "Couldn’t refresh results", message: error.errorDescription) + .padding([.horizontal, .top], 16) + } + ScrollView { + LazyVStack(spacing: 8) { + ForEach(model.results) { repo in + HubRepositoryRow( + repository: repo, + isSelected: model.selectedID == repo.id, + onSelect: { + withAnimation(Theme.Motion.snappy) { + model.selectedID = (model.selectedID == repo.id) ? nil : repo.id + } + }, + onPull: { pullTarget = PullTarget(reference: repo.pullReference()) } + ) + } + } + .padding(16) + } + } + } + + @ViewBuilder private func errorState(_ error: HubError) -> some View { + EmptyStateView( + systemImage: error == .offline ? "wifi.slash" : "exclamationmark.triangle", + title: error == .offline ? "You’re offline" : "Couldn’t reach Docker Hub", + message: error.errorDescription, + actionTitle: "Retry", + actionIcon: "arrow.clockwise", + action: { model.retry() } + ) + } + + @ViewBuilder private var inspector: some View { + if let repo = model.selected { + HubRepositoryDetailView(repository: repo, service: model.service) { reference in + pullTarget = PullTarget(reference: reference) + } + // Fresh instance per repo so @State (tags/isLoading) resets on switch. + .id(repo.id) + } else { + VStack(spacing: 10) { + Image(systemName: "sidebar.right") + .font(.system(size: 30, weight: .light)) + .foregroundStyle(.tertiary) + Text("Select a repository") + .font(Theme.Typography.body) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var inspectorPresented: Binding { + Binding(get: { model.selectedID != nil }, set: { if !$0 { model.selectedID = nil } }) + } +} diff --git a/Features/Explore/ExploreViewModel.swift b/Features/Explore/ExploreViewModel.swift new file mode 100644 index 0000000..dc8f347 --- /dev/null +++ b/Features/Explore/ExploreViewModel.swift @@ -0,0 +1,104 @@ +import SwiftUI +import Observation + +/// Drives the Explore screen: a debounced, cancellable Docker Hub search. +@MainActor +@Observable +final class ExploreViewModel { + let service: DockerHubService + + var query = "" + var results: [HubRepository] = [] + var isLoading = false + var errorState: HubError? + /// True once a real (≥ min length) search has completed, so the empty view + /// can distinguish "type something" from "no matches". + var hasSearched = false + var selectedID: HubRepository.ID? + + @ObservationIgnored private var searchTask: Task? + + /// Minimum query length before a network search fires. + private let minQueryLength = 2 + /// Debounce so typing doesn't hammer the API. + private let debounce = Duration.milliseconds(300) + + init(service: DockerHubService) { + self.service = service + } + + var selected: HubRepository? { results.first { $0.id == selectedID } } + + var subtitle: String { + if !results.isEmpty { + return "\(results.count) result\(results.count == 1 ? "" : "s") on Docker Hub" + } + return "Search Docker Hub" + } + + /// Called on every keystroke: cancels any pending search, clears results when + /// the query is too short, otherwise debounces and searches. + func queryChanged() { + searchTask?.cancel() + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= minQueryLength else { + results = [] + errorState = nil + isLoading = false + hasSearched = false + if selectedID != nil { selectedID = nil } + return + } + searchTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(for: self.debounce) + if Task.isCancelled { return } + await self.performSearch(trimmed) + } + } + + /// Re-runs the current query immediately (used by the error-state Retry). + func retry() { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= minQueryLength else { return } + searchTask?.cancel() + searchTask = Task { [weak self] in + await self?.performSearch(trimmed) + } + } + + /// Cancels any in-flight search (e.g. when the screen disappears). + func cancel() { + searchTask?.cancel() + } + + private func performSearch(_ trimmed: String) async { + isLoading = true + do { + let found = try await service.search(query: trimmed) + try Task.checkCancellation() + guard isCurrent(trimmed) else { return } // superseded by a newer search + withAnimation(Theme.Motion.smooth) { results = found } + errorState = nil + if let selectedID, !found.contains(where: { $0.id == selectedID }) { + self.selectedID = nil + } + } catch is CancellationError { + return // a newer search owns the state now — don't touch isLoading + } catch { + guard isCurrent(trimmed) else { return } + errorState = (error as? HubError) ?? .offline + // Keep any existing results on screen so a transient failure while + // refining a query shows an inline "refresh failed" banner instead of + // dumping the user to a full-screen error and closing the inspector. + if results.isEmpty { selectedID = nil } + } + hasSearched = true + isLoading = false + } + + /// A response is current only if the query still matches and we weren't cancelled. + private func isCurrent(_ trimmed: String) -> Bool { + !Task.isCancelled && trimmed == query.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Features/Explore/HubRepositoryDetailView.swift b/Features/Explore/HubRepositoryDetailView.swift new file mode 100644 index 0000000..900ce23 --- /dev/null +++ b/Features/Explore/HubRepositoryDetailView.swift @@ -0,0 +1,192 @@ +import SwiftUI +import AppKit + +/// Inspector for a selected Docker Hub repository: a summary header plus the +/// repository's tags, each of which can be pulled. +struct HubRepositoryDetailView: View { + let repository: HubRepository + let service: DockerHubService + /// Receives a fully-qualified pull reference (`docker.io/library/nginx:latest`). + var onPull: (String) -> Void + + @State private var tags: [HubTag] = [] + // Starts true so the tags area shows a spinner on first appearance rather + // than a one-frame "No tags found" flash before `.task` runs. Combined with + // `.id(repo.id)` at the call site, switching repos gets a fresh instance, so + // the previous repo's tags never flash under the new header. + @State private var isLoading = true + @State private var errorMessage: String? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + header + Divider().opacity(0.4) + tagsSection + } + .padding(18) + } + .task(id: repository.id) { await loadTags() } + } + + // MARK: Header + + private var header: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Text(repository.repoName) + .font(Theme.Typography.title) + .textSelection(.enabled) + .lineLimit(2) + if repository.isOfficial { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(Color.accentColor) + .help("Official image") + } + } + if let description = repository.shortDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + !description.isEmpty { + Text(description) + .font(Theme.Typography.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + HStack(spacing: 8) { + StatChip(systemImage: "star.fill", text: "\(Formatting.compactCount(repository.starCount)) stars") + StatChip(systemImage: "arrow.down.circle", text: "\(Formatting.compactCount(repository.pullCount)) pulls") + } + HStack(spacing: 8) { + PillButton(style: .accent) { onPull(repository.pullReference()) } label: { + Label("Pull latest", systemImage: "arrow.down.circle.fill") + } + CopyButton(text: repository.pullReference()) + Spacer(minLength: 0) + Link(destination: hubURL) { + Label("Docker Hub", systemImage: "arrow.up.forward.square") + .font(Theme.Typography.caption) + } + .foregroundStyle(.secondary) + } + } + } + + private var hubURL: URL { + let path = repository.isOfficial ? "_/\(repository.repository)" : "r/\(repository.repoName)" + return URL(string: "https://hub.docker.com/\(path)") ?? URL(string: "https://hub.docker.com")! + } + + // MARK: Tags + + @ViewBuilder private var tagsSection: some View { + HStack { + SectionLabel(title: "Tags") + Spacer() + if !isLoading && !tags.isEmpty { + Text("\(tags.count)") + .font(Theme.Typography.caption) + .foregroundStyle(.secondary) + } + } + + if isLoading { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading tags…") + .font(Theme.Typography.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 14) + } else if let errorMessage { + Text(errorMessage) + .font(Theme.Typography.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else if tags.isEmpty { + Text("No tags found for this repository.") + .font(Theme.Typography.callout) + .foregroundStyle(.secondary) + } else { + VStack(spacing: 6) { + ForEach(orderedTags) { tag in + HubTagRow(tag: tag) { onPull(repository.pullReference(tag: tag.name)) } + } + } + } + } + + /// `latest` pinned first (stable), otherwise the API's last-updated order. + private var orderedTags: [HubTag] { + guard let index = tags.firstIndex(where: { $0.name == "latest" }), index != 0 else { return tags } + var reordered = tags + reordered.insert(reordered.remove(at: index), at: 0) + return reordered + } + + private func loadTags() async { + isLoading = true + errorMessage = nil + do { + let fetched = try await service.tags(namespace: repository.namespace, repository: repository.repository) + try Task.checkCancellation() + tags = fetched + } catch is CancellationError { + return // selection changed; the new .task owns state + } catch let error as HubError { + tags = [] + errorMessage = error.errorDescription + } catch { + tags = [] + errorMessage = "Couldn’t load tags." + } + isLoading = false + } +} + +/// One tag row in the repository inspector, with a hover-revealed Pull button. +private struct HubTagRow: View { + let tag: HubTag + var onPull: () -> Void + + @State private var hovering = false + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "tag.fill") + .font(.system(size: 11)) + .foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 2) { + Text(tag.name) + .font(Theme.Typography.headline) + .lineLimit(1) + .textSelection(.enabled) + if !subtitle.isEmpty { + Text(subtitle) + .font(Theme.Typography.monoCaption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 8) + if hovering { + CircleIconButton(systemImage: "arrow.down", help: "Pull \(tag.name)", size: 26, action: onPull) + } + } + .padding(.horizontal, 11) + .padding(.vertical, 8) + .background(Theme.Palette.controlBackground, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .contentShape(Rectangle()) + .onHover { hovering in + withAnimation(Theme.Motion.snappy) { self.hovering = hovering } + } + } + + private var subtitle: String { + var parts: [String] = [] + if let size = tag.displaySize { parts.append(Formatting.bytes(Int64(size))) } + let platforms = tag.platformSummary + if !platforms.isEmpty { parts.append(platforms) } + if let updated = Formatting.relativeISO8601(tag.lastUpdated) { parts.append(updated) } + return parts.joined(separator: " · ") + } +} diff --git a/Features/Explore/HubRepositoryRow.swift b/Features/Explore/HubRepositoryRow.swift new file mode 100644 index 0000000..69c1e42 --- /dev/null +++ b/Features/Explore/HubRepositoryRow.swift @@ -0,0 +1,113 @@ +import SwiftUI +import AppKit + +/// A single Docker Hub search result: name, official badge, description, and +/// star/pull counts — swapped for a quick Pull button on hover/selection. +struct HubRepositoryRow: View { + let repository: HubRepository + let isSelected: Bool + + var onSelect: () -> Void + var onPull: () -> Void + + @State private var hovering = false + + private var showActions: Bool { hovering || isSelected } + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 12) { + icon + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 7) { + Text(repository.repoName) + .font(Theme.Typography.headline) + .lineLimit(1) + if repository.isOfficial { officialBadge } + } + Text(descriptionText) + .font(Theme.Typography.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + ZStack(alignment: .trailing) { + stats.opacity(showActions ? 0 : 1) + actions.opacity(showActions ? 1 : 0) + } + .animation(Theme.Motion.smooth, value: showActions) + } + .padding(.horizontal, 13) + .padding(.vertical, 11) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .contextMenu { menuItems } + .background( + RoundedRectangle(cornerRadius: Theme.Metrics.rowCorner, style: .continuous) + .fill(.thinMaterial) + .overlay { + RoundedRectangle(cornerRadius: Theme.Metrics.rowCorner, style: .continuous) + .fill(hovering ? Theme.Palette.controlBackground : Color.clear) + } + ) + .overlay { + RoundedRectangle(cornerRadius: Theme.Metrics.rowCorner, style: .continuous) + .strokeBorder(isSelected ? Color.accentColor.opacity(0.7) : Theme.Palette.hairline, + lineWidth: isSelected ? 1.5 : 1) + } + .onHover { hovering in + withAnimation(Theme.Motion.snappy) { self.hovering = hovering } + } + .fixedSize(horizontal: false, vertical: true) + } + + private var icon: some View { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(Color.accentColor.opacity(0.14)) + .frame(width: 36, height: 36) + .overlay { + Image(systemName: repository.isOfficial ? "checkmark.seal.fill" : "shippingbox.fill") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Color.accentColor) + } + } + + private var officialBadge: some View { + Text("OFFICIAL") + .font(.system(size: 8.5, weight: .bold)) + .kerning(0.4) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.accentColor.opacity(0.14), in: Capsule()) + } + + private var descriptionText: String { + let trimmed = (repository.shortDescription ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "No description provided." : trimmed + } + + private var stats: some View { + HStack(spacing: 6) { + StatChip(systemImage: "star.fill", text: Formatting.compactCount(repository.starCount)) + StatChip(systemImage: "arrow.down.circle", text: Formatting.compactCount(repository.pullCount)) + } + .fixedSize() + } + + private var actions: some View { + PillButton(style: .accent) { onPull() } label: { + Label("Pull", systemImage: "arrow.down") + } + } + + @ViewBuilder private var menuItems: some View { + Button("Pull latest…", systemImage: "arrow.down.circle.fill", action: onPull) + Button("Copy pull reference", systemImage: "doc.on.doc") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(repository.pullReference(), forType: .string) + } + } +} diff --git a/Features/Images/PullImageView.swift b/Features/Images/PullImageView.swift index ad5a746..602137e 100644 --- a/Features/Images/PullImageView.swift +++ b/Features/Images/PullImageView.swift @@ -9,7 +9,7 @@ struct PullImageView: View { @Environment(\.dismiss) private var dismiss - @State private var reference = "" + @State private var reference: String @State private var lines: [String] = [] @State private var progress: PullProgress? @State private var displayedFraction: Double = 0 @@ -18,6 +18,12 @@ struct PullImageView: View { @State private var errorMessage: String? @State private var showConsole = false + init(service: ImageService, initialReference: String = "", onComplete: @escaping () async -> Void) { + self.service = service + self.onComplete = onComplete + _reference = State(initialValue: initialReference) + } + var body: some View { VStack(spacing: 0) { header diff --git a/README.md b/README.md index 0cf04e6..f361866 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,24 @@ Run and inspect Linux containers, manage images, volumes & networks, and control > The app deploys to macOS 14 so it can launch and show a friendly "not installed" screen, but the backend it drives needs macOS 26. +## Install + +Install the latest release with [Homebrew](https://brew.sh): + +```bash +brew install --cask kylemclaren/tap/container-ui +``` + +The `owner/tap/name` form auto-taps [`kylemclaren/homebrew-tap`](https://github.com/kylemclaren/homebrew-tap), so it's a single step — no separate `brew tap` needed. From then on the app updates itself in place. + +Prefer a direct download? Grab the DMG from the [latest release](https://github.com/kylemclaren/container-ui/releases/latest), drag **ContainerUI** to Applications, and open it. + +> The build isn't notarized yet, so on first launch macOS Gatekeeper may block it. Clear the quarantine flag once: +> +> ```bash +> xattr -dr com.apple.quarantine /Applications/ContainerUI.app +> ``` + ## Build & run This repo doesn't check in an `.xcodeproj`; it's generated from [`project.yml`](project.yml) with XcodeGen. diff --git a/Tests/DockerHubTests.swift b/Tests/DockerHubTests.swift new file mode 100644 index 0000000..18f15c8 --- /dev/null +++ b/Tests/DockerHubTests.swift @@ -0,0 +1,142 @@ +import Foundation +import Testing + +/// Verifies Docker Hub URL building, response decoding, service error mapping, and +/// the formatting helpers the Docker Hub UI relies on. +@Suite("Docker Hub") +struct DockerHubTests { + // MARK: URL building + + @Test func searchURLBuilding() throws { + let url = DockerHubService.searchURL(query: "my app", page: 2, pageSize: 10) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + #expect(components.scheme == "https") + #expect(components.host == "hub.docker.com") + #expect(components.path == "/v2/search/repositories/") + + let query = try #require(components.percentEncodedQuery) + #expect(query.contains("query=my%20app") || query.contains("query=my+app")) + #expect(query.contains("page=2")) + #expect(query.contains("page_size=10")) + } + + @Test func tagsURLBuilding() throws { + let url = DockerHubService.tagsURL(namespace: "library", repository: "nginx") + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + #expect(components.path == "/v2/repositories/library/nginx/tags/") + + let query = try #require(components.percentEncodedQuery) + #expect(query.contains("ordering=last_updated")) + } + + // MARK: Search decoding + + @Test("Search response decodes repos, namespace/repository split, and pull references") + func searchDecoding() throws { + let response = try DockerHubService.decoder.decode(HubSearchResponse.self, from: Fixtures.data(Fixtures.hubSearch)) + #expect(response.results.count == 3) + + let nginx = try #require(response.results.first { $0.repoName == "nginx" }) + #expect(nginx.isOfficial) + #expect(nginx.starCount == 21318) + #expect(nginx.pullCount == 13_114_222_271) + #expect(nginx.namespace == "library") + #expect(nginx.repository == "nginx") + #expect(nginx.pullReference() == "docker.io/library/nginx:latest") + + let grafana = try #require(response.results.first { $0.repoName == "grafana/grafana" }) + #expect(grafana.namespace == "grafana") + #expect(grafana.repository == "grafana") + #expect(!grafana.isOfficial) + #expect(grafana.pullReference(tag: "10.0") == "docker.io/grafana/grafana:10.0") + + let cimg = try #require(response.results.first { $0.repoName == "cimg/postgres" }) + #expect(cimg.shortDescription == "") + } + + // MARK: Tags decoding + + @Test("Tags response decodes platforms, excluding unknown attestation manifests") + func tagsDecoding() throws { + let response = try DockerHubService.decoder.decode(HubTagsResponse.self, from: Fixtures.data(Fixtures.hubTags)) + #expect(response.results.count == 2) + + let latest = try #require(response.results.first { $0.name == "latest" }) + #expect(latest.images.count == 3) // raw manifests, including the "unknown" one + #expect(latest.platforms.count == 2) // "unknown" excluded + #expect(latest.platformSummary.contains("linux/amd64")) + #expect(latest.platformSummary.contains("linux/arm64/v8")) + #expect(!latest.platformSummary.contains("unknown")) + #expect(latest.displaySize != nil) + #expect(latest.lastUpdated == "2026-06-24T04:51:06.973034832Z") // survives as String + } + + // MARK: Service error mapping + + @Test func searchMapsHTTPErrorStatus() async throws { + let service = DockerHubService(client: MockHTTPClient(status: 503, body: "nope")) + + do { + _ = try await service.search(query: "x") + Issue.record("expected search() to throw") + } catch let error as HubError { + #expect(error == .http(status: 503)) + } catch { + Issue.record("unexpected error: \(error)") + } + } + + @Test func searchMapsDecodingFailure() async throws { + let service = DockerHubService(client: MockHTTPClient(status: 200, body: "{ not json")) + + do { + _ = try await service.search(query: "x") + Issue.record("expected search() to throw") + } catch let error as HubError { + guard case .decodingFailed = error else { + Issue.record("expected .decodingFailed, got \(error)") + return + } + } catch { + Issue.record("unexpected error: \(error)") + } + } + + @Test func searchSucceedsAndRecordsRequestedURL() async throws { + let mock = MockHTTPClient(status: 200, body: Fixtures.hubSearch) + let service = DockerHubService(client: mock) + + let results = try await service.search(query: "nginx") + + #expect(results.count == 3) + #expect(mock.lastURL?.host == "hub.docker.com") + } + + // MARK: Formatting.compactCount + + @Test func compactCount() { + #expect(Formatting.compactCount(999) == "999") + #expect(Formatting.compactCount(1_000) == "1K") + #expect(Formatting.compactCount(1_500) == "1.5K") + #expect(Formatting.compactCount(21_318) == "21.3K") + #expect(Formatting.compactCount(1_200_000) == "1.2M") + #expect(Formatting.compactCount(13_114_222_271) == "13.1B") + // Unit-boundary rounding: a value that rounds up to 1000 within a band + // must promote to the next unit, not render "1000M"/"1000K". + #expect(Formatting.compactCount(999_949) == "999.9K") + #expect(Formatting.compactCount(999_950) == "1M") + #expect(Formatting.compactCount(999_999) == "1M") + #expect(Formatting.compactCount(999_949_999) == "999.9M") + #expect(Formatting.compactCount(999_950_000) == "1B") + #expect(Formatting.compactCount(999_999_999) == "1B") + } + + // MARK: Formatting.relativeISO8601 + + @Test func relativeISO8601() { + #expect(Formatting.relativeISO8601("2026-06-24T04:51:06.973034832Z") != nil) // fractional seconds + #expect(Formatting.relativeISO8601("2026-06-24T04:51:00Z") != nil) // plain + #expect(Formatting.relativeISO8601("not a date") == nil) + #expect(Formatting.relativeISO8601(nil) == nil) + } +} diff --git a/Tests/Support/Fixtures.swift b/Tests/Support/Fixtures.swift index b2e36e7..7f1a10d 100644 --- a/Tests/Support/Fixtures.swift +++ b/Tests/Support/Fixtures.swift @@ -154,5 +154,41 @@ enum Fixtures { [ { "appName": "container", "buildType": "debug", "commit": "abcdef1", "version": "0.4.1" } ] """ + /// `GET /v2/search/repositories/?query=nginx` — official + two community repos. + static let hubSearch = """ + { + "count": 3, + "next": "https://hub.docker.com/v2/search/repositories/?page=2&page_size=3&query=nginx", + "previous": "", + "results": [ + { "repo_name": "nginx", "short_description": "Official build of Nginx.", "star_count": 21318, "pull_count": 13114222271, "repo_owner": "", "is_automated": false, "is_official": true }, + { "repo_name": "grafana/grafana", "short_description": "The open observability platform", "star_count": 456, "pull_count": 987654321, "repo_owner": "grafana", "is_automated": false, "is_official": false }, + { "repo_name": "cimg/postgres", "short_description": "", "star_count": 9, "pull_count": 719219989, "repo_owner": "cimg", "is_automated": false, "is_official": false } + ] + } + """ + + /// `GET /v2/repositories/library/nginx/tags/?ordering=last_updated` — one tag with + /// an attestation ("unknown") manifest to exclude, one single-platform tag. + static let hubTags = """ + { + "count": 1231, + "next": "https://hub.docker.com/v2/repositories/library/nginx/tags/?ordering=last_updated&page=2&page_size=2", + "previous": null, + "results": [ + { "creator": 1156886, "id": 987274600, "name": "latest", "full_size": 75271303, "last_updated": "2026-06-24T04:51:06.973034832Z", + "images": [ + { "architecture": "amd64", "variant": null, "digest": "sha256:306d9dea", "os": "linux", "size": 75271303, "status": "active", "last_pushed": "2026-06-24T04:51:06.973034832Z" }, + { "architecture": "arm64", "variant": "v8", "digest": "sha256:aa11bb22", "os": "linux", "size": 67000000, "status": "active", "last_pushed": "2026-06-24T04:51:07.402177787Z" }, + { "architecture": "unknown", "variant": null, "digest": "sha256:39ca2ad7", "os": "unknown", "size": 4265361, "status": "active", "last_pushed": "2026-06-24T04:51:07.402177787Z" } + ] }, + { "creator": 1156886, "id": 987274601, "name": "1.31", "full_size": 67000000, "last_updated": "2026-06-24T04:51:00Z", + "images": [ + { "architecture": "arm64", "variant": "v8", "digest": "sha256:cc33dd44", "os": "linux", "size": 67000000, "status": "active", "last_pushed": "2026-06-24T04:51:00Z" } + ] } + ] + } + """ + static func data(_ string: String) -> Data { Data(string.utf8) } } diff --git a/Tests/Support/MockHTTPClient.swift b/Tests/Support/MockHTTPClient.swift new file mode 100644 index 0000000..2b7cbf1 --- /dev/null +++ b/Tests/Support/MockHTTPClient.swift @@ -0,0 +1,30 @@ +import Foundation + +/// An `HTTPClient` test double: returns programmed responses and records the URLs +/// it was asked to fetch, so `DockerHubService` can be tested without a network. +final class MockHTTPClient: HTTPClient, @unchecked Sendable { + private let lock = NSLock() + private var _requestedURLs: [URL] = [] + + var responder: @Sendable (URL) throws -> (Data, HTTPURLResponse) + + init(responder: @escaping @Sendable (URL) throws -> (Data, HTTPURLResponse)) { + self.responder = responder + } + + /// Convenience: always reply with `body` at the given HTTP status. + convenience init(status: Int = 200, body: String) { + self.init(responder: { url in + let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil)! + return (Data(body.utf8), response) + }) + } + + var requestedURLs: [URL] { lock.lock(); defer { lock.unlock() }; return _requestedURLs } + var lastURL: URL? { lock.lock(); defer { lock.unlock() }; return _requestedURLs.last } + + func get(_ url: URL) async throws -> (Data, HTTPURLResponse) { + lock.lock(); _requestedURLs.append(url); lock.unlock() + return try responder(url) + } +}