Commit 431c255c28

431c255c28a37784de7f2b1bbb813b6554eefd9c

parent: a0f74c5c52

Unsigned

cmc <hello@cleberg.net> · 2026-05-14 18:18 UTC

feat: add App Intents for Hutch navigation

- add read-only App Intents for core Hutch workflows
- route intents through the existing Hutch navigation/deep-link model
- expose Work Queue, Recent Activity, System Status, pinned resources, projects, failed builds, assigned tickets, saved searches, and search where supported
- keep App Intents non-mutating for the initial implementation
- preserve existing widget, Safari extension, and hutch:// routing behavior

References: https://todo.sr.ht/~ccleberg/hutch/71

Layout: unified · split

Hutch/App/AppState.swift +6
@@ -84,6 +84,7 @@ final class AppState {
84 /// Set by the deep link handler; consumed by RootView to drive navigation. 84 /// Set by the deep link handler; consumed by RootView to drive navigation.
85 var pendingDeepLink: DeepLink? 85 var pendingDeepLink: DeepLink?
86 var pendingTabNavigation: TabNavigationTarget? 86 var pendingTabNavigation: TabNavigationTarget?
87 var pendingBuildListFilter: BuildListFilter?
87 var deepLinkError: String? 88 var deepLinkError: String?
88 89
89 // MARK: - Init 90 // MARK: - Init
@@ -373,6 +374,10 @@ final class AppState {
373 selectedTab = .builds 374 selectedTab = .builds
374 } 375 }
375 376
377 func open(_ route: HutchRoute) {
378 pendingDeepLink = DeepLink(route: route)
379 }
380
376 func presentRepositoryDeepLinkError() { 381 func presentRepositoryDeepLinkError() {
377 deepLinkError = "The repository could not be found or is inaccessible." 382 deepLinkError = "The repository could not be found or is inaccessible."
378 } 383 }
@@ -574,6 +579,7 @@ final class AppState {
574 private func resetNavigationState() { 579 private func resetNavigationState() {
575 pendingDeepLink = nil 580 pendingDeepLink = nil
576 pendingTabNavigation = nil 581 pendingTabNavigation = nil
582 pendingBuildListFilter = nil
577 deepLinkError = nil 583 deepLinkError = nil
578 selectedTab = .home 584 selectedTab = .home
579 } 585 }
Hutch/App/DeepLink.swift +186 −24
@@ -3,45 +3,50 @@ import os
3 3
4private let deepLinkParserLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink") 4private let deepLinkParserLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
5 5
6/// Represents a parsed `hutch://` deep link. 6enum HutchWorkQueueScope: String, CaseIterable, Sendable {
7enum DeepLink: Equatable { 7 case all
8 case unread
9 case assigned
10}
11
12enum HutchRoute: Equatable, Sendable {
8 case home 13 case home
9 case work 14 case workQueue(scope: HutchWorkQueueScope = .all)
10 /// hutch://git/<owner>/<repo> or hutch://hg/<owner>/<repo> 15 case recentActivity
11 case repository(service: SRHTService, owner: String, repo: String) 16 case repository(service: SRHTService, owner: String, repo: String)
12 /// hutch://todo/<owner>/<tracker>/<ticketId> 17 case tracker(owner: String, tracker: String)
13 case ticket(owner: String, tracker: String, ticketId: Int) 18 case ticket(owner: String, tracker: String, ticketId: Int)
14 /// hutch://builds/<jobId> or hutch://builds/<owner>/job/<jobId>
15 case build(jobId: Int) 19 case build(jobId: Int)
16 /// hutch://lists/<owner>/<list>
17 case mailingList(owner: String, list: String) 20 case mailingList(owner: String, list: String)
18 /// hutch://lookup/<owner>
19 case userProfile(owner: String) 21 case userProfile(owner: String)
20 /// hutch://builds (tab-level) 22 case builds
21 case buildsTab 23 case failedBuilds
22 /// hutch://repositories (tab-level) 24 case repositories
23 case repositoriesTab 25 case trackers
24 /// hutch://trackers (tab-level)
25 case trackersTab
26 /// hutch://status
27 case systemStatus 26 case systemStatus
28 /// hutch://lookup
29 case lookup 27 case lookup
28 case search(query: String)
29 case projectDashboard(id: String, title: String?)
30 30
31 /// Attempt to parse a URL into a DeepLink.
32 /// Expected format: hutch://<path>
33 init?(url: URL) { 31 init?(url: URL) {
34 guard url.scheme == "hutch" else { return nil } 32 guard url.scheme == "hutch" else { return nil }
35 33
36 let components = url.deepLinkPathComponents 34 let components = url.deepLinkPathComponents
37 deepLinkParserLogger.info("DeepLink parser components for \(url.absoluteString, privacy: .public): \(components.joined(separator: ","), privacy: .public)") 35 let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
36 let queryValue: (String) -> String? = { name in
37 queryItems.first { $0.name == name }?.value?.trimmingCharacters(in: .whitespacesAndNewlines)
38 }
38 39
39 switch components.first { 40 switch components.first {
40 case "home", nil: 41 case "home", nil:
41 self = .home 42 self = .home
42 43
44 case "recent", "recent-activity", "activity":
45 self = .recentActivity
46
43 case "work", "inbox": 47 case "work", "inbox":
44 self = .work 48 let scope = queryValue("scope").flatMap(HutchWorkQueueScope.init(rawValue:)) ?? .all
49 self = .workQueue(scope: scope)
45 50
46 case let .some(serviceName) where ["git", "hg", "todo", "builds", "lists"].contains(serviceName) 51 case let .some(serviceName) where ["git", "hg", "todo", "builds", "lists"].contains(serviceName)
47 && components.count == 2 52 && components.count == 2
@@ -62,6 +67,9 @@ enum DeepLink: Equatable {
62 guard let ticketId = Int(components[3]) else { return nil } 67 guard let ticketId = Int(components[3]) else { return nil }
63 self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId) 68 self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId)
64 69
70 case "todo" where components.count >= 3:
71 self = .tracker(owner: components[1], tracker: components[2])
72
65 case "builds" where components.count >= 2: 73 case "builds" where components.count >= 2:
66 let rawJobId: String 74 let rawJobId: String
67 if components.count >= 4, components[2] == "job" { 75 if components.count >= 4, components[2] == "job" {
@@ -73,16 +81,19 @@ enum DeepLink: Equatable {
73 self = .build(jobId: jobId) 81 self = .build(jobId: jobId)
74 82
75 case "builds": 83 case "builds":
76 self = .buildsTab 84 self = queryValue("filter") == "failed" ? .failedBuilds : .builds
77 85
78 case "lists" where components.count >= 3: 86 case "lists" where components.count >= 3:
79 self = .mailingList(owner: components[1], list: components[2]) 87 self = .mailingList(owner: components[1], list: components[2])
80 88
89 case "projects" where components.count >= 2:
90 self = .projectDashboard(id: components[1], title: queryValue("title"))
91
81 case "repositories": 92 case "repositories":
82 self = .repositoriesTab 93 self = .repositories
83 94
84 case "trackers": 95 case "trackers":
85 self = .trackersTab 96 self = .trackers
86 97
87 case "status": 98 case "status":
88 self = .systemStatus 99 self = .systemStatus
@@ -91,12 +102,163 @@ enum DeepLink: Equatable {
91 self = .userProfile(owner: components[1]) 102 self = .userProfile(owner: components[1])
92 103
93 case "lookup": 104 case "lookup":
94 self = .lookup 105 if let query = queryValue("q"), !query.isEmpty {
106 self = .search(query: query)
107 } else {
108 self = .lookup
109 }
95 110
96 default: 111 default:
97 return nil 112 return nil
98 } 113 }
99 } 114 }
115
116 var url: URL {
117 switch self {
118 case .home:
119 return Self.makeURL(host: "home")
120 case .workQueue(let scope):
121 return Self.makeURL(
122 host: "work",
123 queryItems: scope == .all ? [] : [URLQueryItem(name: "scope", value: scope.rawValue)]
124 )
125 case .recentActivity:
126 return Self.makeURL(host: "recent-activity")
127 case .repository(let service, let owner, let repo):
128 return Self.makeURL(host: service.rawValue, path: [owner, repo])
129 case .tracker(let owner, let tracker):
130 return Self.makeURL(host: "todo", path: [owner, tracker])
131 case .ticket(let owner, let tracker, let ticketId):
132 return Self.makeURL(host: "todo", path: [owner, tracker, String(ticketId)])
133 case .build(let jobId):
134 return Self.makeURL(host: "builds", path: [String(jobId)])
135 case .mailingList(let owner, let list):
136 return Self.makeURL(host: "lists", path: [owner, list])
137 case .userProfile(let owner):
138 return Self.makeURL(host: "lookup", path: [owner])
139 case .builds:
140 return Self.makeURL(host: "builds")
141 case .failedBuilds:
142 return Self.makeURL(host: "builds", queryItems: [URLQueryItem(name: "filter", value: "failed")])
143 case .repositories:
144 return Self.makeURL(host: "repositories")
145 case .trackers:
146 return Self.makeURL(host: "trackers")
147 case .systemStatus:
148 return Self.makeURL(host: "status")
149 case .lookup:
150 return Self.makeURL(host: "lookup")
151 case .search(let query):
152 return Self.makeURL(host: "lookup", queryItems: [URLQueryItem(name: "q", value: query)])
153 case .projectDashboard(let id, let title):
154 return Self.makeURL(
155 host: "projects",
156 path: [id],
157 queryItems: title.map { [URLQueryItem(name: "title", value: $0)] } ?? []
158 )
159 }
160 }
161
162 private static func makeURL(
163 host: String,
164 path: [String] = [],
165 queryItems: [URLQueryItem] = []
166 ) -> URL {
167 var components = URLComponents()
168 components.scheme = "hutch"
169 components.host = host
170 if !path.isEmpty {
171 components.path = "/" + path.joined(separator: "/")
172 }
173 if !queryItems.isEmpty {
174 components.queryItems = queryItems
175 }
176 return components.url!
177 }
178}
179
180/// Represents a parsed `hutch://` deep link.
181enum DeepLink: Equatable {
182 case home
183 case work
184 case workQueue(scope: HutchWorkQueueScope)
185 case recentActivity
186 /// hutch://git/<owner>/<repo> or hutch://hg/<owner>/<repo>
187 case repository(service: SRHTService, owner: String, repo: String)
188 /// hutch://todo/<owner>/<tracker>
189 case tracker(owner: String, tracker: String)
190 /// hutch://todo/<owner>/<tracker>/<ticketId>
191 case ticket(owner: String, tracker: String, ticketId: Int)
192 /// hutch://builds/<jobId> or hutch://builds/<owner>/job/<jobId>
193 case build(jobId: Int)
194 /// hutch://lists/<owner>/<list>
195 case mailingList(owner: String, list: String)
196 /// hutch://lookup/<owner>
197 case userProfile(owner: String)
198 /// hutch://builds (tab-level)
199 case buildsTab
200 /// hutch://repositories (tab-level)
201 case repositoriesTab
202 /// hutch://trackers (tab-level)
203 case trackersTab
204 /// hutch://status
205 case systemStatus
206 /// hutch://lookup
207 case lookup
208 /// hutch://lookup?q=<query>
209 case search(query: String)
210 /// hutch://builds?filter=failed
211 case failedBuilds
212 /// hutch://projects/<rid>
213 case projectDashboard(id: String, title: String?)
214
215 /// Attempt to parse a URL into a DeepLink.
216 /// Expected format: hutch://<path>
217 init?(url: URL) {
218 let components = url.deepLinkPathComponents
219 deepLinkParserLogger.info("DeepLink parser components for \(url.absoluteString, privacy: .public): \(components.joined(separator: ","), privacy: .public)")
220 guard let route = HutchRoute(url: url) else { return nil }
221 self = Self(route: route)
222 }
223
224 init(route: HutchRoute) {
225 switch route {
226 case .home:
227 self = .home
228 case .workQueue(let scope):
229 self = scope == .all ? .work : .workQueue(scope: scope)
230 case .recentActivity:
231 self = .recentActivity
232 case .repository(let service, let owner, let repo):
233 self = .repository(service: service, owner: owner, repo: repo)
234 case .tracker(let owner, let tracker):
235 self = .tracker(owner: owner, tracker: tracker)
236 case .ticket(let owner, let tracker, let ticketId):
237 self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId)
238 case .build(let jobId):
239 self = .build(jobId: jobId)
240 case .mailingList(let owner, let list):
241 self = .mailingList(owner: owner, list: list)
242 case .userProfile(let owner):
243 self = .userProfile(owner: owner)
244 case .builds:
245 self = .buildsTab
246 case .failedBuilds:
247 self = .failedBuilds
248 case .repositories:
249 self = .repositoriesTab
250 case .trackers:
251 self = .trackersTab
252 case .systemStatus:
253 self = .systemStatus
254 case .lookup:
255 self = .lookup
256 case .search(let query):
257 self = .search(query: query)
258 case .projectDashboard(let id, let title):
259 self = .projectDashboard(id: id, title: title)
260 }
261 }
100} 262}
101 263
102private extension URL { 264private extension URL {
Hutch/App/HutchApp.swift +4 −14
@@ -28,20 +28,10 @@ struct HutchApp: App {
28 deepLinkLogger.error("Rejected URL: \(url.absoluteString, privacy: .public)") 28 deepLinkLogger.error("Rejected URL: \(url.absoluteString, privacy: .public)")
29 } 29 }
30 } 30 }
31 .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in 31 .onChange(of: HutchIntentNavigator.shared.pendingRoute) { _, route in
32 guard let destination else { return } 32 guard let route else { return }
33 HutchIntentNavigator.shared.pendingDestination = nil 33 HutchIntentNavigator.shared.pendingRoute = nil
34 let link: DeepLink 34 appState.open(route)
35 switch destination {
36 case .home: link = .home
37 case .work: link = .work
38 case .builds: link = .buildsTab
39 case .repositories: link = .repositoriesTab
40 case .trackers: link = .trackersTab
41 case .systemStatus: link = .systemStatus
42 case .lookup: link = .lookup
43 }
44 appState.pendingDeepLink = link
45 } 35 }
46 } 36 }
47 } 37 }
Hutch/App/HutchIntents.swift +335 −42
@@ -1,46 +1,271 @@
1import AppIntents 1import AppIntents
2import Foundation 2import Foundation
3 3
4// MARK: - Open Hutch Intent 4// MARK: - Navigation Intents
5 5
6enum HutchDestination: String, AppEnum { 6struct OpenWorkQueueIntent: AppIntent {
7 case home 7 static var title: LocalizedStringResource = "Open Work Queue"
8 case work 8 static var description = IntentDescription("Opens Hutch to your Work Queue.")
9 case builds 9 static var openAppWhenRun = true
10 case repositories 10
11 case trackers 11 var route: HutchRoute { .workQueue(scope: .all) }
12 case systemStatus 12
13 case lookup 13 @MainActor
14 14 func perform() async throws -> some IntentResult {
15 static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Hutch Section") 15 HutchIntentNavigator.shared.open(route)
16 16 return .result()
17 static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [ 17 }
18 .home: "Home", 18}
19 .work: "Work", 19
20 .builds: "Builds", 20struct OpenRecentActivityIntent: AppIntent {
21 .repositories: "Repositories", 21 static var title: LocalizedStringResource = "Open Recent Activity"
22 .trackers: "Trackers", 22 static var description = IntentDescription("Opens Hutch to recent activity.")
23 .systemStatus: "System Status", 23 static var openAppWhenRun = true
24 .lookup: "Look Up" 24
25 var route: HutchRoute { .recentActivity }
26
27 @MainActor
28 func perform() async throws -> some IntentResult {
29 HutchIntentNavigator.shared.open(route)
30 return .result()
31 }
32}
33
34struct OpenSystemStatusIntent: AppIntent {
35 static var title: LocalizedStringResource = "Open System Status"
36 static var description = IntentDescription("Opens Hutch to SourceHut system status.")
37 static var openAppWhenRun = true
38
39 var route: HutchRoute { .systemStatus }
40
41 @MainActor
42 func perform() async throws -> some IntentResult {
43 HutchIntentNavigator.shared.open(route)
44 return .result()
45 }
46}
47
48struct OpenPinnedResourceIntent: AppIntent {
49 static var title: LocalizedStringResource = "Open Pinned Resource"
50 static var description = IntentDescription("Opens a pinned Hutch resource.")
51 static var openAppWhenRun = true
52
53 @Parameter(title: "Pinned Resource")
54 var pinnedResource: PinnedResourceEntity
55
56 var route: HutchRoute { pinnedResource.route }
57
58 @MainActor
59 func perform() async throws -> some IntentResult {
60 HutchIntentNavigator.shared.open(route)
61 return .result()
62 }
63}
64
65struct OpenProjectDashboardIntent: AppIntent {
66 static var title: LocalizedStringResource = "Open Project Dashboard"
67 static var description = IntentDescription("Opens a pinned project dashboard in Hutch.")
68 static var openAppWhenRun = true
69
70 @Parameter(title: "Project")
71 var project: ProjectEntity
72
73 var route: HutchRoute {
74 .projectDashboard(id: project.id, title: project.name)
75 }
76
77 @MainActor
78 func perform() async throws -> some IntentResult {
79 HutchIntentNavigator.shared.open(route)
80 return .result()
81 }
82}
83
84enum HutchShortcutScope: String, AppEnum {
85 case all
86
87 static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scope")
88 static var caseDisplayRepresentations: [HutchShortcutScope: DisplayRepresentation] = [
89 .all: "All"
25 ] 90 ]
26} 91}
27 92
28struct OpenHutchIntent: AppIntent { 93struct OpenFailedBuildsIntent: AppIntent {
29 static var title: LocalizedStringResource = "Open Hutch" 94 static var title: LocalizedStringResource = "Open Failed Builds"
30 static var description = IntentDescription("Opens Hutch to a specific section.") 95 static var description = IntentDescription("Opens Hutch to failed builds.")
31 static var openAppWhenRun = true 96 static var openAppWhenRun = true
32 97
33 @Parameter(title: "Section", default: .home) 98 @Parameter(title: "Scope", default: .all)
34 var destination: HutchDestination 99 var scope: HutchShortcutScope
100
101 var route: HutchRoute { .failedBuilds }
35 102
36 @MainActor 103 @MainActor
37 func perform() async throws -> some IntentResult { 104 func perform() async throws -> some IntentResult {
38 HutchIntentNavigator.shared.pendingDestination = destination 105 HutchIntentNavigator.shared.open(route)
39 return .result() 106 return .result()
40 } 107 }
41} 108}
42 109
43// MARK: - Check System Status Intent 110struct OpenAssignedTicketsIntent: AppIntent {
111 static var title: LocalizedStringResource = "Open Assigned Tickets"
112 static var description = IntentDescription("Opens Hutch to tickets assigned to you.")
113 static var openAppWhenRun = true
114
115 @Parameter(title: "Scope", default: .all)
116 var scope: HutchShortcutScope
117
118 var route: HutchRoute { .workQueue(scope: .assigned) }
119
120 @MainActor
121 func perform() async throws -> some IntentResult {
122 HutchIntentNavigator.shared.open(route)
123 return .result()
124 }
125}
126
127struct SearchHutchIntent: AppIntent {
128 static var title: LocalizedStringResource = "Search Hutch"
129 static var description = IntentDescription("Opens Hutch lookup with a search query.")
130 static var openAppWhenRun = true
131
132 @Parameter(title: "Query")
133 var query: String
134
135 var route: HutchRoute {
136 let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines)
137 // TODO: Route to global local search once Hutch has one.
138 return normalized.isEmpty ? .lookup : .search(query: normalized)
139 }
140
141 @MainActor
142 func perform() async throws -> some IntentResult {
143 HutchIntentNavigator.shared.open(route)
144 return .result()
145 }
146}
147
148// TODO: Add OpenSavedSearchIntent when Hutch has global saved-search persistence.
149
150// MARK: - App Entities
151
152struct PinnedResourceEntity: AppEntity, Identifiable {
153 static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Pinned Resource")
154 static var defaultQuery = PinnedResourceQuery()
155
156 let id: String
157 let name: String
158 let subtitle: String
159 let route: HutchRoute
160
161 var displayRepresentation: DisplayRepresentation {
162 DisplayRepresentation(title: "\(name)", subtitle: "\(subtitle)")
163 }
164}
165
166struct PinnedResourceQuery: EntityQuery {
167 @MainActor
168 func entities(for identifiers: [PinnedResourceEntity.ID]) async throws -> [PinnedResourceEntity] {
169 HutchIntentEntityStore.pinnedResources().filter { identifiers.contains($0.id) }
170 }
171
172 @MainActor
173 func suggestedEntities() async throws -> [PinnedResourceEntity] {
174 HutchIntentEntityStore.pinnedResources()
175 }
176}
177
178struct ProjectEntity: AppEntity, Identifiable {
179 static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project")
180 static var defaultQuery = ProjectEntityQuery()
181
182 let id: String
183 let name: String
184
185 var displayRepresentation: DisplayRepresentation {
186 DisplayRepresentation(title: "\(name)")
187 }
188}
189
190struct ProjectEntityQuery: EntityQuery {
191 @MainActor
192 func entities(for identifiers: [ProjectEntity.ID]) async throws -> [ProjectEntity] {
193 HutchIntentEntityStore.projects().filter { identifiers.contains($0.id) }
194 }
195
196 @MainActor
197 func suggestedEntities() async throws -> [ProjectEntity] {
198 HutchIntentEntityStore.projects()
199 }
200}
201
202private enum HutchIntentEntityStore {
203 static func pinnedResources() -> [PinnedResourceEntity] {
204 pins().compactMap { makePinnedResource(from: $0) }
205 }
206
207 static func projects() -> [ProjectEntity] {
208 pins().compactMap { pin in
209 guard pin.kind == .project else { return nil }
210 return ProjectEntity(id: pin.value, name: pin.title)
211 }
212 }
213
214 private static func pins() -> [HomePinRecord] {
215 guard let userKey = ContributionWidgetContextStore.loadActor(),
216 !userKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
217 else {
218 return []
219 }
220
221 return HomePinStore.loadPins(for: userKey, defaults: activeAccountDefaults)
222 }
223
224 private static var activeAccountDefaults: UserDefaults {
225 let activeID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
226 guard !activeID.isEmpty else { return .standard }
227 return AccountDefaultsStore.userDefaults(for: activeID)
228 }
229
230 private static func makePinnedResource(from pin: HomePinRecord) -> PinnedResourceEntity? {
231 guard let route = route(for: pin) else { return nil }
232 return PinnedResourceEntity(
233 id: pin.id,
234 name: pin.title,
235 subtitle: pin.subtitle,
236 route: route
237 )
238 }
239
240 private static func route(for pin: HomePinRecord) -> HutchRoute? {
241 switch pin.kind {
242 case .project:
243 return .projectDashboard(id: pin.value, title: pin.title)
244 case .repository:
245 guard let owner = pin.ownerUsername else { return nil }
246 return .repository(
247 service: pin.service ?? .git,
248 owner: formattedOwner(owner),
249 repo: pin.value
250 )
251 case .tracker:
252 guard let owner = pin.ownerUsername else { return nil }
253 return .tracker(owner: formattedOwner(owner), tracker: pin.value)
254 case .mailingList:
255 guard let owner = pin.ownerUsername else { return nil }
256 return .mailingList(owner: formattedOwner(owner), list: pin.value)
257 case .user:
258 guard let owner = pin.ownerUsername else { return nil }
259 return .userProfile(owner: formattedOwner(owner))
260 }
261 }
262
263 private static func formattedOwner(_ owner: String) -> String {
264 owner.hasPrefix("~") ? owner : "~\(owner)"
265 }
266}
267
268// MARK: - Existing Read-Only Summary Intents
44 269
45struct CheckSystemStatusIntent: AppIntent { 270struct CheckSystemStatusIntent: AppIntent {
46 static var title: LocalizedStringResource = "Check SourceHut Status" 271 static var title: LocalizedStringResource = "Check SourceHut Status"
@@ -64,8 +289,6 @@ struct CheckSystemStatusIntent: AppIntent {
64 } 289 }
65} 290}
66 291
67// MARK: - Check Builds Intent
68
69struct CheckBuildsIntent: AppIntent { 292struct CheckBuildsIntent: AppIntent {
70 static var title: LocalizedStringResource = "Check Hutch Builds" 293 static var title: LocalizedStringResource = "Check Hutch Builds"
71 static var description = IntentDescription("Returns a summary of your recent build status.") 294 static var description = IntentDescription("Returns a summary of your recent build status.")
@@ -107,25 +330,92 @@ struct CheckBuildsIntent: AppIntent {
107struct HutchShortcuts: AppShortcutsProvider { 330struct HutchShortcuts: AppShortcutsProvider {
108 static var appShortcuts: [AppShortcut] { 331 static var appShortcuts: [AppShortcut] {
109 AppShortcut( 332 AppShortcut(
110 intent: OpenHutchIntent(), 333 intent: OpenWorkQueueIntent(),
334 phrases: [
335 "Open my work queue in \(.applicationName)",
336 "Show work in \(.applicationName)"
337 ],
338 shortTitle: "Work Queue",
339 systemImageName: "tray.full"
340 )
341
342 AppShortcut(
343 intent: OpenRecentActivityIntent(),
344 phrases: [
345 "Open recent activity in \(.applicationName)",
346 "Show activity in \(.applicationName)"
347 ],
348 shortTitle: "Recent Activity",
349 systemImageName: "clock.arrow.circlepath"
350 )
351
352 AppShortcut(
353 intent: OpenSystemStatusIntent(),
354 phrases: [
355 "Open system status in \(.applicationName)",
356 "Show SourceHut status in \(.applicationName)"
357 ],
358 shortTitle: "System Status",
359 systemImageName: "server.rack"
360 )
361
362 AppShortcut(
363 intent: OpenPinnedResourceIntent(),
111 phrases: [ 364 phrases: [
112 "Open \(.applicationName)", 365 "Open \(\.$pinnedResource) in \(.applicationName)",
113 "Open \(.applicationName) \(\.$destination)", 366 "Show my pinned \(\.$pinnedResource) in \(.applicationName)"
114 "Show my \(.applicationName) \(\.$destination)",
115 "Go to \(\.$destination) in \(.applicationName)"
116 ], 367 ],
117 shortTitle: "Open Hutch", 368 shortTitle: "Pinned Resource",
118 systemImageName: "house" 369 systemImageName: "pin"
370 )
371
372 AppShortcut(
373 intent: OpenProjectDashboardIntent(),
374 phrases: [
375 "Open \(\.$project) dashboard in \(.applicationName)",
376 "Show project \(\.$project) in \(.applicationName)"
377 ],
378 shortTitle: "Project Dashboard",
379 systemImageName: "square.stack.3d.up"
380 )
381
382 AppShortcut(
383 intent: OpenFailedBuildsIntent(),
384 phrases: [
385 "Open failed builds in \(.applicationName)",
386 "Show failed builds in \(.applicationName)"
387 ],
388 shortTitle: "Failed Builds",
389 systemImageName: "exclamationmark.triangle"
390 )
391
392 AppShortcut(
393 intent: OpenAssignedTicketsIntent(),
394 phrases: [
395 "Open assigned tickets in \(.applicationName)",
396 "Show my assigned tickets in \(.applicationName)"
397 ],
398 shortTitle: "Assigned Tickets",
399 systemImageName: "person.crop.circle.badge.checkmark"
400 )
401
402 AppShortcut(
403 intent: SearchHutchIntent(),
404 phrases: [
405 "Search \(.applicationName)",
406 "Look up something in \(.applicationName)"
407 ],
408 shortTitle: "Search Hutch",
409 systemImageName: "magnifyingglass"
119 ) 410 )
120 411
121 AppShortcut( 412 AppShortcut(
122 intent: CheckSystemStatusIntent(), 413 intent: CheckSystemStatusIntent(),
123 phrases: [ 414 phrases: [
124 "Check \(.applicationName) status", 415 "Check \(.applicationName) status",
125 "Is SourceHut up in \(.applicationName)", 416 "Is SourceHut up in \(.applicationName)"
126 "SourceHut status in \(.applicationName)"
127 ], 417 ],
128 shortTitle: "Check SourceHut Status", 418 shortTitle: "Check Status",
129 systemImageName: "server.rack" 419 systemImageName: "server.rack"
130 ) 420 )
131 421
@@ -133,7 +423,6 @@ struct HutchShortcuts: AppShortcutsProvider {
133 intent: CheckBuildsIntent(), 423 intent: CheckBuildsIntent(),
134 phrases: [ 424 phrases: [
135 "Check my \(.applicationName) builds", 425 "Check my \(.applicationName) builds",
136 "How are my builds in \(.applicationName)",
137 "Build status in \(.applicationName)" 426 "Build status in \(.applicationName)"
138 ], 427 ],
139 shortTitle: "Check Builds", 428 shortTitle: "Check Builds",
@@ -148,9 +437,13 @@ struct HutchShortcuts: AppShortcutsProvider {
148@Observable 437@Observable
149final class HutchIntentNavigator { 438final class HutchIntentNavigator {
150 static let shared = HutchIntentNavigator() 439 static let shared = HutchIntentNavigator()
151 var pendingDestination: HutchDestination? 440 var pendingRoute: HutchRoute?
152 441
153 private init() { 442 private init() {
154 /* Singleton; external code uses `shared`. */ 443 /* Singleton; external code uses `shared`. */
155 } 444 }
445
446 func open(_ route: HutchRoute) {
447 pendingRoute = route
448 }
156} 449}
Hutch/App/RootView.swift +109 −15
@@ -75,8 +75,8 @@ struct RootView: View {
75 HomeView() 75 HomeView()
76 .navigationDestination(for: HomeRoute.self) { route in 76 .navigationDestination(for: HomeRoute.self) { route in
77 switch route { 77 switch route {
78 case .work: 78 case .work(let scope):
79 WorkView() 79 WorkView(initialScope: scope)
80 } 80 }
81 } 81 }
82 } 82 }
@@ -202,9 +202,16 @@ struct RootView: View {
202 homePath = NavigationPath() 202 homePath = NavigationPath()
203 appState.selectedTab = .home 203 appState.selectedTab = .home
204 204
205 case .recentActivity:
206 homePath = NavigationPath()
207 appState.selectedTab = .home
208
205 case .repository(let service, let owner, let repo): 209 case .repository(let service, let owner, let repo):
206 resolveRepositoryLink(service: service, owner: owner, repo: repo) 210 resolveRepositoryLink(service: service, owner: owner, repo: repo)
207 211
212 case .tracker(let owner, let tracker):
213 resolveTrackerLink(owner: owner, tracker: tracker)
214
208 case .build(let jobId): 215 case .build(let jobId):
209 buildsPath = NavigationPath() 216 buildsPath = NavigationPath()
210 appState.selectedTab = .builds 217 appState.selectedTab = .builds
@@ -223,11 +230,39 @@ struct RootView: View {
223 resolveUserProfileLink(owner: owner) 230 resolveUserProfileLink(owner: owner)
224 231
225 case .work: 232 case .work:
226 homePath = NavigationPath() 233 navigateToWork(scope: .all)
227 appState.selectedTab = .home 234
235 case .workQueue(let scope):
236 navigateToWork(scope: scope)
237
238 case .projectDashboard(let id, let title):
239 morePath = NavigationPath()
240 appState.selectedTab = .more
241 Task {
242 await settleNavigationTransition()
243 morePath.append(MoreRoute.projects)
244 morePath.append(MoreRoute.projectDashboard(id: id, title: title))
245 }
246
247 case .failedBuilds:
248 buildsPath = NavigationPath()
249 appState.pendingBuildListFilter = .failed
250 appState.selectedTab = .builds
251
252 case .search(let query):
253 morePath = NavigationPath()
254 appState.selectedTab = .more
228 Task { 255 Task {
229 await settleNavigationTransition() 256 await settleNavigationTransition()
230 homePath.append(HomeRoute.work) 257 morePath.append(MoreRoute.lookup(query: query))
258 }
259
260 case .lookup:
261 morePath = NavigationPath()
262 appState.selectedTab = .more
263 Task {
264 await settleNavigationTransition()
265 morePath.append(MoreRoute.lookup(query: nil))
231 } 266 }
232 267
233 case .buildsTab: 268 case .buildsTab:
@@ -244,14 +279,15 @@ struct RootView: View {
244 279
245 case .systemStatus: 280 case .systemStatus:
246 appState.navigateToSystemStatus() 281 appState.navigateToSystemStatus()
282 }
283 }
247 284
248 case .lookup: 285 private func navigateToWork(scope: HutchWorkQueueScope) {
249 morePath = NavigationPath() 286 homePath = NavigationPath()
250 appState.selectedTab = .more 287 appState.selectedTab = .home
251 Task { 288 Task {
252 await settleNavigationTransition() 289 await settleNavigationTransition()
253 morePath.append(MoreRoute.lookup) 290 homePath.append(HomeRoute.work(scope: scope))
254 }
255 } 291 }
256 } 292 }
257 293
@@ -310,6 +346,22 @@ struct RootView: View {
310 } 346 }
311 } 347 }
312 348
349 private func resolveTrackerLink(owner: String, tracker: String) {
350 isResolvingDeepLink = true
351 Task {
352 defer { isResolvingDeepLink = false }
353 do {
354 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
355 ticketsPath = NavigationPath()
356 appState.selectedTab = .tickets
357 await settleNavigationTransition()
358 ticketsPath.append(trackerSummary)
359 } catch {
360 appState.presentTicketDeepLinkError()
361 }
362 }
363 }
364
313 private func resolveMailingListLink(owner: String, list: String) { 365 private func resolveMailingListLink(owner: String, list: String) {
314 isResolvingDeepLink = true 366 isResolvingDeepLink = true
315 Task { 367 Task {
@@ -375,7 +427,7 @@ enum MoreDestination: Hashable {
375} 427}
376 428
377enum MoreRoute: Hashable { 429enum MoreRoute: Hashable {
378 case lookup 430 case lookup(query: String?)
379 case projects 431 case projects
380 case lists 432 case lists
381 case pastes 433 case pastes
@@ -384,6 +436,7 @@ enum MoreRoute: Hashable {
384 case settings 436 case settings
385 case about 437 case about
386 case userProfile(String) 438 case userProfile(String)
439 case projectDashboard(id: String, title: String?)
387 case mailingList(InboxMailingListReference) 440 case mailingList(InboxMailingListReference)
388 case thread(InboxThreadSummary) 441 case thread(InboxThreadSummary)
389 case manPageBrowser 442 case manPageBrowser
@@ -397,8 +450,8 @@ private struct MoreNavigationRoot: View {
397 MoreView() 450 MoreView()
398 .navigationDestination(for: MoreRoute.self) { route in 451 .navigationDestination(for: MoreRoute.self) { route in
399 switch route { 452 switch route {
400 case .lookup: 453 case .lookup(let query):
401 LookupView() 454 LookupView(initialQuery: query ?? "")
402 case .projects: 455 case .projects:
403 ProjectsListView() 456 ProjectsListView()
404 case .lists: 457 case .lists:
@@ -415,6 +468,8 @@ private struct MoreNavigationRoot: View {
415 AboutView() 468 AboutView()
416 case .userProfile(let owner): 469 case .userProfile(let owner):
417 UserProfileDeepLinkView(owner: owner) 470 UserProfileDeepLinkView(owner: owner)
471 case .projectDashboard(let id, let title):
472 ProjectDashboardDeepLinkView(projectID: id, title: title)
418 case .mailingList(let mailingList): 473 case .mailingList(let mailingList):
419 MailingListDetailView(mailingList: mailingList) 474 MailingListDetailView(mailingList: mailingList)
420 case .thread(let thread): 475 case .thread(let thread):
@@ -484,6 +539,45 @@ struct UserProfileDeepLinkView: View {
484 } 539 }
485} 540}
486 541
542struct ProjectDashboardDeepLinkView: View {
543 @Environment(AppState.self) private var appState
544 let projectID: String
545 let title: String?
546 @State private var project: Project?
547 @State private var errorMessage: String?
548
549 var body: some View {
550 Group {
551 if let project {
552 ProjectDetailView(project: project)
553 } else if let errorMessage {
554 ContentUnavailableView(
555 "Couldn't Open Project",
556 systemImage: "square.stack.3d.up.slash",
557 description: Text(errorMessage)
558 )
559 } else {
560 SRHTLoadingStateView(message: "Loading project...")
561 }
562 }
563 .navigationTitle(title ?? "Project")
564 .navigationBarTitleDisplayMode(.inline)
565 .task(id: projectID) {
566 await loadProject()
567 }
568 }
569
570 @MainActor
571 private func loadProject() async {
572 errorMessage = nil
573 do {
574 project = try await ProjectService(client: appState.client).fetchProjectDetail(rid: projectID)
575 } catch {
576 errorMessage = "The project could not be found or is inaccessible."
577 }
578 }
579}
580
487// MARK: - Ticket Deep Link Navigation Target 581// MARK: - Ticket Deep Link Navigation Target
488 582
489/// Hashable wrapper to push a ticket detail view from a deep link. 583/// Hashable wrapper to push a ticket detail view from a deep link.
Hutch/Views/Builds/BuildListView.swift +9
@@ -124,6 +124,10 @@ struct BuildListView: View {
124 let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults) 124 let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults)
125 vm.repoFilter = savedRepoFilter 125 vm.repoFilter = savedRepoFilter
126 vm.lookbackDays = lookbackDays 126 vm.lookbackDays = lookbackDays
127 if let pendingFilter = appState.pendingBuildListFilter {
128 vm.filter = pendingFilter
129 appState.pendingBuildListFilter = nil
130 }
127 viewModel = vm 131 viewModel = vm
128 await vm.loadJobs() 132 await vm.loadJobs()
129 } 133 }
@@ -134,6 +138,11 @@ struct BuildListView: View {
134 .onChange(of: lookbackDays) { _, newValue in 138 .onChange(of: lookbackDays) { _, newValue in
135 viewModel?.lookbackDays = newValue 139 viewModel?.lookbackDays = newValue
136 } 140 }
141 .onChange(of: appState.pendingBuildListFilter) { _, newValue in
142 guard let newValue else { return }
143 viewModel?.filter = newValue
144 appState.pendingBuildListFilter = nil
145 }
137 .onDisappear { 146 .onDisappear {
138 viewModel?.stopAutoRefresh() 147 viewModel?.stopAutoRefresh()
139 } 148 }
Hutch/Views/Builds/BuildListViewModel.swift +8
@@ -21,6 +21,7 @@ private struct SubmittedJob: Decodable, Sendable {
21 21
22enum BuildListFilter: String, CaseIterable, Sendable { 22enum BuildListFilter: String, CaseIterable, Sendable {
23 case attention = "Attention" 23 case attention = "Attention"
24 case failed = "Failed"
24 case active = "Active" 25 case active = "Active"
25 case all = "All" 26 case all = "All"
26} 27}
@@ -413,6 +414,13 @@ final class BuildListViewModel {
413 case .success, .cancelled: 414 case .success, .cancelled:
414 return false 415 return false
415 } 416 }
417 case .failed:
418 switch job.status {
419 case .failed, .timeout:
420 return true
421 case .success, .cancelled, .running, .queued, .pending:
422 return false
423 }
416 case .active: 424 case .active:
417 switch job.status { 425 switch job.status {
418 case .running, .queued, .pending: 426 case .running, .queued, .pending:
Hutch/Views/Home/HomeView.swift +2 −2
@@ -106,7 +106,7 @@ struct HomeView: View {
106 106
107 private func workSection(_ viewModel: HomeViewModel) -> some View { 107 private func workSection(_ viewModel: HomeViewModel) -> some View {
108 Section("Work") { 108 Section("Work") {
109 NavigationLink(value: HomeRoute.work) { 109 NavigationLink(value: HomeRoute.work(scope: .all)) {
110 HomeSummaryRow( 110 HomeSummaryRow(
111 title: workTitle(viewModel), 111 title: workTitle(viewModel),
112 summary: workSummary(viewModel), 112 summary: workSummary(viewModel),
@@ -424,7 +424,7 @@ struct HomeView: View {
424} 424}
425 425
426enum HomeRoute: Hashable { 426enum HomeRoute: Hashable {
427 case work 427 case work(scope: HutchWorkQueueScope)
428} 428}
429 429
430private enum HomeSummaryEmphasis { 430private enum HomeSummaryEmphasis {
Hutch/Views/Lookup/LookupView.swift +22 −4
@@ -92,11 +92,17 @@ final class LookupViewModel {
92 ) 92 )
93 } 93 }
94 94
95 init(client: SRHTClient, appState: AppState, defaults: UserDefaults = .standard) { 95 init(
96 client: SRHTClient,
97 appState: AppState,
98 defaults: UserDefaults = .standard,
99 initialQuery: String = ""
100 ) {
96 self.client = client 101 self.client = client
97 self.appState = appState 102 self.appState = appState
98 self.defaults = defaults 103 self.defaults = defaults
99 self.history = LookupHistoryStore.load(defaults: defaults) 104 self.history = LookupHistoryStore.load(defaults: defaults)
105 self.inputText = initialQuery.trimmingCharacters(in: .whitespacesAndNewlines)
100 } 106 }
101 107
102 func lookup() async { 108 func lookup() async {
@@ -329,6 +335,11 @@ final class LookupViewModel {
329struct LookupView: View { 335struct LookupView: View {
330 @Environment(AppState.self) private var appState 336 @Environment(AppState.self) private var appState
331 @State private var viewModel: LookupViewModel? 337 @State private var viewModel: LookupViewModel?
338 private let initialQuery: String
339
340 init(initialQuery: String = "") {
341 self.initialQuery = initialQuery
342 }
332 343
333 var body: some View { 344 var body: some View {
334 Group { 345 Group {
@@ -341,7 +352,12 @@ struct LookupView: View {
341 .navigationTitle("Look Up") 352 .navigationTitle("Look Up")
342 .task { 353 .task {
343 if viewModel == nil { 354 if viewModel == nil {
344 viewModel = LookupViewModel(client: appState.client, appState: appState, defaults: appState.accountDefaults) 355 viewModel = LookupViewModel(
356 client: appState.client,
357 appState: appState,
358 defaults: appState.accountDefaults,
359 initialQuery: initialQuery
360 )
345 } 361 }
346 } 362 }
347 } 363 }
@@ -427,8 +443,8 @@ struct LookupView: View {
427 } 443 }
428 .navigationDestination(for: MoreRoute.self) { route in 444 .navigationDestination(for: MoreRoute.self) { route in
429 switch route { 445 switch route {
430 case .lookup: 446 case .lookup(let query):
431 LookupView() 447 LookupView(initialQuery: query ?? "")
432 case .projects: 448 case .projects:
433 ProjectsListView() 449 ProjectsListView()
434 case .lists: 450 case .lists:
@@ -445,6 +461,8 @@ struct LookupView: View {
445 AboutView() 461 AboutView()
446 case .userProfile(let owner): 462 case .userProfile(let owner):
447 UserProfileDeepLinkView(owner: owner) 463 UserProfileDeepLinkView(owner: owner)
464 case .projectDashboard(let id, let title):
465 ProjectDashboardDeepLinkView(projectID: id, title: title)
448 case .mailingList(let mailingList): 466 case .mailingList(let mailingList):
449 MailingListDetailView(mailingList: mailingList) 467 MailingListDetailView(mailingList: mailingList)
450 case .thread(let thread): 468 case .thread(let thread):
Hutch/Views/More/MoreView.swift +1 −1
@@ -13,7 +13,7 @@ struct MoreView: View {
13 var body: some View { 13 var body: some View {
14 List { 14 List {
15 Section("Search") { 15 Section("Search") {
16 NavigationLink(value: MoreRoute.lookup) { 16 NavigationLink(value: MoreRoute.lookup(query: nil)) {
17 Label("Look Up", systemImage: "magnifyingglass") 17 Label("Look Up", systemImage: "magnifyingglass")
18 } 18 }
19 .themedRow() 19 .themedRow()
Hutch/Views/Work/WorkView.swift +18 −10
@@ -1,20 +1,28 @@
1import SwiftUI 1import SwiftUI
2 2
3struct WorkView: View { 3extension HutchWorkQueueScope: Identifiable {
4 private enum Scope: String, CaseIterable, Identifiable { 4 var id: String { rawValue }
5 case all = "All" 5
6 case unread = "Unread" 6 var displayName: String {
7 case assigned = "Assigned" 7 switch self {
8 8 case .all: "All"
9 var id: String { rawValue } 9 case .unread: "Unread"
10 case .assigned: "Assigned"
11 }
10 } 12 }
13}
11 14
15struct WorkView: View {
12 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true 16 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
13 @Environment(AppState.self) private var appState 17 @Environment(AppState.self) private var appState
14 @Environment(\.isAMOLEDTheme) private var isAMOLED 18 @Environment(\.isAMOLEDTheme) private var isAMOLED
15 @Environment(\.scenePhase) private var scenePhase 19 @Environment(\.scenePhase) private var scenePhase
16 @State private var viewModel: HomeViewModel? 20 @State private var viewModel: HomeViewModel?
17 @State private var scope: Scope = .all 21 @State private var scope: HutchWorkQueueScope
22
23 init(initialScope: HutchWorkQueueScope = .all) {
24 _scope = State(initialValue: initialScope)
25 }
18 26
19 var body: some View { 27 var body: some View {
20 Group { 28 Group {
@@ -106,8 +114,8 @@ struct WorkView: View {
106 private var scopeSection: some View { 114 private var scopeSection: some View {
107 Section { 115 Section {
108 Picker("Scope", selection: $scope) { 116 Picker("Scope", selection: $scope) {
109 ForEach(Scope.allCases) { scope in 117 ForEach(HutchWorkQueueScope.allCases) { scope in
110 Text(scope.rawValue).tag(scope) 118 Text(scope.displayName).tag(scope)
111 } 119 }
112 } 120 }
113 .pickerStyle(.segmented) 121 .pickerStyle(.segmented)
HutchTests/BuildListViewModelTests.swift +6
@@ -59,9 +59,15 @@ struct BuildListViewModelTests {
59 filter: .active, 59 filter: .active,
60 lookbackDays: BuildListViewModel.defaultLookbackDays 60 lookbackDays: BuildListViewModel.defaultLookbackDays
61 ) 61 )
62 let failed = BuildListViewModel.filterJobs(
63 jobs,
64 filter: .failed,
65 lookbackDays: BuildListViewModel.defaultLookbackDays
66 )
62 67
63 #expect(attention.map(\.id) == [2, 3]) 68 #expect(attention.map(\.id) == [2, 3])
64 #expect(active.map(\.id) == [3]) 69 #expect(active.map(\.id) == [3])
70 #expect(failed.map(\.id) == [2])
65 } 71 }
66 72
67 @Test 73 @Test
HutchTests/DeepLinkTests.swift +8
@@ -88,6 +88,14 @@ struct DeepLinkTests {
88 #expect(link == .lookup) 88 #expect(link == .lookup)
89 } 89 }
90 90
91 @Test
92 func parsesRouteBackedNavigationLinks() {
93 #expect(DeepLink(url: HutchRoute.workQueue(scope: .assigned).url) == .workQueue(scope: .assigned))
94 #expect(DeepLink(url: HutchRoute.failedBuilds.url) == .failedBuilds)
95 #expect(DeepLink(url: HutchRoute.search(query: "patch queue").url) == .search(query: "patch queue"))
96 #expect(DeepLink(url: HutchRoute.projectDashboard(id: "project-1", title: "Hutch").url) == .projectDashboard(id: "project-1", title: "Hutch"))
97 }
98
91 @Test 99 @Test
92 func parsesUserProfileLink() { 100 func parsesUserProfileLink() {
93 let link = DeepLink(url: HutchDeepLinkURL.userProfile) 101 let link = DeepLink(url: HutchDeepLinkURL.userProfile)
HutchTests/HutchIntentsTests.swift +9
@@ -5,6 +5,15 @@ import Testing
5@MainActor 5@MainActor
6struct HutchIntentsTests { 6struct HutchIntentsTests {
7 7
8 @Test
9 func navigationIntentsMapToCentralRoutes() {
10 #expect(OpenWorkQueueIntent().route == .workQueue(scope: .all))
11 #expect(OpenRecentActivityIntent().route == .recentActivity)
12 #expect(OpenSystemStatusIntent().route == .systemStatus)
13 #expect(OpenFailedBuildsIntent().route == .failedBuilds)
14 #expect(OpenAssignedTicketsIntent().route == .workQueue(scope: .assigned))
15 }
16
8 @Test 17 @Test
9 func checkSystemStatusReturnsOperationalWhenNoDisruption() async throws { 18 func checkSystemStatusReturnsOperationalWhenNoDisruption() async throws {
10 let defaultsName = "HutchIntentsTests-status-\(UUID().uuidString)" 19 let defaultsName = "HutchIntentsTests-status-\(UUID().uuidString)"
Shared/NeedsAttentionSnapshot.swift +13 −8
@@ -58,11 +58,11 @@ enum NeedsAttentionSnapshotStore {
58 private static let snapshotKey = "needsAttention.snapshot" 58 private static let snapshotKey = "needsAttention.snapshot"
59 59
60 static func load( 60 static func load(
61 accountID: String? = ActiveAccountContextStore.load(), 61 accountID: String? = nil,
62 defaults: UserDefaults? = sharedDefaults() 62 defaults: UserDefaults? = sharedDefaults()
63 ) -> NeedsAttentionSnapshot? { 63 ) -> NeedsAttentionSnapshot? {
64 guard let defaults, 64 guard let defaults,
65 let data = defaults.data(forKey: scopedKey(for: accountID)) else { 65 let data = defaults.data(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) else {
66 return nil 66 return nil
67 } 67 }
68 68
@@ -71,7 +71,7 @@ enum NeedsAttentionSnapshotStore {
71 71
72 static func save( 72 static func save(
73 _ snapshot: NeedsAttentionSnapshot, 73 _ snapshot: NeedsAttentionSnapshot,
74 accountID: String? = ActiveAccountContextStore.load(), 74 accountID: String? = nil,
75 defaults: UserDefaults? = sharedDefaults() 75 defaults: UserDefaults? = sharedDefaults()
76 ) { 76 ) {
77 guard let defaults, 77 guard let defaults,
@@ -79,7 +79,7 @@ enum NeedsAttentionSnapshotStore {
79 return 79 return
80 } 80 }
81 81
82 defaults.set(data, forKey: scopedKey(for: accountID)) 82 defaults.set(data, forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults)))
83 reloadWidgetTimelines() 83 reloadWidgetTimelines()
84 } 84 }
85 85
@@ -87,7 +87,7 @@ enum NeedsAttentionSnapshotStore {
87 unreadInboxThreads: Int? = nil, 87 unreadInboxThreads: Int? = nil,
88 assignedOpenTickets: Int? = nil, 88 assignedOpenTickets: Int? = nil,
89 failedBuilds: Int? = nil, 89 failedBuilds: Int? = nil,
90 accountID: String? = ActiveAccountContextStore.load(), 90 accountID: String? = nil,
91 defaults: UserDefaults? = sharedDefaults() 91 defaults: UserDefaults? = sharedDefaults()
92 ) { 92 ) {
93 let existing = load(accountID: accountID, defaults: defaults) 93 let existing = load(accountID: accountID, defaults: defaults)
@@ -102,7 +102,7 @@ enum NeedsAttentionSnapshotStore {
102 102
103 static func adjustUnreadInboxThreads( 103 static func adjustUnreadInboxThreads(
104 by delta: Int, 104 by delta: Int,
105 accountID: String? = ActiveAccountContextStore.load(), 105 accountID: String? = nil,
106 defaults: UserDefaults? = sharedDefaults() 106 defaults: UserDefaults? = sharedDefaults()
107 ) { 107 ) {
108 guard let existing = load(accountID: accountID, defaults: defaults), 108 guard let existing = load(accountID: accountID, defaults: defaults),
@@ -123,10 +123,11 @@ enum NeedsAttentionSnapshotStore {
123 } 123 }
124 124
125 static func clear( 125 static func clear(
126 accountID: String? = ActiveAccountContextStore.load(), 126 accountID: String? = nil,
127 defaults: UserDefaults? = sharedDefaults() 127 defaults: UserDefaults? = sharedDefaults()
128 ) { 128 ) {
129 defaults?.removeObject(forKey: scopedKey(for: accountID)) 129 guard let defaults else { return }
130 defaults.removeObject(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults)))
130 reloadWidgetTimelines() 131 reloadWidgetTimelines()
131 } 132 }
132 133
@@ -139,6 +140,10 @@ enum NeedsAttentionSnapshotStore {
139 return "\(snapshotKey).\(accountID)" 140 return "\(snapshotKey).\(accountID)"
140 } 141 }
141 142
143 private static func resolvedAccountID(_ accountID: String?, defaults: UserDefaults) -> String? {
144 accountID ?? ActiveAccountContextStore.load(defaults: defaults)
145 }
146
142 private static func reloadWidgetTimelines() { 147 private static func reloadWidgetTimelines() {
143 #if canImport(WidgetKit) 148 #if canImport(WidgetKit)
144 WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind) 149 WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind)
Shared/SystemStatusWidgetSnapshot.swift +11 −6
@@ -34,11 +34,11 @@ enum SystemStatusWidgetSnapshotStore {
34 private static let snapshotKey = "systemStatus.widgetSnapshot" 34 private static let snapshotKey = "systemStatus.widgetSnapshot"
35 35
36 static func load( 36 static func load(
37 accountID: String? = ActiveAccountContextStore.load(), 37 accountID: String? = nil,
38 defaults: UserDefaults? = sharedDefaults() 38 defaults: UserDefaults? = sharedDefaults()
39 ) -> SystemStatusWidgetSnapshot? { 39 ) -> SystemStatusWidgetSnapshot? {
40 guard let defaults, 40 guard let defaults,
41 let data = defaults.data(forKey: scopedKey(for: accountID)) else { 41 let data = defaults.data(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) else {
42 return nil 42 return nil
43 } 43 }
44 return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data) 44 return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data)
@@ -46,22 +46,23 @@ enum SystemStatusWidgetSnapshotStore {
46 46
47 static func save( 47 static func save(
48 _ snapshot: SystemStatusWidgetSnapshot, 48 _ snapshot: SystemStatusWidgetSnapshot,
49 accountID: String? = ActiveAccountContextStore.load(), 49 accountID: String? = nil,
50 defaults: UserDefaults? = sharedDefaults() 50 defaults: UserDefaults? = sharedDefaults()
51 ) { 51 ) {
52 guard let defaults, 52 guard let defaults,
53 let data = try? JSONEncoder().encode(snapshot) else { 53 let data = try? JSONEncoder().encode(snapshot) else {
54 return 54 return
55 } 55 }
56 defaults.set(data, forKey: scopedKey(for: accountID)) 56 defaults.set(data, forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults)))
57 reloadWidgetTimelines() 57 reloadWidgetTimelines()
58 } 58 }
59 59
60 static func clear( 60 static func clear(
61 accountID: String? = ActiveAccountContextStore.load(), 61 accountID: String? = nil,
62 defaults: UserDefaults? = sharedDefaults() 62 defaults: UserDefaults? = sharedDefaults()
63 ) { 63 ) {
64 defaults?.removeObject(forKey: scopedKey(for: accountID)) 64 guard let defaults else { return }
65 defaults.removeObject(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults)))
65 reloadWidgetTimelines() 66 reloadWidgetTimelines()
66 } 67 }
67 68
@@ -74,6 +75,10 @@ enum SystemStatusWidgetSnapshotStore {
74 return "\(snapshotKey).\(accountID)" 75 return "\(snapshotKey).\(accountID)"
75 } 76 }
76 77
78 private static func resolvedAccountID(_ accountID: String?, defaults: UserDefaults) -> String? {
79 accountID ?? ActiveAccountContextStore.load(defaults: defaults)
80 }
81
77 private static func reloadWidgetTimelines() { 82 private static func reloadWidgetTimelines() {
78 #if canImport(WidgetKit) 83 #if canImport(WidgetKit)
79 WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind) 84 WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind)