summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/App/AppStorageKeys.swift2
-rw-r--r--Hutch/Views/Builds/BuildListView.swift58
-rw-r--r--Hutch/Views/Builds/BuildListViewModel.swift66
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 = """