Commit 301b072f3b
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | |||
| 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | CODE_SIGN_STYLE = Automatic; | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 36; | 518 | CURRENT_PROJECT_VERSION = 37; |
| 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | ENABLE_PREVIEWS = YES; | 520 | ENABLE_PREVIEWS = YES; |
| 521 | GENERATE_INFOPLIST_FILE = YES; | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | |||
| 532 | "$(inherited)", | 532 | "$(inherited)", |
| 533 | "@executable_path/Frameworks", | 533 | "@executable_path/Frameworks", |
| 534 | ); | 534 | ); |
| 535 | MARKETING_VERSION = 2.16.0; | 535 | MARKETING_VERSION = 2.16.1; |
| 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | PRODUCT_NAME = "$(TARGET_NAME)"; | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | |||
| 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | CODE_SIGN_STYLE = Automatic; | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 36; | 555 | CURRENT_PROJECT_VERSION = 37; |
| 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | ENABLE_PREVIEWS = YES; | 557 | ENABLE_PREVIEWS = YES; |
| 558 | GENERATE_INFOPLIST_FILE = YES; | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | |||
| 569 | "$(inherited)", | 569 | "$(inherited)", |
| 570 | "@executable_path/Frameworks", | 570 | "@executable_path/Frameworks", |
| 571 | ); | 571 | ); |
| 572 | MARKETING_VERSION = 2.16.0; | 572 | MARKETING_VERSION = 2.16.1; |
| 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | PRODUCT_NAME = "$(TARGET_NAME)"; | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | |||
| 632 | APPLICATION_EXTENSION_API_ONLY = YES; | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | CODE_SIGN_STYLE = Automatic; | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 36; | 635 | CURRENT_PROJECT_VERSION = 37; |
| 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | GENERATE_INFOPLIST_FILE = NO; | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | |||
| 642 | "@executable_path/Frameworks", | 642 | "@executable_path/Frameworks", |
| 643 | "@executable_path/../../Frameworks", | 643 | "@executable_path/../../Frameworks", |
| 644 | ); | 644 | ); |
| 645 | MARKETING_VERSION = 2.16.0; | 645 | MARKETING_VERSION = 2.16.1; |
| 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | PRODUCT_NAME = "$(TARGET_NAME)"; | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | SKIP_INSTALL = YES; | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | |||
| 661 | APPLICATION_EXTENSION_API_ONLY = YES; | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | CODE_SIGN_STYLE = Automatic; | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 36; | 664 | CURRENT_PROJECT_VERSION = 37; |
| 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | GENERATE_INFOPLIST_FILE = NO; | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | |||
| 671 | "@executable_path/Frameworks", | 671 | "@executable_path/Frameworks", |
| 672 | "@executable_path/../../Frameworks", | 672 | "@executable_path/../../Frameworks", |
| 673 | ); | 673 | ); |
| 674 | MARKETING_VERSION = 2.16.0; | 674 | MARKETING_VERSION = 2.16.1; |
| 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | PRODUCT_NAME = "$(TARGET_NAME)"; | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | SKIP_INSTALL = YES; | 677 | SKIP_INSTALL = YES; |
Hutch/App/AppStorageKeys.swift +2
| @@ -11,4 +11,6 @@ enum AppStorageKeys { | |||
| 11 | static let homeProjectsExpanded = "homeProjectsExpanded" | 11 | static let homeProjectsExpanded = "homeProjectsExpanded" |
| 12 | static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" | 12 | static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" |
| 13 | static let homeBuildsExpanded = "homeBuildsExpanded" | 13 | static let homeBuildsExpanded = "homeBuildsExpanded" |
| 14 | static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" | ||
| 15 | static let buildsRepoFilter = "buildsRepoFilter" | ||
| 14 | } | 16 | } |
Hutch/Views/Builds/BuildListView.swift +57 −1
| @@ -2,11 +2,17 @@ import SwiftUI | |||
| 2 | 2 | ||
| 3 | struct BuildListView: View { | 3 | struct BuildListView: View { |
| 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true |
| 5 | @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0 | ||
| 6 | @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = "" | ||
| 5 | @Environment(AppState.self) private var appState | 7 | @Environment(AppState.self) private var appState |
| 6 | @State private var viewModel: BuildListViewModel? | 8 | @State private var viewModel: BuildListViewModel? |
| 7 | @State private var showSubmitSheet = false | 9 | @State private var showSubmitSheet = false |
| 8 | @State private var submittedJobId: Int? | 10 | @State private var submittedJobId: Int? |
| 9 | 11 | ||
| 12 | private var autoRefreshInterval: AutoRefreshInterval { | ||
| 13 | AutoRefreshInterval(rawValue: autoRefreshRawValue) ?? .off | ||
| 14 | } | ||
| 15 | |||
| 10 | var body: some View { | 16 | var body: some View { |
| 11 | Group { | 17 | Group { |
| 12 | if let viewModel { | 18 | if let viewModel { |
| @@ -17,7 +23,52 @@ struct BuildListView: View { | |||
| 17 | } | 23 | } |
| 18 | .navigationTitle("Builds") | 24 | .navigationTitle("Builds") |
| 19 | .toolbar { | 25 | .toolbar { |
| 20 | if viewModel != nil { | 26 | if let viewModel { |
| 27 | ToolbarItem(placement: .topBarLeading) { | ||
| 28 | Menu { | ||
| 29 | Section("Auto-Refresh") { | ||
| 30 | ForEach(AutoRefreshInterval.allCases, id: \.self) { interval in | ||
| 31 | Button { | ||
| 32 | autoRefreshRawValue = interval.rawValue | ||
| 33 | viewModel.startAutoRefresh(interval: interval) | ||
| 34 | } label: { | ||
| 35 | if interval.rawValue == autoRefreshRawValue { | ||
| 36 | Label(interval.label, systemImage: "checkmark") | ||
| 37 | } else { | ||
| 38 | Text(interval.label) | ||
| 39 | } | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | Section("Filter by Tag") { | ||
| 44 | Button { | ||
| 45 | savedRepoFilter = "" | ||
| 46 | viewModel.repoFilter = "" | ||
| 47 | } label: { | ||
| 48 | if savedRepoFilter.isEmpty { | ||
| 49 | Label("All", systemImage: "checkmark") | ||
| 50 | } else { | ||
| 51 | Text("All") | ||
| 52 | } | ||
| 53 | } | ||
| 54 | ForEach(viewModel.availableTags, id: \.self) { tag in | ||
| 55 | Button { | ||
| 56 | savedRepoFilter = tag | ||
| 57 | viewModel.repoFilter = tag | ||
| 58 | } label: { | ||
| 59 | if savedRepoFilter == tag { | ||
| 60 | Label(tag, systemImage: "checkmark") | ||
| 61 | } else { | ||
| 62 | Text(tag) | ||
| 63 | } | ||
| 64 | } | ||
| 65 | } | ||
| 66 | } | ||
| 67 | } label: { | ||
| 68 | Image(systemName: "line.3.horizontal.decrease.circle") | ||
| 69 | } | ||
| 70 | .accessibilityLabel("Build filters") | ||
| 71 | } | ||
| 21 | ToolbarItem(placement: .topBarTrailing) { | 72 | ToolbarItem(placement: .topBarTrailing) { |
| 22 | Button { | 73 | Button { |
| 23 | showSubmitSheet = true | 74 | showSubmitSheet = true |
| @@ -54,10 +105,15 @@ struct BuildListView: View { | |||
| 54 | .task { | 105 | .task { |
| 55 | if viewModel == nil { | 106 | if viewModel == nil { |
| 56 | let vm = BuildListViewModel(client: appState.client) | 107 | let vm = BuildListViewModel(client: appState.client) |
| 108 | vm.repoFilter = savedRepoFilter | ||
| 57 | viewModel = vm | 109 | viewModel = vm |
| 58 | await vm.loadJobs() | 110 | await vm.loadJobs() |
| 111 | vm.startAutoRefresh(interval: autoRefreshInterval) | ||
| 59 | } | 112 | } |
| 60 | } | 113 | } |
| 114 | .onDisappear { | ||
| 115 | viewModel?.stopAutoRefresh() | ||
| 116 | } | ||
| 61 | } | 117 | } |
| 62 | 118 | ||
| 63 | @ViewBuilder | 119 | @ViewBuilder |
Hutch/Views/Builds/BuildListViewModel.swift +63 −3
| @@ -25,6 +25,20 @@ enum BuildListFilter: String, CaseIterable, Sendable { | |||
| 25 | case all = "All" | 25 | case all = "All" |
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | enum AutoRefreshInterval: Int, CaseIterable, Sendable { | ||
| 29 | case off = 0 | ||
| 30 | case fiveSeconds = 5 | ||
| 31 | case tenSeconds = 10 | ||
| 32 | |||
| 33 | var label: String { | ||
| 34 | switch self { | ||
| 35 | case .off: "Off" | ||
| 36 | case .fiveSeconds: "5s" | ||
| 37 | case .tenSeconds: "10s" | ||
| 38 | } | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 28 | // MARK: - View Model | 42 | // MARK: - View Model |
| 29 | 43 | ||
| 30 | @Observable | 44 | @Observable |
| @@ -39,10 +53,15 @@ final class BuildListViewModel { | |||
| 39 | var error: String? | 53 | var error: String? |
| 40 | var filter: BuildListFilter = .attention | 54 | var filter: BuildListFilter = .attention |
| 41 | var searchText = "" | 55 | var searchText = "" |
| 56 | var repoFilter: String = "" { | ||
| 57 | didSet { if repoFilter != oldValue { repoFilterDidChange() } } | ||
| 58 | } | ||
| 42 | 59 | ||
| 43 | private var cursor: String? | 60 | private var cursor: String? |
| 44 | private var hasMore = true | 61 | private var hasMore = true |
| 45 | private let client: SRHTClient | 62 | private let client: SRHTClient |
| 63 | private var refreshTask: Task<Void, Never>? | ||
| 64 | private var isAutoRefreshing = false | ||
| 46 | 65 | ||
| 47 | private static let cacheKey = "builds.jobs" | 66 | private static let cacheKey = "builds.jobs" |
| 48 | 67 | ||
| @@ -50,11 +69,22 @@ final class BuildListViewModel { | |||
| 50 | self.client = client | 69 | self.client = client |
| 51 | } | 70 | } |
| 52 | 71 | ||
| 72 | /// Unique tags across all loaded jobs, sorted alphabetically. | ||
| 73 | var availableTags: [String] { | ||
| 74 | let allTags = Set(jobs.flatMap(\.tags)) | ||
| 75 | return allTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } | ||
| 76 | } | ||
| 77 | |||
| 53 | var filteredJobs: [JobSummary] { | 78 | var filteredJobs: [JobSummary] { |
| 54 | let statusFiltered = Self.filterJobs(jobs, filter: filter) | 79 | var result = Self.filterJobs(jobs, filter: filter) |
| 80 | |||
| 81 | if !repoFilter.isEmpty { | ||
| 82 | result = result.filter { $0.tags.contains(repoFilter) } | ||
| 83 | } | ||
| 84 | |||
| 55 | let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | 85 | let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() |
| 56 | guard !q.isEmpty else { return statusFiltered } | 86 | guard !q.isEmpty else { return result } |
| 57 | return statusFiltered.filter { | 87 | return result.filter { |
| 58 | String($0.id).contains(q) || | 88 | String($0.id).contains(q) || |
| 59 | $0.tags.contains { $0.lowercased().contains(q) } || | 89 | $0.tags.contains { $0.lowercased().contains(q) } || |
| 60 | ($0.note?.lowercased().contains(q) == true) || | 90 | ($0.note?.lowercased().contains(q) == true) || |
| @@ -62,6 +92,36 @@ final class BuildListViewModel { | |||
| 62 | } | 92 | } |
| 63 | } | 93 | } |
| 64 | 94 | ||
| 95 | // MARK: - Auto-Refresh | ||
| 96 | |||
| 97 | func startAutoRefresh(interval: AutoRefreshInterval) { | ||
| 98 | stopAutoRefresh() | ||
| 99 | guard interval != .off else { return } | ||
| 100 | let seconds = interval.rawValue | ||
| 101 | refreshTask = Task { [weak self] in | ||
| 102 | while !Task.isCancelled { | ||
| 103 | try? await Task.sleep(for: .seconds(seconds)) | ||
| 104 | guard !Task.isCancelled, let self else { return } | ||
| 105 | guard !self.isAutoRefreshing, !self.isLoading, !self.isRefreshing else { continue } | ||
| 106 | self.isAutoRefreshing = true | ||
| 107 | await self.loadJobs() | ||
| 108 | self.isAutoRefreshing = false | ||
| 109 | } | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | func stopAutoRefresh() { | ||
| 114 | refreshTask?.cancel() | ||
| 115 | refreshTask = nil | ||
| 116 | } | ||
| 117 | |||
| 118 | private func repoFilterDidChange() { | ||
| 119 | // Reset to empty if the selected tag no longer exists | ||
| 120 | if !repoFilter.isEmpty, !availableTags.contains(repoFilter) { | ||
| 121 | repoFilter = "" | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 65 | // MARK: - Query | 125 | // MARK: - Query |
| 66 | 126 | ||
| 67 | private static let query = """ | 127 | private static let query = """ |