diff options
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/App/AppStorageKeys.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListView.swift | 58 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListViewModel.swift | 66 |
4 files changed, 130 insertions, 12 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 70c6c29..b1ee360 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -515,7 +515,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -532,7 +532,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.16.0; + MARKETING_VERSION = 2.16.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -552,7 +552,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.16.0; + MARKETING_VERSION = 2.16.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -632,7 +632,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -642,7 +642,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.16.0; + MARKETING_VERSION = 2.16.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -661,7 +661,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 37; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -671,7 +671,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.16.0; + MARKETING_VERSION = 2.16.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift index 68a9588..ff309d4 100644 --- a/Hutch/App/AppStorageKeys.swift +++ b/Hutch/App/AppStorageKeys.swift @@ -11,4 +11,6 @@ enum AppStorageKeys { static let homeProjectsExpanded = "homeProjectsExpanded" static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" static let homeBuildsExpanded = "homeBuildsExpanded" + static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" + static let buildsRepoFilter = "buildsRepoFilter" } diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift index 705f97c..b53d64b 100644 --- a/Hutch/Views/Builds/BuildListView.swift +++ b/Hutch/Views/Builds/BuildListView.swift @@ -2,11 +2,17 @@ import SwiftUI struct BuildListView: View { @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true + @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0 + @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = "" @Environment(AppState.self) private var appState @State private var viewModel: BuildListViewModel? @State private var showSubmitSheet = false @State private var submittedJobId: Int? + private var autoRefreshInterval: AutoRefreshInterval { + AutoRefreshInterval(rawValue: autoRefreshRawValue) ?? .off + } + var body: some View { Group { if let viewModel { @@ -17,7 +23,52 @@ struct BuildListView: View { } .navigationTitle("Builds") .toolbar { - if viewModel != nil { + if let viewModel { + ToolbarItem(placement: .topBarLeading) { + Menu { + Section("Auto-Refresh") { + ForEach(AutoRefreshInterval.allCases, id: \.self) { interval in + Button { + autoRefreshRawValue = interval.rawValue + viewModel.startAutoRefresh(interval: interval) + } label: { + if interval.rawValue == autoRefreshRawValue { + Label(interval.label, systemImage: "checkmark") + } else { + Text(interval.label) + } + } + } + } + Section("Filter by Tag") { + Button { + savedRepoFilter = "" + viewModel.repoFilter = "" + } label: { + if savedRepoFilter.isEmpty { + Label("All", systemImage: "checkmark") + } else { + Text("All") + } + } + ForEach(viewModel.availableTags, id: \.self) { tag in + Button { + savedRepoFilter = tag + viewModel.repoFilter = tag + } label: { + if savedRepoFilter == tag { + Label(tag, systemImage: "checkmark") + } else { + Text(tag) + } + } + } + } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + .accessibilityLabel("Build filters") + } ToolbarItem(placement: .topBarTrailing) { Button { showSubmitSheet = true @@ -54,10 +105,15 @@ struct BuildListView: View { .task { if viewModel == nil { let vm = BuildListViewModel(client: appState.client) + vm.repoFilter = savedRepoFilter viewModel = vm await vm.loadJobs() + vm.startAutoRefresh(interval: autoRefreshInterval) } } + .onDisappear { + viewModel?.stopAutoRefresh() + } } @ViewBuilder diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift index 8468cff..da29ca5 100644 --- a/Hutch/Views/Builds/BuildListViewModel.swift +++ b/Hutch/Views/Builds/BuildListViewModel.swift @@ -25,6 +25,20 @@ enum BuildListFilter: String, CaseIterable, Sendable { case all = "All" } +enum AutoRefreshInterval: Int, CaseIterable, Sendable { + case off = 0 + case fiveSeconds = 5 + case tenSeconds = 10 + + var label: String { + switch self { + case .off: "Off" + case .fiveSeconds: "5s" + case .tenSeconds: "10s" + } + } +} + // MARK: - View Model @Observable @@ -39,10 +53,15 @@ final class BuildListViewModel { var error: String? var filter: BuildListFilter = .attention var searchText = "" + var repoFilter: String = "" { + didSet { if repoFilter != oldValue { repoFilterDidChange() } } + } private var cursor: String? private var hasMore = true private let client: SRHTClient + private var refreshTask: Task<Void, Never>? + private var isAutoRefreshing = false private static let cacheKey = "builds.jobs" @@ -50,11 +69,22 @@ final class BuildListViewModel { self.client = client } + /// Unique tags across all loaded jobs, sorted alphabetically. + var availableTags: [String] { + let allTags = Set(jobs.flatMap(\.tags)) + return allTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + var filteredJobs: [JobSummary] { - let statusFiltered = Self.filterJobs(jobs, filter: filter) + var result = Self.filterJobs(jobs, filter: filter) + + if !repoFilter.isEmpty { + result = result.filter { $0.tags.contains(repoFilter) } + } + let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !q.isEmpty else { return statusFiltered } - return statusFiltered.filter { + guard !q.isEmpty else { return result } + return result.filter { String($0.id).contains(q) || $0.tags.contains { $0.lowercased().contains(q) } || ($0.note?.lowercased().contains(q) == true) || @@ -62,6 +92,36 @@ final class BuildListViewModel { } } + // MARK: - Auto-Refresh + + func startAutoRefresh(interval: AutoRefreshInterval) { + stopAutoRefresh() + guard interval != .off else { return } + let seconds = interval.rawValue + refreshTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(seconds)) + guard !Task.isCancelled, let self else { return } + guard !self.isAutoRefreshing, !self.isLoading, !self.isRefreshing else { continue } + self.isAutoRefreshing = true + await self.loadJobs() + self.isAutoRefreshing = false + } + } + } + + func stopAutoRefresh() { + refreshTask?.cancel() + refreshTask = nil + } + + private func repoFilterDidChange() { + // Reset to empty if the selected tag no longer exists + if !repoFilter.isEmpty, !availableTags.contains(repoFilter) { + repoFilter = "" + } + } + // MARK: - Query private static let query = """ |
