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