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 {
8484 /// Set by the deep link handler; consumed by RootView to drive navigation.
8585 var pendingDeepLink: DeepLink?
8686 var pendingTabNavigation: TabNavigationTarget?
87 var pendingBuildListFilter: BuildListFilter?
8788 var deepLinkError: String?
8889
8990 // MARK: - Init
@@ -373,6 +374,10 @@ final class AppState {
373374 selectedTab = .builds
374375 }
375376
377 func open(_ route: HutchRoute) {
378 pendingDeepLink = DeepLink(route: route)
379 }
380
376381 func presentRepositoryDeepLinkError() {
377382 deepLinkError = "The repository could not be found or is inaccessible."
378383 }
@@ -574,6 +579,7 @@ final class AppState {
574579 private func resetNavigationState() {
575580 pendingDeepLink = nil
576581 pendingTabNavigation = nil
582 pendingBuildListFilter = nil
577583 deepLinkError = nil
578584 selectedTab = .home
579585 }
Hutch/App/DeepLink.swift +186 −24
@@ -3,45 +3,50 @@ import os
33
44private let deepLinkParserLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink")
55
6/// Represents a parsed `hutch://` deep link.
7enum DeepLink: Equatable {
6enum HutchWorkQueueScope: String, CaseIterable, Sendable {
7 case all
8 case unread
9 case assigned
10}
11
12enum HutchRoute: Equatable, Sendable {
813 case home
9 case work
10 /// hutch://git/<owner>/<repo> or hutch://hg/<owner>/<repo>
14 case workQueue(scope: HutchWorkQueueScope = .all)
15 case recentActivity
1116 case repository(service: SRHTService, owner: String, repo: String)
12 /// hutch://todo/<owner>/<tracker>/<ticketId>
17 case tracker(owner: String, tracker: String)
1318 case ticket(owner: String, tracker: String, ticketId: Int)
14 /// hutch://builds/<jobId> or hutch://builds/<owner>/job/<jobId>
1519 case build(jobId: Int)
16 /// hutch://lists/<owner>/<list>
1720 case mailingList(owner: String, list: String)
18 /// hutch://lookup/<owner>
1921 case userProfile(owner: String)
20 /// hutch://builds (tab-level)
21 case buildsTab
22 /// hutch://repositories (tab-level)
23 case repositoriesTab
24 /// hutch://trackers (tab-level)
25 case trackersTab
26 /// hutch://status
22 case builds
23 case failedBuilds
24 case repositories
25 case trackers
2726 case systemStatus
28 /// hutch://lookup
2927 case lookup
28 case search(query: String)
29 case projectDashboard(id: String, title: String?)
3030
31 /// Attempt to parse a URL into a DeepLink.
32 /// Expected format: hutch://<path>
3331 init?(url: URL) {
3432 guard url.scheme == "hutch" else { return nil }
3533
3634 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 }
3839
3940 switch components.first {
4041 case "home", nil:
4142 self = .home
4243
44 case "recent", "recent-activity", "activity":
45 self = .recentActivity
46
4347 case "work", "inbox":
44 self = .work
48 let scope = queryValue("scope").flatMap(HutchWorkQueueScope.init(rawValue:)) ?? .all
49 self = .workQueue(scope: scope)
4550
4651 case let .some(serviceName) where ["git", "hg", "todo", "builds", "lists"].contains(serviceName)
4752 && components.count == 2
@@ -62,6 +67,9 @@ enum DeepLink: Equatable {
6267 guard let ticketId = Int(components[3]) else { return nil }
6368 self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId)
6469
70 case "todo" where components.count >= 3:
71 self = .tracker(owner: components[1], tracker: components[2])
72
6573 case "builds" where components.count >= 2:
6674 let rawJobId: String
6775 if components.count >= 4, components[2] == "job" {
@@ -73,16 +81,19 @@ enum DeepLink: Equatable {
7381 self = .build(jobId: jobId)
7482
7583 case "builds":
76 self = .buildsTab
84 self = queryValue("filter") == "failed" ? .failedBuilds : .builds
7785
7886 case "lists" where components.count >= 3:
7987 self = .mailingList(owner: components[1], list: components[2])
8088
89 case "projects" where components.count >= 2:
90 self = .projectDashboard(id: components[1], title: queryValue("title"))
91
8192 case "repositories":
82 self = .repositoriesTab
93 self = .repositories
8394
8495 case "trackers":
85 self = .trackersTab
96 self = .trackers
8697
8798 case "status":
8899 self = .systemStatus
@@ -91,12 +102,163 @@ enum DeepLink: Equatable {
91102 self = .userProfile(owner: components[1])
92103
93104 case "lookup":
94 self = .lookup
105 if let query = queryValue("q"), !query.isEmpty {
106 self = .search(query: query)
107 } else {
108 self = .lookup
109 }
95110
96111 default:
97112 return nil
98113 }
99114 }
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 }
100262}
101263
102264private extension URL {
Hutch/App/HutchApp.swift +4 −14
@@ -28,20 +28,10 @@ struct HutchApp: App {
2828 deepLinkLogger.error("Rejected URL: \(url.absoluteString, privacy: .public)")
2929 }
3030 }
31 .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in
32 guard let destination else { return }
33 HutchIntentNavigator.shared.pendingDestination = nil
34 let link: DeepLink
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
31 .onChange(of: HutchIntentNavigator.shared.pendingRoute) { _, route in
32 guard let route else { return }
33 HutchIntentNavigator.shared.pendingRoute = nil
34 appState.open(route)
4535 }
4636 }
4737 }
Hutch/App/HutchIntents.swift +335 −42
@@ -1,46 +1,271 @@
11import AppIntents
22import Foundation
33
4// MARK: - Open Hutch Intent
5
6enum HutchDestination: String, AppEnum {
7 case home
8 case work
9 case builds
10 case repositories
11 case trackers
12 case systemStatus
13 case lookup
14
15 static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Hutch Section")
16
17 static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [
18 .home: "Home",
19 .work: "Work",
20 .builds: "Builds",
21 .repositories: "Repositories",
22 .trackers: "Trackers",
23 .systemStatus: "System Status",
24 .lookup: "Look Up"
4// MARK: - Navigation Intents
5
6struct OpenWorkQueueIntent: AppIntent {
7 static var title: LocalizedStringResource = "Open Work Queue"
8 static var description = IntentDescription("Opens Hutch to your Work Queue.")
9 static var openAppWhenRun = true
10
11 var route: HutchRoute { .workQueue(scope: .all) }
12
13 @MainActor
14 func perform() async throws -> some IntentResult {
15 HutchIntentNavigator.shared.open(route)
16 return .result()
17 }
18}
19
20struct OpenRecentActivityIntent: AppIntent {
21 static var title: LocalizedStringResource = "Open Recent Activity"
22 static var description = IntentDescription("Opens Hutch to recent activity.")
23 static var openAppWhenRun = true
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"
2590 ]
2691}
2792
28struct OpenHutchIntent: AppIntent {
29 static var title: LocalizedStringResource = "Open Hutch"
30 static var description = IntentDescription("Opens Hutch to a specific section.")
93struct OpenFailedBuildsIntent: AppIntent {
94 static var title: LocalizedStringResource = "Open Failed Builds"
95 static var description = IntentDescription("Opens Hutch to failed builds.")
3196 static var openAppWhenRun = true
3297
33 @Parameter(title: "Section", default: .home)
34 var destination: HutchDestination
98 @Parameter(title: "Scope", default: .all)
99 var scope: HutchShortcutScope
100
101 var route: HutchRoute { .failedBuilds }
35102
36103 @MainActor
37104 func perform() async throws -> some IntentResult {
38 HutchIntentNavigator.shared.pendingDestination = destination
105 HutchIntentNavigator.shared.open(route)
39106 return .result()
40107 }
41108}
42109
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
44269
45270struct CheckSystemStatusIntent: AppIntent {
46271 static var title: LocalizedStringResource = "Check SourceHut Status"
@@ -64,8 +289,6 @@ struct CheckSystemStatusIntent: AppIntent {
64289 }
65290}
66291
67// MARK: - Check Builds Intent
68
69292struct CheckBuildsIntent: AppIntent {
70293 static var title: LocalizedStringResource = "Check Hutch Builds"
71294 static var description = IntentDescription("Returns a summary of your recent build status.")
@@ -107,25 +330,92 @@ struct CheckBuildsIntent: AppIntent {
107330struct HutchShortcuts: AppShortcutsProvider {
108331 static var appShortcuts: [AppShortcut] {
109332 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(),
111364 phrases: [
112 "Open \(.applicationName)",
113 "Open \(.applicationName) \(\.$destination)",
114 "Show my \(.applicationName) \(\.$destination)",
115 "Go to \(\.$destination) in \(.applicationName)"
365 "Open \(\.$pinnedResource) in \(.applicationName)",
366 "Show my pinned \(\.$pinnedResource) in \(.applicationName)"
116367 ],
117 shortTitle: "Open Hutch",
118 systemImageName: "house"
368 shortTitle: "Pinned Resource",
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"
119410 )
120411
121412 AppShortcut(
122413 intent: CheckSystemStatusIntent(),
123414 phrases: [
124415 "Check \(.applicationName) status",
125 "Is SourceHut up in \(.applicationName)",
126 "SourceHut status in \(.applicationName)"
416 "Is SourceHut up in \(.applicationName)"
127417 ],
128 shortTitle: "Check SourceHut Status",
418 shortTitle: "Check Status",
129419 systemImageName: "server.rack"
130420 )
131421
@@ -133,7 +423,6 @@ struct HutchShortcuts: AppShortcutsProvider {
133423 intent: CheckBuildsIntent(),
134424 phrases: [
135425 "Check my \(.applicationName) builds",
136 "How are my builds in \(.applicationName)",
137426 "Build status in \(.applicationName)"
138427 ],
139428 shortTitle: "Check Builds",
@@ -148,9 +437,13 @@ struct HutchShortcuts: AppShortcutsProvider {
148437@Observable
149438final class HutchIntentNavigator {
150439 static let shared = HutchIntentNavigator()
151 var pendingDestination: HutchDestination?
440 var pendingRoute: HutchRoute?
152441
153442 private init() {
154443 /* Singleton; external code uses `shared`. */
155444 }
445
446 func open(_ route: HutchRoute) {
447 pendingRoute = route
448 }
156449}
Hutch/App/RootView.swift +109 −15
@@ -75,8 +75,8 @@ struct RootView: View {
7575 HomeView()
7676 .navigationDestination(for: HomeRoute.self) { route in
7777 switch route {
78 case .work:
79 WorkView()
78 case .work(let scope):
79 WorkView(initialScope: scope)
8080 }
8181 }
8282 }
@@ -202,9 +202,16 @@ struct RootView: View {
202202 homePath = NavigationPath()
203203 appState.selectedTab = .home
204204
205 case .recentActivity:
206 homePath = NavigationPath()
207 appState.selectedTab = .home
208
205209 case .repository(let service, let owner, let repo):
206210 resolveRepositoryLink(service: service, owner: owner, repo: repo)
207211
212 case .tracker(let owner, let tracker):
213 resolveTrackerLink(owner: owner, tracker: tracker)
214
208215 case .build(let jobId):
209216 buildsPath = NavigationPath()
210217 appState.selectedTab = .builds
@@ -223,11 +230,39 @@ struct RootView: View {
223230 resolveUserProfileLink(owner: owner)
224231
225232 case .work:
226 homePath = NavigationPath()
227 appState.selectedTab = .home
233 navigateToWork(scope: .all)
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
228255 Task {
229256 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))
231266 }
232267
233268 case .buildsTab:
@@ -244,14 +279,15 @@ struct RootView: View {
244279
245280 case .systemStatus:
246281 appState.navigateToSystemStatus()
282 }
283 }
247284
248 case .lookup:
249 morePath = NavigationPath()
250 appState.selectedTab = .more
251 Task {
252 await settleNavigationTransition()
253 morePath.append(MoreRoute.lookup)
254 }
285 private func navigateToWork(scope: HutchWorkQueueScope) {
286 homePath = NavigationPath()
287 appState.selectedTab = .home
288 Task {
289 await settleNavigationTransition()
290 homePath.append(HomeRoute.work(scope: scope))
255291 }
256292 }
257293
@@ -310,6 +346,22 @@ struct RootView: View {
310346 }
311347 }
312348
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
313365 private func resolveMailingListLink(owner: String, list: String) {
314366 isResolvingDeepLink = true
315367 Task {
@@ -375,7 +427,7 @@ enum MoreDestination: Hashable {
375427}
376428
377429enum MoreRoute: Hashable {
378 case lookup
430 case lookup(query: String?)
379431 case projects
380432 case lists
381433 case pastes
@@ -384,6 +436,7 @@ enum MoreRoute: Hashable {
384436 case settings
385437 case about
386438 case userProfile(String)
439 case projectDashboard(id: String, title: String?)
387440 case mailingList(InboxMailingListReference)
388441 case thread(InboxThreadSummary)
389442 case manPageBrowser
@@ -397,8 +450,8 @@ private struct MoreNavigationRoot: View {
397450 MoreView()
398451 .navigationDestination(for: MoreRoute.self) { route in
399452 switch route {
400 case .lookup:
401 LookupView()
453 case .lookup(let query):
454 LookupView(initialQuery: query ?? "")
402455 case .projects:
403456 ProjectsListView()
404457 case .lists:
@@ -415,6 +468,8 @@ private struct MoreNavigationRoot: View {
415468 AboutView()
416469 case .userProfile(let owner):
417470 UserProfileDeepLinkView(owner: owner)
471 case .projectDashboard(let id, let title):
472 ProjectDashboardDeepLinkView(projectID: id, title: title)
418473 case .mailingList(let mailingList):
419474 MailingListDetailView(mailingList: mailingList)
420475 case .thread(let thread):
@@ -484,6 +539,45 @@ struct UserProfileDeepLinkView: View {
484539 }
485540}
486541
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
487581// MARK: - Ticket Deep Link Navigation Target
488582
489583/// 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 {
124124 let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults)
125125 vm.repoFilter = savedRepoFilter
126126 vm.lookbackDays = lookbackDays
127 if let pendingFilter = appState.pendingBuildListFilter {
128 vm.filter = pendingFilter
129 appState.pendingBuildListFilter = nil
130 }
127131 viewModel = vm
128132 await vm.loadJobs()
129133 }
@@ -134,6 +138,11 @@ struct BuildListView: View {
134138 .onChange(of: lookbackDays) { _, newValue in
135139 viewModel?.lookbackDays = newValue
136140 }
141 .onChange(of: appState.pendingBuildListFilter) { _, newValue in
142 guard let newValue else { return }
143 viewModel?.filter = newValue
144 appState.pendingBuildListFilter = nil
145 }
137146 .onDisappear {
138147 viewModel?.stopAutoRefresh()
139148 }
Hutch/Views/Builds/BuildListViewModel.swift +8
@@ -21,6 +21,7 @@ private struct SubmittedJob: Decodable, Sendable {
2121
2222enum BuildListFilter: String, CaseIterable, Sendable {
2323 case attention = "Attention"
24 case failed = "Failed"
2425 case active = "Active"
2526 case all = "All"
2627}
@@ -413,6 +414,13 @@ final class BuildListViewModel {
413414 case .success, .cancelled:
414415 return false
415416 }
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 }
416424 case .active:
417425 switch job.status {
418426 case .running, .queued, .pending:
Hutch/Views/Home/HomeView.swift +2 −2
@@ -106,7 +106,7 @@ struct HomeView: View {
106106
107107 private func workSection(_ viewModel: HomeViewModel) -> some View {
108108 Section("Work") {
109 NavigationLink(value: HomeRoute.work) {
109 NavigationLink(value: HomeRoute.work(scope: .all)) {
110110 HomeSummaryRow(
111111 title: workTitle(viewModel),
112112 summary: workSummary(viewModel),
@@ -424,7 +424,7 @@ struct HomeView: View {
424424}
425425
426426enum HomeRoute: Hashable {
427 case work
427 case work(scope: HutchWorkQueueScope)
428428}
429429
430430private enum HomeSummaryEmphasis {
Hutch/Views/Lookup/LookupView.swift +22 −4
@@ -92,11 +92,17 @@ final class LookupViewModel {
9292 )
9393 }
9494
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 ) {
96101 self.client = client
97102 self.appState = appState
98103 self.defaults = defaults
99104 self.history = LookupHistoryStore.load(defaults: defaults)
105 self.inputText = initialQuery.trimmingCharacters(in: .whitespacesAndNewlines)
100106 }
101107
102108 func lookup() async {
@@ -329,6 +335,11 @@ final class LookupViewModel {
329335struct LookupView: View {
330336 @Environment(AppState.self) private var appState
331337 @State private var viewModel: LookupViewModel?
338 private let initialQuery: String
339
340 init(initialQuery: String = "") {
341 self.initialQuery = initialQuery
342 }
332343
333344 var body: some View {
334345 Group {
@@ -341,7 +352,12 @@ struct LookupView: View {
341352 .navigationTitle("Look Up")
342353 .task {
343354 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 )
345361 }
346362 }
347363 }
@@ -427,8 +443,8 @@ struct LookupView: View {
427443 }
428444 .navigationDestination(for: MoreRoute.self) { route in
429445 switch route {
430 case .lookup:
431 LookupView()
446 case .lookup(let query):
447 LookupView(initialQuery: query ?? "")
432448 case .projects:
433449 ProjectsListView()
434450 case .lists:
@@ -445,6 +461,8 @@ struct LookupView: View {
445461 AboutView()
446462 case .userProfile(let owner):
447463 UserProfileDeepLinkView(owner: owner)
464 case .projectDashboard(let id, let title):
465 ProjectDashboardDeepLinkView(projectID: id, title: title)
448466 case .mailingList(let mailingList):
449467 MailingListDetailView(mailingList: mailingList)
450468 case .thread(let thread):
Hutch/Views/More/MoreView.swift +1 −1
@@ -13,7 +13,7 @@ struct MoreView: View {
1313 var body: some View {
1414 List {
1515 Section("Search") {
16 NavigationLink(value: MoreRoute.lookup) {
16 NavigationLink(value: MoreRoute.lookup(query: nil)) {
1717 Label("Look Up", systemImage: "magnifyingglass")
1818 }
1919 .themedRow()
Hutch/Views/Work/WorkView.swift +18 −10
@@ -1,20 +1,28 @@
11import SwiftUI
22
3struct WorkView: View {
4 private enum Scope: String, CaseIterable, Identifiable {
5 case all = "All"
6 case unread = "Unread"
7 case assigned = "Assigned"
8
9 var id: String { rawValue }
3extension HutchWorkQueueScope: Identifiable {
4 var id: String { rawValue }
5
6 var displayName: String {
7 switch self {
8 case .all: "All"
9 case .unread: "Unread"
10 case .assigned: "Assigned"
11 }
1012 }
13}
1114
15struct WorkView: View {
1216 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
1317 @Environment(AppState.self) private var appState
1418 @Environment(\.isAMOLEDTheme) private var isAMOLED
1519 @Environment(\.scenePhase) private var scenePhase
1620 @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 }
1826
1927 var body: some View {
2028 Group {
@@ -106,8 +114,8 @@ struct WorkView: View {
106114 private var scopeSection: some View {
107115 Section {
108116 Picker("Scope", selection: $scope) {
109 ForEach(Scope.allCases) { scope in
110 Text(scope.rawValue).tag(scope)
117 ForEach(HutchWorkQueueScope.allCases) { scope in
118 Text(scope.displayName).tag(scope)
111119 }
112120 }
113121 .pickerStyle(.segmented)
HutchTests/BuildListViewModelTests.swift +6
@@ -59,9 +59,15 @@ struct BuildListViewModelTests {
5959 filter: .active,
6060 lookbackDays: BuildListViewModel.defaultLookbackDays
6161 )
62 let failed = BuildListViewModel.filterJobs(
63 jobs,
64 filter: .failed,
65 lookbackDays: BuildListViewModel.defaultLookbackDays
66 )
6267
6368 #expect(attention.map(\.id) == [2, 3])
6469 #expect(active.map(\.id) == [3])
70 #expect(failed.map(\.id) == [2])
6571 }
6672
6773 @Test
HutchTests/DeepLinkTests.swift +8
@@ -88,6 +88,14 @@ struct DeepLinkTests {
8888 #expect(link == .lookup)
8989 }
9090
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
9199 @Test
92100 func parsesUserProfileLink() {
93101 let link = DeepLink(url: HutchDeepLinkURL.userProfile)
HutchTests/HutchIntentsTests.swift +9
@@ -5,6 +5,15 @@ import Testing
55@MainActor
66struct HutchIntentsTests {
77
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
817 @Test
918 func checkSystemStatusReturnsOperationalWhenNoDisruption() async throws {
1019 let defaultsName = "HutchIntentsTests-status-\(UUID().uuidString)"
Shared/NeedsAttentionSnapshot.swift +13 −8
@@ -58,11 +58,11 @@ enum NeedsAttentionSnapshotStore {
5858 private static let snapshotKey = "needsAttention.snapshot"
5959
6060 static func load(
61 accountID: String? = ActiveAccountContextStore.load(),
61 accountID: String? = nil,
6262 defaults: UserDefaults? = sharedDefaults()
6363 ) -> NeedsAttentionSnapshot? {
6464 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 {
6666 return nil
6767 }
6868
@@ -71,7 +71,7 @@ enum NeedsAttentionSnapshotStore {
7171
7272 static func save(
7373 _ snapshot: NeedsAttentionSnapshot,
74 accountID: String? = ActiveAccountContextStore.load(),
74 accountID: String? = nil,
7575 defaults: UserDefaults? = sharedDefaults()
7676 ) {
7777 guard let defaults,
@@ -79,7 +79,7 @@ enum NeedsAttentionSnapshotStore {
7979 return
8080 }
8181
82 defaults.set(data, forKey: scopedKey(for: accountID))
82 defaults.set(data, forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults)))
8383 reloadWidgetTimelines()
8484 }
8585
@@ -87,7 +87,7 @@ enum NeedsAttentionSnapshotStore {
8787 unreadInboxThreads: Int? = nil,
8888 assignedOpenTickets: Int? = nil,
8989 failedBuilds: Int? = nil,
90 accountID: String? = ActiveAccountContextStore.load(),
90 accountID: String? = nil,
9191 defaults: UserDefaults? = sharedDefaults()
9292 ) {
9393 let existing = load(accountID: accountID, defaults: defaults)
@@ -102,7 +102,7 @@ enum NeedsAttentionSnapshotStore {
102102
103103 static func adjustUnreadInboxThreads(
104104 by delta: Int,
105 accountID: String? = ActiveAccountContextStore.load(),
105 accountID: String? = nil,
106106 defaults: UserDefaults? = sharedDefaults()
107107 ) {
108108 guard let existing = load(accountID: accountID, defaults: defaults),
@@ -123,10 +123,11 @@ enum NeedsAttentionSnapshotStore {
123123 }
124124
125125 static func clear(
126 accountID: String? = ActiveAccountContextStore.load(),
126 accountID: String? = nil,
127127 defaults: UserDefaults? = sharedDefaults()
128128 ) {
129 defaults?.removeObject(forKey: scopedKey(for: accountID))
129 guard let defaults else { return }
130 defaults.removeObject(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults)))
130131 reloadWidgetTimelines()
131132 }
132133
@@ -139,6 +140,10 @@ enum NeedsAttentionSnapshotStore {
139140 return "\(snapshotKey).\(accountID)"
140141 }
141142
143 private static func resolvedAccountID(_ accountID: String?, defaults: UserDefaults) -> String? {
144 accountID ?? ActiveAccountContextStore.load(defaults: defaults)
145 }
146
142147 private static func reloadWidgetTimelines() {
143148 #if canImport(WidgetKit)
144149 WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind)
Shared/SystemStatusWidgetSnapshot.swift +11 −6
@@ -34,11 +34,11 @@ enum SystemStatusWidgetSnapshotStore {
3434 private static let snapshotKey = "systemStatus.widgetSnapshot"
3535
3636 static func load(
37 accountID: String? = ActiveAccountContextStore.load(),
37 accountID: String? = nil,
3838 defaults: UserDefaults? = sharedDefaults()
3939 ) -> SystemStatusWidgetSnapshot? {
4040 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 {
4242 return nil
4343 }
4444 return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data)
@@ -46,22 +46,23 @@ enum SystemStatusWidgetSnapshotStore {
4646
4747 static func save(
4848 _ snapshot: SystemStatusWidgetSnapshot,
49 accountID: String? = ActiveAccountContextStore.load(),
49 accountID: String? = nil,
5050 defaults: UserDefaults? = sharedDefaults()
5151 ) {
5252 guard let defaults,
5353 let data = try? JSONEncoder().encode(snapshot) else {
5454 return
5555 }
56 defaults.set(data, forKey: scopedKey(for: accountID))
56 defaults.set(data, forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults)))
5757 reloadWidgetTimelines()
5858 }
5959
6060 static func clear(
61 accountID: String? = ActiveAccountContextStore.load(),
61 accountID: String? = nil,
6262 defaults: UserDefaults? = sharedDefaults()
6363 ) {
64 defaults?.removeObject(forKey: scopedKey(for: accountID))
64 guard let defaults else { return }
65 defaults.removeObject(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults)))
6566 reloadWidgetTimelines()
6667 }
6768
@@ -74,6 +75,10 @@ enum SystemStatusWidgetSnapshotStore {
7475 return "\(snapshotKey).\(accountID)"
7576 }
7677
78 private static func resolvedAccountID(_ accountID: String?, defaults: UserDefaults) -> String? {
79 accountID ?? ActiveAccountContextStore.load(defaults: defaults)
80 }
81
7782 private static func reloadWidgetTimelines() {
7883 #if canImport(WidgetKit)
7984 WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind)