summaryrefslogtreecommitdiff
path: root/Hutch
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
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')
-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
-rw-r--r--Hutch/Views/Builds/BuildListView.swift9
-rw-r--r--Hutch/Views/Builds/BuildListViewModel.swift8
-rw-r--r--Hutch/Views/Home/HomeView.swift4
-rw-r--r--Hutch/Views/Lookup/LookupView.swift26
-rw-r--r--Hutch/Views/More/MoreView.swift2
-rw-r--r--Hutch/Views/Work/WorkView.swift28
11 files changed, 700 insertions, 112 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.
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)