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
7 changes: 7 additions & 0 deletions App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Observation
enum SidebarItem: String, CaseIterable, Identifiable, Hashable {
case containers
case images
case explore
case volumes
case networks
case system
Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions App/CommandPalette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
92 changes: 90 additions & 2 deletions App/Sidebar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
130 changes: 130 additions & 0 deletions Core/Models/DockerHub.swift
Original file line number Diff line number Diff line change
@@ -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[..<slash])
}

/// The bare repository name (`nginx` from `nginx/nginx-ingress`).
var repository: String {
guard let slash = repoName.firstIndex(of: "/") else { return repoName }
return String(repoName[repoName.index(after: slash)...])
}

/// Fully-qualified reference for `container image pull`, e.g.
/// `docker.io/library/nginx:latest`.
func pullReference(tag: String = "latest") -> 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"
}
}
47 changes: 47 additions & 0 deletions Core/Models/Formatting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading