krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.6.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}