From 51b2eb0e6296734960f37cebd40fee34a1f261f9 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 14 May 2026 13:18:55 -0500 Subject: 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 --- Hutch/App/AppState.swift | 6 + Hutch/App/DeepLink.swift | 210 ++++++++++++++-- Hutch/App/HutchApp.swift | 18 +- Hutch/App/HutchIntents.swift | 377 ++++++++++++++++++++++++---- Hutch/App/RootView.swift | 124 +++++++-- Hutch/Views/Builds/BuildListView.swift | 9 + Hutch/Views/Builds/BuildListViewModel.swift | 8 + Hutch/Views/Home/HomeView.swift | 4 +- Hutch/Views/Lookup/LookupView.swift | 26 +- Hutch/Views/More/MoreView.swift | 2 +- Hutch/Views/Work/WorkView.swift | 28 ++- HutchTests/BuildListViewModelTests.swift | 6 + HutchTests/DeepLinkTests.swift | 8 + HutchTests/HutchIntentsTests.swift | 9 + Shared/NeedsAttentionSnapshot.swift | 21 +- Shared/SystemStatusWidgetSnapshot.swift | 17 +- 16 files changed, 747 insertions(+), 126 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// or hutch://hg// + case workQueue(scope: HutchWorkQueueScope = .all) + case recentActivity case repository(service: SRHTService, owner: String, repo: String) - /// hutch://todo/// + case tracker(owner: String, tracker: String) case ticket(owner: String, tracker: String, ticketId: Int) - /// hutch://builds/ or hutch://builds//job/ case build(jobId: Int) - /// hutch://lists// case mailingList(owner: String, list: String) - /// hutch://lookup/ 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:// 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// or hutch://hg// + case repository(service: SRHTService, owner: String, repo: String) + /// hutch://todo// + case tracker(owner: String, tracker: String) + /// hutch://todo/// + case ticket(owner: String, tracker: String, ticketId: Int) + /// hutch://builds/ or hutch://builds//job/ + case build(jobId: Int) + /// hutch://lists// + case mailingList(owner: String, list: String) + /// hutch://lookup/ + 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= + case search(query: String) + /// hutch://builds?filter=failed + case failedBuilds + /// hutch://projects/ + case projectDashboard(id: String, title: String?) + + /// Attempt to parse a URL into a DeepLink. + /// Expected format: hutch:// + 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. diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift index 20414d6..cc31e67 100644 --- a/Hutch/Views/Builds/BuildListView.swift +++ b/Hutch/Views/Builds/BuildListView.swift @@ -124,6 +124,10 @@ struct BuildListView: View { let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults) vm.repoFilter = savedRepoFilter vm.lookbackDays = lookbackDays + if let pendingFilter = appState.pendingBuildListFilter { + vm.filter = pendingFilter + appState.pendingBuildListFilter = nil + } viewModel = vm await vm.loadJobs() } @@ -134,6 +138,11 @@ struct BuildListView: View { .onChange(of: lookbackDays) { _, newValue in viewModel?.lookbackDays = newValue } + .onChange(of: appState.pendingBuildListFilter) { _, newValue in + guard let newValue else { return } + viewModel?.filter = newValue + appState.pendingBuildListFilter = nil + } .onDisappear { viewModel?.stopAutoRefresh() } diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift index 42500a8..eb01d9e 100644 --- a/Hutch/Views/Builds/BuildListViewModel.swift +++ b/Hutch/Views/Builds/BuildListViewModel.swift @@ -21,6 +21,7 @@ private struct SubmittedJob: Decodable, Sendable { enum BuildListFilter: String, CaseIterable, Sendable { case attention = "Attention" + case failed = "Failed" case active = "Active" case all = "All" } @@ -413,6 +414,13 @@ final class BuildListViewModel { case .success, .cancelled: return false } + case .failed: + switch job.status { + case .failed, .timeout: + return true + case .success, .cancelled, .running, .queued, .pending: + return false + } case .active: switch job.status { case .running, .queued, .pending: diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index 9e06a00..409d095 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -106,7 +106,7 @@ struct HomeView: View { private func workSection(_ viewModel: HomeViewModel) -> some View { Section("Work") { - NavigationLink(value: HomeRoute.work) { + NavigationLink(value: HomeRoute.work(scope: .all)) { HomeSummaryRow( title: workTitle(viewModel), summary: workSummary(viewModel), @@ -424,7 +424,7 @@ struct HomeView: View { } enum HomeRoute: Hashable { - case work + case work(scope: HutchWorkQueueScope) } private enum HomeSummaryEmphasis { diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift index 55c421b..6f7ebe1 100644 --- a/Hutch/Views/Lookup/LookupView.swift +++ b/Hutch/Views/Lookup/LookupView.swift @@ -92,11 +92,17 @@ final class LookupViewModel { ) } - init(client: SRHTClient, appState: AppState, defaults: UserDefaults = .standard) { + init( + client: SRHTClient, + appState: AppState, + defaults: UserDefaults = .standard, + initialQuery: String = "" + ) { self.client = client self.appState = appState self.defaults = defaults self.history = LookupHistoryStore.load(defaults: defaults) + self.inputText = initialQuery.trimmingCharacters(in: .whitespacesAndNewlines) } func lookup() async { @@ -329,6 +335,11 @@ final class LookupViewModel { struct LookupView: View { @Environment(AppState.self) private var appState @State private var viewModel: LookupViewModel? + private let initialQuery: String + + init(initialQuery: String = "") { + self.initialQuery = initialQuery + } var body: some View { Group { @@ -341,7 +352,12 @@ struct LookupView: View { .navigationTitle("Look Up") .task { if viewModel == nil { - viewModel = LookupViewModel(client: appState.client, appState: appState, defaults: appState.accountDefaults) + viewModel = LookupViewModel( + client: appState.client, + appState: appState, + defaults: appState.accountDefaults, + initialQuery: initialQuery + ) } } } @@ -427,8 +443,8 @@ struct LookupView: View { } .navigationDestination(for: MoreRoute.self) { route in switch route { - case .lookup: - LookupView() + case .lookup(let query): + LookupView(initialQuery: query ?? "") case .projects: ProjectsListView() case .lists: @@ -445,6 +461,8 @@ struct LookupView: 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): diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift index 5c757ef..ad02077 100644 --- a/Hutch/Views/More/MoreView.swift +++ b/Hutch/Views/More/MoreView.swift @@ -13,7 +13,7 @@ struct MoreView: View { var body: some View { List { Section("Search") { - NavigationLink(value: MoreRoute.lookup) { + NavigationLink(value: MoreRoute.lookup(query: nil)) { Label("Look Up", systemImage: "magnifyingglass") } .themedRow() diff --git a/Hutch/Views/Work/WorkView.swift b/Hutch/Views/Work/WorkView.swift index 8b932dd..6ba1d0c 100644 --- a/Hutch/Views/Work/WorkView.swift +++ b/Hutch/Views/Work/WorkView.swift @@ -1,20 +1,28 @@ import SwiftUI -struct WorkView: View { - private enum Scope: String, CaseIterable, Identifiable { - case all = "All" - case unread = "Unread" - case assigned = "Assigned" - - var id: String { rawValue } +extension HutchWorkQueueScope: Identifiable { + var id: String { rawValue } + + var displayName: String { + switch self { + case .all: "All" + case .unread: "Unread" + case .assigned: "Assigned" + } } +} +struct WorkView: View { @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true @Environment(AppState.self) private var appState @Environment(\.isAMOLEDTheme) private var isAMOLED @Environment(\.scenePhase) private var scenePhase @State private var viewModel: HomeViewModel? - @State private var scope: Scope = .all + @State private var scope: HutchWorkQueueScope + + init(initialScope: HutchWorkQueueScope = .all) { + _scope = State(initialValue: initialScope) + } var body: some View { Group { @@ -106,8 +114,8 @@ struct WorkView: View { private var scopeSection: some View { Section { Picker("Scope", selection: $scope) { - ForEach(Scope.allCases) { scope in - Text(scope.rawValue).tag(scope) + ForEach(HutchWorkQueueScope.allCases) { scope in + Text(scope.displayName).tag(scope) } } .pickerStyle(.segmented) diff --git a/HutchTests/BuildListViewModelTests.swift b/HutchTests/BuildListViewModelTests.swift index aa2e46b..8ee5b81 100644 --- a/HutchTests/BuildListViewModelTests.swift +++ b/HutchTests/BuildListViewModelTests.swift @@ -59,9 +59,15 @@ struct BuildListViewModelTests { filter: .active, lookbackDays: BuildListViewModel.defaultLookbackDays ) + let failed = BuildListViewModel.filterJobs( + jobs, + filter: .failed, + lookbackDays: BuildListViewModel.defaultLookbackDays + ) #expect(attention.map(\.id) == [2, 3]) #expect(active.map(\.id) == [3]) + #expect(failed.map(\.id) == [2]) } @Test diff --git a/HutchTests/DeepLinkTests.swift b/HutchTests/DeepLinkTests.swift index 500c4cd..d62cfef 100644 --- a/HutchTests/DeepLinkTests.swift +++ b/HutchTests/DeepLinkTests.swift @@ -88,6 +88,14 @@ struct DeepLinkTests { #expect(link == .lookup) } + @Test + func parsesRouteBackedNavigationLinks() { + #expect(DeepLink(url: HutchRoute.workQueue(scope: .assigned).url) == .workQueue(scope: .assigned)) + #expect(DeepLink(url: HutchRoute.failedBuilds.url) == .failedBuilds) + #expect(DeepLink(url: HutchRoute.search(query: "patch queue").url) == .search(query: "patch queue")) + #expect(DeepLink(url: HutchRoute.projectDashboard(id: "project-1", title: "Hutch").url) == .projectDashboard(id: "project-1", title: "Hutch")) + } + @Test func parsesUserProfileLink() { let link = DeepLink(url: HutchDeepLinkURL.userProfile) diff --git a/HutchTests/HutchIntentsTests.swift b/HutchTests/HutchIntentsTests.swift index 3cb7ad6..ddbe973 100644 --- a/HutchTests/HutchIntentsTests.swift +++ b/HutchTests/HutchIntentsTests.swift @@ -5,6 +5,15 @@ import Testing @MainActor struct HutchIntentsTests { + @Test + func navigationIntentsMapToCentralRoutes() { + #expect(OpenWorkQueueIntent().route == .workQueue(scope: .all)) + #expect(OpenRecentActivityIntent().route == .recentActivity) + #expect(OpenSystemStatusIntent().route == .systemStatus) + #expect(OpenFailedBuildsIntent().route == .failedBuilds) + #expect(OpenAssignedTicketsIntent().route == .workQueue(scope: .assigned)) + } + @Test func checkSystemStatusReturnsOperationalWhenNoDisruption() async throws { let defaultsName = "HutchIntentsTests-status-\(UUID().uuidString)" diff --git a/Shared/NeedsAttentionSnapshot.swift b/Shared/NeedsAttentionSnapshot.swift index d4c581c..5f584c3 100644 --- a/Shared/NeedsAttentionSnapshot.swift +++ b/Shared/NeedsAttentionSnapshot.swift @@ -58,11 +58,11 @@ enum NeedsAttentionSnapshotStore { private static let snapshotKey = "needsAttention.snapshot" static func load( - accountID: String? = ActiveAccountContextStore.load(), + accountID: String? = nil, defaults: UserDefaults? = sharedDefaults() ) -> NeedsAttentionSnapshot? { guard let defaults, - let data = defaults.data(forKey: scopedKey(for: accountID)) else { + let data = defaults.data(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) else { return nil } @@ -71,7 +71,7 @@ enum NeedsAttentionSnapshotStore { static func save( _ snapshot: NeedsAttentionSnapshot, - accountID: String? = ActiveAccountContextStore.load(), + accountID: String? = nil, defaults: UserDefaults? = sharedDefaults() ) { guard let defaults, @@ -79,7 +79,7 @@ enum NeedsAttentionSnapshotStore { return } - defaults.set(data, forKey: scopedKey(for: accountID)) + defaults.set(data, forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) reloadWidgetTimelines() } @@ -87,7 +87,7 @@ enum NeedsAttentionSnapshotStore { unreadInboxThreads: Int? = nil, assignedOpenTickets: Int? = nil, failedBuilds: Int? = nil, - accountID: String? = ActiveAccountContextStore.load(), + accountID: String? = nil, defaults: UserDefaults? = sharedDefaults() ) { let existing = load(accountID: accountID, defaults: defaults) @@ -102,7 +102,7 @@ enum NeedsAttentionSnapshotStore { static func adjustUnreadInboxThreads( by delta: Int, - accountID: String? = ActiveAccountContextStore.load(), + accountID: String? = nil, defaults: UserDefaults? = sharedDefaults() ) { guard let existing = load(accountID: accountID, defaults: defaults), @@ -123,10 +123,11 @@ enum NeedsAttentionSnapshotStore { } static func clear( - accountID: String? = ActiveAccountContextStore.load(), + accountID: String? = nil, defaults: UserDefaults? = sharedDefaults() ) { - defaults?.removeObject(forKey: scopedKey(for: accountID)) + guard let defaults else { return } + defaults.removeObject(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) reloadWidgetTimelines() } @@ -139,6 +140,10 @@ enum NeedsAttentionSnapshotStore { return "\(snapshotKey).\(accountID)" } + private static func resolvedAccountID(_ accountID: String?, defaults: UserDefaults) -> String? { + accountID ?? ActiveAccountContextStore.load(defaults: defaults) + } + private static func reloadWidgetTimelines() { #if canImport(WidgetKit) WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind) diff --git a/Shared/SystemStatusWidgetSnapshot.swift b/Shared/SystemStatusWidgetSnapshot.swift index 0b19365..025e58c 100644 --- a/Shared/SystemStatusWidgetSnapshot.swift +++ b/Shared/SystemStatusWidgetSnapshot.swift @@ -34,11 +34,11 @@ enum SystemStatusWidgetSnapshotStore { private static let snapshotKey = "systemStatus.widgetSnapshot" static func load( - accountID: String? = ActiveAccountContextStore.load(), + accountID: String? = nil, defaults: UserDefaults? = sharedDefaults() ) -> SystemStatusWidgetSnapshot? { guard let defaults, - let data = defaults.data(forKey: scopedKey(for: accountID)) else { + let data = defaults.data(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) else { return nil } return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data) @@ -46,22 +46,23 @@ enum SystemStatusWidgetSnapshotStore { static func save( _ snapshot: SystemStatusWidgetSnapshot, - accountID: String? = ActiveAccountContextStore.load(), + accountID: String? = nil, defaults: UserDefaults? = sharedDefaults() ) { guard let defaults, let data = try? JSONEncoder().encode(snapshot) else { return } - defaults.set(data, forKey: scopedKey(for: accountID)) + defaults.set(data, forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) reloadWidgetTimelines() } static func clear( - accountID: String? = ActiveAccountContextStore.load(), + accountID: String? = nil, defaults: UserDefaults? = sharedDefaults() ) { - defaults?.removeObject(forKey: scopedKey(for: accountID)) + guard let defaults else { return } + defaults.removeObject(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) reloadWidgetTimelines() } @@ -74,6 +75,10 @@ enum SystemStatusWidgetSnapshotStore { return "\(snapshotKey).\(accountID)" } + private static func resolvedAccountID(_ accountID: String?, defaults: UserDefaults) -> String? { + accountID ?? ActiveAccountContextStore.load(defaults: defaults) + } + private static func reloadWidgetTimelines() { #if canImport(WidgetKit) WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind) -- cgit v1.2.3