Commit aa49c4e3da

aa49c4e3da7e696a203b6ee9bff8a1a3a35023f4

parent: e1f56d98a0

Unsigned

cmc <hello@cleberg.net> · 2026-04-23 22:39 UTC

fix: profile loading error and user-timeline prefs update

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -517,7 +517,7 @@
517517 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
518518 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
519519 CODE_SIGN_STYLE = Automatic;
520 CURRENT_PROJECT_VERSION = 80;
520 CURRENT_PROJECT_VERSION = 81;
521521 DEVELOPMENT_TEAM = ZCNAX3VL9D;
522522 ENABLE_PREVIEWS = YES;
523523 GENERATE_INFOPLIST_FILE = YES;
@@ -534,7 +534,7 @@
534534 "$(inherited)",
535535 "@executable_path/Frameworks",
536536 );
537 MARKETING_VERSION = 3.2.0;
537 MARKETING_VERSION = 3.2.1;
538538 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
539539 PRODUCT_NAME = "$(TARGET_NAME)";
540540 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -554,7 +554,7 @@
554554 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
555555 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
556556 CODE_SIGN_STYLE = Automatic;
557 CURRENT_PROJECT_VERSION = 80;
557 CURRENT_PROJECT_VERSION = 81;
558558 DEVELOPMENT_TEAM = ZCNAX3VL9D;
559559 ENABLE_PREVIEWS = YES;
560560 GENERATE_INFOPLIST_FILE = YES;
@@ -571,7 +571,7 @@
571571 "$(inherited)",
572572 "@executable_path/Frameworks",
573573 );
574 MARKETING_VERSION = 3.2.0;
574 MARKETING_VERSION = 3.2.1;
575575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
576576 PRODUCT_NAME = "$(TARGET_NAME)";
577577 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
634634 APPLICATION_EXTENSION_API_ONLY = YES;
635635 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
636636 CODE_SIGN_STYLE = Automatic;
637 CURRENT_PROJECT_VERSION = 80;
637 CURRENT_PROJECT_VERSION = 81;
638638 DEVELOPMENT_TEAM = ZCNAX3VL9D;
639639 GENERATE_INFOPLIST_FILE = NO;
640640 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -644,7 +644,7 @@
644644 "@executable_path/Frameworks",
645645 "@executable_path/../../Frameworks",
646646 );
647 MARKETING_VERSION = 3.2.0;
647 MARKETING_VERSION = 3.2.1;
648648 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
649649 PRODUCT_NAME = "$(TARGET_NAME)";
650650 SKIP_INSTALL = YES;
@@ -663,7 +663,7 @@
663663 APPLICATION_EXTENSION_API_ONLY = YES;
664664 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
665665 CODE_SIGN_STYLE = Automatic;
666 CURRENT_PROJECT_VERSION = 80;
666 CURRENT_PROJECT_VERSION = 81;
667667 DEVELOPMENT_TEAM = ZCNAX3VL9D;
668668 GENERATE_INFOPLIST_FILE = NO;
669669 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -673,7 +673,7 @@
673673 "@executable_path/Frameworks",
674674 "@executable_path/../../Frameworks",
675675 );
676 MARKETING_VERSION = 3.2.0;
676 MARKETING_VERSION = 3.2.1;
677677 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
678678 PRODUCT_NAME = "$(TARGET_NAME)";
679679 SKIP_INSTALL = YES;
Hutch/App/AppStorageKeys.swift +1
@@ -18,6 +18,7 @@ enum AppStorageKeys {
1818 static let homeBuildsExpanded = "homeBuildsExpanded"
1919 static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
2020 static let buildsRepoFilter = "buildsRepoFilter"
21 static let buildsLookbackDays = "buildsLookbackDays"
2122 static let ticketFilterState = "ticketFilterState"
2223 static let ticketSavedFilters = "ticketSavedFilters"
2324 static let appTheme = "appTheme"
Hutch/Models/Meta.swift +7 −1
@@ -22,10 +22,16 @@ struct UserProfile: Codable, Sendable {
2222
2323struct SSHKey: Codable, Sendable, Identifiable {
2424 let id: Int
25 let fingerprint: String
2625 let comment: String?
2726 let created: Date
2827 let lastUsed: Date?
28
29 var displayLabel: String {
30 if let comment, !comment.isEmpty {
31 return comment
32 }
33 return "SSH key #\(id)"
34 }
2935}
3036
3137struct SSHKeyPage: Codable, Sendable {
Hutch/Views/Builds/BuildListView.swift +26
@@ -4,6 +4,8 @@ struct BuildListView: View {
44 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
55 @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0
66 @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = ""
7 @AppStorage(AppStorageKeys.buildsLookbackDays, store: .standard)
8 private var lookbackDays = BuildListViewModel.defaultLookbackDays
79 @Environment(AppState.self) private var appState
810 @Environment(\.isAMOLEDTheme) private var isAMOLED
911 @State private var viewModel: BuildListViewModel?
@@ -65,6 +67,20 @@ struct BuildListView: View {
6567 }
6668 }
6769 }
70 Section("Timeframe") {
71 ForEach(HomeViewModel.allowedFailedBuildLookbackDays, id: \.self) { days in
72 Button {
73 lookbackDays = days
74 viewModel.lookbackDays = days
75 } label: {
76 if lookbackDays == days {
77 Label(HomeViewModel.failedBuildLookbackLabel(days: days), systemImage: "checkmark")
78 } else {
79 Text(HomeViewModel.failedBuildLookbackLabel(days: days))
80 }
81 }
82 }
83 }
6884 } label: {
6985 Image(systemName: "line.3.horizontal.decrease.circle")
7086 }
@@ -107,6 +123,7 @@ struct BuildListView: View {
107123 if viewModel == nil {
108124 let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults)
109125 vm.repoFilter = savedRepoFilter
126 vm.lookbackDays = lookbackDays
110127 viewModel = vm
111128 await vm.loadJobs()
112129 }
@@ -114,6 +131,9 @@ struct BuildListView: View {
114131 // onDisappear stops it when navigating away.
115132 viewModel?.startAutoRefresh(interval: autoRefreshInterval)
116133 }
134 .onChange(of: lookbackDays) { _, newValue in
135 viewModel?.lookbackDays = newValue
136 }
117137 .onDisappear {
118138 viewModel?.stopAutoRefresh()
119139 }
@@ -236,6 +256,12 @@ struct BuildListView: View {
236256 systemImage: "magnifyingglass",
237257 description: Text("No builds matched “\(viewModel.searchText)”.")
238258 )
259 } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty {
260 ContentUnavailableView(
261 "No Builds In Timeframe",
262 systemImage: "calendar.badge.clock",
263 description: Text("No builds were updated \(HomeViewModel.failedBuildLookbackLabel(days: lookbackDays)).")
264 )
239265 } else if viewModel.jobs.isEmpty, viewModel.error == nil {
240266 ContentUnavailableView(
241267 "No Builds",
Hutch/Views/Builds/BuildListViewModel.swift +25 −3
@@ -45,6 +45,7 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable {
4545@MainActor
4646final class BuildListViewModel {
4747 private static let searchHistoryScopeID = "builds"
48 nonisolated static let defaultLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
4849
4950 private(set) var jobs: [JobSummary] = [] {
5051 didSet { updateFilteredJobs() }
@@ -68,6 +69,9 @@ final class BuildListViewModel {
6869 updateFilteredJobs()
6970 }
7071 }
72 var lookbackDays = defaultLookbackDays {
73 didSet { updateFilteredJobs() }
74 }
7175 // Cached filtered result. Updated whenever jobs, filter, searchText, or
7276 // repoFilter changes. Only notifies observers when the content actually
7377 // differs, which prevents the list from re-rendering on auto-refresh when
@@ -99,7 +103,7 @@ final class BuildListViewModel {
99103 }
100104
101105 private func updateFilteredJobs() {
102 var result = Self.filterJobs(jobs, filter: filter)
106 var result = Self.filterJobs(jobs, filter: filter, lookbackDays: lookbackDays)
103107 if !repoFilter.isEmpty {
104108 result = result.filter { $0.tags.contains(repoFilter) }
105109 }
@@ -382,8 +386,14 @@ final class BuildListViewModel {
382386 let cancel: CancelResult
383387 }
384388
385 nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] {
386 jobs.filter { job in
389 nonisolated static func filterJobs(
390 _ jobs: [JobSummary],
391 filter: BuildListFilter,
392 lookbackDays: Int,
393 now: Date = .now,
394 calendar: Calendar = .current
395 ) -> [JobSummary] {
396 let filteredByStatus = jobs.filter { job in
387397 switch filter {
388398 case .attention:
389399 switch job.status {
@@ -403,6 +413,18 @@ final class BuildListViewModel {
403413 return true
404414 }
405415 }
416
417 let normalizedLookbackDays = HomeViewModel.allowedFailedBuildLookbackDays.contains(lookbackDays)
418 ? lookbackDays
419 : defaultLookbackDays
420 let startOfToday = calendar.startOfDay(for: now)
421 let windowStart = calendar.date(
422 byAdding: .day,
423 value: -(normalizedLookbackDays - 1),
424 to: startOfToday
425 ) ?? startOfToday
426
427 return filteredByStatus.filter { $0.updated >= windowStart }
406428 }
407429
408430 nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] {
Hutch/Views/Home/HomeView.swift +3
@@ -56,6 +56,9 @@ struct HomeView: View {
5656 loadRecentActivity()
5757 }
5858 }
59 .onChange(of: failedBuildLookbackDays) { _, _ in
60 viewModel?.refreshNeedsAttentionSnapshot()
61 }
5962 }
6063
6164 @ViewBuilder
Hutch/Views/Home/HomeViewModel.swift +4
@@ -670,6 +670,10 @@ final class HomeViewModel {
670670 persistNeedsAttentionSnapshot()
671671 }
672672
673 func refreshNeedsAttentionSnapshot() {
674 persistNeedsAttentionSnapshot()
675 }
676
673677 private func loadProjects() async -> Result<[Project], Error> {
674678 do {
675679 return .success(try await projectService.fetchProjects())
Hutch/Views/More/ProfileView.swift +2 −2
@@ -243,7 +243,7 @@ struct ProfileView: View {
243243 Section {
244244 ForEach(viewModel.sshKeys) { key in
245245 VStack(alignment: .leading, spacing: 2) {
246 Text(key.fingerprint)
246 Text(key.displayLabel)
247247 .font(.caption.monospaced())
248248 .lineLimit(1)
249249 .truncationMode(.middle)
@@ -677,7 +677,7 @@ private enum ProfileDestructiveAction {
677677 var message: String {
678678 switch self {
679679 case .deleteSSHKey(let key):
680 "Remove SSH key \(key.fingerprint) from your account?"
680 "Remove \(key.displayLabel) from your account?"
681681 case .deletePGPKey(let key):
682682 "Remove PGP key \(key.fingerprint) from your account?"
683683 }
Hutch/Views/Settings/SettingsViewModel.swift +2 −2
@@ -90,7 +90,7 @@ final class SettingsViewModel {
9090 avatar
9191 userType
9292 sshKeys {
93 results { id fingerprint comment created lastUsed }
93 results { id comment created lastUsed }
9494 cursor
9595 }
9696 pgpKeys {
@@ -114,7 +114,7 @@ final class SettingsViewModel {
114114 private static let createSSHKeyMutation = """
115115 mutation createSSHKey($key: String!) {
116116 createSSHKey(key: $key) {
117 id fingerprint comment created lastUsed
117 id comment created lastUsed
118118 }
119119 }
120120 """
HutchTests/BuildListViewModelTests.swift +53 −5
@@ -49,22 +49,70 @@ struct BuildListViewModelTests {
4949 makeJob(id: 4, status: .cancelled, tags: [])
5050 ]
5151
52 let attention = BuildListViewModel.filterJobs(jobs, filter: .attention)
53 let active = BuildListViewModel.filterJobs(jobs, filter: .active)
52 let attention = BuildListViewModel.filterJobs(
53 jobs,
54 filter: .attention,
55 lookbackDays: BuildListViewModel.defaultLookbackDays
56 )
57 let active = BuildListViewModel.filterJobs(
58 jobs,
59 filter: .active,
60 lookbackDays: BuildListViewModel.defaultLookbackDays
61 )
5462
5563 #expect(attention.map(\.id) == [2, 3])
5664 #expect(active.map(\.id) == [3])
5765 }
5866
67 @Test
68 func buildFilterRestrictsJobsToSelectedLookbackWindow() {
69 let now = Date(timeIntervalSince1970: 60 * 60 * 24 * 20)
70 let jobs = [
71 makeJob(
72 id: 1,
73 status: .failed,
74 tags: [],
75 updated: now.addingTimeInterval(-(60 * 60 * 24))
76 ),
77 makeJob(
78 id: 2,
79 status: .running,
80 tags: [],
81 updated: now.addingTimeInterval(-(60 * 60 * 24 * 8))
82 ),
83 makeJob(
84 id: 3,
85 status: .success,
86 tags: [],
87 updated: now.addingTimeInterval(-(60 * 60 * 24 * 2))
88 ),
89 ]
90
91 let filtered = BuildListViewModel.filterJobs(
92 jobs,
93 filter: .all,
94 lookbackDays: 3,
95 now: now,
96 calendar: Calendar(identifier: .gregorian)
97 )
98
99 #expect(filtered.map(\.id) == [1, 3])
100 }
101
59102 private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] {
60103 BuildListViewModel.searchJobs(jobs, matching: query)
61104 }
62105
63 private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary {
106 private func makeJob(
107 id: Int,
108 status: JobStatus = .success,
109 tags: [String],
110 updated: Date = Date()
111 ) -> JobSummary {
64112 JobSummary(
65113 id: id,
66 created: Date(),
67 updated: Date(),
114 created: updated,
115 updated: updated,
68116 status: status,
69117 note: nil,
70118 tags: tags,
HutchTests/SettingsViewModelTests.swift +55
@@ -2,6 +2,37 @@ import Foundation
22import Testing
33@testable import Hutch
44
5private final class SettingsViewModelCapturingURLProtocol: URLProtocol, @unchecked Sendable {
6 nonisolated(unsafe) static var capturedRequests: [URLRequest] = []
7
8 override class func canInit(with _: URLRequest) -> Bool { true }
9 override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
10
11 override func startLoading() {
12 Self.capturedRequests.append(request)
13
14 let response = HTTPURLResponse(
15 url: request.url!,
16 statusCode: 401,
17 httpVersion: nil,
18 headerFields: nil
19 )!
20 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
21 client?.urlProtocol(self, didLoad: Data())
22 client?.urlProtocolDidFinishLoading(self)
23 }
24
25 override func stopLoading() {
26 // No cleanup is needed because the stub responds immediately in `startLoading()`.
27 }
28
29 static func makeSession() -> URLSession {
30 let config = URLSessionConfiguration.ephemeral
31 config.protocolClasses = [Self.self]
32 return URLSession(configuration: config)
33 }
34}
35
536private struct DeletePGPKeyEnvelope: Decodable {
637 let deletePGPKey: DeleteResultPayload?
738}
@@ -10,6 +41,7 @@ private struct DeleteResultPayload: Decodable {
1041 let id: Int?
1142}
1243
44@Suite(.serialized)
1345struct SettingsViewModelTests {
1446
1547 @Test
@@ -36,4 +68,27 @@ struct SettingsViewModelTests {
3668 #expect(decoded.data?.deletePGPKey == nil)
3769 #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true)
3870 }
71
72 @Test
73 @MainActor
74 func loadProfileDoesNotRequestSSHKeyFingerprintField() async throws {
75 SettingsViewModelCapturingURLProtocol.capturedRequests = []
76
77 let client = SRHTClient(
78 session: SettingsViewModelCapturingURLProtocol.makeSession(),
79 token: "test-token"
80 )
81 let viewModel = SettingsViewModel(client: client)
82
83 await viewModel.loadProfile()
84
85 let request = try #require(SettingsViewModelCapturingURLProtocol.capturedRequests.first)
86 let body = try #require(request.httpBody)
87 let jsonObject = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
88 let query = try #require(jsonObject["query"] as? String)
89
90 #expect(query.contains("sshKeys"))
91 #expect(!query.contains("results { id fingerprint comment created lastUsed }"))
92 #expect(!query.contains("fingerprint comment created lastUsed"))
93 }
3994}