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}