Commit 301b072f3b
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | ||
| 515 | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 36; | |
| 518 | CURRENT_PROJECT_VERSION = 37; | |
| 519 | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | 520 | ENABLE_PREVIEWS = YES; |
| 521 | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | ||
| 532 | 532 | "$(inherited)", |
| 533 | 533 | "@executable_path/Frameworks", |
| 534 | 534 | ); |
| 535 | MARKETING_VERSION = 2.16.0; | |
| 535 | MARKETING_VERSION = 2.16.1; | |
| 536 | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | ||
| 552 | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 36; | |
| 555 | CURRENT_PROJECT_VERSION = 37; | |
| 556 | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | 557 | ENABLE_PREVIEWS = YES; |
| 558 | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | ||
| 569 | 569 | "$(inherited)", |
| 570 | 570 | "@executable_path/Frameworks", |
| 571 | 571 | ); |
| 572 | MARKETING_VERSION = 2.16.0; | |
| 572 | MARKETING_VERSION = 2.16.1; | |
| 573 | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | ||
| 632 | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 36; | |
| 635 | CURRENT_PROJECT_VERSION = 37; | |
| 636 | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | ||
| 642 | 642 | "@executable_path/Frameworks", |
| 643 | 643 | "@executable_path/../../Frameworks", |
| 644 | 644 | ); |
| 645 | MARKETING_VERSION = 2.16.0; | |
| 645 | MARKETING_VERSION = 2.16.1; | |
| 646 | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | ||
| 661 | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 36; | |
| 664 | CURRENT_PROJECT_VERSION = 37; | |
| 665 | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | ||
| 671 | 671 | "@executable_path/Frameworks", |
| 672 | 672 | "@executable_path/../../Frameworks", |
| 673 | 673 | ); |
| 674 | MARKETING_VERSION = 2.16.0; | |
| 674 | MARKETING_VERSION = 2.16.1; | |
| 675 | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | 677 | SKIP_INSTALL = YES; |
Hutch/App/AppStorageKeys.swift +2
| @@ -11,4 +11,6 @@ enum AppStorageKeys { | ||
| 11 | 11 | static let homeProjectsExpanded = "homeProjectsExpanded" |
| 12 | 12 | static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" |
| 13 | 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 | 3 | struct BuildListView: View { |
| 4 | 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true |
| 5 | @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0 | |
| 6 | @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = "" | |
| 5 | 7 | @Environment(AppState.self) private var appState |
| 6 | 8 | @State private var viewModel: BuildListViewModel? |
| 7 | 9 | @State private var showSubmitSheet = false |
| 8 | 10 | @State private var submittedJobId: Int? |
| 9 | 11 | |
| 12 | private var autoRefreshInterval: AutoRefreshInterval { | |
| 13 | AutoRefreshInterval(rawValue: autoRefreshRawValue) ?? .off | |
| 14 | } | |
| 15 | ||
| 10 | 16 | var body: some View { |
| 11 | 17 | Group { |
| 12 | 18 | if let viewModel { |
| @@ -17,7 +23,52 @@ struct BuildListView: View { | ||
| 17 | 23 | } |
| 18 | 24 | .navigationTitle("Builds") |
| 19 | 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 | 72 | ToolbarItem(placement: .topBarTrailing) { |
| 22 | 73 | Button { |
| 23 | 74 | showSubmitSheet = true |
| @@ -54,10 +105,15 @@ struct BuildListView: View { | ||
| 54 | 105 | .task { |
| 55 | 106 | if viewModel == nil { |
| 56 | 107 | let vm = BuildListViewModel(client: appState.client) |
| 108 | vm.repoFilter = savedRepoFilter | |
| 57 | 109 | viewModel = vm |
| 58 | 110 | await vm.loadJobs() |
| 111 | vm.startAutoRefresh(interval: autoRefreshInterval) | |
| 59 | 112 | } |
| 60 | 113 | } |
| 114 | .onDisappear { | |
| 115 | viewModel?.stopAutoRefresh() | |
| 116 | } | |
| 61 | 117 | } |
| 62 | 118 | |
| 63 | 119 | @ViewBuilder |
Hutch/Views/Builds/BuildListViewModel.swift +63 −3
| @@ -25,6 +25,20 @@ enum BuildListFilter: String, CaseIterable, Sendable { | ||
| 25 | 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 | 42 | // MARK: - View Model |
| 29 | 43 | |
| 30 | 44 | @Observable |
| @@ -39,10 +53,15 @@ final class BuildListViewModel { | ||
| 39 | 53 | var error: String? |
| 40 | 54 | var filter: BuildListFilter = .attention |
| 41 | 55 | var searchText = "" |
| 56 | var repoFilter: String = "" { | |
| 57 | didSet { if repoFilter != oldValue { repoFilterDidChange() } } | |
| 58 | } | |
| 42 | 59 | |
| 43 | 60 | private var cursor: String? |
| 44 | 61 | private var hasMore = true |
| 45 | 62 | private let client: SRHTClient |
| 63 | private var refreshTask: Task<Void, Never>? | |
| 64 | private var isAutoRefreshing = false | |
| 46 | 65 | |
| 47 | 66 | private static let cacheKey = "builds.jobs" |
| 48 | 67 | |
| @@ -50,11 +69,22 @@ final class BuildListViewModel { | ||
| 50 | 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 | 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 | 85 | let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() |
| 56 | guard !q.isEmpty else { return statusFiltered } | |
| 57 | return statusFiltered.filter { | |
| 86 | guard !q.isEmpty else { return result } | |
| 87 | return result.filter { | |
| 58 | 88 | String($0.id).contains(q) || |
| 59 | 89 | $0.tags.contains { $0.lowercased().contains(q) } || |
| 60 | 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 | 125 | // MARK: - Query |
| 66 | 126 | |
| 67 | 127 | private static let query = """ |