summaryrefslogtreecommitdiff
path: root/Hutch/App
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-05-14 13:18:55 -0500
committerChristian Cleberg <[email protected]>2026-05-14 13:27:41 -0500
commit51b2eb0e6296734960f37cebd40fee34a1f261f9 (patch)
tree65063727d8dcff3540818676d7b53288499475dd /Hutch/App
parent9a853f8777230e0639261b5bc817201ec2d9deca (diff)
downloadhutch-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.swift6
-rw-r--r--Hutch/App/DeepLink.swift210
-rw-r--r--Hutch/App/HutchApp.swift18
-rw-r--r--Hutch/App/HutchIntents.swift377
-rw-r--r--Hutch/App/RootView.swift124
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.