krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.5.0: Hutch/App/HutchIntents.swift · raw
1import AppIntents
2import Foundation
3
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"
90 ]
91}
92
93struct OpenFailedBuildsIntent: AppIntent {
94 static var title: LocalizedStringResource = "Open Failed Builds"
95 static var description = IntentDescription("Opens Hutch to failed builds.")
96 static var openAppWhenRun = true
97
98 @Parameter(title: "Scope", default: .all)
99 var scope: HutchShortcutScope
100
101 var route: HutchRoute { .failedBuilds }
102
103 @MainActor
104 func perform() async throws -> some IntentResult {
105 HutchIntentNavigator.shared.open(route)
106 return .result()
107 }
108}
109
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
269
270struct CheckSystemStatusIntent: AppIntent {
271 static var title: LocalizedStringResource = "Check SourceHut Status"
272 static var description = IntentDescription("Returns the current SourceHut system status.")
273
274 @MainActor
275 func perform() async throws -> some IntentResult & ReturnsValue<String> {
276 guard let snapshot = SystemStatusWidgetSnapshotStore.load() else {
277 return .result(value: "System status is unavailable. Open Hutch to refresh.")
278 }
279
280 if snapshot.hasDisruption {
281 let disrupted = snapshot.services
282 .filter { $0.requiresAttention }
283 .map { "\($0.name): \($0.status)" }
284 .joined(separator: ", ")
285 return .result(value: "SourceHut disruption detected: \(disrupted)")
286 }
287
288 return .result(value: "All SourceHut services operational.")
289 }
290}
291
292struct CheckBuildsIntent: AppIntent {
293 static var title: LocalizedStringResource = "Check Hutch Builds"
294 static var description = IntentDescription("Returns a summary of your recent build status.")
295
296 @MainActor
297 func perform() async throws -> some IntentResult & ReturnsValue<String> {
298 guard let snapshot = NeedsAttentionSnapshotStore.load() else {
299 return .result(value: "Build status unavailable. Open Hutch to refresh.")
300 }
301
302 var parts: [String] = []
303
304 if let failed = snapshot.failedBuilds {
305 if failed > 0 {
306 parts.append("\(failed) failed build\(failed == 1 ? "" : "s")")
307 } else {
308 parts.append("No failed builds")
309 }
310 }
311
312 if let unread = snapshot.unreadInboxThreads, unread > 0 {
313 parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")")
314 }
315
316 if let assigned = snapshot.assignedOpenTickets, assigned > 0 {
317 parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")")
318 }
319
320 if parts.isEmpty {
321 return .result(value: "No recent data. Open Hutch to refresh.")
322 }
323
324 return .result(value: parts.joined(separator: ". ") + ".")
325 }
326}
327
328// MARK: - Shortcuts Provider
329
330struct HutchShortcuts: AppShortcutsProvider {
331 static var appShortcuts: [AppShortcut] {
332 AppShortcut(
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(),
364 phrases: [
365 "Open \(\.$pinnedResource) in \(.applicationName)",
366 "Show my pinned \(\.$pinnedResource) in \(.applicationName)"
367 ],
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"
410 )
411
412 AppShortcut(
413 intent: CheckSystemStatusIntent(),
414 phrases: [
415 "Check \(.applicationName) status",
416 "Is SourceHut up in \(.applicationName)"
417 ],
418 shortTitle: "Check Status",
419 systemImageName: "server.rack"
420 )
421
422 AppShortcut(
423 intent: CheckBuildsIntent(),
424 phrases: [
425 "Check my \(.applicationName) builds",
426 "Build status in \(.applicationName)"
427 ],
428 shortTitle: "Check Builds",
429 systemImageName: "hammer"
430 )
431 }
432}
433
434// MARK: - Intent Navigator
435
436@MainActor
437@Observable
438final class HutchIntentNavigator {
439 static let shared = HutchIntentNavigator()
440 var pendingRoute: HutchRoute?
441
442 private init() {
443 /* Singleton; external code uses `shared`. */
444 }
445
446 func open(_ route: HutchRoute) {
447 pendingRoute = route
448 }
449}