diff --git a/.swiftlint.yml b/.swiftlint.yml index b666f39..c348f34 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -9,7 +9,7 @@ excluded: analyzer_rules: - unused_declaration - unused_import -enabled_rules: +opt_in_rules: - accessibility_label_for_image - accessibility_trait_for_button - array_init @@ -104,8 +104,6 @@ enabled_rules: - unneeded_parentheses_in_closure_argument - unowned_variable_capture - untyped_error_in_catch - - unused_declaration - - unused_import - unused_parameter - vertical_parameter_alignment_on_call - vertical_whitespace_opening_braces @@ -176,8 +174,6 @@ number_separator: minimum_length: 5 redundant_type_annotation: consider_default_literal_types_redundant: true -trailing_comma: - mandatory_comma: true type_body_length: 400 unneeded_override: affect_initializers: true diff --git a/Bulkhead.xcodeproj/project.pbxproj b/Bulkhead.xcodeproj/project.pbxproj index f400dea..b50b1de 100644 --- a/Bulkhead.xcodeproj/project.pbxproj +++ b/Bulkhead.xcodeproj/project.pbxproj @@ -278,7 +278,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 0.0.5; + MARKETING_VERSION = 0.0.6; PRODUCT_BUNDLE_IDENTIFIER = co.fwoar.dockerui.DockerUI; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -310,7 +310,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 0.0.5; + MARKETING_VERSION = 0.0.6; PRODUCT_BUNDLE_IDENTIFIER = co.fwoar.dockerui.DockerUI; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/Bulkhead/Docker/DockerExecutor.swift b/Bulkhead/Docker/DockerExecutor.swift index b9615a1..9dd65ab 100644 --- a/Bulkhead/Docker/DockerExecutor.swift +++ b/Bulkhead/Docker/DockerExecutor.swift @@ -1,6 +1,4 @@ import Foundation -import Network -import SwiftUI // For URL struct DockerHTTPRequest { let path: String diff --git a/Bulkhead/Docker/DockerManager.swift b/Bulkhead/Docker/DockerManager.swift index 7f70822..c29bbd6 100644 --- a/Bulkhead/Docker/DockerManager.swift +++ b/Bulkhead/Docker/DockerManager.swift @@ -1,6 +1,5 @@ import Combine import Foundation -import SwiftUI class DockerPublication: ObservableObject { @Published var containers: [DockerContainer] = [] @@ -39,10 +38,10 @@ class DockerPublication: ObservableObject { clearImageListError() } - @MainActor - func updateSocketPath(_ new: String) { - socketPath = new - } + // @MainActor + // func updateSocketPath(_ new: String) { + // socketPath = new + // } @MainActor func clearContainerListError() { @@ -146,12 +145,12 @@ class DockerManager { return enriched } - func clearEnrichmentCache() { - // Write operation with barrier - cacheQueue.async(flags: .barrier) { - self.enrichmentCache.removeAll() - } - } + // func clearEnrichmentCache() { + // // Write operation with barrier + // cacheQueue.async(flags: .barrier) { + // self.enrichmentCache.removeAll() + // } + // } // Now async again to await the Task from tryCommand func fetchImages() async { diff --git a/Bulkhead/Docker/LogFetcher.swift b/Bulkhead/Docker/LogFetcher.swift index 478a8f8..440d276 100644 --- a/Bulkhead/Docker/LogFetcher.swift +++ b/Bulkhead/Docker/LogFetcher.swift @@ -1,5 +1,3 @@ -import Foundation - final class LogFetcher { private let executor: DockerExecutor diff --git a/Bulkhead/Docker/LogManager.swift b/Bulkhead/Docker/LogManager.swift index 8599d35..49542e7 100644 --- a/Bulkhead/Docker/LogManager.swift +++ b/Bulkhead/Docker/LogManager.swift @@ -1,5 +1,4 @@ import Foundation -import OSLog import os.log func getDateFormatter() -> DateFormatter { diff --git a/Bulkhead/UI/ContainerDetailView.swift b/Bulkhead/UI/ContainerDetailView.swift index d8a5240..3719896 100644 --- a/Bulkhead/UI/ContainerDetailView.swift +++ b/Bulkhead/UI/ContainerDetailView.swift @@ -1,10 +1,5 @@ import SwiftUI -struct FilesystemLocation: Hashable { - let container: DockerContainer - let path: String -} - extension ContainerState { fileprivate var color: Color { switch self { @@ -35,38 +30,6 @@ extension ContainerState { } } -extension HealthStatus { - fileprivate var color: Color { - switch self { - case .healthy: return .green - case .unhealthy: return .red - case .starting: return .orange - case .none: return .secondary - case .unknown: return .secondary - } - } - - fileprivate var icon: String { - switch self { - case .healthy: return "checkmark.circle.fill" - case .unhealthy: return "xmark.circle.fill" - case .starting: return "arrow.triangle.2.circlepath.circle.fill" - case .none: return "minus.circle.fill" - case .unknown: return "questionmark.circle.fill" - } - } - - fileprivate var label: String { - switch self { - case .healthy: return "Healthy" - case .unhealthy: return "Unhealthy" - case .starting: return "Starting" - case .none: return "No Health Check" - case .unknown: return "Unknown" - } - } -} - struct ContainerDetailViewInner: View { @EnvironmentObject var publication: DockerPublication private let appEnv: ApplicationEnvironment @@ -74,7 +37,7 @@ struct ContainerDetailViewInner: View { @StateObject private var model: ContainerDetailModel let container: DockerContainer @State private var selectedPath: String? - @Environment(\.colorScheme) var colorScheme + @Environment(\.isGlobalErrorShowing) private var isGlobalErrorShowing private var manager: DockerManager { appEnv.manager } @@ -255,21 +218,6 @@ struct ContainerDetailViewInner: View { } } - @ViewBuilder - private func pathDetailRow(_ label: String, _ paths: [String]) -> some View { - HStack(alignment: .firstTextBaseline) { - Text("\(label):") - .fontWeight(.semibold) - .frame(width: 200, alignment: .leading) - VStack(alignment: .leading) { - ForEach(paths, id: \.self) { path in - Text(path) - .font(.system(.body, design: .monospaced)) - } - } - } - } - @ViewBuilder private func environmentSection(_ env: [String]) -> some View { sectionHeader("Environment") @@ -299,6 +247,7 @@ struct ContainerDetailViewInner: View { } } else { envDetailRow(key, value) + } } else { envDetailRow("", envVar) @@ -328,7 +277,6 @@ struct ContainerDetailView: View { let container: DockerContainer let appEnv: ApplicationEnvironment @EnvironmentObject var logManager: LogManager - private var manager: DockerManager { appEnv.manager } var body: some View { ContainerDetailViewInner(appEnv: appEnv, container: container, logManager: logManager) diff --git a/Bulkhead/UI/ContainerListView.swift b/Bulkhead/UI/ContainerSummaryView.swift similarity index 56% rename from Bulkhead/UI/ContainerListView.swift rename to Bulkhead/UI/ContainerSummaryView.swift index 71ffcc4..ae93816 100644 --- a/Bulkhead/UI/ContainerListView.swift +++ b/Bulkhead/UI/ContainerSummaryView.swift @@ -1,72 +1,30 @@ import Foundation import SwiftUI -struct ContainerListView: View { - @Environment(\.openWindow) private var openWindow - @EnvironmentObject var publication: DockerPublication - let containers: [DockerContainer] - @Binding var selectedContainer: DockerContainer? - @Binding var searchFocused: Bool +struct ContainerSummaryView: View { + var container: DockerContainer - var backgroundColor: Color - var shadowColor: Color let manager: DockerManager let appEnv: ApplicationEnvironment - private var containerSearchConfig: SearchConfiguration { - SearchConfiguration( - placeholder: "Search containers...", - filter: { container, query in - let searchQuery = query.lowercased() - // Search in container name - if let name = container.names.first?.lowercased(), name.contains(searchQuery) { - return true - } - // Search in image name - if container.image.lowercased().contains(searchQuery) { - return true - } - // Search in status - return container.status.lowercased().contains(searchQuery) - } - ) - } - var body: some View { - ListView( - items: containers, - selectedItem: $selectedContainer, - backgroundColor: backgroundColor, - shadowColor: shadowColor, - searchConfig: containerSearchConfig, - listError: publication.containerListError, - listErrorTitle: "Failed to Load Containers", - searchFocused: $searchFocused - ) { container in - // Type erase the content view - - HStack { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Text(container.names.first ?? "Unnamed") - .font(.headline) - } - if container.status.lowercased().contains("up") { - StatusBadgeView(text: container.status, color: .green) - } else { - StatusBadgeView(text: container.status, color: .secondary) - } - Text(container.image) - .font(.subheadline) - .foregroundStyle(.secondary) + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(container.names.first ?? "Unnamed") + .font(.headline) + } + if container.status.lowercased().contains("up") { + StatusBadgeView(text: container.status, color: .green) + } else { + StatusBadgeView(text: container.status, color: .secondary) } - Spacer() - ContainerActionsView(container: container, manager: manager) + Text(container.image) + .font(.subheadline) + .foregroundStyle(.secondary) } - - } detail: { container in - // Type erase the detail view - ContainerDetailView(container: container, appEnv: appEnv) + Spacer() + ContainerActionsView(container: container, manager: manager) } } } @@ -96,7 +54,7 @@ struct ContainerActionsView: View { @Environment(\.openWindow) private var openWindow let container: DockerContainer let manager: DockerManager - @EnvironmentObject var publication: DockerPublication // Use ObservedObject if manager might change + @State private var isActionPending = false // State for loading indicator var body: some View { diff --git a/Bulkhead/UI/ContentView.swift b/Bulkhead/UI/ContentView.swift index 80206fc..0244452 100644 --- a/Bulkhead/UI/ContentView.swift +++ b/Bulkhead/UI/ContentView.swift @@ -1,6 +1,12 @@ import Foundation import SwiftUI +// Define focus states outside the view struct +enum ListViewFocusTarget: Hashable { + case search + case item(AnyHashable) +} + // Define Environment Key for Global Error State struct IsGlobalErrorShowingKey: EnvironmentKey { static let defaultValue = false @@ -13,116 +19,165 @@ extension EnvironmentValues { } } +enum MainTabs { + case containers + case images +} + struct ContentView: View { // Observe publication directly @EnvironmentObject var publication: DockerPublication // Receive manager via init let manager: DockerManager let appEnv: ApplicationEnvironment - @Binding var selectedTab: Int + @Binding var selectedTab: MainTabs @Environment(\.colorScheme) private var colorScheme + @FocusState.Binding var focusState: ListViewFocusTarget? + @State private var selectedContainer: DockerContainer? + @State private var lastContainer: DockerContainer? = nil + @State private var containerSearchText = "" + @State private var selectedImage: DockerImage? - @Binding private var searchFocused: Bool + @State private var lastImage: DockerImage? = nil + @State private var imageSearchText = "" + + var filteredContainers: [DockerContainer] { + publication.containers.filter { it in + guard containerSearchText != "" else { return true } + guard let firstName = it.names.first else { return false } + return firstName.contains(containerSearchText) + } + } + + var filteredImages: [DockerImage] { + publication.images.filter { it in + guard imageSearchText != "" else { return true } + guard let firstTag = it.RepoTags?.first else { return false } + return firstTag.contains(imageSearchText) + } + } // Updated init init( - selectedTab: Binding, searchFocused: Binding, manager: DockerManager, - appEnv: ApplicationEnvironment + selectedTab: Binding, + manager: DockerManager, + appEnv: ApplicationEnvironment, + focusState: FocusState.Binding ) { self.manager = manager self.appEnv = appEnv _selectedTab = selectedTab - _searchFocused = searchFocused - } - - private var backgroundColor: Color { - colorScheme == .dark ? Color(NSColor.controlBackgroundColor) : Color.white - } - - private var shadowColor: Color { - colorScheme == .dark ? Color.black.opacity(0.2) : Color.black.opacity(0.05) + _focusState = focusState } // Get errors from observed publication - private var globalConnectionError: DockerError? { - if let err = publication.containerListError, err.isConnectionError { return err } - if let err = publication.imageListError, err.isConnectionError { return err } - return nil - } - - private var containerListView: some View { - ContainerListView( - // Pass data array from publication - containers: publication.containers, - selectedContainer: $selectedContainer, - searchFocused: $searchFocused, - backgroundColor: backgroundColor, - shadowColor: shadowColor, - // Pass manager instance - manager: manager, - appEnv: appEnv - ) - } - - private var imageListView: some View { - ImageListView( - backgroundColor: backgroundColor, - shadowColor: shadowColor, - // Pass data array from publication - images: publication.images, - searchFocused: $searchFocused, - selectedImage: $selectedImage, - // Pass manager instance - manager: manager, - appEnv: appEnv - ) - } + // private var globalConnectionError: DockerError? { + // if let err = publication.containerListError, err.isConnectionError { return err } + // if let err = publication.imageListError, err.isConnectionError { return err } + // return nil + // } var body: some View { // Main content with potential error overlay - ZStack { + NavigationSplitView { TabView(selection: $selectedTab) { - // Container List View - containerListView - .tabItem { - Label("Containers", systemImage: "shippingbox.fill") + VStack { + SearchField( + placeholder: "Search Containers . . .", + text: $containerSearchText, + focusBinding: $focusState, + focusCase: .search, + options: nil + ) + + List(filteredContainers, id: \.self, selection: $selectedContainer) { container in + NavigationLink { + ContainerDetailView(container: container, appEnv: appEnv) + } label: { + ContainerSummaryView(container: container, manager: appEnv.manager, appEnv: appEnv) + .focused($focusState, equals: .item(container)) + } } - .tag(0) - // Image List View - imageListView - .tabItem { - Label("Images", systemImage: "photo.stack.fill") + .onChange(of: publication.containers) { _, n in + guard selectedContainer == nil && lastContainer == nil else { return } + if let firstContainer = filteredContainers.first { + DispatchQueue.main.async { + selectedContainer = firstContainer + } + } } - .tag(1) - } - .frame(minWidth: 800, minHeight: 600) - // Pass down environment value based on global error state - .environment(\.isGlobalErrorShowing, globalConnectionError != nil) - - // Overlay Error View if a global connection error exists - if let error = globalConnectionError { - // Background layer that fills the space - Rectangle() - .fill(.ultraThinMaterial.opacity(0.9)) // Apply material to the background - .ignoresSafeArea() // Ensure it covers the whole window area if needed - .overlay( // Place the ErrorView content on top, centered by default - ErrorView(error: error, title: "Connection Error", style: .prominent) - .padding() // Add padding around the ErrorView content + + } + .padding(4) + .tabItem { + Label("Containers", systemImage: "shippingbox.fill") + } + .tag(MainTabs.containers) + + VStack { + SearchField( + placeholder: "Search Images . . .", + text: $imageSearchText, + focusBinding: $focusState, + focusCase: .search, + options: nil ) + + List(filteredImages, id: \.self, selection: $selectedImage) { image in + NavigationLink { + ImageDetailView(image: image, appEnv: appEnv) + .focused($focusState, equals: .item(image)) + } label: { + ImageSummaryView(image: image) + } + } + .onChange(of: publication.images) { _, n in + guard selectedImage == nil && lastImage == nil else { return } + if let firstImage = filteredImages.first { + DispatchQueue.main.async { + selectedImage = firstImage + } + } + } + + } + .tabItem { + Label("Images", systemImage: "photo.stack.fill") + } + .tag(MainTabs.images) + .onChange(of: selectedTab) { _, newTab in + DispatchQueue.main.async { + if newTab == .containers { + lastImage = selectedImage + selectedContainer = lastContainer ?? publication.containers.first + selectedImage = nil + } else if newTab == .images { + lastContainer = selectedContainer + selectedImage = lastImage ?? publication.images.first + selectedContainer = nil + } + } + } } + } detail: { + Text("Select an object") } - .onAppear { - Task { - await manager.fetchContainers() - await manager.fetchImages() - } - } - // .onChange can monitor publication.containers directly - .onChange(of: publication.containers) { _, newContainers in - if selectedContainer == nil && !newContainers.isEmpty { - selectedContainer = newContainers[0] + + .navigationSplitViewColumnWidth(min: 250, ideal: 320, max: 800) + .onKeyPress( + .escape, + action: { + .onKeyPress(.escape) { + if focusState == .search { + if selectedTab == .containers { + containerSearchText = "" + } else { + imageSearchText = "" + } + return .handled + } + return .ignored } - } } } diff --git a/Bulkhead/UI/DockerUIApp.swift b/Bulkhead/UI/DockerUIApp.swift index 0898800..d56bed4 100644 --- a/Bulkhead/UI/DockerUIApp.swift +++ b/Bulkhead/UI/DockerUIApp.swift @@ -10,6 +10,7 @@ class ApplicationEnvironment { self.manager = DockerManager(logManager: logManager, publication: publication) // Start initial data fetch + let manager = manager Task { await manager.fetchContainers() await manager.fetchImages() @@ -22,8 +23,9 @@ struct DockerUIApp: App { private let appEnv = ApplicationEnvironment() private var manager: DockerManager { appEnv.manager } @Environment(\.openWindow) private var openWindow - @State private var selectedTab = 0 - @State private var isSearchFocused = false + @State private var selectedTab = MainTabs.containers + + @FocusState private var focusState: ListViewFocusTarget? init() { // needed for the protocol @@ -32,7 +34,10 @@ struct DockerUIApp: App { var body: some Scene { WindowGroup { ContentView( - selectedTab: $selectedTab, searchFocused: $isSearchFocused, manager: manager, appEnv: appEnv + selectedTab: $selectedTab, + manager: manager, + appEnv: appEnv, + focusState: $focusState ) .environmentObject(appEnv.logManager) .environmentObject(appEnv.publication) @@ -68,6 +73,10 @@ struct DockerUIApp: App { openWindow(id: "Log") } .keyboardShortcut("l", modifiers: [.command, .shift]) + Button("Search") { + focusState = .search + } + .keyboardShortcut("f") Divider() @@ -81,11 +90,6 @@ struct DockerUIApp: App { Divider() - Button("Search") { - isSearchFocused = true - } - .keyboardShortcut("f") - Button("Next Item") { // Navigation handled by ListView } diff --git a/Bulkhead/UI/ErrorView.swift b/Bulkhead/UI/ErrorView.swift index 7adb684..7cf3bdd 100644 --- a/Bulkhead/UI/ErrorView.swift +++ b/Bulkhead/UI/ErrorView.swift @@ -116,53 +116,3 @@ struct ErrorView: View { .padding(.vertical, 2) // Minimal padding } } - -#Preview { - // Example usage with a sample error and actions - enum PreviewError: Error, LocalizedError { - case sampleConnectionError - case sampleAuthError - - var errorDescription: String? { - switch self { - case .sampleConnectionError: "Could not connect to the server. Network might be unavailable." - case .sampleAuthError: "Authentication failed. Invalid credentials." - } - } - var recoverySuggestion: String? { - switch self { - case .sampleConnectionError: "Check network connection and server address." - case .sampleAuthError: "Please check your username and password." - } - } - } - - // Example actions - let retryAction = ErrorAction(label: "Retry") { print("Retry tapped") } - let settingsAction = ErrorAction(label: "Settings") { print("Settings tapped") } - - return ScrollView { - VStack(alignment: .leading, spacing: 20) { - Text("Prominent Style (No Actions):").font(.title2) - ErrorView(error: PreviewError.sampleConnectionError) - - Divider() - - Text("Prominent Style (With Actions):").font(.title2) - ErrorView( - error: PreviewError.sampleConnectionError, title: "Connection Failed", - actions: [retryAction, settingsAction]) - ErrorView(error: PreviewError.sampleAuthError, title: "Auth Failed", actions: [retryAction]) - - Divider() - - Text("Compact Style:").font(.title2) - ErrorView(error: PreviewError.sampleConnectionError, style: .compact) - // Note: Actions are not displayed in compact style - ErrorView( - error: PreviewError.sampleAuthError, title: "Auth Failed", style: .compact, - actions: [retryAction]) - } - .padding() - } -} diff --git a/Bulkhead/UI/FilesystemBrowserView.swift b/Bulkhead/UI/FilesystemBrowserView.swift index e5a2f06..98a93fd 100644 --- a/Bulkhead/UI/FilesystemBrowserView.swift +++ b/Bulkhead/UI/FilesystemBrowserView.swift @@ -110,7 +110,6 @@ struct FilesystemBrowserView: View { @State private var isExecuting = false @State private var fetchError: DockerError? - private var manager: DockerManager { appEnv.manager } var hoveredId: String? { hoveredEntry?.id } init(container: DockerContainer, appEnv: ApplicationEnvironment, initialPath: String? = nil) { diff --git a/Bulkhead/UI/ImageDetailView.swift b/Bulkhead/UI/ImageDetailView.swift index 6c4a982..f02d883 100644 --- a/Bulkhead/UI/ImageDetailView.swift +++ b/Bulkhead/UI/ImageDetailView.swift @@ -154,8 +154,6 @@ struct ImageDetailViewInner: View { struct ImageDetailView: View { @EnvironmentObject var publication: DockerPublication - @Environment(\.colorScheme) var colorScheme - @Environment(\.isGlobalErrorShowing) private var isGlobalErrorShowing let image: DockerImage let appEnv: ApplicationEnvironment @@ -310,27 +308,3 @@ struct DetailRow: View { } } } - -extension ImageInspection { - func asDictionary() -> [String: Any] { - // Simplified example - needs actual implementation based on ImageInspection properties - [ - "Id": Id, - "Parent": Parent as Any, - "RepoTags": RepoTags as Any, - // ... include all other relevant properties ... - "Config": Config.asDictionary(), - ] - } -} - -extension ImageConfig { - func asDictionary() -> [String: Any] { - // Simplified example - needs actual implementation - [ - "Entrypoint": entrypoint as Any, - "Cmd": cmd as Any, - // ... include all other relevant properties ... - ] - } -} diff --git a/Bulkhead/UI/ImageListView.swift b/Bulkhead/UI/ImageListView.swift deleted file mode 100644 index 50afbb4..0000000 --- a/Bulkhead/UI/ImageListView.swift +++ /dev/null @@ -1,104 +0,0 @@ -import SwiftUI - -struct ImageListView: View { - @EnvironmentObject var publication: DockerPublication - var backgroundColor: Color - var shadowColor: Color - let images: [DockerImage] - @Binding var searchFocused: Bool - @Binding var selectedImage: DockerImage? - let manager: DockerManager - let appEnv: ApplicationEnvironment - - private var imageSearchConfig: SearchConfiguration { - SearchConfiguration( - placeholder: "Search images...", - filter: { image, query in - let searchQuery = query.lowercased() - // Search in repo tags - if let tags = image.RepoTags { - if tags.contains(where: { $0.lowercased().contains(searchQuery) }) { - return true - } - } - // Search in ID (shortened, prefix match) - let idToSearch = - (image.Id.starts(with: "sha256:") ? String(image.Id.dropFirst(7)) : image.Id).lowercased() - return idToSearch.prefix(searchQuery.count) == searchQuery - } - ) - } - - private func formatSize(_ size: Int) -> String { - let formatter = ByteCountFormatter() - formatter.countStyle = .file - return formatter.string(fromByteCount: Int64(size)) - } - - private func formatDate(_ timestamp: Int) -> String { - let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - return formatter.localizedString(for: date, relativeTo: Date()) - } - - var body: some View { - ListView( - items: images, - selectedItem: $selectedImage, - backgroundColor: backgroundColor, - shadowColor: shadowColor, - searchConfig: imageSearchConfig, - listError: publication.imageListError, - listErrorTitle: "Failed to Load Images", - searchFocused: $searchFocused - ) { image in - // Type erase the content view - AnyView( - HStack { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Image(systemName: "photo") - .foregroundStyle(.secondary) - Text(image.RepoTags?.first ?? "") - .font(.headline) - } - - if let tags = image.RepoTags, tags.count > 1 { - Text("\(tags.count - 1) more tags") - .font(.caption) - .foregroundStyle(.secondary) - } - - HStack(spacing: 8) { - Text(formatSize(image.Size)) - .font(.caption) - .foregroundStyle(.secondary) - Text("•") - .font(.caption) - .foregroundStyle(.secondary) - Text("Created \(formatDate(image.Created))") - .font(.caption) - .foregroundStyle(.secondary) - } - } - Spacer() - Text( - (image.Id.starts(with: "sha256:") ? String(image.Id.dropFirst(7)) : image.Id).prefix(12) - ) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } - ) - } detail: { image in - // Type erase the detail view - ImageDetailView(image: image, appEnv: appEnv) - } - - .onChange(of: images) { _, newImages in - if selectedImage == nil && !newImages.isEmpty { - selectedImage = newImages.first - } - } - } -} diff --git a/Bulkhead/UI/ImageSummaryView.swift b/Bulkhead/UI/ImageSummaryView.swift new file mode 100644 index 0000000..8879434 --- /dev/null +++ b/Bulkhead/UI/ImageSummaryView.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct ImageSummaryView: View { + let image: DockerImage + + private func formatSize(_ size: Int) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(size)) + } + + private func formatDate(_ timestamp: Int) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: date, relativeTo: Date()) + } + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Image(systemName: "photo") + .foregroundStyle(.secondary) + Text(image.RepoTags?.first ?? "") + .font(.headline) + } + + if let tags = image.RepoTags, tags.count > 1 { + Text("\(tags.count - 1) more tags") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 8) { + Text(formatSize(image.Size)) + .font(.caption) + .foregroundStyle(.secondary) + Text("•") + .font(.caption) + .foregroundStyle(.secondary) + Text("Created \(formatDate(image.Created))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Text( + (image.Id.starts(with: "sha256:") ? String(image.Id.dropFirst(7)) : image.Id).prefix(12) + ) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } +} diff --git a/Bulkhead/UI/ListView.swift b/Bulkhead/UI/ListView.swift deleted file mode 100644 index ed0e677..0000000 --- a/Bulkhead/UI/ListView.swift +++ /dev/null @@ -1,304 +0,0 @@ -import SwiftUI - -// Define focus states outside the view struct -enum ListViewFocusTarget: Hashable { - case search - case item(AnyHashable) -} - -// ObservableObject to hold state that needs to persist -class ListViewState: ObservableObject { - @Published var lastKnownFocus: ListViewFocusTarget? - @Published var searchText = "" -} - -struct SearchOptions { - var caseSensitive = false - var matchWholeWords = false - // Command-F shortcut - var keyboardShortcut: KeyEquivalent = "f" - var modifiers: EventModifiers = .command -} - -struct SearchConfiguration { - let placeholder: String - let filter: (T, String) -> Bool - var options = SearchOptions() -} - -struct ListView: View { - let items: [T] - @Binding var selectedItem: T? - var backgroundColor: Color - var shadowColor: Color - var searchConfig: SearchConfiguration? - var listError: DockerError? - var listErrorTitle = "Error Loading List" - @FocusState private var focusedField: ListViewFocusTarget? - @StateObject private var viewState = ListViewState() - @State private var selectionTask: Task? - @Binding var searchFocused: Bool - @Environment(\.isGlobalErrorShowing) private var isGlobalErrorShowing - @ViewBuilder var content: (T) -> Master - @ViewBuilder var detail: (T) -> Detail - - // Computed property for filtered items - private var filteredItems: [T] { - // Read searchText from viewState - guard let config = searchConfig, !viewState.searchText.isEmpty else { - return items - } - // Use viewState.searchText for filtering - return items.filter { config.filter($0, viewState.searchText) } - } - - private func selectNextItem() { - let currentItems = filteredItems - guard !currentItems.isEmpty else { return } - - if let currentIndex = currentItems.firstIndex(where: { $0.id == selectedItem?.id }) { - if currentIndex < currentItems.count - 1 { - // Move to next item if not the last one - selectedItem = currentItems[currentIndex + 1] - if let newlySelectedItem = selectedItem { - let newFocus: ListViewFocusTarget = .item(AnyHashable(newlySelectedItem.id)) - focusedField = newFocus - } - } else { - // Already at the last item, do nothing - } - } else if let firstItem = currentItems.first { - // If no item is selected, select the first one (should focus be set here too?) - selectedItem = firstItem - if let newlySelectedItem = selectedItem { - let newFocus: ListViewFocusTarget = .item(AnyHashable(newlySelectedItem.id)) - focusedField = newFocus - } - } - } - - private func selectPreviousItem() { - let currentItems = filteredItems - guard !currentItems.isEmpty else { return } - - if let currentIndex = currentItems.firstIndex(where: { $0.id == selectedItem?.id }) { - if currentIndex > 0 { - // Move to previous item if not the first one - selectedItem = currentItems[currentIndex - 1] - if let newlySelectedItem = selectedItem { - let newFocus: ListViewFocusTarget = .item(AnyHashable(newlySelectedItem.id)) - focusedField = newFocus - } - } else { - // If already at the first item, move focus to the search field - focusedField = .search - } - } else if let firstItem = currentItems.first { - // If no item is selected, but list is not empty, select first and focus search - // This case might be less common with the new down arrow logic, but handles edge cases - selectedItem = firstItem - focusedField = .search - } else { - // If list is empty or no selection, focus search - focusedField = .search - } - } - - private func itemView(for item: T) -> some View { - let isSelected = selectedItem?.id == item.id - - // Revert to HStack structure - return HStack { - content(item) - .padding() // Padding inside the background - .frame(maxWidth: .infinity, alignment: .leading) - } - // Apply background and shape styling to the HStack - .background(isSelected ? backgroundColor.opacity(0.8) : backgroundColor) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(isSelected ? Color.accentColor.opacity(0.7) : .clear, lineWidth: 1.5) - ) - .shadow(color: shadowColor, radius: 2, x: 0, y: 1) - // Modifiers applied to the HStack - .contentShape(Rectangle()) // Explicitly define the shape for interaction - .padding(.horizontal) // Padding outside the background/shadow for spacing - .id(item.id) - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) - .focusable(true) // <<< Make the HStack itself focusable - .focused($focusedField, equals: .item(AnyHashable(item.id))) // Bind focus state - .onTapGesture { // Use tap gesture for selection - withAnimation { - let newFocus: ListViewFocusTarget = .item(AnyHashable(item.id)) - selectedItem = item - focusedField = newFocus - } - } - // Add onHover to change cursor - .onHover { isHovering in - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - } - - private var listColumnContent: some View { - VStack(spacing: 0) { - // Show local list error ONLY if a global error isn't already showing - if !isGlobalErrorShowing, let error = listError { - ErrorView(error: error, title: listErrorTitle, style: .compact) - .padding() - .frame(maxHeight: .infinity) - } else { - if let config = searchConfig { - SearchField( - placeholder: config.placeholder, - text: $viewState.searchText, - focusBinding: $focusedField, - focusCase: ListViewFocusTarget.search - ) - Divider() - } - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 8) { - ForEach(filteredItems) { item in - itemView(for: item) - } - } - .padding(.vertical) - } - .onChange(of: searchFocused) { oldValue, newValue in - if oldValue != newValue && newValue == true { - focusedField = .search - } - } - .onChange(of: selectedItem) { _, newItem in - handleSelectionChange(newItem: newItem, proxy: proxy) - } - } - } - } - } - - private func handleSelectionChange(newItem: T?, proxy: ScrollViewProxy) { - selectionTask?.cancel() - selectionTask = Task { - do { - try await Task.sleep(nanoseconds: 100_000_000) - guard !Task.isCancelled else { return } - - if let item = newItem { - await MainActor.run { - withAnimation { - proxy.scrollTo(item.id, anchor: .center) - let newFocus: ListViewFocusTarget = .item(AnyHashable(item.id)) - focusedField = newFocus - } - } - } else { - if searchConfig != nil { - await MainActor.run { - focusedField = .search - } - } - } - } catch is CancellationError { - // no action here - } catch { - print("Error during selection debounce sleep: \(error)") - } - } - } - - var body: some View { - NavigationSplitView { - listColumnContent - .onChange(of: focusedField) { _, newValue in - viewState.lastKnownFocus = newValue - if newValue != .search { - searchFocused = false - } - } - .onChange(of: items) { oldItems, newItems in - if oldItems.isEmpty && !newItems.isEmpty && focusedField == nil { - setupInitialFocus() - } - } - .navigationSplitViewColumnWidth(min: 250, ideal: 320, max: 800) - } detail: { - if let selected = selectedItem { - detail(selected) - } else { - Text("Select an item to view details") - .foregroundColor(.secondary) - } - } - .onKeyPress(.downArrow) { - if focusedField == .search { - if let firstItem = filteredItems.first { - let newFocus: ListViewFocusTarget = .item(AnyHashable(firstItem.id)) - focusedField = newFocus - selectedItem = firstItem - return .handled - } - return .handled - } - selectNextItem() - return .handled - } - .onKeyPress(.upArrow) { - if focusedField == .search { - return .handled - } - selectPreviousItem() - return .handled - } - .onKeyPress(.escape) { - if focusedField == ListViewFocusTarget.search && !viewState.searchText.isEmpty { - DispatchQueue.main.async { - viewState.searchText = "" - } - return .handled - } - if focusedField != ListViewFocusTarget.search { - focusedField = ListViewFocusTarget.search - return .handled - } - return .ignored - } - .onKeyPress(.return) { - if case .item(let itemIdHashable) = focusedField, - let itemId = itemIdHashable.base as? T.ID, - let currentItem = filteredItems.first(where: { $0.id == itemId }) - { - selectedItem = currentItem - focusedField = .item(itemIdHashable) - return .handled - } - if focusedField == .search { - if let firstItem = filteredItems.first { - let newFocus: ListViewFocusTarget = .item(AnyHashable(firstItem.id)) - focusedField = newFocus - selectedItem = firstItem - return .handled - } - } - return .ignored - } - } - - private func setupInitialFocus() { - if focusedField == nil && !items.isEmpty { - focusedField = .item(AnyHashable(items[0].id)) - selectedItem = items[0] - } else if focusedField == nil && searchConfig != nil { - focusedField = .search - } - viewState.lastKnownFocus = focusedField - } -} diff --git a/Bulkhead/UI/SearchField.swift b/Bulkhead/UI/SearchField.swift index bf6c982..5992136 100644 --- a/Bulkhead/UI/SearchField.swift +++ b/Bulkhead/UI/SearchField.swift @@ -1,10 +1,19 @@ import SwiftUI -struct SearchField: View { +struct SearchOptions { + var caseSensitive = false + var matchWholeWords = false + // Command-F shortcut + var keyboardShortcut: KeyEquivalent = "f" + var modifiers: EventModifiers = .command +} + +struct SearchField: View { let placeholder: String @Binding var text: String - var focusBinding: FocusState.Binding + @FocusState.Binding var focusBinding: ListViewFocusTarget? let focusCase: ListViewFocusTarget + let options: SearchOptions? var body: some View { HStack { @@ -12,7 +21,14 @@ struct SearchField: Vie .foregroundStyle(.secondary) TextField(placeholder, text: $text) .textFieldStyle(.plain) - .focused(focusBinding, equals: focusCase) + .onKeyPress(.escape) { + DispatchQueue.main.async { + text = "" + } + return .handled + } + .focused($focusBinding, equals: focusCase) + if !text.isEmpty { Button(action: { text = "" }) { Image(systemName: "xmark.circle.fill")