From 0427c912198dd98b955b09425b318e3bace6dfe9 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Thu, 3 Apr 2025 00:15:55 -0700 Subject: [PATCH 01/11] refactor: cleanup view hierarchy --- Bulkhead/UI/ContainerListView.swift | 3 - Bulkhead/UI/ContentView.swift | 105 +++++++++++------------- Bulkhead/UI/ErrorView.swift | 50 ------------ Bulkhead/UI/ImageListView.swift | 3 - Bulkhead/UI/ListView.swift | 119 ++++++++++++---------------- Bulkhead/UI/SearchField.swift | 2 +- 6 files changed, 98 insertions(+), 184 deletions(-) diff --git a/Bulkhead/UI/ContainerListView.swift b/Bulkhead/UI/ContainerListView.swift index 71ffcc4..8a04f6d 100644 --- a/Bulkhead/UI/ContainerListView.swift +++ b/Bulkhead/UI/ContainerListView.swift @@ -64,9 +64,6 @@ struct ContainerListView: View { ContainerActionsView(container: container, manager: manager) } - } detail: { container in - // Type erase the detail view - ContainerDetailView(container: container, appEnv: appEnv) } } } diff --git a/Bulkhead/UI/ContentView.swift b/Bulkhead/UI/ContentView.swift index 80206fc..a91ceb2 100644 --- a/Bulkhead/UI/ContentView.swift +++ b/Bulkhead/UI/ContentView.swift @@ -51,77 +51,64 @@ struct ContentView: View { 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 - ) - } - 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") - } - .tag(0) + ContainerListView( + // Pass data array from publication + containers: publication.containers, + selectedContainer: $selectedContainer, + searchFocused: $searchFocused, + backgroundColor: backgroundColor, + shadowColor: shadowColor, + // Pass manager instance + manager: manager, + appEnv: appEnv + ) + .tabItem { + Label("Containers", systemImage: "shippingbox.fill") + } + .tag(0) + // Image List View - imageListView - .tabItem { - Label("Images", systemImage: "photo.stack.fill") - } - .tag(1) + ImageListView( + backgroundColor: backgroundColor, + shadowColor: shadowColor, + // Pass data array from publication + images: publication.images, + searchFocused: $searchFocused, + selectedImage: $selectedImage, + // Pass manager instance + manager: manager, + appEnv: appEnv + ) + .tabItem { + Label("Images", systemImage: "photo.stack.fill") + } + .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 + } detail: { 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 + ErrorView( + error: error, title: "Connection Error", style: .prominent, + actions: [ + ErrorAction(label: "Refresh") {} + ] + ) + .padding() // Add padding around the ErrorView content ) - } - } - .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] + } else if selectedTab == 0, let selectedContainer { + ContainerDetailView(container: selectedContainer, appEnv: appEnv) + } else if let selectedImage { + ImageDetailView(image: selectedImage, appEnv: appEnv) + } else { + Text("Nothing Selected!") } } } 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/ImageListView.swift b/Bulkhead/UI/ImageListView.swift index 50afbb4..5d994b8 100644 --- a/Bulkhead/UI/ImageListView.swift +++ b/Bulkhead/UI/ImageListView.swift @@ -90,9 +90,6 @@ struct ImageListView: View { .foregroundStyle(.secondary) } ) - } detail: { image in - // Type erase the detail view - ImageDetailView(image: image, appEnv: appEnv) } .onChange(of: images) { _, newImages in diff --git a/Bulkhead/UI/ListView.swift b/Bulkhead/UI/ListView.swift index ed0e677..3a5bcc4 100644 --- a/Bulkhead/UI/ListView.swift +++ b/Bulkhead/UI/ListView.swift @@ -26,7 +26,7 @@ struct SearchConfiguration { var options = SearchOptions() } -struct ListView: View { +struct ListView: View { let items: [T] @Binding var selectedItem: T? var backgroundColor: Color @@ -40,7 +40,6 @@ struct ListView: View { @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] { @@ -146,7 +145,38 @@ struct ListView: View { } } - private var listColumnContent: some View { + 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 { VStack(spacing: 0) { // Show local list error ONLY if a global error isn't already showing if !isGlobalErrorShowing, let error = listError { @@ -155,12 +185,13 @@ struct ListView: View { .frame(maxHeight: .infinity) } else { if let config = searchConfig { - SearchField( + SearchField( placeholder: config.placeholder, text: $viewState.searchText, focusBinding: $focusedField, focusCase: ListViewFocusTarget.search ) + Divider() } ScrollViewReader { proxy in @@ -182,79 +213,31 @@ struct ListView: View { } } } - } - } - - 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)") + }.onChange(of: focusedField) { _, newValue in + viewState.lastKnownFocus = newValue + if newValue != .search { + searchFocused = false } } - } - - 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) + .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 } diff --git a/Bulkhead/UI/SearchField.swift b/Bulkhead/UI/SearchField.swift index bf6c982..380be5e 100644 --- a/Bulkhead/UI/SearchField.swift +++ b/Bulkhead/UI/SearchField.swift @@ -1,6 +1,6 @@ import SwiftUI -struct SearchField: View { +struct SearchField: View { let placeholder: String @Binding var text: String var focusBinding: FocusState.Binding From 4e656df1e63d9b55b1da410b71c802227fec5ae1 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Thu, 3 Apr 2025 20:49:54 -0700 Subject: [PATCH 02/11] feat: use an enum for tab handling --- Bulkhead/UI/ContainerListView.swift | 14 +- Bulkhead/UI/ContentView.swift | 36 +++-- Bulkhead/UI/DockerUIApp.swift | 7 +- Bulkhead/UI/ImageListView.swift | 14 +- Bulkhead/UI/ListView.swift | 200 ++++++++++++++-------------- 5 files changed, 140 insertions(+), 131 deletions(-) diff --git a/Bulkhead/UI/ContainerListView.swift b/Bulkhead/UI/ContainerListView.swift index 8a04f6d..d497689 100644 --- a/Bulkhead/UI/ContainerListView.swift +++ b/Bulkhead/UI/ContainerListView.swift @@ -2,17 +2,19 @@ import Foundation import SwiftUI struct ContainerListView: View { - @Environment(\.openWindow) private var openWindow - @EnvironmentObject var publication: DockerPublication - let containers: [DockerContainer] + var backgroundColor: Color + var shadowColor: Color + @Binding var selectedContainer: DockerContainer? + @Binding var searchFocused: Bool - var backgroundColor: Color - var shadowColor: Color let manager: DockerManager let appEnv: ApplicationEnvironment + @Environment(\.openWindow) private var openWindow + @EnvironmentObject var publication: DockerPublication + private var containerSearchConfig: SearchConfiguration { SearchConfiguration( placeholder: "Search containers...", @@ -34,7 +36,7 @@ struct ContainerListView: View { var body: some View { ListView( - items: containers, + items: publication.containers, selectedItem: $selectedContainer, backgroundColor: backgroundColor, shadowColor: shadowColor, diff --git a/Bulkhead/UI/ContentView.swift b/Bulkhead/UI/ContentView.swift index a91ceb2..c88e67d 100644 --- a/Bulkhead/UI/ContentView.swift +++ b/Bulkhead/UI/ContentView.swift @@ -13,21 +13,29 @@ 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 @State private var selectedContainer: DockerContainer? @State private var selectedImage: DockerImage? + @Binding private var searchFocused: Bool // Updated init init( - selectedTab: Binding, searchFocused: Binding, manager: DockerManager, + selectedTab: Binding, + searchFocused: Binding, + manager: DockerManager, appEnv: ApplicationEnvironment ) { self.manager = manager @@ -57,37 +65,37 @@ struct ContentView: View { TabView(selection: $selectedTab) { // Container List View ContainerListView( - // Pass data array from publication - containers: publication.containers, - selectedContainer: $selectedContainer, - searchFocused: $searchFocused, backgroundColor: backgroundColor, shadowColor: shadowColor, - // Pass manager instance + + selectedContainer: $selectedContainer, + + searchFocused: $searchFocused, + manager: manager, appEnv: appEnv ) .tabItem { Label("Containers", systemImage: "shippingbox.fill") } - .tag(0) + .tag(MainTabs.containers) // Image List View ImageListView( backgroundColor: backgroundColor, shadowColor: shadowColor, - // Pass data array from publication - images: publication.images, - searchFocused: $searchFocused, + selectedImage: $selectedImage, - // Pass manager instance + + searchFocused: $searchFocused, + manager: manager, appEnv: appEnv ) .tabItem { Label("Images", systemImage: "photo.stack.fill") } - .tag(1) + .tag(MainTabs.images) } } detail: { if let error = globalConnectionError { @@ -103,7 +111,7 @@ struct ContentView: View { ) .padding() // Add padding around the ErrorView content ) - } else if selectedTab == 0, let selectedContainer { + } else if selectedTab == .containers, let selectedContainer { ContainerDetailView(container: selectedContainer, appEnv: appEnv) } else if let selectedImage { ImageDetailView(image: selectedImage, appEnv: appEnv) diff --git a/Bulkhead/UI/DockerUIApp.swift b/Bulkhead/UI/DockerUIApp.swift index 0898800..b8d62fd 100644 --- a/Bulkhead/UI/DockerUIApp.swift +++ b/Bulkhead/UI/DockerUIApp.swift @@ -22,7 +22,7 @@ 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 selectedTab = MainTabs.containers @State private var isSearchFocused = false init() { @@ -32,7 +32,10 @@ struct DockerUIApp: App { var body: some Scene { WindowGroup { ContentView( - selectedTab: $selectedTab, searchFocused: $isSearchFocused, manager: manager, appEnv: appEnv + selectedTab: $selectedTab, + searchFocused: $isSearchFocused, + manager: manager, + appEnv: appEnv ) .environmentObject(appEnv.logManager) .environmentObject(appEnv.publication) diff --git a/Bulkhead/UI/ImageListView.swift b/Bulkhead/UI/ImageListView.swift index 5d994b8..6de4aec 100644 --- a/Bulkhead/UI/ImageListView.swift +++ b/Bulkhead/UI/ImageListView.swift @@ -4,9 +4,11 @@ struct ImageListView: View { @EnvironmentObject var publication: DockerPublication var backgroundColor: Color var shadowColor: Color - let images: [DockerImage] - @Binding var searchFocused: Bool + @Binding var selectedImage: DockerImage? + + @Binding var searchFocused: Bool + let manager: DockerManager let appEnv: ApplicationEnvironment @@ -44,7 +46,7 @@ struct ImageListView: View { var body: some View { ListView( - items: images, + items: publication.images, selectedItem: $selectedImage, backgroundColor: backgroundColor, shadowColor: shadowColor, @@ -91,11 +93,5 @@ struct ImageListView: View { } ) } - - .onChange(of: images) { _, newImages in - if selectedImage == nil && !newImages.isEmpty { - selectedImage = newImages.first - } - } } } diff --git a/Bulkhead/UI/ListView.swift b/Bulkhead/UI/ListView.swift index 3a5bcc4..4d24d0f 100644 --- a/Bulkhead/UI/ListView.swift +++ b/Bulkhead/UI/ListView.swift @@ -51,6 +51,106 @@ struct ListView: View { return items.filter { config.filter($0, viewState.searchText) } } + var body: some View { + // NavigationSplitView { + 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) + } + } + } + }.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) { + selectNextItem() + return .handled + } + .onKeyPress(.upArrow) { + 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 selectNextItem() { let currentItems = filteredItems guard !currentItems.isEmpty else { return } @@ -175,106 +275,6 @@ struct ListView: View { } } - var body: some View { - // NavigationSplitView { - 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) - } - } - } - }.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) { - selectNextItem() - return .handled - } - .onKeyPress(.upArrow) { - 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)) From 5ad60d576ffd0d7cce5768a04904d91566d02bc7 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Thu, 3 Apr 2025 21:29:09 -0700 Subject: [PATCH 03/11] refactor: cleanup search field --- Bulkhead/UI/ListView.swift | 52 +++++++++++++---------------------- Bulkhead/UI/SearchField.swift | 18 ++++++++++-- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/Bulkhead/UI/ListView.swift b/Bulkhead/UI/ListView.swift index 4d24d0f..4bf885a 100644 --- a/Bulkhead/UI/ListView.swift +++ b/Bulkhead/UI/ListView.swift @@ -12,43 +12,32 @@ class ListViewState: ObservableObject { @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 searchConfig: SearchConfiguration var listError: DockerError? var listErrorTitle = "Error Loading List" + @Binding var searchFocused: Bool + @ViewBuilder var content: (T) -> Master + @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 // Computed property for filtered items private var filteredItems: [T] { // Read searchText from viewState - guard let config = searchConfig, !viewState.searchText.isEmpty else { + guard !viewState.searchText.isEmpty else { return items } // Use viewState.searchText for filtering - return items.filter { config.filter($0, viewState.searchText) } + return items.filter { + searchConfig.filter($0, viewState.searchText) + } } var body: some View { @@ -60,16 +49,15 @@ struct ListView: View { .padding() .frame(maxHeight: .infinity) } else { - if let config = searchConfig { - SearchField( - placeholder: config.placeholder, - text: $viewState.searchText, - focusBinding: $focusedField, - focusCase: ListViewFocusTarget.search - ) + SearchField( + config: searchConfig, + text: $viewState.searchText, + focusBinding: $focusedField, + focusCase: ListViewFocusTarget.search + ) + + Divider() - Divider() - } ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 8) { @@ -261,10 +249,8 @@ struct ListView: View { } } } else { - if searchConfig != nil { - await MainActor.run { - focusedField = .search - } + await MainActor.run { + focusedField = .search } } } catch is CancellationError { @@ -279,7 +265,7 @@ struct ListView: View { if focusedField == nil && !items.isEmpty { focusedField = .item(AnyHashable(items[0].id)) selectedItem = items[0] - } else if focusedField == nil && searchConfig != nil { + } else if focusedField == nil { focusedField = .search } viewState.lastKnownFocus = focusedField diff --git a/Bulkhead/UI/SearchField.swift b/Bulkhead/UI/SearchField.swift index 380be5e..521092e 100644 --- a/Bulkhead/UI/SearchField.swift +++ b/Bulkhead/UI/SearchField.swift @@ -1,7 +1,21 @@ 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 SearchConfiguration { let placeholder: String + let filter: (T, String) -> Bool + var options = SearchOptions() +} + +struct SearchField: View { + let config: SearchConfiguration @Binding var text: String var focusBinding: FocusState.Binding let focusCase: ListViewFocusTarget @@ -10,7 +24,7 @@ struct SearchField: View { HStack { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) - TextField(placeholder, text: $text) + TextField(config.placeholder, text: $text) .textFieldStyle(.plain) .focused(focusBinding, equals: focusCase) if !text.isEmpty { From c068efba649fb3dc548ba55c1ddd636e699a24c0 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Fri, 4 Apr 2025 00:19:17 -0700 Subject: [PATCH 04/11] feat: figure out focus --- Bulkhead/UI/ContainerListView.swift | 5 +- Bulkhead/UI/ContentView.swift | 51 ++++++++++------- Bulkhead/UI/DockerUIApp.swift | 8 +-- Bulkhead/UI/ImageListView.swift | 3 +- Bulkhead/UI/ListView.swift | 88 ++++++++++------------------- Bulkhead/UI/SearchField.swift | 15 ++++- 6 files changed, 84 insertions(+), 86 deletions(-) diff --git a/Bulkhead/UI/ContainerListView.swift b/Bulkhead/UI/ContainerListView.swift index d497689..053c409 100644 --- a/Bulkhead/UI/ContainerListView.swift +++ b/Bulkhead/UI/ContainerListView.swift @@ -7,6 +7,7 @@ struct ContainerListView: View { @Binding var selectedContainer: DockerContainer? + @Binding var searchText: String @Binding var searchFocused: Bool let manager: DockerManager @@ -43,7 +44,8 @@ struct ContainerListView: View { searchConfig: containerSearchConfig, listError: publication.containerListError, listErrorTitle: "Failed to Load Containers", - searchFocused: $searchFocused + searchFocused: $searchFocused, + searchText: $searchText ) { container in // Type erase the content view @@ -67,6 +69,7 @@ struct ContainerListView: View { } } + } } diff --git a/Bulkhead/UI/ContentView.swift b/Bulkhead/UI/ContentView.swift index c88e67d..9a429a5 100644 --- a/Bulkhead/UI/ContentView.swift +++ b/Bulkhead/UI/ContentView.swift @@ -29,6 +29,7 @@ struct ContentView: View { @State private var selectedContainer: DockerContainer? @State private var selectedImage: DockerImage? + @State private var searchText = "" @Binding private var searchFocused: Bool // Updated init @@ -70,6 +71,7 @@ struct ContentView: View { selectedContainer: $selectedContainer, + searchText: $searchText, searchFocused: $searchFocused, manager: manager, @@ -97,27 +99,36 @@ struct ContentView: View { } .tag(MainTabs.images) } + // } detail: { - if let error = globalConnectionError { - 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, - actions: [ - ErrorAction(label: "Refresh") {} - ] - ) - .padding() // Add padding around the ErrorView content - ) - } else if selectedTab == .containers, let selectedContainer { - ContainerDetailView(container: selectedContainer, appEnv: appEnv) - } else if let selectedImage { - ImageDetailView(image: selectedImage, appEnv: appEnv) - } else { - Text("Nothing Selected!") - } + // if let error = globalConnectionError { + // 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, + // actions: [ + // ErrorAction(label: "Refresh") {} + // ] + // ) + // .padding() // Add padding around the ErrorView content + // ) + // } else if selectedTab == .containers, let selectedContainer { + // ContainerDetailView(container: selectedContainer, appEnv: appEnv) + // } else if let selectedImage { + // ImageDetailView(image: selectedImage, appEnv: appEnv) + // } else { + // Text("Nothing Selected!") + // } } + // .navigationSplitViewColumnWidth(min: 250, ideal: 320, max: 800) + .onKeyPress( + .escape, + action: { + print("NOTICE ME: content view received escape \(searchFocused)") + searchText = "" + return searchFocused ? .handled : .ignored + }) } } diff --git a/Bulkhead/UI/DockerUIApp.swift b/Bulkhead/UI/DockerUIApp.swift index b8d62fd..9747bbd 100644 --- a/Bulkhead/UI/DockerUIApp.swift +++ b/Bulkhead/UI/DockerUIApp.swift @@ -84,10 +84,10 @@ struct DockerUIApp: App { Divider() - Button("Search") { - isSearchFocused = true - } - .keyboardShortcut("f") + // Button("Search") { + // isSearchFocused = true + // } + // .keyboardShortcut("f") Button("Next Item") { // Navigation handled by ListView diff --git a/Bulkhead/UI/ImageListView.swift b/Bulkhead/UI/ImageListView.swift index 6de4aec..6d55e2b 100644 --- a/Bulkhead/UI/ImageListView.swift +++ b/Bulkhead/UI/ImageListView.swift @@ -53,7 +53,8 @@ struct ImageListView: View { searchConfig: imageSearchConfig, listError: publication.imageListError, listErrorTitle: "Failed to Load Images", - searchFocused: $searchFocused + searchFocused: $searchFocused, + searchText: .constant("") ) { image in // Type erase the content view AnyView( diff --git a/Bulkhead/UI/ListView.swift b/Bulkhead/UI/ListView.swift index 4bf885a..b3e7f67 100644 --- a/Bulkhead/UI/ListView.swift +++ b/Bulkhead/UI/ListView.swift @@ -21,6 +21,7 @@ struct ListView: View { var listError: DockerError? var listErrorTitle = "Error Loading List" @Binding var searchFocused: Bool + @Binding var searchText: String @ViewBuilder var content: (T) -> Master @FocusState private var focusedField: ListViewFocusTarget? @@ -49,9 +50,9 @@ struct ListView: View { .padding() .frame(maxHeight: .infinity) } else { - SearchField( + SearchField( config: searchConfig, - text: $viewState.searchText, + text: $searchText, focusBinding: $focusedField, focusCase: ListViewFocusTarget.search ) @@ -67,76 +68,49 @@ struct ListView: View { } .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) } } } }.onChange(of: focusedField) { _, newValue in + guard let newValue else { return } viewState.lastKnownFocus = newValue - if newValue != .search { - searchFocused = false - } + print("NOTICE ME: change of focused field \(newValue == .search) \(newValue)") + searchFocused = newValue == .search } .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) { + // selectNextItem() + // return .handled + // } + // .onKeyPress(.upArrow) { + // selectPreviousItem() + // return .handled + // } + // .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 // } - .onKeyPress(.downArrow) { - selectNextItem() - return .handled - } - .onKeyPress(.upArrow) { - 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 selectNextItem() { diff --git a/Bulkhead/UI/SearchField.swift b/Bulkhead/UI/SearchField.swift index 521092e..78e9430 100644 --- a/Bulkhead/UI/SearchField.swift +++ b/Bulkhead/UI/SearchField.swift @@ -14,10 +14,10 @@ struct SearchConfiguration { var options = SearchOptions() } -struct SearchField: View { +struct SearchField: View { let config: SearchConfiguration @Binding var text: String - var focusBinding: FocusState.Binding + @FocusState.Binding var focusBinding: ListViewFocusTarget? let focusCase: ListViewFocusTarget var body: some View { @@ -26,7 +26,16 @@ struct SearchField: View { .foregroundStyle(.secondary) TextField(config.placeholder, text: $text) .textFieldStyle(.plain) - .focused(focusBinding, equals: focusCase) + .onKeyPress(.escape) { + print("NOTICE ME: search received escape") + + DispatchQueue.main.async { + text = "" + } + return .handled + } + .focused($focusBinding, equals: focusCase) + if !text.isEmpty { Button(action: { text = "" }) { Image(systemName: "xmark.circle.fill") From 81079650336f1e529f41bda9cb215776ca99434c Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Fri, 4 Apr 2025 01:02:37 -0700 Subject: [PATCH 05/11] refactor: get rid of ListView --- ...tView.swift => ContainerSummaryView.swift} | 78 ++++----------- Bulkhead/UI/ContentView.swift | 65 ++++-------- Bulkhead/UI/ImageListView.swift | 98 ------------------- Bulkhead/UI/ImageSummaryView.swift | 55 +++++++++++ 4 files changed, 90 insertions(+), 206 deletions(-) rename Bulkhead/UI/{ContainerListView.swift => ContainerSummaryView.swift} (59%) delete mode 100644 Bulkhead/UI/ImageListView.swift create mode 100644 Bulkhead/UI/ImageSummaryView.swift diff --git a/Bulkhead/UI/ContainerListView.swift b/Bulkhead/UI/ContainerSummaryView.swift similarity index 59% rename from Bulkhead/UI/ContainerListView.swift rename to Bulkhead/UI/ContainerSummaryView.swift index 053c409..88fc2bc 100644 --- a/Bulkhead/UI/ContainerListView.swift +++ b/Bulkhead/UI/ContainerSummaryView.swift @@ -1,75 +1,31 @@ import Foundation import SwiftUI -struct ContainerListView: View { - var backgroundColor: Color - var shadowColor: Color - - @Binding var selectedContainer: DockerContainer? - - @Binding var searchText: String - @Binding var searchFocused: Bool +struct ContainerSummaryView: View { + var container: DockerContainer let manager: DockerManager let appEnv: ApplicationEnvironment - @Environment(\.openWindow) private var openWindow - @EnvironmentObject var publication: DockerPublication - - 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: publication.containers, - selectedItem: $selectedContainer, - backgroundColor: backgroundColor, - shadowColor: shadowColor, - searchConfig: containerSearchConfig, - listError: publication.containerListError, - listErrorTitle: "Failed to Load Containers", - searchFocused: $searchFocused, - searchText: $searchText - ) { 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) } - Spacer() - ContainerActionsView(container: container, manager: manager) + 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) } - + Spacer() + ContainerActionsView(container: container, manager: manager) } - } } diff --git a/Bulkhead/UI/ContentView.swift b/Bulkhead/UI/ContentView.swift index 9a429a5..8ad349f 100644 --- a/Bulkhead/UI/ContentView.swift +++ b/Bulkhead/UI/ContentView.swift @@ -64,65 +64,36 @@ struct ContentView: View { // Main content with potential error overlay NavigationSplitView { TabView(selection: $selectedTab) { - // Container List View - ContainerListView( - backgroundColor: backgroundColor, - shadowColor: shadowColor, - - selectedContainer: $selectedContainer, - - searchText: $searchText, - searchFocused: $searchFocused, - - manager: manager, - appEnv: appEnv - ) + List(publication.containers, selection: $selectedContainer) { container in + NavigationLink { + ContainerDetailView(container: container, appEnv: appEnv) + } label: { + ContainerSummaryView(container: container, manager: appEnv.manager, appEnv: appEnv) + } + .padding(4) + } .tabItem { Label("Containers", systemImage: "shippingbox.fill") } .tag(MainTabs.containers) - // Image List View - ImageListView( - backgroundColor: backgroundColor, - shadowColor: shadowColor, - - selectedImage: $selectedImage, - - searchFocused: $searchFocused, - - manager: manager, - appEnv: appEnv - ) + List(publication.images, selection: $selectedImage) { image in + NavigationLink { + ImageDetailView(image: image, appEnv: appEnv) + } label: { + ImageSummaryView(image: image) + } + } .tabItem { Label("Images", systemImage: "photo.stack.fill") } .tag(MainTabs.images) + } - // } detail: { - // if let error = globalConnectionError { - // 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, - // actions: [ - // ErrorAction(label: "Refresh") {} - // ] - // ) - // .padding() // Add padding around the ErrorView content - // ) - // } else if selectedTab == .containers, let selectedContainer { - // ContainerDetailView(container: selectedContainer, appEnv: appEnv) - // } else if let selectedImage { - // ImageDetailView(image: selectedImage, appEnv: appEnv) - // } else { - // Text("Nothing Selected!") - // } + Text("Select an object") } - // .navigationSplitViewColumnWidth(min: 250, ideal: 320, max: 800) + .navigationSplitViewColumnWidth(min: 250, ideal: 320, max: 800) .onKeyPress( .escape, action: { diff --git a/Bulkhead/UI/ImageListView.swift b/Bulkhead/UI/ImageListView.swift deleted file mode 100644 index 6d55e2b..0000000 --- a/Bulkhead/UI/ImageListView.swift +++ /dev/null @@ -1,98 +0,0 @@ -import SwiftUI - -struct ImageListView: View { - @EnvironmentObject var publication: DockerPublication - var backgroundColor: Color - var shadowColor: Color - - @Binding var selectedImage: DockerImage? - - @Binding var searchFocused: Bool - - 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: publication.images, - selectedItem: $selectedImage, - backgroundColor: backgroundColor, - shadowColor: shadowColor, - searchConfig: imageSearchConfig, - listError: publication.imageListError, - listErrorTitle: "Failed to Load Images", - searchFocused: $searchFocused, - searchText: .constant("") - ) { 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) - } - ) - } - } -} 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) + } + } +} From c4056b6c81ec18d832dd3896894a486831a64e88 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Fri, 4 Apr 2025 01:06:23 -0700 Subject: [PATCH 06/11] refactor: remove list view class --- Bulkhead/UI/ContentView.swift | 13 ++ Bulkhead/UI/ListView.swift | 247 ---------------------------------- 2 files changed, 13 insertions(+), 247 deletions(-) delete mode 100644 Bulkhead/UI/ListView.swift diff --git a/Bulkhead/UI/ContentView.swift b/Bulkhead/UI/ContentView.swift index 8ad349f..88d99fa 100644 --- a/Bulkhead/UI/ContentView.swift +++ b/Bulkhead/UI/ContentView.swift @@ -1,6 +1,19 @@ import Foundation 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 = "" +} + // Define Environment Key for Global Error State struct IsGlobalErrorShowingKey: EnvironmentKey { static let defaultValue = false diff --git a/Bulkhead/UI/ListView.swift b/Bulkhead/UI/ListView.swift deleted file mode 100644 index b3e7f67..0000000 --- a/Bulkhead/UI/ListView.swift +++ /dev/null @@ -1,247 +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 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" - @Binding var searchFocused: Bool - @Binding var searchText: String - @ViewBuilder var content: (T) -> Master - - @FocusState private var focusedField: ListViewFocusTarget? - @StateObject private var viewState = ListViewState() - @State private var selectionTask: Task? - @Environment(\.isGlobalErrorShowing) private var isGlobalErrorShowing - - // Computed property for filtered items - private var filteredItems: [T] { - // Read searchText from viewState - guard !viewState.searchText.isEmpty else { - return items - } - // Use viewState.searchText for filtering - return items.filter { - searchConfig.filter($0, viewState.searchText) - } - } - - var body: some View { - // NavigationSplitView { - 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 { - SearchField( - config: searchConfig, - text: $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: selectedItem) { _, newItem in - handleSelectionChange(newItem: newItem, proxy: proxy) - } - } - } - }.onChange(of: focusedField) { _, newValue in - guard let newValue else { return } - viewState.lastKnownFocus = newValue - print("NOTICE ME: change of focused field \(newValue == .search) \(newValue)") - searchFocused = newValue == .search - } - .onChange(of: items) { oldItems, newItems in - if oldItems.isEmpty && !newItems.isEmpty && focusedField == nil { - setupInitialFocus() - } - } - // .onKeyPress(.downArrow) { - // selectNextItem() - // return .handled - // } - // .onKeyPress(.upArrow) { - // selectPreviousItem() - // return .handled - // } - // .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 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 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 { - await MainActor.run { - focusedField = .search - } - } - } catch is CancellationError { - // no action here - } catch { - print("Error during selection debounce sleep: \(error)") - } - } - } - - private func setupInitialFocus() { - if focusedField == nil && !items.isEmpty { - focusedField = .item(AnyHashable(items[0].id)) - selectedItem = items[0] - } else if focusedField == nil { - focusedField = .search - } - viewState.lastKnownFocus = focusedField - } -} From 88cba5974337d8e94041bc0ee21e3488db4fade3 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sat, 5 Apr 2025 01:58:43 -0700 Subject: [PATCH 07/11] feat: re-implement search code cleanup --- Bulkhead/UI/ContainerDetailView.swift | 56 +----------- Bulkhead/UI/ContainerSummaryView.swift | 2 +- Bulkhead/UI/ContentView.swift | 110 +++++++++++++++--------- Bulkhead/UI/DockerUIApp.swift | 17 ++-- Bulkhead/UI/FilesystemBrowserView.swift | 1 - Bulkhead/UI/ImageDetailView.swift | 26 ------ Bulkhead/UI/SearchField.swift | 13 +-- 7 files changed, 85 insertions(+), 140 deletions(-) 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/ContainerSummaryView.swift b/Bulkhead/UI/ContainerSummaryView.swift index 88fc2bc..ae93816 100644 --- a/Bulkhead/UI/ContainerSummaryView.swift +++ b/Bulkhead/UI/ContainerSummaryView.swift @@ -54,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 88d99fa..d42834d 100644 --- a/Bulkhead/UI/ContentView.swift +++ b/Bulkhead/UI/ContentView.swift @@ -1,19 +1,12 @@ import Foundation 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 = "" -} - // Define Environment Key for Global Error State struct IsGlobalErrorShowingKey: EnvironmentKey { static let defaultValue = false @@ -41,78 +34,115 @@ struct ContentView: View { @Environment(\.colorScheme) private var colorScheme @State private var selectedContainer: DockerContainer? @State private var selectedImage: DockerImage? + @FocusState.Binding var focusState: ListViewFocusTarget? + + @State private var imageSearchText = "" + @State private var containerSearchText = "" + + 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) + } + } - @State private var searchText = "" - @Binding private var searchFocused: Bool + 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(containerSearchText) + } + } // Updated init init( selectedTab: Binding, - searchFocused: Binding, manager: DockerManager, - appEnv: ApplicationEnvironment + 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 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 NavigationSplitView { TabView(selection: $selectedTab) { - List(publication.containers, selection: $selectedContainer) { container in - NavigationLink { - ContainerDetailView(container: container, appEnv: appEnv) - } label: { - ContainerSummaryView(container: container, manager: appEnv.manager, appEnv: appEnv) + VStack { + SearchField( + placeholder: "Search Containers . . .", + text: $containerSearchText, + focusBinding: $focusState, + focusCase: .search, + options: nil + ) + + List(filteredContainers, selection: $selectedContainer) { container in + NavigationLink { + ContainerDetailView(container: container, appEnv: appEnv) + } label: { + ContainerSummaryView(container: container, manager: appEnv.manager, appEnv: appEnv) + .focused($focusState, equals: .item(container)) + } } - .padding(4) } + .padding(4) .tabItem { Label("Containers", systemImage: "shippingbox.fill") } .tag(MainTabs.containers) - List(publication.images, selection: $selectedImage) { image in - NavigationLink { - ImageDetailView(image: image, appEnv: appEnv) - } label: { - ImageSummaryView(image: image) + VStack { + SearchField( + placeholder: "Search Images . . .", + text: $imageSearchText, + focusBinding: $focusState, + focusCase: .search, + options: nil + ) + + List(filteredImages, selection: $selectedImage) { image in + NavigationLink { + ImageDetailView(image: image, appEnv: appEnv) + .focused($focusState, equals: .item(image)) + } label: { + ImageSummaryView(image: image) + } } } .tabItem { Label("Images", systemImage: "photo.stack.fill") } .tag(MainTabs.images) - } } detail: { Text("Select an object") } + .navigationSplitViewColumnWidth(min: 250, ideal: 320, max: 800) .onKeyPress( .escape, action: { - print("NOTICE ME: content view received escape \(searchFocused)") - searchText = "" - return searchFocused ? .handled : .ignored + 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 9747bbd..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() @@ -23,7 +24,8 @@ struct DockerUIApp: App { private var manager: DockerManager { appEnv.manager } @Environment(\.openWindow) private var openWindow @State private var selectedTab = MainTabs.containers - @State private var isSearchFocused = false + + @FocusState private var focusState: ListViewFocusTarget? init() { // needed for the protocol @@ -33,9 +35,9 @@ struct DockerUIApp: App { WindowGroup { ContentView( selectedTab: $selectedTab, - searchFocused: $isSearchFocused, manager: manager, - appEnv: appEnv + appEnv: appEnv, + focusState: $focusState ) .environmentObject(appEnv.logManager) .environmentObject(appEnv.publication) @@ -71,6 +73,10 @@ struct DockerUIApp: App { openWindow(id: "Log") } .keyboardShortcut("l", modifiers: [.command, .shift]) + Button("Search") { + focusState = .search + } + .keyboardShortcut("f") Divider() @@ -84,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/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/SearchField.swift b/Bulkhead/UI/SearchField.swift index 78e9430..5992136 100644 --- a/Bulkhead/UI/SearchField.swift +++ b/Bulkhead/UI/SearchField.swift @@ -8,27 +8,20 @@ struct SearchOptions { var modifiers: EventModifiers = .command } -struct SearchConfiguration { +struct SearchField: View { let placeholder: String - let filter: (T, String) -> Bool - var options = SearchOptions() -} - -struct SearchField: View { - let config: SearchConfiguration @Binding var text: String @FocusState.Binding var focusBinding: ListViewFocusTarget? let focusCase: ListViewFocusTarget + let options: SearchOptions? var body: some View { HStack { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) - TextField(config.placeholder, text: $text) + TextField(placeholder, text: $text) .textFieldStyle(.plain) .onKeyPress(.escape) { - print("NOTICE ME: search received escape") - DispatchQueue.main.async { text = "" } From 244b87bb1034956bc882fd0661e15f9abc2b1d3b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sat, 5 Apr 2025 01:59:04 -0700 Subject: [PATCH 08/11] chore: lint --- .swiftlint.yml | 6 +----- Bulkhead/Docker/DockerExecutor.swift | 2 -- Bulkhead/Docker/DockerManager.swift | 21 ++++++++++----------- Bulkhead/Docker/LogFetcher.swift | 2 -- Bulkhead/Docker/LogManager.swift | 1 - 5 files changed, 11 insertions(+), 21 deletions(-) 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/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 { From fc2738c4f04726f9560dce74f01f0a2fdd588351 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sat, 5 Apr 2025 01:59:45 -0700 Subject: [PATCH 09/11] chore: version bump --- Bulkhead.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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; From b2d10f3f7eeccdb922d1bfe144f9d8e59189a61c Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sat, 5 Apr 2025 02:11:23 -0700 Subject: [PATCH 10/11] fix: search images correctly --- Bulkhead/UI/ContentView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bulkhead/UI/ContentView.swift b/Bulkhead/UI/ContentView.swift index d42834d..313753d 100644 --- a/Bulkhead/UI/ContentView.swift +++ b/Bulkhead/UI/ContentView.swift @@ -36,8 +36,8 @@ struct ContentView: View { @State private var selectedImage: DockerImage? @FocusState.Binding var focusState: ListViewFocusTarget? - @State private var imageSearchText = "" @State private var containerSearchText = "" + @State private var imageSearchText = "" var filteredContainers: [DockerContainer] { publication.containers.filter { it in @@ -51,7 +51,7 @@ struct ContentView: View { publication.images.filter { it in guard imageSearchText != "" else { return true } guard let firstTag = it.RepoTags?.first else { return false } - return firstTag.contains(containerSearchText) + return firstTag.contains(imageSearchText) } } From 9966694bff7eac841b79e2da00e027e00ee47b01 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sat, 5 Apr 2025 03:26:47 -0700 Subject: [PATCH 11/11] feat: re-implement selection stickines --- Bulkhead/UI/ContentView.swift | 45 +++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/Bulkhead/UI/ContentView.swift b/Bulkhead/UI/ContentView.swift index 313753d..0244452 100644 --- a/Bulkhead/UI/ContentView.swift +++ b/Bulkhead/UI/ContentView.swift @@ -32,11 +32,14 @@ struct ContentView: View { let appEnv: ApplicationEnvironment @Binding var selectedTab: MainTabs @Environment(\.colorScheme) private var colorScheme - @State private var selectedContainer: DockerContainer? - @State private var selectedImage: DockerImage? @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? + @State private var lastImage: DockerImage? = nil @State private var imageSearchText = "" var filteredContainers: [DockerContainer] { @@ -88,7 +91,7 @@ struct ContentView: View { options: nil ) - List(filteredContainers, selection: $selectedContainer) { container in + List(filteredContainers, id: \.self, selection: $selectedContainer) { container in NavigationLink { ContainerDetailView(container: container, appEnv: appEnv) } label: { @@ -96,6 +99,15 @@ struct ContentView: View { .focused($focusState, equals: .item(container)) } } + .onChange(of: publication.containers) { _, n in + guard selectedContainer == nil && lastContainer == nil else { return } + if let firstContainer = filteredContainers.first { + DispatchQueue.main.async { + selectedContainer = firstContainer + } + } + } + } .padding(4) .tabItem { @@ -112,7 +124,7 @@ struct ContentView: View { options: nil ) - List(filteredImages, selection: $selectedImage) { image in + List(filteredImages, id: \.self, selection: $selectedImage) { image in NavigationLink { ImageDetailView(image: image, appEnv: appEnv) .focused($focusState, equals: .item(image)) @@ -120,11 +132,33 @@ struct ContentView: View { 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") @@ -134,6 +168,7 @@ struct ContentView: View { .onKeyPress( .escape, action: { + .onKeyPress(.escape) { if focusState == .search { if selectedTab == .containers { containerSearchText = "" @@ -143,6 +178,6 @@ struct ContentView: View { return .handled } return .ignored - }) + } } }