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 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 36;
518 CURRENT_PROJECT_VERSION = 37;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 2.16.0;
535 MARKETING_VERSION = 2.16.1;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 36;
555 CURRENT_PROJECT_VERSION = 37;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 2.16.0;
572 MARKETING_VERSION = 2.16.1;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 36;
635 CURRENT_PROJECT_VERSION = 37;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 2.16.0;
645 MARKETING_VERSION = 2.16.1;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 36;
664 CURRENT_PROJECT_VERSION = 37;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 2.16.0;
674 MARKETING_VERSION = 2.16.1;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/App/AppStorageKeys.swift +2
@@ -11,4 +11,6 @@ enum AppStorageKeys {
1111 static let homeProjectsExpanded = "homeProjectsExpanded"
1212 static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded"
1313 static let homeBuildsExpanded = "homeBuildsExpanded"
14 static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
15 static let buildsRepoFilter = "buildsRepoFilter"
1416}
Hutch/Views/Builds/BuildListView.swift +57 −1
@@ -2,11 +2,17 @@ import SwiftUI
22
33struct BuildListView: View {
44 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
5 @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0
6 @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = ""
57 @Environment(AppState.self) private var appState
68 @State private var viewModel: BuildListViewModel?
79 @State private var showSubmitSheet = false
810 @State private var submittedJobId: Int?
911
12 private var autoRefreshInterval: AutoRefreshInterval {
13 AutoRefreshInterval(rawValue: autoRefreshRawValue) ?? .off
14 }
15
1016 var body: some View {
1117 Group {
1218 if let viewModel {
@@ -17,7 +23,52 @@ struct BuildListView: View {
1723 }
1824 .navigationTitle("Builds")
1925 .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 }
2172 ToolbarItem(placement: .topBarTrailing) {
2273 Button {
2374 showSubmitSheet = true
@@ -54,10 +105,15 @@ struct BuildListView: View {
54105 .task {
55106 if viewModel == nil {
56107 let vm = BuildListViewModel(client: appState.client)
108 vm.repoFilter = savedRepoFilter
57109 viewModel = vm
58110 await vm.loadJobs()
111 vm.startAutoRefresh(interval: autoRefreshInterval)
59112 }
60113 }
114 .onDisappear {
115 viewModel?.stopAutoRefresh()
116 }
61117 }
62118
63119 @ViewBuilder
Hutch/Views/Builds/BuildListViewModel.swift +63 −3
@@ -25,6 +25,20 @@ enum BuildListFilter: String, CaseIterable, Sendable {
2525 case all = "All"
2626}
2727
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
2842// MARK: - View Model
2943
3044@Observable
@@ -39,10 +53,15 @@ final class BuildListViewModel {
3953 var error: String?
4054 var filter: BuildListFilter = .attention
4155 var searchText = ""
56 var repoFilter: String = "" {
57 didSet { if repoFilter != oldValue { repoFilterDidChange() } }
58 }
4259
4360 private var cursor: String?
4461 private var hasMore = true
4562 private let client: SRHTClient
63 private var refreshTask: Task<Void, Never>?
64 private var isAutoRefreshing = false
4665
4766 private static let cacheKey = "builds.jobs"
4867
@@ -50,11 +69,22 @@ final class BuildListViewModel {
5069 self.client = client
5170 }
5271
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
5378 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
5585 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 {
5888 String($0.id).contains(q) ||
5989 $0.tags.contains { $0.lowercased().contains(q) } ||
6090 ($0.note?.lowercased().contains(q) == true) ||
@@ -62,6 +92,36 @@ final class BuildListViewModel {
6292 }
6393 }
6494
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
65125 // MARK: - Query
66126
67127 private static let query = """