summaryrefslogtreecommitdiff
path: root/Hutch/App/HutchIntents.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/App/HutchIntents.swift')
-rw-r--r--Hutch/App/HutchIntents.swift377
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
+ }
}