Commit 301b072f3b

301b072f3bc1f267369975129869e122cc676d8a

parent: fbba92ac45

Unsigned

cmc <hello@cleberg.net> · 2026-04-12 23:59 UTC

feat: add builds auto-refresh and repo filtering

implements: https://todo.sr.ht/~ccleberg/hutch/17
implements: https://todo.sr.ht/~ccleberg/hutch/18

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
3struct BuildListView: View { 3struct 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
28enum 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 = """