krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

b3d3e68e742650cb8a448a1f51fea7fb7783c36f

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-12T23:59:34Z

feat: add builds auto-refresh and repo filtering

implements: https://todo.sr.ht/~ccleberg/hutch/17
implements: https://todo.sr.ht/~ccleberg/hutch/18
 Hutch.xcodeproj/project.pbxproj             | 16 +++----
 Hutch/App/AppStorageKeys.swift              |  2 +
 Hutch/Views/Builds/BuildListView.swift      | 58 ++++++++++++++++++++++++-
 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 = """