krz/hutch

an ios client for sourcehut

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

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