diff --git a/Orchard/Models.swift b/Orchard/Models.swift index a3a05a8..463222d 100644 --- a/Orchard/Models.swift +++ b/Orchard/Models.swift @@ -419,6 +419,14 @@ struct ContainerImageDescriptor: Codable, Equatable { } } +// MARK: - Image Size Status + +enum ImageSizeStatus: Equatable { + case loading + case known(Int64) + case failed +} + // MARK: - Image Inspection Models struct ImageInspection { diff --git a/Orchard/Services/ImageService.swift b/Orchard/Services/ImageService.swift index 8f18efe..5c854cc 100644 --- a/Orchard/Services/ImageService.swift +++ b/Orchard/Services/ImageService.swift @@ -1,5 +1,150 @@ import Foundation import SwiftUI +import Darwin + +// MARK: - Host architecture (for picking image variant size) + +/// Resolved at first access. Honors Rosetta: a translated x86_64 process on Apple +/// Silicon still pulls arm64 container images, so ask the host before falling back to +/// the process slice. +let hostContainerArchitecture: String = { + var translated: Int32 = 0 + var size = MemoryLayout.size + let rc = sysctlbyname("sysctl.proc_translated", &translated, &size, nil, 0) + if rc == 0 && translated == 1 { + return "arm64" + } + + var machineSize: Int = 0 + sysctlbyname("hw.machine", nil, &machineSize, nil, 0) + guard machineSize > 0 else { return "arm64" } + var bytes = [CChar](repeating: 0, count: machineSize) + sysctlbyname("hw.machine", &bytes, &machineSize, nil, 0) + let raw = String(cString: bytes) + return raw.contains("arm64") ? "arm64" : "amd64" +}() + +/// Normalizes a user-provided image reference to the canonical form +/// `/[:tag|@digest]`. +func canonicalImageReference(_ ref: String) -> String { + let trimmed = ref.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + + let segments = trimmed.split(separator: "/", omittingEmptySubsequences: false) + let firstSegment = segments.first.map(String.init) ?? trimmed + let looksLikeRegistry = + segments.count > 1 && + (firstSegment.contains(".") || + firstSegment.contains(":") || + firstSegment == "localhost") + + if looksLikeRegistry { return trimmed } + if trimmed.contains("/") { return "docker.io/\(trimmed)" } + return "docker.io/library/\(trimmed)" +} + +/// Picks the variant matching this host's platform; falls back to the first variant. +func hostVariantSize(_ variants: [ImageInspection.Variant]) -> Int64 { + let target = "linux/\(hostContainerArchitecture)" + if let match = variants.first(where: { $0.platform == target }) { + return match.size + } + return variants.first?.size ?? 0 +} + +func dockerHubSearchURL(query: String, page: Int) -> URL? { + var components = URLComponents() + components.scheme = "https" + components.host = "hub.docker.com" + components.path = "/v2/search/repositories/" + components.queryItems = [ + URLQueryItem(name: "query", value: query), + URLQueryItem(name: "page_size", value: "25"), + URLQueryItem(name: "page", value: String(page)) + ] + return components.url +} + +private struct ImageInspectionTimeoutError: LocalizedError { + var errorDescription: String? { "Image inspection timed out" } +} + +private let imageInspectTimeoutNanoseconds: UInt64 = 20_000_000_000 + +private final class OneShotContinuation: @unchecked Sendable { + private let lock = NSLock() + private var didResume = false + + func resume(_ continuation: CheckedContinuation, with result: Result) -> Bool { + lock.lock() + guard !didResume else { + lock.unlock() + return false + } + didResume = true + lock.unlock() + + continuation.resume(with: result) + return true + } +} + +private func isCancellation(_ error: Error) -> Bool { + if error is CancellationError { return true } + return (error as? URLError)?.code == .cancelled +} + +private func inspectImageWithTimeout(reference: String, backend: ContainerBackend) async throws -> ImageInspection { + let race = OneShotContinuation() + + return try await withCheckedThrowingContinuation { continuation in + let inspectionTask = Task { + do { + let inspection = try await backend.inspectImage(reference: reference) + _ = race.resume(continuation, with: .success(inspection)) + } catch { + _ = race.resume(continuation, with: .failure(error)) + } + } + + Task { + do { + try await Task.sleep(nanoseconds: imageInspectTimeoutNanoseconds) + } catch { + return + } + + inspectionTask.cancel() + _ = race.resume(continuation, with: .failure(ImageInspectionTimeoutError())) + } + } +} + +// MARK: - Inspect concurrency limiter + +actor InspectGate { + private let limit: Int + private var inflight = 0 + private var waiters: [CheckedContinuation] = [] + + init(limit: Int = 4) { self.limit = limit } + + func enter() async { + if inflight < limit { + inflight += 1 + return + } + await withCheckedContinuation { continuation in waiters.append(continuation) } + } + + func leave() { + if waiters.isEmpty { + inflight -= 1 + } else { + waiters.removeFirst().resume() + } + } +} /// Owns image state and operations: listing, inspection, pulling, deletion, and Docker /// Hub search. Backed by the XPC image client plus a Docker Hub HTTP search. @@ -8,8 +153,18 @@ final class ImageService: ObservableObject { @Published var images: [ContainerImage] = [] @Published var isImagesLoading = false @Published var pullProgress: [String: ImagePullProgress] = [:] + @Published var imageSizes: [String: ImageSizeStatus] = [:] @Published var isSearching = false @Published var searchResults: [RegistrySearchResult] = [] + @Published var searchResultsHasMore = false + @Published var isLoadingMoreSearchResults = false + + private let inspectGate = InspectGate() + private let imageSizeRetryDelay: TimeInterval = 30 + private var imageSizeRetryAfter: [String: Date] = [:] + private var searchResultsPage = 0 + private var lastSearchQuery = "" + private var searchGeneration = 0 private let backend: ContainerBackend private let alertCenter: AlertCenter @@ -36,6 +191,15 @@ final class ImageService: ObservableObject { } } self.isImagesLoading = false + + let existingRefs = Set(newImages.map(\.reference)) + imageSizes = imageSizes.filter { ref, _ in + existingRefs.contains(ref) + } + imageSizeRetryAfter = imageSizeRetryAfter.filter { ref, _ in + existingRefs.contains(ref) + } + enrichImageSizes(for: newImages) } catch { self.alertCenter.error(error.localizedDescription, source: showLoading ? .user : .background) self.isImagesLoading = false @@ -47,8 +211,73 @@ final class ImageService: ObservableObject { try await backend.inspectImage(reference: reference) } + func sizeText(for image: ContainerImage) -> String { + switch imageSizes[image.reference] { + case .known(let size): + return ByteFormat.string(size) + case .loading, .none: + return "…" + case .failed: + return "—" + } + } + + func sortSize(for image: ContainerImage) -> Int64 { + if case .known(let size) = imageSizes[image.reference] { + return size + } + return Int64(image.descriptor.size) + } + + private func enrichImageSizes(for images: [ContainerImage]) { + let backend = backend + let inspectGate = inspectGate + let retryDelay = imageSizeRetryDelay + let now = Date() + + for image in images { + let ref = image.reference + switch imageSizes[ref] { + case .known, .loading: + continue + case .failed: + if let retryAt = imageSizeRetryAfter[ref], retryAt > now { + continue + } + case .none: + break + } + + imageSizes[ref] = .loading + Task { + await inspectGate.enter() + let result: ImageSizeStatus + do { + let inspection = try await inspectImageWithTimeout(reference: ref, backend: backend) + result = .known(hostVariantSize(inspection.variants)) + } catch { + result = .failed + } + await inspectGate.leave() + await MainActor.run { + self.imageSizes[ref] = result + if case .failed = result { + self.imageSizeRetryAfter[ref] = Date().addingTimeInterval(retryDelay) + } else { + self.imageSizeRetryAfter.removeValue(forKey: ref) + } + } + } + } + } + + func dismissPullProgress(_ imageName: String) { + pullProgress.removeValue(forKey: imageName) + } + func pull(_ imageName: String) async { - let cleanImageName = imageName.trimmingCharacters(in: .whitespacesAndNewlines) + let cleanImageName = canonicalImageReference(imageName) + guard !cleanImageName.isEmpty else { return } pullProgress[cleanImageName] = ImagePullProgress( imageName: cleanImageName, status: .pulling, progress: 0.0, message: "Pulling image..." @@ -57,12 +286,15 @@ final class ImageService: ObservableObject { do { try await backend.pullImage(reference: cleanImageName) - pullProgress[cleanImageName] = ImagePullProgress( + let completedProgress = ImagePullProgress( imageName: cleanImageName, status: .completed, progress: 1.0, message: "Pull completed successfully" ) + pullProgress[cleanImageName] = completedProgress Task { await self.load() } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - self.pullProgress.removeValue(forKey: cleanImageName) + if self.pullProgress[cleanImageName]?.id == completedProgress.id { + self.pullProgress.removeValue(forKey: cleanImageName) + } } } catch { let errorMsg = error.localizedDescription @@ -76,34 +308,103 @@ final class ImageService: ObservableObject { func search(_ query: String) async { guard !query.isEmpty else { searchResults = [] + searchResultsHasMore = false + isSearching = false + isLoadingMoreSearchResults = false + searchResultsPage = 0 + lastSearchQuery = "" + searchGeneration += 1 return } + searchGeneration += 1 + let generation = searchGeneration isSearching = true + searchResults = [] + searchResultsHasMore = false + isLoadingMoreSearchResults = false + searchResultsPage = 0 + lastSearchQuery = query - do { - let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query - let urlString = "https://hub.docker.com/v2/search/repositories/?query=\(encodedQuery)&page_size=25" + await fetchSearchPage(query: query, page: 1, append: false, generation: generation) + if generation == searchGeneration { + isSearching = false + } + } + + func loadMoreSearchResults() async { + guard !lastSearchQuery.isEmpty, + searchResultsHasMore, + !isLoadingMoreSearchResults + else { return } - guard let url = URL(string: urlString) else { - isSearching = false - self.alertCenter.error("Invalid search query") + isLoadingMoreSearchResults = true + let query = lastSearchQuery + let generation = searchGeneration + let page = searchResultsPage + 1 + await fetchSearchPage(query: query, page: page, append: true, generation: generation) + if generation == searchGeneration { + isLoadingMoreSearchResults = false + } + } + + private func fetchSearchPage(query: String, page: Int, append: Bool, generation: Int) async { + do { + guard let url = dockerHubSearchURL(query: query, page: page) else { + if !append { searchResults = [] } + searchResultsHasMore = false + alertCenter.error("Invalid search query") return } let (data, _) = try await URLSession.shared.data(from: url) - let results = parseDockerHubSearch(data: data) - self.searchResults = results - self.isSearching = false + guard generation == searchGeneration, query == lastSearchQuery, !Task.isCancelled else { + return + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let results = json["results"] as? [[String: Any]] else { + if !append { searchResults = [] } + searchResultsHasMore = false + return + } + + let newResults: [RegistrySearchResult] = results.compactMap { result in + guard let name = result["repo_name"] as? String else { return nil } + let fullName = name.contains("/") ? "docker.io/\(name)" : "docker.io/library/\(name)" + + return RegistrySearchResult( + name: fullName, + description: result["short_description"] as? String, + isOfficial: (result["is_official"] as? Bool) ?? false, + starCount: result["star_count"] as? Int + ) + } + + if append { + searchResults.append(contentsOf: newResults) + } else { + searchResults = newResults + } + searchResultsHasMore = json["next"] as? String != nil + searchResultsPage = page } catch { - self.alertCenter.error("Failed to search images: \(error.localizedDescription)") - self.isSearching = false - self.searchResults = [] + guard generation == searchGeneration, query == lastSearchQuery, !isCancellation(error), !Task.isCancelled else { + return + } + alertCenter.error("Failed to search images: \(error.localizedDescription)") + if !append { searchResults = [] } + searchResultsHasMore = false } } func clearSearchResults() { searchResults = [] + searchResultsHasMore = false + isLoadingMoreSearchResults = false + searchResultsPage = 0 + lastSearchQuery = "" + searchGeneration += 1 } func delete(_ imageReference: String) async { diff --git a/Orchard/Views/Features/Containers/ContainerDetail.swift b/Orchard/Views/Features/Containers/ContainerDetail.swift index eeee9b7..70da69b 100644 --- a/Orchard/Views/Features/Containers/ContainerDetail.swift +++ b/Orchard/Views/Features/Containers/ContainerDetail.swift @@ -551,7 +551,7 @@ struct ContainerImageDetailView: View { InfoRow(label: "Tag", value: imageTag) InfoRow( label: "Size", - value: ByteFormat.string(image.descriptor.size)) + value: imageService.sizeText(for: image)) if let created = createdDate { InfoRow(label: "Created", value: formatDate(created)) } diff --git a/Orchard/Views/Features/Containers/RunContainer.swift b/Orchard/Views/Features/Containers/RunContainer.swift index 0054c43..578b2b1 100644 --- a/Orchard/Views/Features/Containers/RunContainer.swift +++ b/Orchard/Views/Features/Containers/RunContainer.swift @@ -3,34 +3,66 @@ import AppKit struct RunContainerView: View { @EnvironmentObject var containerListService: ContainerListService + @EnvironmentObject var imageService: ImageService @Environment(\.dismiss) var dismiss let imageName: String + let allowsImageSelection: Bool @State private var config: ContainerRunConfig @State private var isRunning = false @State private var nameValidationError: String? init(imageName: String) { self.imageName = imageName + self.allowsImageSelection = false - // Generate a default container name from the image - let cleanName = imageName - .replacingOccurrences(of: "docker.io/library/", with: "") - .replacingOccurrences(of: "docker.io/", with: "") - .split(separator: ":").first.map(String.init) ?? "container" + _config = State(initialValue: ContainerRunConfig( + name: RunContainerView.derivedName(from: imageName), + image: imageName + )) + } + + /// Picker mode: no preselected image; user picks/filters from local images + /// or pastes any reference. + init() { + self.imageName = "" + self.allowsImageSelection = true + + _config = State(initialValue: ContainerRunConfig(name: "", image: "")) + } + + private static func derivedName(from imageRef: String) -> String { + let trimmed = imageRef.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "container" } - _config = State(initialValue: ContainerRunConfig(name: cleanName, image: imageName)) + var s = trimmed + if let at = s.firstIndex(of: "@") { s = String(s[..? + @State private var directReference: String = "" @FocusState private var isSearchFieldFocused: Bool + private var trimmedDirectReference: String { + directReference.trimmingCharacters(in: .whitespacesAndNewlines) + } + var body: some View { VStack(spacing: 0) { - // Search header searchHeader Divider() - // Search results or empty state if searchQuery.isEmpty && imageService.searchResults.isEmpty { emptySearchState } else if imageService.isSearching { @@ -27,14 +30,8 @@ struct ImageSearchView: View { } else { emptySearchState } - - // Active pulls section - if !imageService.pullProgress.isEmpty { - Divider() - activePullsSection - } } - .frame(width: 920, height: 600) + .frame(minWidth: 720, idealWidth: 920, minHeight: 560, idealHeight: 640) .onAppear { isSearchFieldFocused = true } @@ -69,16 +66,21 @@ struct ImageSearchView: View { .buttonStyle(.borderless) } - // Search field HStack { SwiftUI.Image(systemName: "magnifyingglass") .foregroundColor(.secondary) - TextField("Search for images (e.g., nginx, postgres, alpine)...", text: $searchQuery) + TextField("Search for images...", text: $searchQuery) .textFieldStyle(.plain) .focused($isSearchFieldFocused) - .onSubmit { - performSearch() + .onSubmit(performSearchImmediately) + .onChange(of: searchQuery) { _, newValue in + if newValue.isEmpty { + searchTask?.cancel() + imageService.clearSearchResults() + } else { + performSearch() + } } if !searchQuery.isEmpty { @@ -92,9 +94,7 @@ struct ImageSearchView: View { .buttonStyle(.plain) } - Button(action: { - performSearch() - }) { + Button(action: performSearchImmediately) { Text("Search") .fontWeight(.medium) } @@ -108,11 +108,39 @@ struct ImageSearchView: View { RoundedRectangle(cornerRadius: 8) .stroke(Color.accentColor.opacity(0.3), lineWidth: 1) ) + + HStack(spacing: 8) { + Text("Or pull by reference:") + .font(.caption) + .foregroundColor(.secondary) + + TextField("e.g. ghcr.io/apple/containerization/vminit:0.33.3", text: $directReference) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .onSubmit(pullByReference) + + Button("Pull") { + pullByReference() + } + .controlSize(.small) + .disabled(trimmedDirectReference.isEmpty) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(NSColor.textBackgroundColor).opacity(0.5)) + .cornerRadius(6) } .padding() .background(Color(NSColor.controlBackgroundColor)) } + private func pullByReference() { + let ref = trimmedDirectReference + guard !ref.isEmpty else { return } + Task { await imageService.pull(ref) } + directReference = "" + } + private var emptySearchState: some View { VStack(spacing: 20) { SwiftUI.Image(systemName: "magnifyingglass.circle") @@ -196,67 +224,66 @@ struct ImageSearchView: View { private var searchResultsList: some View { ScrollView { - LazyVGrid(columns: Array(repeating: GridItem(.fixed(280), spacing: 16), count: 3), spacing: 16) { - ForEach(imageService.searchResults.prefix(12)) { result in + LazyVGrid(columns: [GridItem(.adaptive(minimum: 260, maximum: 320), spacing: 16)], spacing: 16) { + ForEach(imageService.searchResults) { result in SearchResultRow(result: result) } } .padding(20) - } - .frame(width: 900, height: 400) - } - - private var activePullsSection: some View { - VStack(spacing: 0) { - HStack { - Text("Active Downloads") - .font(.headline) - .foregroundColor(.secondary) - Spacer() - } - .padding() - .background(Color(NSColor.controlBackgroundColor)) - ForEach(Array(imageService.pullProgress.values), id: \.id) { progress in - PullProgressRow(progress: progress) - .padding(.horizontal) - .padding(.vertical, 8) + if imageService.searchResultsHasMore { + ProgressView() + .padding(.vertical, 16) + .frame(maxWidth: .infinity) + .onAppear { + Task { await imageService.loadMoreSearchResults() } + } } } + .frame(maxWidth: .infinity, maxHeight: .infinity) } private func performSearch() { - // Cancel any existing search searchTask?.cancel() - // Start new search with debounce searchTask = Task { - try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 second debounce + try? await Task.sleep(nanoseconds: 300_000_000) if !Task.isCancelled { await imageService.search(searchQuery) } } } + + /// Skip the typing debounce and fire the current query right away. + /// Used by Enter key and the Search button - explicit user intent. + private func performSearchImmediately() { + searchTask?.cancel() + let query = searchQuery + guard !query.isEmpty else { return } + searchTask = Task { await imageService.search(query) } + } } struct SearchResultRow: View { @EnvironmentObject var imageService: ImageService let result: RegistrySearchResult @State private var isHovered = false - @State private var showRunContainer = false private var isPulling: Bool { - imageService.pullProgress[result.name] != nil + imageService.pullProgress[result.name]?.status == .pulling } private var isAlreadyPulled: Bool { - imageService.images.contains { $0.reference.contains(result.displayName) } + imageService.images.contains { image in + image.reference == result.name + || image.reference.hasPrefix(result.name + ":") + || image.reference.hasPrefix(result.name + "@") + } } var body: some View { VStack(alignment: .leading, spacing: 8) { - // Header with icon and name HStack(spacing: 8) { SwiftUI.Image(systemName: result.isOfficial ? "checkmark.seal.fill" : "cube.transparent") .font(.title3) @@ -269,7 +296,6 @@ struct SearchResultRow: View { .lineLimit(1) .truncationMode(.tail) - // Metadata HStack(spacing: 6) { if result.isOfficial { Text("Official") @@ -296,7 +322,6 @@ struct SearchResultRow: View { } } - // Description if let description = result.description, !description.isEmpty { Text(description) .font(.system(size: 11)) @@ -308,23 +333,21 @@ struct SearchResultRow: View { Spacer(minLength: 4) - // Pull/Run button if isPulling { ProgressView() .scaleEffect(0.7) .frame(height: 24) } else if isAlreadyPulled { - Button(action: { - showRunContainer = true - }) { - Text("Run") + HStack(spacing: 4) { + SwiftUI.Image(systemName: "checkmark.circle.fill") + .font(.system(size: 11)) + Text("Already pulled") .font(.system(size: 11, weight: .medium)) - .foregroundColor(.white) - .frame(maxWidth: .infinity, minHeight: 24) - .background(Color.green) - .cornerRadius(4) } - .buttonStyle(.plain) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, minHeight: 24) + .background(Color.secondary.opacity(0.1)) + .cornerRadius(4) } else { Button(action: { Task { @@ -354,14 +377,12 @@ struct SearchResultRow: View { isHovered = hovered } } - .sheet(isPresented: $showRunContainer) { - RunContainerView(imageName: result.name) - } } } struct PullProgressRow: View { let progress: ImagePullProgress + var onDismiss: (() -> Void)? = nil var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -373,10 +394,15 @@ struct PullProgressRow: View { Text(progress.imageName) .font(.subheadline) .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.middle) Text(progress.message) .font(.caption) .foregroundColor(.secondary) + .lineLimit(2) + .truncationMode(.tail) + .help(progress.message) } Spacer() @@ -385,10 +411,20 @@ struct PullProgressRow: View { ProgressView() .scaleEffect(0.7) } + + if let onDismiss { + Button(action: onDismiss) { + SwiftUI.Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .help("Dismiss") + } } if progress.status == .pulling { - ProgressView(value: progress.progress) + ProgressView() .progressViewStyle(.linear) } } diff --git a/Orchard/Views/Features/Images/ListImages.swift b/Orchard/Views/Features/Images/ListImages.swift index e21c7a7..0d7be8c 100644 --- a/Orchard/Views/Features/Images/ListImages.swift +++ b/Orchard/Views/Features/Images/ListImages.swift @@ -14,6 +14,10 @@ struct ImagesListView: View { var body: some View { VStack(spacing: 0) { + if !imageService.pullProgress.isEmpty { + pullProgressBanner + Divider() + } imagesList } .sheet(isPresented: $showImageSearch) { @@ -21,6 +25,23 @@ struct ImagesListView: View { } } + private var pullProgressBanner: some View { + VStack(spacing: 6) { + // Sort by imageName so the row order is stable across status + // updates — Dictionary.values has no guaranteed iteration order. + ForEach( + imageService.pullProgress.values.sorted { $0.imageName < $1.imageName }, + id: \.id + ) { progress in + PullProgressRow(progress: progress) { + imageService.dismissPullProgress(progress.imageName) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + private var imagesList: some View { List(selection: $selectedImage) { ForEach(Array(filteredImages), id: \.reference) { image in @@ -38,7 +59,7 @@ struct ImagesListView: View { private func imageRowView(for image: ContainerImage) -> some View { let imageName = imageName(from: image.reference) let imageTag = imageTag(from: image.reference) - let sizeText = ByteFormat.string(image.descriptor.size) + let sizeText = imageService.sizeText(for: image) return ListItemRow( icon: "cube.transparent", @@ -113,7 +134,11 @@ struct ImagesListView: View { case .tag: filtered.sort { ascending ? imageTag(from: $0.reference) < imageTag(from: $1.reference) : imageTag(from: $0.reference) > imageTag(from: $1.reference) } case .size: - filtered.sort { ascending ? $0.descriptor.size < $1.descriptor.size : $0.descriptor.size > $1.descriptor.size } + filtered.sort { + ascending + ? imageService.sortSize(for: $0) < imageService.sortSize(for: $1) + : imageService.sortSize(for: $0) > imageService.sortSize(for: $1) + } } return filtered diff --git a/Orchard/Views/Layout/ThreeColumnLayout.swift b/Orchard/Views/Layout/ThreeColumnLayout.swift index 561ff1a..e228e22 100644 --- a/Orchard/Views/Layout/ThreeColumnLayout.swift +++ b/Orchard/Views/Layout/ThreeColumnLayout.swift @@ -12,6 +12,8 @@ struct ThreeColumnLayout: View { @AppStorage("containerRunningFirst") private var containerRunningFirst: Bool = true @AppStorage("imageSortBy") private var imageSortBy: ImageSortOption = .name @AppStorage("imageSortAscending") private var imageSortAscending: Bool = true + @State private var splitVisibility: NavigationSplitViewVisibility = .all + @State private var showRunContainerSheet: Bool = false @Binding var selectedTab: TabSelection @Binding var selectedContainer: String? @Binding var selectedContainers: Set @@ -51,9 +53,13 @@ struct ThreeColumnLayout: View { } } + private var sidebarCollapsed: Bool { + splitVisibility == .doubleColumn || splitVisibility == .detailOnly + } + var body: some View { if needsMiddleColumn { - NavigationSplitView { + NavigationSplitView(columnVisibility: $splitVisibility) { // First Column - Sidebar with navigation tabs TabColumnView( selectedTab: $selectedTab, @@ -196,10 +202,18 @@ struct ThreeColumnLayout: View { Spacer() // Add resource button for tabs that support it - if selectedTab == .images { + if selectedTab == .containers { + Button(action: { showRunContainerSheet = true }) { + SwiftUI.Image(systemName: "plus") + .foregroundColor(.primary) + .font(.system(size: 14, weight: .medium)) + } + .buttonStyle(.plain) + .help("Run Container") + } else if selectedTab == .images { Button(action: { showImageSearch = true }) { SwiftUI.Image(systemName: "plus") - .foregroundColor(.white) + .foregroundColor(.primary) .font(.system(size: 14, weight: .medium)) } .buttonStyle(.plain) @@ -207,7 +221,7 @@ struct ThreeColumnLayout: View { } else if selectedTab == .dns { Button(action: { showAddDNSDomainSheet = true }) { SwiftUI.Image(systemName: "plus") - .foregroundColor(.white) + .foregroundColor(.primary) .font(.system(size: 14, weight: .medium)) } .buttonStyle(.plain) @@ -215,7 +229,7 @@ struct ThreeColumnLayout: View { } else if selectedTab == .networks { Button(action: { showAddNetworkSheet = true }) { SwiftUI.Image(systemName: "plus") - .foregroundColor(.white) + .foregroundColor(.primary) .font(.system(size: 14, weight: .medium)) } .buttonStyle(.plain) @@ -233,7 +247,7 @@ struct ThreeColumnLayout: View { } } .padding(.horizontal, 16) - .padding(.top, 20) + .padding(.top, sidebarCollapsed ? 52 : 20) .padding(.bottom, 12) ListColumnView( @@ -266,6 +280,9 @@ struct ThreeColumnLayout: View { } .ignoresSafeArea(.container, edges: .top) .navigationSplitViewColumnWidth(min: 300, ideal: 400, max: 500) + .sheet(isPresented: $showRunContainerSheet) { + RunContainerView() + } } detail: { // Third Column - Detail view for selected item DetailContentView( @@ -287,7 +304,7 @@ struct ThreeColumnLayout: View { .ignoresSafeArea(.container, edges: .top) } } else { - NavigationSplitView { + NavigationSplitView(columnVisibility: $splitVisibility) { // First Column - Sidebar with navigation tabs TabColumnView( selectedTab: $selectedTab, diff --git a/OrchardTests/ImageServiceTests.swift b/OrchardTests/ImageServiceTests.swift index 58ec2b3..51d79cd 100644 --- a/OrchardTests/ImageServiceTests.swift +++ b/OrchardTests/ImageServiceTests.swift @@ -47,15 +47,15 @@ func imageLoadFailure(_ c: (showLoading: Bool, expectsAlert: Bool)) async { // MARK: - pull @MainActor -@Test("Images pull: success marks progress completed and pulls the trimmed reference") +@Test("Images pull: success marks progress completed and pulls the canonical reference") func imagePullSuccess() async { let backend = MockContainerBackend() let (service, alert) = makeImageService(backend) await service.pull(" nginx:latest ") // leading/trailing space trimmed - #expect(backend.pulledReferences == ["nginx:latest"]) - #expect(service.pullProgress["nginx:latest"]?.status == .completed) + #expect(backend.pulledReferences == ["docker.io/library/nginx:latest"]) + #expect(service.pullProgress["docker.io/library/nginx:latest"]?.status == .completed) #expect(alert.current == nil) } @@ -69,12 +69,37 @@ func imagePullFailureAlerts() async { await service.pull("nginx:latest") // Match the case, not the (localized, brittle) message. - if case .failed = service.pullProgress["nginx:latest"]?.status {} else { - Issue.record("expected .failed pull status, got \(String(describing: service.pullProgress["nginx:latest"]?.status))") + if case .failed = service.pullProgress["docker.io/library/nginx:latest"]?.status {} else { + Issue.record("expected .failed pull status, got \(String(describing: service.pullProgress["docker.io/library/nginx:latest"]?.status))") } #expect(alert.current != nil) } +@Test("Image references canonicalize Docker Hub names and preserve explicit registries") +func imageReferenceCanonicalization() { + #expect(canonicalImageReference("nginx") == "docker.io/library/nginx") + #expect(canonicalImageReference("nginx:latest") == "docker.io/library/nginx:latest") + #expect(canonicalImageReference("bitnami/redis:7") == "docker.io/bitnami/redis:7") + #expect(canonicalImageReference("ghcr.io/acme/app:1") == "ghcr.io/acme/app:1") + #expect(canonicalImageReference("localhost:5000/acme/app:1") == "localhost:5000/acme/app:1") + #expect(canonicalImageReference("registry.example.com:5000/repo:1") == "registry.example.com:5000/repo:1") + #expect(canonicalImageReference("nginx@sha256:abc123") == "docker.io/library/nginx@sha256:abc123") +} + +@Test("Docker Hub search URL escapes reserved query delimiters") +func dockerHubSearchURLEncoding() throws { + let url = try #require(dockerHubSearchURL(query: "foo&page=999=a b", page: 2)) + 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/") + #expect(components.queryItems?.first(where: { $0.name == "query" })?.value == "foo&page=999=a b") + #expect(components.queryItems?.first(where: { $0.name == "page_size" })?.value == "25") + #expect(components.queryItems?.first(where: { $0.name == "page" })?.value == "2") + #expect(url.absoluteString.contains("query=foo%26page%3D999%3Da%20b")) +} + // MARK: - delete @MainActor @@ -115,6 +140,7 @@ func imageSearchEmptyQueryClears() async { await service.search("") #expect(service.searchResults.isEmpty) + #expect(service.searchResultsHasMore == false) // KNOWN-ISSUE (2026-07-04): search(_:) hardcodes URLSession.shared with no transport // seam, so the guard can't be verified to skip the network, and the non-empty query // path isn't unit-testable without hitting hub.docker.com.