diff options
Diffstat (limited to 'Hutch/App/HutchIntents.swift')
| -rw-r--r-- | Hutch/App/HutchIntents.swift | 377 |
1 files changed, 335 insertions, 42 deletions
diff --git a/Hutch/App/HutchIntents.swift b/Hutch/App/HutchIntents.swift index 5751ef5..c2f144a 100644 --- a/Hutch/App/HutchIntents.swift +++ b/Hutch/App/HutchIntents.swift @@ -1,46 +1,271 @@ import AppIntents import Foundation -// MARK: - Open Hutch Intent - -enum HutchDestination: String, AppEnum { - case home - case work - case builds - case repositories - case trackers - case systemStatus - case lookup - - static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Hutch Section") - - static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [ - .home: "Home", - .work: "Work", - .builds: "Builds", - .repositories: "Repositories", - .trackers: "Trackers", - .systemStatus: "System Status", - .lookup: "Look Up" +// MARK: - Navigation Intents + +struct OpenWorkQueueIntent: AppIntent { + static var title: LocalizedStringResource = "Open Work Queue" + static var description = IntentDescription("Opens Hutch to your Work Queue.") + static var openAppWhenRun = true + + var route: HutchRoute { .workQueue(scope: .all) } + + @MainActor + func perform() async throws -> some IntentResult { + HutchIntentNavigator.shared.open(route) + return .result() + } +} + +struct OpenRecentActivityIntent: AppIntent { + static var title: LocalizedStringResource = "Open Recent Activity" + static var description = IntentDescription("Opens Hutch to recent activity.") + static var openAppWhenRun = true + + var route: HutchRoute { .recentActivity } + + @MainActor + func perform() async throws -> some IntentResult { + HutchIntentNavigator.shared.open(route) + return .result() + } +} + +struct OpenSystemStatusIntent: AppIntent { + static var title: LocalizedStringResource = "Open System Status" + static var description = IntentDescription("Opens Hutch to SourceHut system status.") + static var openAppWhenRun = true + + var route: HutchRoute { .systemStatus } + + @MainActor + func perform() async throws -> some IntentResult { + HutchIntentNavigator.shared.open(route) + return .result() + } +} + +struct OpenPinnedResourceIntent: AppIntent { + static var title: LocalizedStringResource = "Open Pinned Resource" + static var description = IntentDescription("Opens a pinned Hutch resource.") + static var openAppWhenRun = true + + @Parameter(title: "Pinned Resource") + var pinnedResource: PinnedResourceEntity + + var route: HutchRoute { pinnedResource.route } + + @MainActor + func perform() async throws -> some IntentResult { + HutchIntentNavigator.shared.open(route) + return .result() + } +} + +struct OpenProjectDashboardIntent: AppIntent { + static var title: LocalizedStringResource = "Open Project Dashboard" + static var description = IntentDescription("Opens a pinned project dashboard in Hutch.") + static var openAppWhenRun = true + + @Parameter(title: "Project") + var project: ProjectEntity + + var route: HutchRoute { + .projectDashboard(id: project.id, title: project.name) + } + + @MainActor + func perform() async throws -> some IntentResult { + HutchIntentNavigator.shared.open(route) + return .result() + } +} + +enum HutchShortcutScope: String, AppEnum { + case all + + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scope") + static var caseDisplayRepresentations: [HutchShortcutScope: DisplayRepresentation] = [ + .all: "All" ] } -struct OpenHutchIntent: AppIntent { - static var title: LocalizedStringResource = "Open Hutch" - static var description = IntentDescription("Opens Hutch to a specific section.") +struct OpenFailedBuildsIntent: AppIntent { + static var title: LocalizedStringResource = "Open Failed Builds" + static var description = IntentDescription("Opens Hutch to failed builds.") static var openAppWhenRun = true - @Parameter(title: "Section", default: .home) - var destination: HutchDestination + @Parameter(title: "Scope", default: .all) + var scope: HutchShortcutScope + + var route: HutchRoute { .failedBuilds } @MainActor func perform() async throws -> some IntentResult { - HutchIntentNavigator.shared.pendingDestination = destination + HutchIntentNavigator.shared.open(route) return .result() } } -// MARK: - Check System Status Intent +struct OpenAssignedTicketsIntent: AppIntent { + static var title: LocalizedStringResource = "Open Assigned Tickets" + static var description = IntentDescription("Opens Hutch to tickets assigned to you.") + static var openAppWhenRun = true + + @Parameter(title: "Scope", default: .all) + var scope: HutchShortcutScope + + var route: HutchRoute { .workQueue(scope: .assigned) } + + @MainActor + func perform() async throws -> some IntentResult { + HutchIntentNavigator.shared.open(route) + return .result() + } +} + +struct SearchHutchIntent: AppIntent { + static var title: LocalizedStringResource = "Search Hutch" + static var description = IntentDescription("Opens Hutch lookup with a search query.") + static var openAppWhenRun = true + + @Parameter(title: "Query") + var query: String + + var route: HutchRoute { + let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines) + // TODO: Route to global local search once Hutch has one. + return normalized.isEmpty ? .lookup : .search(query: normalized) + } + + @MainActor + func perform() async throws -> some IntentResult { + HutchIntentNavigator.shared.open(route) + return .result() + } +} + +// TODO: Add OpenSavedSearchIntent when Hutch has global saved-search persistence. + +// MARK: - App Entities + +struct PinnedResourceEntity: AppEntity, Identifiable { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Pinned Resource") + static var defaultQuery = PinnedResourceQuery() + + let id: String + let name: String + let subtitle: String + let route: HutchRoute + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)", subtitle: "\(subtitle)") + } +} + +struct PinnedResourceQuery: EntityQuery { + @MainActor + func entities(for identifiers: [PinnedResourceEntity.ID]) async throws -> [PinnedResourceEntity] { + HutchIntentEntityStore.pinnedResources().filter { identifiers.contains($0.id) } + } + + @MainActor + func suggestedEntities() async throws -> [PinnedResourceEntity] { + HutchIntentEntityStore.pinnedResources() + } +} + +struct ProjectEntity: AppEntity, Identifiable { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project") + static var defaultQuery = ProjectEntityQuery() + + let id: String + let name: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +struct ProjectEntityQuery: EntityQuery { + @MainActor + func entities(for identifiers: [ProjectEntity.ID]) async throws -> [ProjectEntity] { + HutchIntentEntityStore.projects().filter { identifiers.contains($0.id) } + } + + @MainActor + func suggestedEntities() async throws -> [ProjectEntity] { + HutchIntentEntityStore.projects() + } +} + +private enum HutchIntentEntityStore { + static func pinnedResources() -> [PinnedResourceEntity] { + pins().compactMap { makePinnedResource(from: $0) } + } + + static func projects() -> [ProjectEntity] { + pins().compactMap { pin in + guard pin.kind == .project else { return nil } + return ProjectEntity(id: pin.value, name: pin.title) + } + } + + private static func pins() -> [HomePinRecord] { + guard let userKey = ContributionWidgetContextStore.loadActor(), + !userKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return [] + } + + return HomePinStore.loadPins(for: userKey, defaults: activeAccountDefaults) + } + + private static var activeAccountDefaults: UserDefaults { + let activeID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? "" + guard !activeID.isEmpty else { return .standard } + return AccountDefaultsStore.userDefaults(for: activeID) + } + + private static func makePinnedResource(from pin: HomePinRecord) -> PinnedResourceEntity? { + guard let route = route(for: pin) else { return nil } + return PinnedResourceEntity( + id: pin.id, + name: pin.title, + subtitle: pin.subtitle, + route: route + ) + } + + private static func route(for pin: HomePinRecord) -> HutchRoute? { + switch pin.kind { + case .project: + return .projectDashboard(id: pin.value, title: pin.title) + case .repository: + guard let owner = pin.ownerUsername else { return nil } + return .repository( + service: pin.service ?? .git, + owner: formattedOwner(owner), + repo: pin.value + ) + case .tracker: + guard let owner = pin.ownerUsername else { return nil } + return .tracker(owner: formattedOwner(owner), tracker: pin.value) + case .mailingList: + guard let owner = pin.ownerUsername else { return nil } + return .mailingList(owner: formattedOwner(owner), list: pin.value) + case .user: + guard let owner = pin.ownerUsername else { return nil } + return .userProfile(owner: formattedOwner(owner)) + } + } + + private static func formattedOwner(_ owner: String) -> String { + owner.hasPrefix("~") ? owner : "~\(owner)" + } +} + +// MARK: - Existing Read-Only Summary Intents struct CheckSystemStatusIntent: AppIntent { static var title: LocalizedStringResource = "Check SourceHut Status" @@ -64,8 +289,6 @@ struct CheckSystemStatusIntent: AppIntent { } } -// MARK: - Check Builds Intent - struct CheckBuildsIntent: AppIntent { static var title: LocalizedStringResource = "Check Hutch Builds" static var description = IntentDescription("Returns a summary of your recent build status.") @@ -107,25 +330,92 @@ struct CheckBuildsIntent: AppIntent { struct HutchShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( - intent: OpenHutchIntent(), + intent: OpenWorkQueueIntent(), + phrases: [ + "Open my work queue in \(.applicationName)", + "Show work in \(.applicationName)" + ], + shortTitle: "Work Queue", + systemImageName: "tray.full" + ) + + AppShortcut( + intent: OpenRecentActivityIntent(), + phrases: [ + "Open recent activity in \(.applicationName)", + "Show activity in \(.applicationName)" + ], + shortTitle: "Recent Activity", + systemImageName: "clock.arrow.circlepath" + ) + + AppShortcut( + intent: OpenSystemStatusIntent(), + phrases: [ + "Open system status in \(.applicationName)", + "Show SourceHut status in \(.applicationName)" + ], + shortTitle: "System Status", + systemImageName: "server.rack" + ) + + AppShortcut( + intent: OpenPinnedResourceIntent(), phrases: [ - "Open \(.applicationName)", - "Open \(.applicationName) \(\.$destination)", - "Show my \(.applicationName) \(\.$destination)", - "Go to \(\.$destination) in \(.applicationName)" + "Open \(\.$pinnedResource) in \(.applicationName)", + "Show my pinned \(\.$pinnedResource) in \(.applicationName)" ], - shortTitle: "Open Hutch", - systemImageName: "house" + shortTitle: "Pinned Resource", + systemImageName: "pin" + ) + + AppShortcut( + intent: OpenProjectDashboardIntent(), + phrases: [ + "Open \(\.$project) dashboard in \(.applicationName)", + "Show project \(\.$project) in \(.applicationName)" + ], + shortTitle: "Project Dashboard", + systemImageName: "square.stack.3d.up" + ) + + AppShortcut( + intent: OpenFailedBuildsIntent(), + phrases: [ + "Open failed builds in \(.applicationName)", + "Show failed builds in \(.applicationName)" + ], + shortTitle: "Failed Builds", + systemImageName: "exclamationmark.triangle" + ) + + AppShortcut( + intent: OpenAssignedTicketsIntent(), + phrases: [ + "Open assigned tickets in \(.applicationName)", + "Show my assigned tickets in \(.applicationName)" + ], + shortTitle: "Assigned Tickets", + systemImageName: "person.crop.circle.badge.checkmark" + ) + + AppShortcut( + intent: SearchHutchIntent(), + phrases: [ + "Search \(.applicationName)", + "Look up something in \(.applicationName)" + ], + shortTitle: "Search Hutch", + systemImageName: "magnifyingglass" ) AppShortcut( intent: CheckSystemStatusIntent(), phrases: [ "Check \(.applicationName) status", - "Is SourceHut up in \(.applicationName)", - "SourceHut status in \(.applicationName)" + "Is SourceHut up in \(.applicationName)" ], - shortTitle: "Check SourceHut Status", + shortTitle: "Check Status", systemImageName: "server.rack" ) @@ -133,7 +423,6 @@ struct HutchShortcuts: AppShortcutsProvider { intent: CheckBuildsIntent(), phrases: [ "Check my \(.applicationName) builds", - "How are my builds in \(.applicationName)", "Build status in \(.applicationName)" ], shortTitle: "Check Builds", @@ -148,9 +437,13 @@ struct HutchShortcuts: AppShortcutsProvider { @Observable final class HutchIntentNavigator { static let shared = HutchIntentNavigator() - var pendingDestination: HutchDestination? + var pendingRoute: HutchRoute? private init() { /* Singleton; external code uses `shared`. */ } + + func open(_ route: HutchRoute) { + pendingRoute = route + } } |
