diff options
| author | Christian Cleberg <[email protected]> | 2026-04-23 17:39:13 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-23 17:39:13 -0500 |
| commit | 66601a8091a73f3e31f4fb534c38b3eca4756e93 (patch) | |
| tree | f85cd883e63177d25dfe5993a4d545efa4812923 | |
| parent | 380fb866d090c5f13b2ad3f88f1fe23c4ade4801 (diff) | |
| download | hutch-3.2.1.tar.gz hutch-3.2.1.tar.bz2 hutch-3.2.1.zip | |
fix: profile loading error and user-timeline prefs updatev3.2.1
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/App/AppStorageKeys.swift | 1 | ||||
| -rw-r--r-- | Hutch/Models/Meta.swift | 8 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListView.swift | 26 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListViewModel.swift | 28 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 3 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 4 | ||||
| -rw-r--r-- | Hutch/Views/More/ProfileView.swift | 4 | ||||
| -rw-r--r-- | Hutch/Views/Settings/SettingsViewModel.swift | 4 | ||||
| -rw-r--r-- | HutchTests/BuildListViewModelTests.swift | 58 | ||||
| -rw-r--r-- | HutchTests/SettingsViewModelTests.swift | 55 |
11 files changed, 186 insertions, 21 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 3d4f7a2..bf0dee0 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -517,7 +517,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 80; + CURRENT_PROJECT_VERSION = 81; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -534,7 +534,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.2.0; + MARKETING_VERSION = 3.2.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -554,7 +554,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 80; + CURRENT_PROJECT_VERSION = 81; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -571,7 +571,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.2.0; + MARKETING_VERSION = 3.2.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -634,7 +634,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 80; + CURRENT_PROJECT_VERSION = 81; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -644,7 +644,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.2.0; + MARKETING_VERSION = 3.2.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -663,7 +663,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 80; + CURRENT_PROJECT_VERSION = 81; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -673,7 +673,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.2.0; + MARKETING_VERSION = 3.2.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 30cd0d0..de3ce54 100644 --- a/Hutch/App/AppStorageKeys.swift +++ b/Hutch/App/AppStorageKeys.swift @@ -18,6 +18,7 @@ enum AppStorageKeys { static let homeBuildsExpanded = "homeBuildsExpanded" static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" static let buildsRepoFilter = "buildsRepoFilter" + static let buildsLookbackDays = "buildsLookbackDays" static let ticketFilterState = "ticketFilterState" static let ticketSavedFilters = "ticketSavedFilters" static let appTheme = "appTheme" diff --git a/Hutch/Models/Meta.swift b/Hutch/Models/Meta.swift index c0434bc..91bd7e3 100644 --- a/Hutch/Models/Meta.swift +++ b/Hutch/Models/Meta.swift @@ -22,10 +22,16 @@ struct UserProfile: Codable, Sendable { struct SSHKey: Codable, Sendable, Identifiable { let id: Int - let fingerprint: String let comment: String? let created: Date let lastUsed: Date? + + var displayLabel: String { + if let comment, !comment.isEmpty { + return comment + } + return "SSH key #\(id)" + } } struct SSHKeyPage: Codable, Sendable { diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift index 8de2220..20414d6 100644 --- a/Hutch/Views/Builds/BuildListView.swift +++ b/Hutch/Views/Builds/BuildListView.swift @@ -4,6 +4,8 @@ struct BuildListView: View { @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0 @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = "" + @AppStorage(AppStorageKeys.buildsLookbackDays, store: .standard) + private var lookbackDays = BuildListViewModel.defaultLookbackDays @Environment(AppState.self) private var appState @Environment(\.isAMOLEDTheme) private var isAMOLED @State private var viewModel: BuildListViewModel? @@ -65,6 +67,20 @@ struct BuildListView: View { } } } + Section("Timeframe") { + ForEach(HomeViewModel.allowedFailedBuildLookbackDays, id: \.self) { days in + Button { + lookbackDays = days + viewModel.lookbackDays = days + } label: { + if lookbackDays == days { + Label(HomeViewModel.failedBuildLookbackLabel(days: days), systemImage: "checkmark") + } else { + Text(HomeViewModel.failedBuildLookbackLabel(days: days)) + } + } + } + } } label: { Image(systemName: "line.3.horizontal.decrease.circle") } @@ -107,6 +123,7 @@ struct BuildListView: View { if viewModel == nil { let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults) vm.repoFilter = savedRepoFilter + vm.lookbackDays = lookbackDays viewModel = vm await vm.loadJobs() } @@ -114,6 +131,9 @@ struct BuildListView: View { // onDisappear stops it when navigating away. viewModel?.startAutoRefresh(interval: autoRefreshInterval) } + .onChange(of: lookbackDays) { _, newValue in + viewModel?.lookbackDays = newValue + } .onDisappear { viewModel?.stopAutoRefresh() } @@ -236,6 +256,12 @@ struct BuildListView: View { systemImage: "magnifyingglass", description: Text("No builds matched “\(viewModel.searchText)”.") ) + } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty { + ContentUnavailableView( + "No Builds In Timeframe", + systemImage: "calendar.badge.clock", + description: Text("No builds were updated \(HomeViewModel.failedBuildLookbackLabel(days: lookbackDays)).") + ) } else if viewModel.jobs.isEmpty, viewModel.error == nil { ContentUnavailableView( "No Builds", diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift index cf5d786..14f50fd 100644 --- a/Hutch/Views/Builds/BuildListViewModel.swift +++ b/Hutch/Views/Builds/BuildListViewModel.swift @@ -45,6 +45,7 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable { @MainActor final class BuildListViewModel { private static let searchHistoryScopeID = "builds" + nonisolated static let defaultLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays private(set) var jobs: [JobSummary] = [] { didSet { updateFilteredJobs() } @@ -68,6 +69,9 @@ final class BuildListViewModel { updateFilteredJobs() } } + var lookbackDays = defaultLookbackDays { + didSet { updateFilteredJobs() } + } // Cached filtered result. Updated whenever jobs, filter, searchText, or // repoFilter changes. Only notifies observers when the content actually // differs, which prevents the list from re-rendering on auto-refresh when @@ -99,7 +103,7 @@ final class BuildListViewModel { } private func updateFilteredJobs() { - var result = Self.filterJobs(jobs, filter: filter) + var result = Self.filterJobs(jobs, filter: filter, lookbackDays: lookbackDays) if !repoFilter.isEmpty { result = result.filter { $0.tags.contains(repoFilter) } } @@ -382,8 +386,14 @@ final class BuildListViewModel { let cancel: CancelResult } - nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] { - jobs.filter { job in + nonisolated static func filterJobs( + _ jobs: [JobSummary], + filter: BuildListFilter, + lookbackDays: Int, + now: Date = .now, + calendar: Calendar = .current + ) -> [JobSummary] { + let filteredByStatus = jobs.filter { job in switch filter { case .attention: switch job.status { @@ -403,6 +413,18 @@ final class BuildListViewModel { return true } } + + let normalizedLookbackDays = HomeViewModel.allowedFailedBuildLookbackDays.contains(lookbackDays) + ? lookbackDays + : defaultLookbackDays + let startOfToday = calendar.startOfDay(for: now) + let windowStart = calendar.date( + byAdding: .day, + value: -(normalizedLookbackDays - 1), + to: startOfToday + ) ?? startOfToday + + return filteredByStatus.filter { $0.updated >= windowStart } } nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] { diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index 406e2ad..9e06a00 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -56,6 +56,9 @@ struct HomeView: View { loadRecentActivity() } } + .onChange(of: failedBuildLookbackDays) { _, _ in + viewModel?.refreshNeedsAttentionSnapshot() + } } @ViewBuilder diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index 7ef82e9..142eee4 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -670,6 +670,10 @@ final class HomeViewModel { persistNeedsAttentionSnapshot() } + func refreshNeedsAttentionSnapshot() { + persistNeedsAttentionSnapshot() + } + private func loadProjects() async -> Result<[Project], Error> { do { return .success(try await projectService.fetchProjects()) diff --git a/Hutch/Views/More/ProfileView.swift b/Hutch/Views/More/ProfileView.swift index 8f353ee..45f7d06 100644 --- a/Hutch/Views/More/ProfileView.swift +++ b/Hutch/Views/More/ProfileView.swift @@ -243,7 +243,7 @@ struct ProfileView: View { Section { ForEach(viewModel.sshKeys) { key in VStack(alignment: .leading, spacing: 2) { - Text(key.fingerprint) + Text(key.displayLabel) .font(.caption.monospaced()) .lineLimit(1) .truncationMode(.middle) @@ -677,7 +677,7 @@ private enum ProfileDestructiveAction { var message: String { switch self { case .deleteSSHKey(let key): - "Remove SSH key \(key.fingerprint) from your account?" + "Remove \(key.displayLabel) from your account?" case .deletePGPKey(let key): "Remove PGP key \(key.fingerprint) from your account?" } diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift index 0b55e6a..edfaad4 100644 --- a/Hutch/Views/Settings/SettingsViewModel.swift +++ b/Hutch/Views/Settings/SettingsViewModel.swift @@ -90,7 +90,7 @@ final class SettingsViewModel { avatar userType sshKeys { - results { id fingerprint comment created lastUsed } + results { id comment created lastUsed } cursor } pgpKeys { @@ -114,7 +114,7 @@ final class SettingsViewModel { private static let createSSHKeyMutation = """ mutation createSSHKey($key: String!) { createSSHKey(key: $key) { - id fingerprint comment created lastUsed + id comment created lastUsed } } """ diff --git a/HutchTests/BuildListViewModelTests.swift b/HutchTests/BuildListViewModelTests.swift index fccd7cf..aa2e46b 100644 --- a/HutchTests/BuildListViewModelTests.swift +++ b/HutchTests/BuildListViewModelTests.swift @@ -49,22 +49,70 @@ struct BuildListViewModelTests { makeJob(id: 4, status: .cancelled, tags: []) ] - let attention = BuildListViewModel.filterJobs(jobs, filter: .attention) - let active = BuildListViewModel.filterJobs(jobs, filter: .active) + let attention = BuildListViewModel.filterJobs( + jobs, + filter: .attention, + lookbackDays: BuildListViewModel.defaultLookbackDays + ) + let active = BuildListViewModel.filterJobs( + jobs, + filter: .active, + lookbackDays: BuildListViewModel.defaultLookbackDays + ) #expect(attention.map(\.id) == [2, 3]) #expect(active.map(\.id) == [3]) } + @Test + func buildFilterRestrictsJobsToSelectedLookbackWindow() { + let now = Date(timeIntervalSince1970: 60 * 60 * 24 * 20) + let jobs = [ + makeJob( + id: 1, + status: .failed, + tags: [], + updated: now.addingTimeInterval(-(60 * 60 * 24)) + ), + makeJob( + id: 2, + status: .running, + tags: [], + updated: now.addingTimeInterval(-(60 * 60 * 24 * 8)) + ), + makeJob( + id: 3, + status: .success, + tags: [], + updated: now.addingTimeInterval(-(60 * 60 * 24 * 2)) + ), + ] + + let filtered = BuildListViewModel.filterJobs( + jobs, + filter: .all, + lookbackDays: 3, + now: now, + calendar: Calendar(identifier: .gregorian) + ) + + #expect(filtered.map(\.id) == [1, 3]) + } + private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] { BuildListViewModel.searchJobs(jobs, matching: query) } - private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary { + private func makeJob( + id: Int, + status: JobStatus = .success, + tags: [String], + updated: Date = Date() + ) -> JobSummary { JobSummary( id: id, - created: Date(), - updated: Date(), + created: updated, + updated: updated, status: status, note: nil, tags: tags, diff --git a/HutchTests/SettingsViewModelTests.swift b/HutchTests/SettingsViewModelTests.swift index fc18162..9e4b912 100644 --- a/HutchTests/SettingsViewModelTests.swift +++ b/HutchTests/SettingsViewModelTests.swift @@ -2,6 +2,37 @@ import Foundation import Testing @testable import Hutch +private final class SettingsViewModelCapturingURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var capturedRequests: [URLRequest] = [] + + override class func canInit(with _: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.capturedRequests.append(request) + + let response = HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: nil, + headerFields: nil + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() { + // No cleanup is needed because the stub responds immediately in `startLoading()`. + } + + static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [Self.self] + return URLSession(configuration: config) + } +} + private struct DeletePGPKeyEnvelope: Decodable { let deletePGPKey: DeleteResultPayload? } @@ -10,6 +41,7 @@ private struct DeleteResultPayload: Decodable { let id: Int? } +@Suite(.serialized) struct SettingsViewModelTests { @Test @@ -36,4 +68,27 @@ struct SettingsViewModelTests { #expect(decoded.data?.deletePGPKey == nil) #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true) } + + @Test + @MainActor + func loadProfileDoesNotRequestSSHKeyFingerprintField() async throws { + SettingsViewModelCapturingURLProtocol.capturedRequests = [] + + let client = SRHTClient( + session: SettingsViewModelCapturingURLProtocol.makeSession(), + token: "test-token" + ) + let viewModel = SettingsViewModel(client: client) + + await viewModel.loadProfile() + + let request = try #require(SettingsViewModelCapturingURLProtocol.capturedRequests.first) + let body = try #require(request.httpBody) + let jsonObject = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + let query = try #require(jsonObject["query"] as? String) + + #expect(query.contains("sshKeys")) + #expect(!query.contains("results { id fingerprint comment created lastUsed }")) + #expect(!query.contains("fingerprint comment created lastUsed")) + } } |
