krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.8.1: 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 // Routes to Lookup for now; repoint at a global content search when Hutch
138 // gains one — tracked in ROADMAP.md § "App Intent gaps".
139 return normalized.isEmpty ? .lookup : .search(query: normalized)
140 }
141
142 @MainActor
143 func perform() async throws -> some IntentResult {
144 HutchIntentNavigator.shared.open(route)
145 return .result()
146 }
147}
148
149// An OpenSavedSearchIntent belongs here once Hutch has global saved-search
150// persistence — tracked in ROADMAP.md § "App Intent gaps".
151
152// MARK: - App Entities
153
154struct PinnedResourceEntity: AppEntity, Identifiable {
155 static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Pinned Resource")
156 static var defaultQuery = PinnedResourceQuery()
157
158 let id: String
159 let name: String
160 let subtitle: String
161 let route: HutchRoute
162
163 var displayRepresentation: DisplayRepresentation {
164 DisplayRepresentation(title: "\(name)", subtitle: "\(subtitle)")
165 }
166}
167
168struct PinnedResourceQuery: EntityQuery {
169 @MainActor
170 func entities(for identifiers: [PinnedResourceEntity.ID]) async throws -> [PinnedResourceEntity] {
171 HutchIntentEntityStore.pinnedResources().filter { identifiers.contains($0.id) }
172 }
173
174 @MainActor
175 func suggestedEntities() async throws -> [PinnedResourceEntity] {
176 HutchIntentEntityStore.pinnedResources()
177 }
178}
179
180struct ProjectEntity: AppEntity, Identifiable {
181 static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project")
182 static var defaultQuery = ProjectEntityQuery()
183
184 let id: String
185 let name: String
186
187 var displayRepresentation: DisplayRepresentation {
188 DisplayRepresentation(title: "\(name)")
189 }
190}
191
192struct ProjectEntityQuery: EntityQuery {
193 @MainActor
194 func entities(for identifiers: [ProjectEntity.ID]) async throws -> [ProjectEntity] {
195 HutchIntentEntityStore.projects().filter { identifiers.contains($0.id) }
196 }
197
198 @MainActor
199 func suggestedEntities() async throws -> [ProjectEntity] {
200 HutchIntentEntityStore.projects()
201 }
202}
203
204private enum HutchIntentEntityStore {
205 static func pinnedResources() -> [PinnedResourceEntity] {
206 pins().compactMap { makePinnedResource(from: $0) }
207 }
208
209 static func projects() -> [ProjectEntity] {
210 pins().compactMap { pin in
211 guard pin.kind == .project else { return nil }
212 return ProjectEntity(id: pin.value, name: pin.title)
213 }
214 }
215
216 private static func pins() -> [HomePinRecord] {
217 guard let userKey = ContributionWidgetContextStore.loadActor(),
218 !userKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
219 else {
220 return []
221 }
222
223 return HomePinStore.loadPins(for: userKey, defaults: activeAccountDefaults)
224 }
225
226 private static var activeAccountDefaults: UserDefaults {
227 let activeID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
228 guard !activeID.isEmpty else { return .standard }
229 return AccountDefaultsStore.userDefaults(for: activeID)
230 }
231
232 private static func makePinnedResource(from pin: HomePinRecord) -> PinnedResourceEntity? {
233 guard let route = route(for: pin) else { return nil }
234 return PinnedResourceEntity(
235 id: pin.id,
236 name: pin.title,
237 subtitle: pin.subtitle,
238 route: route
239 )
240 }
241
242 private static func route(for pin: HomePinRecord) -> HutchRoute? {
243 switch pin.kind {
244 case .project:
245 return .projectDashboard(id: pin.value, title: pin.title)
246 case .repository:
247 guard let owner = pin.ownerUsername else { return nil }
248 return .repository(
249 service: pin.service ?? .git,
250 owner: formattedOwner(owner),
251 repo: pin.value
252 )
253 case .tracker:
254 guard let owner = pin.ownerUsername else { return nil }
255 return .tracker(owner: formattedOwner(owner), tracker: pin.value)
256 case .mailingList:
257 guard let owner = pin.ownerUsername else { return nil }
258 return .mailingList(owner: formattedOwner(owner), list: pin.value)
259 case .user:
260 guard let owner = pin.ownerUsername else { return nil }
261 return .userProfile(owner: formattedOwner(owner))
262 }
263 }
264
265 private static func formattedOwner(_ owner: String) -> String {
266 owner.hasPrefix("~") ? owner : "~\(owner)"
267 }
268}
269
270// MARK: - Existing Read-Only Summary Intents
271
272struct CheckSystemStatusIntent: AppIntent {
273 static var title: LocalizedStringResource = "Check SourceHut Status"
274 static var description = IntentDescription("Returns the current SourceHut system status.")
275
276 @MainActor
277 func perform() async throws -> some IntentResult & ReturnsValue<String> {
278 guard let snapshot = SystemStatusWidgetSnapshotStore.load() else {
279 return .result(value: "System status is unavailable. Open Hutch to refresh.")
280 }
281
282 if snapshot.hasDisruption {
283 let disrupted = snapshot.services
284 .filter { $0.requiresAttention }
285 .map { "\($0.name): \($0.status)" }
286 .joined(separator: ", ")
287 return .result(value: "SourceHut disruption detected: \(disrupted)")
288 }
289
290 return .result(value: "All SourceHut services operational.")
291 }
292}
293
294struct CheckBuildsIntent: AppIntent {
295 static var title: LocalizedStringResource = "Check Hutch Builds"
296 static var description = IntentDescription("Returns a summary of your recent build status.")
297
298 @MainActor
299 func perform() async throws -> some IntentResult & ReturnsValue<String> {
300 guard let snapshot = NeedsAttentionSnapshotStore.load() else {
301 return .result(value: "Build status unavailable. Open Hutch to refresh.")
302 }
303
304 var parts: [String] = []
305
306 if let failed = snapshot.failedBuilds {
307 if failed > 0 {
308 parts.append("\(failed) failed build\(failed == 1 ? "" : "s")")
309 } else {
310 parts.append("No failed builds")
311 }
312 }
313
314 if let unread = snapshot.unreadInboxThreads, unread > 0 {
315 parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")")
316 }
317
318 if let assigned = snapshot.assignedOpenTickets, assigned > 0 {
319 parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")")
320 }
321
322 if parts.isEmpty {
323 return .result(value: "No recent data. Open Hutch to refresh.")
324 }
325
326 return .result(value: parts.joined(separator: ". ") + ".")
327 }
328}
329
330// MARK: - Shortcuts Provider
331
332struct HutchShortcuts: AppShortcutsProvider {
333 static var appShortcuts: [AppShortcut] {
334 AppShortcut(
335 intent: OpenWorkQueueIntent(),
336 phrases: [
337 "Open my work queue in \(.applicationName)",
338 "Show work in \(.applicationName)"
339 ],
340 shortTitle: "Work Queue",
341 systemImageName: "tray.full"
342 )
343
344 AppShortcut(
345 intent: OpenRecentActivityIntent(),
346 phrases: [
347 "Open recent activity in \(.applicationName)",
348 "Show activity in \(.applicationName)"
349 ],
350 shortTitle: "Recent Activity",
351 systemImageName: "clock.arrow.circlepath"
352 )
353
354 AppShortcut(
355 intent: OpenSystemStatusIntent(),
356 phrases: [
357 "Open system status in \(.applicationName)",
358 "Show SourceHut status in \(.applicationName)"
359 ],
360 shortTitle: "System Status",
361 systemImageName: "server.rack"
362 )
363
364 AppShortcut(
365 intent: OpenPinnedResourceIntent(),
366 phrases: [
367 "Open \(\.$pinnedResource) in \(.applicationName)",
368 "Show my pinned \(\.$pinnedResource) in \(.applicationName)"
369 ],
370 shortTitle: "Pinned Resource",
371 systemImageName: "pin"
372 )
373
374 AppShortcut(
375 intent: OpenProjectDashboardIntent(),
376 phrases: [
377 "Open \(\.$project) dashboard in \(.applicationName)",
378 "Show project \(\.$project) in \(.applicationName)"
379 ],
380 shortTitle: "Project Dashboard",
381 systemImageName: "square.stack.3d.up"
382 )
383
384 AppShortcut(
385 intent: OpenFailedBuildsIntent(),
386 phrases: [
387 "Open failed builds in \(.applicationName)",
388 "Show failed builds in \(.applicationName)"
389 ],
390 shortTitle: "Failed Builds",
391 systemImageName: "exclamationmark.triangle"
392 )
393
394 AppShortcut(
395 intent: OpenAssignedTicketsIntent(),
396 phrases: [
397 "Open assigned tickets in \(.applicationName)",
398 "Show my assigned tickets in \(.applicationName)"
399 ],
400 shortTitle: "Assigned Tickets",
401 systemImageName: "person.crop.circle.badge.checkmark"
402 )
403
404 AppShortcut(
405 intent: SearchHutchIntent(),
406 phrases: [
407 "Search \(.applicationName)",
408 "Look up something in \(.applicationName)"
409 ],
410 shortTitle: "Search Hutch",
411 systemImageName: "magnifyingglass"
412 )
413
414 AppShortcut(
415 intent: CheckSystemStatusIntent(),
416 phrases: [
417 "Check \(.applicationName) status",
418 "Is SourceHut up in \(.applicationName)"
419 ],
420 shortTitle: "Check Status",
421 systemImageName: "server.rack"
422 )
423
424 AppShortcut(
425 intent: CheckBuildsIntent(),
426 phrases: [
427 "Check my \(.applicationName) builds",
428 "Build status in \(.applicationName)"
429 ],
430 shortTitle: "Check Builds",
431 systemImageName: "hammer"
432 )
433 }
434}
435
436// MARK: - Intent Navigator
437
438@MainActor
439@Observable
440final class HutchIntentNavigator {
441 static let shared = HutchIntentNavigator()
442 var pendingRoute: HutchRoute?
443
444 private init() {
445 /* Singleton; external code uses `shared`. */
446 }
447
448 func open(_ route: HutchRoute) {
449 pendingRoute = route
450 }
451}