diff options
| author | Christian Cleberg <[email protected]> | 2026-05-14 13:18:55 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-05-14 13:27:41 -0500 |
| commit | 51b2eb0e6296734960f37cebd40fee34a1f261f9 (patch) | |
| tree | 65063727d8dcff3540818676d7b53288499475dd /Hutch/App | |
| parent | 9a853f8777230e0639261b5bc817201ec2d9deca (diff) | |
| download | hutch-51b2eb0e6296734960f37cebd40fee34a1f261f9.tar.gz hutch-51b2eb0e6296734960f37cebd40fee34a1f261f9.tar.bz2 hutch-51b2eb0e6296734960f37cebd40fee34a1f261f9.zip | |
feat: add App Intents for Hutch navigation
- add read-only App Intents for core Hutch workflows
- route intents through the existing Hutch navigation/deep-link model
- expose Work Queue, Recent Activity, System Status, pinned resources, projects, failed builds, assigned tickets, saved searches, and search where supported
- keep App Intents non-mutating for the initial implementation
- preserve existing widget, Safari extension, and hutch:// routing behavior
References: https://todo.sr.ht/~ccleberg/hutch/71
Diffstat (limited to 'Hutch/App')
| -rw-r--r-- | Hutch/App/AppState.swift | 6 | ||||
| -rw-r--r-- | Hutch/App/DeepLink.swift | 210 | ||||
| -rw-r--r-- | Hutch/App/HutchApp.swift | 18 | ||||
| -rw-r--r-- | Hutch/App/HutchIntents.swift | 377 | ||||
| -rw-r--r-- | Hutch/App/RootView.swift | 124 |
5 files changed, 640 insertions, 95 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift index a5500fe..479f610 100644 --- a/Hutch/App/AppState.swift +++ b/Hutch/App/AppState.swift @@ -84,6 +84,7 @@ final class AppState { /// Set by the deep link handler; consumed by RootView to drive navigation. var pendingDeepLink: DeepLink? var pendingTabNavigation: TabNavigationTarget? + var pendingBuildListFilter: BuildListFilter? var deepLinkError: String? // MARK: - Init @@ -373,6 +374,10 @@ final class AppState { selectedTab = .builds } + func open(_ route: HutchRoute) { + pendingDeepLink = DeepLink(route: route) + } + func presentRepositoryDeepLinkError() { deepLinkError = "The repository could not be found or is inaccessible." } @@ -574,6 +579,7 @@ final class AppState { private func resetNavigationState() { pendingDeepLink = nil pendingTabNavigation = nil + pendingBuildListFilter = nil deepLinkError = nil selectedTab = .home } diff --git a/Hutch/App/DeepLink.swift b/Hutch/App/DeepLink.swift index 574481d..7467fc8 100644 --- a/Hutch/App/DeepLink.swift +++ b/Hutch/App/DeepLink.swift @@ -3,45 +3,50 @@ import os private let deepLinkParserLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink") -/// Represents a parsed `hutch://` deep link. -enum DeepLink: Equatable { +enum HutchWorkQueueScope: String, CaseIterable, Sendable { + case all + case unread + case assigned +} + +enum HutchRoute: Equatable, Sendable { case home - case work - /// hutch://git/<owner>/<repo> or hutch://hg/<owner>/<repo> + case workQueue(scope: HutchWorkQueueScope = .all) + case recentActivity case repository(service: SRHTService, owner: String, repo: String) - /// hutch://todo/<owner>/<tracker>/<ticketId> + case tracker(owner: String, tracker: String) case ticket(owner: String, tracker: String, ticketId: Int) - /// hutch://builds/<jobId> or hutch://builds/<owner>/job/<jobId> case build(jobId: Int) - /// hutch://lists/<owner>/<list> case mailingList(owner: String, list: String) - /// hutch://lookup/<owner> case userProfile(owner: String) - /// hutch://builds (tab-level) - case buildsTab - /// hutch://repositories (tab-level) - case repositoriesTab - /// hutch://trackers (tab-level) - case trackersTab - /// hutch://status + case builds + case failedBuilds + case repositories + case trackers case systemStatus - /// hutch://lookup case lookup + case search(query: String) + case projectDashboard(id: String, title: String?) - /// Attempt to parse a URL into a DeepLink. - /// Expected format: hutch://<path> init?(url: URL) { guard url.scheme == "hutch" else { return nil } let components = url.deepLinkPathComponents - deepLinkParserLogger.info("DeepLink parser components for \(url.absoluteString, privacy: .public): \(components.joined(separator: ","), privacy: .public)") + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + let queryValue: (String) -> String? = { name in + queryItems.first { $0.name == name }?.value?.trimmingCharacters(in: .whitespacesAndNewlines) + } switch components.first { case "home", nil: self = .home + case "recent", "recent-activity", "activity": + self = .recentActivity + case "work", "inbox": - self = .work + let scope = queryValue("scope").flatMap(HutchWorkQueueScope.init(rawValue:)) ?? .all + self = .workQueue(scope: scope) case let .some(serviceName) where ["git", "hg", "todo", "builds", "lists"].contains(serviceName) && components.count == 2 @@ -62,6 +67,9 @@ enum DeepLink: Equatable { guard let ticketId = Int(components[3]) else { return nil } self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId) + case "todo" where components.count >= 3: + self = .tracker(owner: components[1], tracker: components[2]) + case "builds" where components.count >= 2: let rawJobId: String if components.count >= 4, components[2] == "job" { @@ -73,16 +81,19 @@ enum DeepLink: Equatable { self = .build(jobId: jobId) case "builds": - self = .buildsTab + self = queryValue("filter") == "failed" ? .failedBuilds : .builds case "lists" where components.count >= 3: self = .mailingList(owner: components[1], list: components[2]) + case "projects" where components.count >= 2: + self = .projectDashboard(id: components[1], title: queryValue("title")) + case "repositories": - self = .repositoriesTab + self = .repositories case "trackers": - self = .trackersTab + self = .trackers case "status": self = .systemStatus @@ -91,12 +102,163 @@ enum DeepLink: Equatable { self = .userProfile(owner: components[1]) case "lookup": - self = .lookup + if let query = queryValue("q"), !query.isEmpty { + self = .search(query: query) + } else { + self = .lookup + } default: return nil } } + + var url: URL { + switch self { + case .home: + return Self.makeURL(host: "home") + case .workQueue(let scope): + return Self.makeURL( + host: "work", + queryItems: scope == .all ? [] : [URLQueryItem(name: "scope", value: scope.rawValue)] + ) + case .recentActivity: + return Self.makeURL(host: "recent-activity") + case .repository(let service, let owner, let repo): + return Self.makeURL(host: service.rawValue, path: [owner, repo]) + case .tracker(let owner, let tracker): + return Self.makeURL(host: "todo", path: [owner, tracker]) + case .ticket(let owner, let tracker, let ticketId): + return Self.makeURL(host: "todo", path: [owner, tracker, String(ticketId)]) + case .build(let jobId): + return Self.makeURL(host: "builds", path: [String(jobId)]) + case .mailingList(let owner, let list): + return Self.makeURL(host: "lists", path: [owner, list]) + case .userProfile(let owner): + return Self.makeURL(host: "lookup", path: [owner]) + case .builds: + return Self.makeURL(host: "builds") + case .failedBuilds: + return Self.makeURL(host: "builds", queryItems: [URLQueryItem(name: "filter", value: "failed")]) + case .repositories: + return Self.makeURL(host: "repositories") + case .trackers: + return Self.makeURL(host: "trackers") + case .systemStatus: + return Self.makeURL(host: "status") + case .lookup: + return Self.makeURL(host: "lookup") + case .search(let query): + return Self.makeURL(host: "lookup", queryItems: [URLQueryItem(name: "q", value: query)]) + case .projectDashboard(let id, let title): + return Self.makeURL( + host: "projects", + path: [id], + queryItems: title.map { [URLQueryItem(name: "title", value: $0)] } ?? [] + ) + } + } + + private static func makeURL( + host: String, + path: [String] = [], + queryItems: [URLQueryItem] = [] + ) -> URL { + var components = URLComponents() + components.scheme = "hutch" + components.host = host + if !path.isEmpty { + components.path = "/" + path.joined(separator: "/") + } + if !queryItems.isEmpty { + components.queryItems = queryItems + } + return components.url! + } +} + +/// Represents a parsed `hutch://` deep link. +enum DeepLink: Equatable { + case home + case work + case workQueue(scope: HutchWorkQueueScope) + case recentActivity + /// hutch://git/<owner>/<repo> or hutch://hg/<owner>/<repo> + case repository(service: SRHTService, owner: String, repo: String) + /// hutch://todo/<owner>/<tracker> + case tracker(owner: String, tracker: String) + /// hutch://todo/<owner>/<tracker>/<ticketId> + case ticket(owner: String, tracker: String, ticketId: Int) + /// hutch://builds/<jobId> or hutch://builds/<owner>/job/<jobId> + case build(jobId: Int) + /// hutch://lists/<owner>/<list> + case mailingList(owner: String, list: String) + /// hutch://lookup/<owner> + case userProfile(owner: String) + /// hutch://builds (tab-level) + case buildsTab + /// hutch://repositories (tab-level) + case repositoriesTab + /// hutch://trackers (tab-level) + case trackersTab + /// hutch://status + case systemStatus + /// hutch://lookup + case lookup + /// hutch://lookup?q=<query> + case search(query: String) + /// hutch://builds?filter=failed + case failedBuilds + /// hutch://projects/<rid> + case projectDashboard(id: String, title: String?) + + /// Attempt to parse a URL into a DeepLink. + /// Expected format: hutch://<path> + init?(url: URL) { + let components = url.deepLinkPathComponents + deepLinkParserLogger.info("DeepLink parser components for \(url.absoluteString, privacy: .public): \(components.joined(separator: ","), privacy: .public)") + guard let route = HutchRoute(url: url) else { return nil } + self = Self(route: route) + } + + init(route: HutchRoute) { + switch route { + case .home: + self = .home + case .workQueue(let scope): + self = scope == .all ? .work : .workQueue(scope: scope) + case .recentActivity: + self = .recentActivity + case .repository(let service, let owner, let repo): + self = .repository(service: service, owner: owner, repo: repo) + case .tracker(let owner, let tracker): + self = .tracker(owner: owner, tracker: tracker) + case .ticket(let owner, let tracker, let ticketId): + self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId) + case .build(let jobId): + self = .build(jobId: jobId) + case .mailingList(let owner, let list): + self = .mailingList(owner: owner, list: list) + case .userProfile(let owner): + self = .userProfile(owner: owner) + case .builds: + self = .buildsTab + case .failedBuilds: + self = .failedBuilds + case .repositories: + self = .repositoriesTab + case .trackers: + self = .trackersTab + case .systemStatus: + self = .systemStatus + case .lookup: + self = .lookup + case .search(let query): + self = .search(query: query) + case .projectDashboard(let id, let title): + self = .projectDashboard(id: id, title: title) + } + } } private extension URL { diff --git a/Hutch/App/HutchApp.swift b/Hutch/App/HutchApp.swift index 69d1311..2b1977a 100644 --- a/Hutch/App/HutchApp.swift +++ b/Hutch/App/HutchApp.swift @@ -28,20 +28,10 @@ struct HutchApp: App { deepLinkLogger.error("Rejected URL: \(url.absoluteString, privacy: .public)") } } - .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in - guard let destination else { return } - HutchIntentNavigator.shared.pendingDestination = nil - let link: DeepLink - switch destination { - case .home: link = .home - case .work: link = .work - case .builds: link = .buildsTab - case .repositories: link = .repositoriesTab - case .trackers: link = .trackersTab - case .systemStatus: link = .systemStatus - case .lookup: link = .lookup - } - appState.pendingDeepLink = link + .onChange(of: HutchIntentNavigator.shared.pendingRoute) { _, route in + guard let route else { return } + HutchIntentNavigator.shared.pendingRoute = nil + appState.open(route) } } } 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 + } } diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 7c8037e..e223766 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -75,8 +75,8 @@ struct RootView: View { HomeView() .navigationDestination(for: HomeRoute.self) { route in switch route { - case .work: - WorkView() + case .work(let scope): + WorkView(initialScope: scope) } } } @@ -202,9 +202,16 @@ struct RootView: View { homePath = NavigationPath() appState.selectedTab = .home + case .recentActivity: + homePath = NavigationPath() + appState.selectedTab = .home + case .repository(let service, let owner, let repo): resolveRepositoryLink(service: service, owner: owner, repo: repo) + case .tracker(let owner, let tracker): + resolveTrackerLink(owner: owner, tracker: tracker) + case .build(let jobId): buildsPath = NavigationPath() appState.selectedTab = .builds @@ -223,11 +230,39 @@ struct RootView: View { resolveUserProfileLink(owner: owner) case .work: - homePath = NavigationPath() - appState.selectedTab = .home + navigateToWork(scope: .all) + + case .workQueue(let scope): + navigateToWork(scope: scope) + + case .projectDashboard(let id, let title): + morePath = NavigationPath() + appState.selectedTab = .more + Task { + await settleNavigationTransition() + morePath.append(MoreRoute.projects) + morePath.append(MoreRoute.projectDashboard(id: id, title: title)) + } + + case .failedBuilds: + buildsPath = NavigationPath() + appState.pendingBuildListFilter = .failed + appState.selectedTab = .builds + + case .search(let query): + morePath = NavigationPath() + appState.selectedTab = .more Task { await settleNavigationTransition() - homePath.append(HomeRoute.work) + morePath.append(MoreRoute.lookup(query: query)) + } + + case .lookup: + morePath = NavigationPath() + appState.selectedTab = .more + Task { + await settleNavigationTransition() + morePath.append(MoreRoute.lookup(query: nil)) } case .buildsTab: @@ -244,14 +279,15 @@ struct RootView: View { case .systemStatus: appState.navigateToSystemStatus() + } + } - case .lookup: - morePath = NavigationPath() - appState.selectedTab = .more - Task { - await settleNavigationTransition() - morePath.append(MoreRoute.lookup) - } + private func navigateToWork(scope: HutchWorkQueueScope) { + homePath = NavigationPath() + appState.selectedTab = .home + Task { + await settleNavigationTransition() + homePath.append(HomeRoute.work(scope: scope)) } } @@ -310,6 +346,22 @@ struct RootView: View { } } + private func resolveTrackerLink(owner: String, tracker: String) { + isResolvingDeepLink = true + Task { + defer { isResolvingDeepLink = false } + do { + let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker) + ticketsPath = NavigationPath() + appState.selectedTab = .tickets + await settleNavigationTransition() + ticketsPath.append(trackerSummary) + } catch { + appState.presentTicketDeepLinkError() + } + } + } + private func resolveMailingListLink(owner: String, list: String) { isResolvingDeepLink = true Task { @@ -375,7 +427,7 @@ enum MoreDestination: Hashable { } enum MoreRoute: Hashable { - case lookup + case lookup(query: String?) case projects case lists case pastes @@ -384,6 +436,7 @@ enum MoreRoute: Hashable { case settings case about case userProfile(String) + case projectDashboard(id: String, title: String?) case mailingList(InboxMailingListReference) case thread(InboxThreadSummary) case manPageBrowser @@ -397,8 +450,8 @@ private struct MoreNavigationRoot: View { MoreView() .navigationDestination(for: MoreRoute.self) { route in switch route { - case .lookup: - LookupView() + case .lookup(let query): + LookupView(initialQuery: query ?? "") case .projects: ProjectsListView() case .lists: @@ -415,6 +468,8 @@ private struct MoreNavigationRoot: View { AboutView() case .userProfile(let owner): UserProfileDeepLinkView(owner: owner) + case .projectDashboard(let id, let title): + ProjectDashboardDeepLinkView(projectID: id, title: title) case .mailingList(let mailingList): MailingListDetailView(mailingList: mailingList) case .thread(let thread): @@ -484,6 +539,45 @@ struct UserProfileDeepLinkView: View { } } +struct ProjectDashboardDeepLinkView: View { + @Environment(AppState.self) private var appState + let projectID: String + let title: String? + @State private var project: Project? + @State private var errorMessage: String? + + var body: some View { + Group { + if let project { + ProjectDetailView(project: project) + } else if let errorMessage { + ContentUnavailableView( + "Couldn't Open Project", + systemImage: "square.stack.3d.up.slash", + description: Text(errorMessage) + ) + } else { + SRHTLoadingStateView(message: "Loading project...") + } + } + .navigationTitle(title ?? "Project") + .navigationBarTitleDisplayMode(.inline) + .task(id: projectID) { + await loadProject() + } + } + + @MainActor + private func loadProject() async { + errorMessage = nil + do { + project = try await ProjectService(client: appState.client).fetchProjectDetail(rid: projectID) + } catch { + errorMessage = "The project could not be found or is inaccessible." + } + } +} + // MARK: - Ticket Deep Link Navigation Target /// Hashable wrapper to push a ticket detail view from a deep link. |
