Commit 431c255c28
Unsigned
Layout: unified · split
Hutch/App/AppState.swift +6
| @@ -84,6 +84,7 @@ final class AppState { | ||
| 84 | 84 | /// Set by the deep link handler; consumed by RootView to drive navigation. |
| 85 | 85 | var pendingDeepLink: DeepLink? |
| 86 | 86 | var pendingTabNavigation: TabNavigationTarget? |
| 87 | var pendingBuildListFilter: BuildListFilter? | |
| 87 | 88 | var deepLinkError: String? |
| 88 | 89 | |
| 89 | 90 | // MARK: - Init |
| @@ -373,6 +374,10 @@ final class AppState { | ||
| 373 | 374 | selectedTab = .builds |
| 374 | 375 | } |
| 375 | 376 | |
| 377 | func open(_ route: HutchRoute) { | |
| 378 | pendingDeepLink = DeepLink(route: route) | |
| 379 | } | |
| 380 | ||
| 376 | 381 | func presentRepositoryDeepLinkError() { |
| 377 | 382 | deepLinkError = "The repository could not be found or is inaccessible." |
| 378 | 383 | } |
| @@ -574,6 +579,7 @@ final class AppState { | ||
| 574 | 579 | private func resetNavigationState() { |
| 575 | 580 | pendingDeepLink = nil |
| 576 | 581 | pendingTabNavigation = nil |
| 582 | pendingBuildListFilter = nil | |
| 577 | 583 | deepLinkError = nil |
| 578 | 584 | selectedTab = .home |
| 579 | 585 | } |
Hutch/App/DeepLink.swift +186 −24
| @@ -3,45 +3,50 @@ import os | ||
| 3 | 3 | |
| 4 | 4 | private let deepLinkParserLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink") |
| 5 | 5 | |
| 6 | /// Represents a parsed `hutch://` deep link. | |
| 7 | enum DeepLink: Equatable { | |
| 6 | enum HutchWorkQueueScope: String, CaseIterable, Sendable { | |
| 7 | case all | |
| 8 | case unread | |
| 9 | case assigned | |
| 10 | } | |
| 11 | ||
| 12 | enum HutchRoute: Equatable, Sendable { | |
| 8 | 13 | case home |
| 9 | case work | |
| 10 | /// hutch://git/<owner>/<repo> or hutch://hg/<owner>/<repo> | |
| 14 | case workQueue(scope: HutchWorkQueueScope = .all) | |
| 15 | case recentActivity | |
| 11 | 16 | case repository(service: SRHTService, owner: String, repo: String) |
| 12 | /// hutch://todo/<owner>/<tracker>/<ticketId> | |
| 17 | case tracker(owner: String, tracker: String) | |
| 13 | 18 | case ticket(owner: String, tracker: String, ticketId: Int) |
| 14 | /// hutch://builds/<jobId> or hutch://builds/<owner>/job/<jobId> | |
| 15 | 19 | case build(jobId: Int) |
| 16 | /// hutch://lists/<owner>/<list> | |
| 17 | 20 | case mailingList(owner: String, list: String) |
| 18 | /// hutch://lookup/<owner> | |
| 19 | 21 | case userProfile(owner: String) |
| 20 | /// hutch://builds (tab-level) | |
| 21 | case buildsTab | |
| 22 | /// hutch://repositories (tab-level) | |
| 23 | case repositoriesTab | |
| 24 | /// hutch://trackers (tab-level) | |
| 25 | case trackersTab | |
| 26 | /// hutch://status | |
| 22 | case builds | |
| 23 | case failedBuilds | |
| 24 | case repositories | |
| 25 | case trackers | |
| 27 | 26 | case systemStatus |
| 28 | /// hutch://lookup | |
| 29 | 27 | case lookup |
| 28 | case search(query: String) | |
| 29 | case projectDashboard(id: String, title: String?) | |
| 30 | 30 | |
| 31 | /// Attempt to parse a URL into a DeepLink. | |
| 32 | /// Expected format: hutch://<path> | |
| 33 | 31 | init?(url: URL) { |
| 34 | 32 | guard url.scheme == "hutch" else { return nil } |
| 35 | 33 | |
| 36 | 34 | let components = url.deepLinkPathComponents |
| 37 | deepLinkParserLogger.info("DeepLink parser components for \(url.absoluteString, privacy: .public): \(components.joined(separator: ","), privacy: .public)") | |
| 35 | let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] | |
| 36 | let queryValue: (String) -> String? = { name in | |
| 37 | queryItems.first { $0.name == name }?.value?.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 38 | } | |
| 38 | 39 | |
| 39 | 40 | switch components.first { |
| 40 | 41 | case "home", nil: |
| 41 | 42 | self = .home |
| 42 | 43 | |
| 44 | case "recent", "recent-activity", "activity": | |
| 45 | self = .recentActivity | |
| 46 | ||
| 43 | 47 | case "work", "inbox": |
| 44 | self = .work | |
| 48 | let scope = queryValue("scope").flatMap(HutchWorkQueueScope.init(rawValue:)) ?? .all | |
| 49 | self = .workQueue(scope: scope) | |
| 45 | 50 | |
| 46 | 51 | case let .some(serviceName) where ["git", "hg", "todo", "builds", "lists"].contains(serviceName) |
| 47 | 52 | && components.count == 2 |
| @@ -62,6 +67,9 @@ enum DeepLink: Equatable { | ||
| 62 | 67 | guard let ticketId = Int(components[3]) else { return nil } |
| 63 | 68 | self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId) |
| 64 | 69 | |
| 70 | case "todo" where components.count >= 3: | |
| 71 | self = .tracker(owner: components[1], tracker: components[2]) | |
| 72 | ||
| 65 | 73 | case "builds" where components.count >= 2: |
| 66 | 74 | let rawJobId: String |
| 67 | 75 | if components.count >= 4, components[2] == "job" { |
| @@ -73,16 +81,19 @@ enum DeepLink: Equatable { | ||
| 73 | 81 | self = .build(jobId: jobId) |
| 74 | 82 | |
| 75 | 83 | case "builds": |
| 76 | self = .buildsTab | |
| 84 | self = queryValue("filter") == "failed" ? .failedBuilds : .builds | |
| 77 | 85 | |
| 78 | 86 | case "lists" where components.count >= 3: |
| 79 | 87 | self = .mailingList(owner: components[1], list: components[2]) |
| 80 | 88 | |
| 89 | case "projects" where components.count >= 2: | |
| 90 | self = .projectDashboard(id: components[1], title: queryValue("title")) | |
| 91 | ||
| 81 | 92 | case "repositories": |
| 82 | self = .repositoriesTab | |
| 93 | self = .repositories | |
| 83 | 94 | |
| 84 | 95 | case "trackers": |
| 85 | self = .trackersTab | |
| 96 | self = .trackers | |
| 86 | 97 | |
| 87 | 98 | case "status": |
| 88 | 99 | self = .systemStatus |
| @@ -91,12 +102,163 @@ enum DeepLink: Equatable { | ||
| 91 | 102 | self = .userProfile(owner: components[1]) |
| 92 | 103 | |
| 93 | 104 | case "lookup": |
| 94 | self = .lookup | |
| 105 | if let query = queryValue("q"), !query.isEmpty { | |
| 106 | self = .search(query: query) | |
| 107 | } else { | |
| 108 | self = .lookup | |
| 109 | } | |
| 95 | 110 | |
| 96 | 111 | default: |
| 97 | 112 | return nil |
| 98 | 113 | } |
| 99 | 114 | } |
| 115 | ||
| 116 | var url: URL { | |
| 117 | switch self { | |
| 118 | case .home: | |
| 119 | return Self.makeURL(host: "home") | |
| 120 | case .workQueue(let scope): | |
| 121 | return Self.makeURL( | |
| 122 | host: "work", | |
| 123 | queryItems: scope == .all ? [] : [URLQueryItem(name: "scope", value: scope.rawValue)] | |
| 124 | ) | |
| 125 | case .recentActivity: | |
| 126 | return Self.makeURL(host: "recent-activity") | |
| 127 | case .repository(let service, let owner, let repo): | |
| 128 | return Self.makeURL(host: service.rawValue, path: [owner, repo]) | |
| 129 | case .tracker(let owner, let tracker): | |
| 130 | return Self.makeURL(host: "todo", path: [owner, tracker]) | |
| 131 | case .ticket(let owner, let tracker, let ticketId): | |
| 132 | return Self.makeURL(host: "todo", path: [owner, tracker, String(ticketId)]) | |
| 133 | case .build(let jobId): | |
| 134 | return Self.makeURL(host: "builds", path: [String(jobId)]) | |
| 135 | case .mailingList(let owner, let list): | |
| 136 | return Self.makeURL(host: "lists", path: [owner, list]) | |
| 137 | case .userProfile(let owner): | |
| 138 | return Self.makeURL(host: "lookup", path: [owner]) | |
| 139 | case .builds: | |
| 140 | return Self.makeURL(host: "builds") | |
| 141 | case .failedBuilds: | |
| 142 | return Self.makeURL(host: "builds", queryItems: [URLQueryItem(name: "filter", value: "failed")]) | |
| 143 | case .repositories: | |
| 144 | return Self.makeURL(host: "repositories") | |
| 145 | case .trackers: | |
| 146 | return Self.makeURL(host: "trackers") | |
| 147 | case .systemStatus: | |
| 148 | return Self.makeURL(host: "status") | |
| 149 | case .lookup: | |
| 150 | return Self.makeURL(host: "lookup") | |
| 151 | case .search(let query): | |
| 152 | return Self.makeURL(host: "lookup", queryItems: [URLQueryItem(name: "q", value: query)]) | |
| 153 | case .projectDashboard(let id, let title): | |
| 154 | return Self.makeURL( | |
| 155 | host: "projects", | |
| 156 | path: [id], | |
| 157 | queryItems: title.map { [URLQueryItem(name: "title", value: $0)] } ?? [] | |
| 158 | ) | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | private static func makeURL( | |
| 163 | host: String, | |
| 164 | path: [String] = [], | |
| 165 | queryItems: [URLQueryItem] = [] | |
| 166 | ) -> URL { | |
| 167 | var components = URLComponents() | |
| 168 | components.scheme = "hutch" | |
| 169 | components.host = host | |
| 170 | if !path.isEmpty { | |
| 171 | components.path = "/" + path.joined(separator: "/") | |
| 172 | } | |
| 173 | if !queryItems.isEmpty { | |
| 174 | components.queryItems = queryItems | |
| 175 | } | |
| 176 | return components.url! | |
| 177 | } | |
| 178 | } | |
| 179 | ||
| 180 | /// Represents a parsed `hutch://` deep link. | |
| 181 | enum DeepLink: Equatable { | |
| 182 | case home | |
| 183 | case work | |
| 184 | case workQueue(scope: HutchWorkQueueScope) | |
| 185 | case recentActivity | |
| 186 | /// hutch://git/<owner>/<repo> or hutch://hg/<owner>/<repo> | |
| 187 | case repository(service: SRHTService, owner: String, repo: String) | |
| 188 | /// hutch://todo/<owner>/<tracker> | |
| 189 | case tracker(owner: String, tracker: String) | |
| 190 | /// hutch://todo/<owner>/<tracker>/<ticketId> | |
| 191 | case ticket(owner: String, tracker: String, ticketId: Int) | |
| 192 | /// hutch://builds/<jobId> or hutch://builds/<owner>/job/<jobId> | |
| 193 | case build(jobId: Int) | |
| 194 | /// hutch://lists/<owner>/<list> | |
| 195 | case mailingList(owner: String, list: String) | |
| 196 | /// hutch://lookup/<owner> | |
| 197 | case userProfile(owner: String) | |
| 198 | /// hutch://builds (tab-level) | |
| 199 | case buildsTab | |
| 200 | /// hutch://repositories (tab-level) | |
| 201 | case repositoriesTab | |
| 202 | /// hutch://trackers (tab-level) | |
| 203 | case trackersTab | |
| 204 | /// hutch://status | |
| 205 | case systemStatus | |
| 206 | /// hutch://lookup | |
| 207 | case lookup | |
| 208 | /// hutch://lookup?q=<query> | |
| 209 | case search(query: String) | |
| 210 | /// hutch://builds?filter=failed | |
| 211 | case failedBuilds | |
| 212 | /// hutch://projects/<rid> | |
| 213 | case projectDashboard(id: String, title: String?) | |
| 214 | ||
| 215 | /// Attempt to parse a URL into a DeepLink. | |
| 216 | /// Expected format: hutch://<path> | |
| 217 | init?(url: URL) { | |
| 218 | let components = url.deepLinkPathComponents | |
| 219 | deepLinkParserLogger.info("DeepLink parser components for \(url.absoluteString, privacy: .public): \(components.joined(separator: ","), privacy: .public)") | |
| 220 | guard let route = HutchRoute(url: url) else { return nil } | |
| 221 | self = Self(route: route) | |
| 222 | } | |
| 223 | ||
| 224 | init(route: HutchRoute) { | |
| 225 | switch route { | |
| 226 | case .home: | |
| 227 | self = .home | |
| 228 | case .workQueue(let scope): | |
| 229 | self = scope == .all ? .work : .workQueue(scope: scope) | |
| 230 | case .recentActivity: | |
| 231 | self = .recentActivity | |
| 232 | case .repository(let service, let owner, let repo): | |
| 233 | self = .repository(service: service, owner: owner, repo: repo) | |
| 234 | case .tracker(let owner, let tracker): | |
| 235 | self = .tracker(owner: owner, tracker: tracker) | |
| 236 | case .ticket(let owner, let tracker, let ticketId): | |
| 237 | self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId) | |
| 238 | case .build(let jobId): | |
| 239 | self = .build(jobId: jobId) | |
| 240 | case .mailingList(let owner, let list): | |
| 241 | self = .mailingList(owner: owner, list: list) | |
| 242 | case .userProfile(let owner): | |
| 243 | self = .userProfile(owner: owner) | |
| 244 | case .builds: | |
| 245 | self = .buildsTab | |
| 246 | case .failedBuilds: | |
| 247 | self = .failedBuilds | |
| 248 | case .repositories: | |
| 249 | self = .repositoriesTab | |
| 250 | case .trackers: | |
| 251 | self = .trackersTab | |
| 252 | case .systemStatus: | |
| 253 | self = .systemStatus | |
| 254 | case .lookup: | |
| 255 | self = .lookup | |
| 256 | case .search(let query): | |
| 257 | self = .search(query: query) | |
| 258 | case .projectDashboard(let id, let title): | |
| 259 | self = .projectDashboard(id: id, title: title) | |
| 260 | } | |
| 261 | } | |
| 100 | 262 | } |
| 101 | 263 | |
| 102 | 264 | private extension URL { |
Hutch/App/HutchApp.swift +4 −14
| @@ -28,20 +28,10 @@ struct HutchApp: App { | ||
| 28 | 28 | deepLinkLogger.error("Rejected URL: \(url.absoluteString, privacy: .public)") |
| 29 | 29 | } |
| 30 | 30 | } |
| 31 | .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in | |
| 32 | guard let destination else { return } | |
| 33 | HutchIntentNavigator.shared.pendingDestination = nil | |
| 34 | let link: DeepLink | |
| 35 | switch destination { | |
| 36 | case .home: link = .home | |
| 37 | case .work: link = .work | |
| 38 | case .builds: link = .buildsTab | |
| 39 | case .repositories: link = .repositoriesTab | |
| 40 | case .trackers: link = .trackersTab | |
| 41 | case .systemStatus: link = .systemStatus | |
| 42 | case .lookup: link = .lookup | |
| 43 | } | |
| 44 | appState.pendingDeepLink = link | |
| 31 | .onChange(of: HutchIntentNavigator.shared.pendingRoute) { _, route in | |
| 32 | guard let route else { return } | |
| 33 | HutchIntentNavigator.shared.pendingRoute = nil | |
| 34 | appState.open(route) | |
| 45 | 35 | } |
| 46 | 36 | } |
| 47 | 37 | } |
Hutch/App/HutchIntents.swift +335 −42
| @@ -1,46 +1,271 @@ | ||
| 1 | 1 | import AppIntents |
| 2 | 2 | import Foundation |
| 3 | 3 | |
| 4 | // MARK: - Open Hutch Intent | |
| 5 | ||
| 6 | enum HutchDestination: String, AppEnum { | |
| 7 | case home | |
| 8 | case work | |
| 9 | case builds | |
| 10 | case repositories | |
| 11 | case trackers | |
| 12 | case systemStatus | |
| 13 | case lookup | |
| 14 | ||
| 15 | static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Hutch Section") | |
| 16 | ||
| 17 | static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [ | |
| 18 | .home: "Home", | |
| 19 | .work: "Work", | |
| 20 | .builds: "Builds", | |
| 21 | .repositories: "Repositories", | |
| 22 | .trackers: "Trackers", | |
| 23 | .systemStatus: "System Status", | |
| 24 | .lookup: "Look Up" | |
| 4 | // MARK: - Navigation Intents | |
| 5 | ||
| 6 | struct OpenWorkQueueIntent: AppIntent { | |
| 7 | static var title: LocalizedStringResource = "Open Work Queue" | |
| 8 | static var description = IntentDescription("Opens Hutch to your Work Queue.") | |
| 9 | static var openAppWhenRun = true | |
| 10 | ||
| 11 | var route: HutchRoute { .workQueue(scope: .all) } | |
| 12 | ||
| 13 | @MainActor | |
| 14 | func perform() async throws -> some IntentResult { | |
| 15 | HutchIntentNavigator.shared.open(route) | |
| 16 | return .result() | |
| 17 | } | |
| 18 | } | |
| 19 | ||
| 20 | struct OpenRecentActivityIntent: AppIntent { | |
| 21 | static var title: LocalizedStringResource = "Open Recent Activity" | |
| 22 | static var description = IntentDescription("Opens Hutch to recent activity.") | |
| 23 | static var openAppWhenRun = true | |
| 24 | ||
| 25 | var route: HutchRoute { .recentActivity } | |
| 26 | ||
| 27 | @MainActor | |
| 28 | func perform() async throws -> some IntentResult { | |
| 29 | HutchIntentNavigator.shared.open(route) | |
| 30 | return .result() | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | struct OpenSystemStatusIntent: AppIntent { | |
| 35 | static var title: LocalizedStringResource = "Open System Status" | |
| 36 | static var description = IntentDescription("Opens Hutch to SourceHut system status.") | |
| 37 | static var openAppWhenRun = true | |
| 38 | ||
| 39 | var route: HutchRoute { .systemStatus } | |
| 40 | ||
| 41 | @MainActor | |
| 42 | func perform() async throws -> some IntentResult { | |
| 43 | HutchIntentNavigator.shared.open(route) | |
| 44 | return .result() | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | struct OpenPinnedResourceIntent: AppIntent { | |
| 49 | static var title: LocalizedStringResource = "Open Pinned Resource" | |
| 50 | static var description = IntentDescription("Opens a pinned Hutch resource.") | |
| 51 | static var openAppWhenRun = true | |
| 52 | ||
| 53 | @Parameter(title: "Pinned Resource") | |
| 54 | var pinnedResource: PinnedResourceEntity | |
| 55 | ||
| 56 | var route: HutchRoute { pinnedResource.route } | |
| 57 | ||
| 58 | @MainActor | |
| 59 | func perform() async throws -> some IntentResult { | |
| 60 | HutchIntentNavigator.shared.open(route) | |
| 61 | return .result() | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | struct OpenProjectDashboardIntent: AppIntent { | |
| 66 | static var title: LocalizedStringResource = "Open Project Dashboard" | |
| 67 | static var description = IntentDescription("Opens a pinned project dashboard in Hutch.") | |
| 68 | static var openAppWhenRun = true | |
| 69 | ||
| 70 | @Parameter(title: "Project") | |
| 71 | var project: ProjectEntity | |
| 72 | ||
| 73 | var route: HutchRoute { | |
| 74 | .projectDashboard(id: project.id, title: project.name) | |
| 75 | } | |
| 76 | ||
| 77 | @MainActor | |
| 78 | func perform() async throws -> some IntentResult { | |
| 79 | HutchIntentNavigator.shared.open(route) | |
| 80 | return .result() | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | enum HutchShortcutScope: String, AppEnum { | |
| 85 | case all | |
| 86 | ||
| 87 | static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scope") | |
| 88 | static var caseDisplayRepresentations: [HutchShortcutScope: DisplayRepresentation] = [ | |
| 89 | .all: "All" | |
| 25 | 90 | ] |
| 26 | 91 | } |
| 27 | 92 | |
| 28 | struct OpenHutchIntent: AppIntent { | |
| 29 | static var title: LocalizedStringResource = "Open Hutch" | |
| 30 | static var description = IntentDescription("Opens Hutch to a specific section.") | |
| 93 | struct OpenFailedBuildsIntent: AppIntent { | |
| 94 | static var title: LocalizedStringResource = "Open Failed Builds" | |
| 95 | static var description = IntentDescription("Opens Hutch to failed builds.") | |
| 31 | 96 | static var openAppWhenRun = true |
| 32 | 97 | |
| 33 | @Parameter(title: "Section", default: .home) | |
| 34 | var destination: HutchDestination | |
| 98 | @Parameter(title: "Scope", default: .all) | |
| 99 | var scope: HutchShortcutScope | |
| 100 | ||
| 101 | var route: HutchRoute { .failedBuilds } | |
| 35 | 102 | |
| 36 | 103 | @MainActor |
| 37 | 104 | func perform() async throws -> some IntentResult { |
| 38 | HutchIntentNavigator.shared.pendingDestination = destination | |
| 105 | HutchIntentNavigator.shared.open(route) | |
| 39 | 106 | return .result() |
| 40 | 107 | } |
| 41 | 108 | } |
| 42 | 109 | |
| 43 | // MARK: - Check System Status Intent | |
| 110 | struct OpenAssignedTicketsIntent: AppIntent { | |
| 111 | static var title: LocalizedStringResource = "Open Assigned Tickets" | |
| 112 | static var description = IntentDescription("Opens Hutch to tickets assigned to you.") | |
| 113 | static var openAppWhenRun = true | |
| 114 | ||
| 115 | @Parameter(title: "Scope", default: .all) | |
| 116 | var scope: HutchShortcutScope | |
| 117 | ||
| 118 | var route: HutchRoute { .workQueue(scope: .assigned) } | |
| 119 | ||
| 120 | @MainActor | |
| 121 | func perform() async throws -> some IntentResult { | |
| 122 | HutchIntentNavigator.shared.open(route) | |
| 123 | return .result() | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | struct SearchHutchIntent: AppIntent { | |
| 128 | static var title: LocalizedStringResource = "Search Hutch" | |
| 129 | static var description = IntentDescription("Opens Hutch lookup with a search query.") | |
| 130 | static var openAppWhenRun = true | |
| 131 | ||
| 132 | @Parameter(title: "Query") | |
| 133 | var query: String | |
| 134 | ||
| 135 | var route: HutchRoute { | |
| 136 | let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 137 | // TODO: Route to global local search once Hutch has one. | |
| 138 | return normalized.isEmpty ? .lookup : .search(query: normalized) | |
| 139 | } | |
| 140 | ||
| 141 | @MainActor | |
| 142 | func perform() async throws -> some IntentResult { | |
| 143 | HutchIntentNavigator.shared.open(route) | |
| 144 | return .result() | |
| 145 | } | |
| 146 | } | |
| 147 | ||
| 148 | // TODO: Add OpenSavedSearchIntent when Hutch has global saved-search persistence. | |
| 149 | ||
| 150 | // MARK: - App Entities | |
| 151 | ||
| 152 | struct PinnedResourceEntity: AppEntity, Identifiable { | |
| 153 | static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Pinned Resource") | |
| 154 | static var defaultQuery = PinnedResourceQuery() | |
| 155 | ||
| 156 | let id: String | |
| 157 | let name: String | |
| 158 | let subtitle: String | |
| 159 | let route: HutchRoute | |
| 160 | ||
| 161 | var displayRepresentation: DisplayRepresentation { | |
| 162 | DisplayRepresentation(title: "\(name)", subtitle: "\(subtitle)") | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | struct PinnedResourceQuery: EntityQuery { | |
| 167 | @MainActor | |
| 168 | func entities(for identifiers: [PinnedResourceEntity.ID]) async throws -> [PinnedResourceEntity] { | |
| 169 | HutchIntentEntityStore.pinnedResources().filter { identifiers.contains($0.id) } | |
| 170 | } | |
| 171 | ||
| 172 | @MainActor | |
| 173 | func suggestedEntities() async throws -> [PinnedResourceEntity] { | |
| 174 | HutchIntentEntityStore.pinnedResources() | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | struct ProjectEntity: AppEntity, Identifiable { | |
| 179 | static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project") | |
| 180 | static var defaultQuery = ProjectEntityQuery() | |
| 181 | ||
| 182 | let id: String | |
| 183 | let name: String | |
| 184 | ||
| 185 | var displayRepresentation: DisplayRepresentation { | |
| 186 | DisplayRepresentation(title: "\(name)") | |
| 187 | } | |
| 188 | } | |
| 189 | ||
| 190 | struct ProjectEntityQuery: EntityQuery { | |
| 191 | @MainActor | |
| 192 | func entities(for identifiers: [ProjectEntity.ID]) async throws -> [ProjectEntity] { | |
| 193 | HutchIntentEntityStore.projects().filter { identifiers.contains($0.id) } | |
| 194 | } | |
| 195 | ||
| 196 | @MainActor | |
| 197 | func suggestedEntities() async throws -> [ProjectEntity] { | |
| 198 | HutchIntentEntityStore.projects() | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | private enum HutchIntentEntityStore { | |
| 203 | static func pinnedResources() -> [PinnedResourceEntity] { | |
| 204 | pins().compactMap { makePinnedResource(from: $0) } | |
| 205 | } | |
| 206 | ||
| 207 | static func projects() -> [ProjectEntity] { | |
| 208 | pins().compactMap { pin in | |
| 209 | guard pin.kind == .project else { return nil } | |
| 210 | return ProjectEntity(id: pin.value, name: pin.title) | |
| 211 | } | |
| 212 | } | |
| 213 | ||
| 214 | private static func pins() -> [HomePinRecord] { | |
| 215 | guard let userKey = ContributionWidgetContextStore.loadActor(), | |
| 216 | !userKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | |
| 217 | else { | |
| 218 | return [] | |
| 219 | } | |
| 220 | ||
| 221 | return HomePinStore.loadPins(for: userKey, defaults: activeAccountDefaults) | |
| 222 | } | |
| 223 | ||
| 224 | private static var activeAccountDefaults: UserDefaults { | |
| 225 | let activeID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? "" | |
| 226 | guard !activeID.isEmpty else { return .standard } | |
| 227 | return AccountDefaultsStore.userDefaults(for: activeID) | |
| 228 | } | |
| 229 | ||
| 230 | private static func makePinnedResource(from pin: HomePinRecord) -> PinnedResourceEntity? { | |
| 231 | guard let route = route(for: pin) else { return nil } | |
| 232 | return PinnedResourceEntity( | |
| 233 | id: pin.id, | |
| 234 | name: pin.title, | |
| 235 | subtitle: pin.subtitle, | |
| 236 | route: route | |
| 237 | ) | |
| 238 | } | |
| 239 | ||
| 240 | private static func route(for pin: HomePinRecord) -> HutchRoute? { | |
| 241 | switch pin.kind { | |
| 242 | case .project: | |
| 243 | return .projectDashboard(id: pin.value, title: pin.title) | |
| 244 | case .repository: | |
| 245 | guard let owner = pin.ownerUsername else { return nil } | |
| 246 | return .repository( | |
| 247 | service: pin.service ?? .git, | |
| 248 | owner: formattedOwner(owner), | |
| 249 | repo: pin.value | |
| 250 | ) | |
| 251 | case .tracker: | |
| 252 | guard let owner = pin.ownerUsername else { return nil } | |
| 253 | return .tracker(owner: formattedOwner(owner), tracker: pin.value) | |
| 254 | case .mailingList: | |
| 255 | guard let owner = pin.ownerUsername else { return nil } | |
| 256 | return .mailingList(owner: formattedOwner(owner), list: pin.value) | |
| 257 | case .user: | |
| 258 | guard let owner = pin.ownerUsername else { return nil } | |
| 259 | return .userProfile(owner: formattedOwner(owner)) | |
| 260 | } | |
| 261 | } | |
| 262 | ||
| 263 | private static func formattedOwner(_ owner: String) -> String { | |
| 264 | owner.hasPrefix("~") ? owner : "~\(owner)" | |
| 265 | } | |
| 266 | } | |
| 267 | ||
| 268 | // MARK: - Existing Read-Only Summary Intents | |
| 44 | 269 | |
| 45 | 270 | struct CheckSystemStatusIntent: AppIntent { |
| 46 | 271 | static var title: LocalizedStringResource = "Check SourceHut Status" |
| @@ -64,8 +289,6 @@ struct CheckSystemStatusIntent: AppIntent { | ||
| 64 | 289 | } |
| 65 | 290 | } |
| 66 | 291 | |
| 67 | // MARK: - Check Builds Intent | |
| 68 | ||
| 69 | 292 | struct CheckBuildsIntent: AppIntent { |
| 70 | 293 | static var title: LocalizedStringResource = "Check Hutch Builds" |
| 71 | 294 | static var description = IntentDescription("Returns a summary of your recent build status.") |
| @@ -107,25 +330,92 @@ struct CheckBuildsIntent: AppIntent { | ||
| 107 | 330 | struct HutchShortcuts: AppShortcutsProvider { |
| 108 | 331 | static var appShortcuts: [AppShortcut] { |
| 109 | 332 | AppShortcut( |
| 110 | intent: OpenHutchIntent(), | |
| 333 | intent: OpenWorkQueueIntent(), | |
| 334 | phrases: [ | |
| 335 | "Open my work queue in \(.applicationName)", | |
| 336 | "Show work in \(.applicationName)" | |
| 337 | ], | |
| 338 | shortTitle: "Work Queue", | |
| 339 | systemImageName: "tray.full" | |
| 340 | ) | |
| 341 | ||
| 342 | AppShortcut( | |
| 343 | intent: OpenRecentActivityIntent(), | |
| 344 | phrases: [ | |
| 345 | "Open recent activity in \(.applicationName)", | |
| 346 | "Show activity in \(.applicationName)" | |
| 347 | ], | |
| 348 | shortTitle: "Recent Activity", | |
| 349 | systemImageName: "clock.arrow.circlepath" | |
| 350 | ) | |
| 351 | ||
| 352 | AppShortcut( | |
| 353 | intent: OpenSystemStatusIntent(), | |
| 354 | phrases: [ | |
| 355 | "Open system status in \(.applicationName)", | |
| 356 | "Show SourceHut status in \(.applicationName)" | |
| 357 | ], | |
| 358 | shortTitle: "System Status", | |
| 359 | systemImageName: "server.rack" | |
| 360 | ) | |
| 361 | ||
| 362 | AppShortcut( | |
| 363 | intent: OpenPinnedResourceIntent(), | |
| 111 | 364 | phrases: [ |
| 112 | "Open \(.applicationName)", | |
| 113 | "Open \(.applicationName) \(\.$destination)", | |
| 114 | "Show my \(.applicationName) \(\.$destination)", | |
| 115 | "Go to \(\.$destination) in \(.applicationName)" | |
| 365 | "Open \(\.$pinnedResource) in \(.applicationName)", | |
| 366 | "Show my pinned \(\.$pinnedResource) in \(.applicationName)" | |
| 116 | 367 | ], |
| 117 | shortTitle: "Open Hutch", | |
| 118 | systemImageName: "house" | |
| 368 | shortTitle: "Pinned Resource", | |
| 369 | systemImageName: "pin" | |
| 370 | ) | |
| 371 | ||
| 372 | AppShortcut( | |
| 373 | intent: OpenProjectDashboardIntent(), | |
| 374 | phrases: [ | |
| 375 | "Open \(\.$project) dashboard in \(.applicationName)", | |
| 376 | "Show project \(\.$project) in \(.applicationName)" | |
| 377 | ], | |
| 378 | shortTitle: "Project Dashboard", | |
| 379 | systemImageName: "square.stack.3d.up" | |
| 380 | ) | |
| 381 | ||
| 382 | AppShortcut( | |
| 383 | intent: OpenFailedBuildsIntent(), | |
| 384 | phrases: [ | |
| 385 | "Open failed builds in \(.applicationName)", | |
| 386 | "Show failed builds in \(.applicationName)" | |
| 387 | ], | |
| 388 | shortTitle: "Failed Builds", | |
| 389 | systemImageName: "exclamationmark.triangle" | |
| 390 | ) | |
| 391 | ||
| 392 | AppShortcut( | |
| 393 | intent: OpenAssignedTicketsIntent(), | |
| 394 | phrases: [ | |
| 395 | "Open assigned tickets in \(.applicationName)", | |
| 396 | "Show my assigned tickets in \(.applicationName)" | |
| 397 | ], | |
| 398 | shortTitle: "Assigned Tickets", | |
| 399 | systemImageName: "person.crop.circle.badge.checkmark" | |
| 400 | ) | |
| 401 | ||
| 402 | AppShortcut( | |
| 403 | intent: SearchHutchIntent(), | |
| 404 | phrases: [ | |
| 405 | "Search \(.applicationName)", | |
| 406 | "Look up something in \(.applicationName)" | |
| 407 | ], | |
| 408 | shortTitle: "Search Hutch", | |
| 409 | systemImageName: "magnifyingglass" | |
| 119 | 410 | ) |
| 120 | 411 | |
| 121 | 412 | AppShortcut( |
| 122 | 413 | intent: CheckSystemStatusIntent(), |
| 123 | 414 | phrases: [ |
| 124 | 415 | "Check \(.applicationName) status", |
| 125 | "Is SourceHut up in \(.applicationName)", | |
| 126 | "SourceHut status in \(.applicationName)" | |
| 416 | "Is SourceHut up in \(.applicationName)" | |
| 127 | 417 | ], |
| 128 | shortTitle: "Check SourceHut Status", | |
| 418 | shortTitle: "Check Status", | |
| 129 | 419 | systemImageName: "server.rack" |
| 130 | 420 | ) |
| 131 | 421 | |
| @@ -133,7 +423,6 @@ struct HutchShortcuts: AppShortcutsProvider { | ||
| 133 | 423 | intent: CheckBuildsIntent(), |
| 134 | 424 | phrases: [ |
| 135 | 425 | "Check my \(.applicationName) builds", |
| 136 | "How are my builds in \(.applicationName)", | |
| 137 | 426 | "Build status in \(.applicationName)" |
| 138 | 427 | ], |
| 139 | 428 | shortTitle: "Check Builds", |
| @@ -148,9 +437,13 @@ struct HutchShortcuts: AppShortcutsProvider { | ||
| 148 | 437 | @Observable |
| 149 | 438 | final class HutchIntentNavigator { |
| 150 | 439 | static let shared = HutchIntentNavigator() |
| 151 | var pendingDestination: HutchDestination? | |
| 440 | var pendingRoute: HutchRoute? | |
| 152 | 441 | |
| 153 | 442 | private init() { |
| 154 | 443 | /* Singleton; external code uses `shared`. */ |
| 155 | 444 | } |
| 445 | ||
| 446 | func open(_ route: HutchRoute) { | |
| 447 | pendingRoute = route | |
| 448 | } | |
| 156 | 449 | } |
Hutch/App/RootView.swift +109 −15
| @@ -75,8 +75,8 @@ struct RootView: View { | ||
| 75 | 75 | HomeView() |
| 76 | 76 | .navigationDestination(for: HomeRoute.self) { route in |
| 77 | 77 | switch route { |
| 78 | case .work: | |
| 79 | WorkView() | |
| 78 | case .work(let scope): | |
| 79 | WorkView(initialScope: scope) | |
| 80 | 80 | } |
| 81 | 81 | } |
| 82 | 82 | } |
| @@ -202,9 +202,16 @@ struct RootView: View { | ||
| 202 | 202 | homePath = NavigationPath() |
| 203 | 203 | appState.selectedTab = .home |
| 204 | 204 | |
| 205 | case .recentActivity: | |
| 206 | homePath = NavigationPath() | |
| 207 | appState.selectedTab = .home | |
| 208 | ||
| 205 | 209 | case .repository(let service, let owner, let repo): |
| 206 | 210 | resolveRepositoryLink(service: service, owner: owner, repo: repo) |
| 207 | 211 | |
| 212 | case .tracker(let owner, let tracker): | |
| 213 | resolveTrackerLink(owner: owner, tracker: tracker) | |
| 214 | ||
| 208 | 215 | case .build(let jobId): |
| 209 | 216 | buildsPath = NavigationPath() |
| 210 | 217 | appState.selectedTab = .builds |
| @@ -223,11 +230,39 @@ struct RootView: View { | ||
| 223 | 230 | resolveUserProfileLink(owner: owner) |
| 224 | 231 | |
| 225 | 232 | case .work: |
| 226 | homePath = NavigationPath() | |
| 227 | appState.selectedTab = .home | |
| 233 | navigateToWork(scope: .all) | |
| 234 | ||
| 235 | case .workQueue(let scope): | |
| 236 | navigateToWork(scope: scope) | |
| 237 | ||
| 238 | case .projectDashboard(let id, let title): | |
| 239 | morePath = NavigationPath() | |
| 240 | appState.selectedTab = .more | |
| 241 | Task { | |
| 242 | await settleNavigationTransition() | |
| 243 | morePath.append(MoreRoute.projects) | |
| 244 | morePath.append(MoreRoute.projectDashboard(id: id, title: title)) | |
| 245 | } | |
| 246 | ||
| 247 | case .failedBuilds: | |
| 248 | buildsPath = NavigationPath() | |
| 249 | appState.pendingBuildListFilter = .failed | |
| 250 | appState.selectedTab = .builds | |
| 251 | ||
| 252 | case .search(let query): | |
| 253 | morePath = NavigationPath() | |
| 254 | appState.selectedTab = .more | |
| 228 | 255 | Task { |
| 229 | 256 | await settleNavigationTransition() |
| 230 | homePath.append(HomeRoute.work) | |
| 257 | morePath.append(MoreRoute.lookup(query: query)) | |
| 258 | } | |
| 259 | ||
| 260 | case .lookup: | |
| 261 | morePath = NavigationPath() | |
| 262 | appState.selectedTab = .more | |
| 263 | Task { | |
| 264 | await settleNavigationTransition() | |
| 265 | morePath.append(MoreRoute.lookup(query: nil)) | |
| 231 | 266 | } |
| 232 | 267 | |
| 233 | 268 | case .buildsTab: |
| @@ -244,14 +279,15 @@ struct RootView: View { | ||
| 244 | 279 | |
| 245 | 280 | case .systemStatus: |
| 246 | 281 | appState.navigateToSystemStatus() |
| 282 | } | |
| 283 | } | |
| 247 | 284 | |
| 248 | case .lookup: | |
| 249 | morePath = NavigationPath() | |
| 250 | appState.selectedTab = .more | |
| 251 | Task { | |
| 252 | await settleNavigationTransition() | |
| 253 | morePath.append(MoreRoute.lookup) | |
| 254 | } | |
| 285 | private func navigateToWork(scope: HutchWorkQueueScope) { | |
| 286 | homePath = NavigationPath() | |
| 287 | appState.selectedTab = .home | |
| 288 | Task { | |
| 289 | await settleNavigationTransition() | |
| 290 | homePath.append(HomeRoute.work(scope: scope)) | |
| 255 | 291 | } |
| 256 | 292 | } |
| 257 | 293 | |
| @@ -310,6 +346,22 @@ struct RootView: View { | ||
| 310 | 346 | } |
| 311 | 347 | } |
| 312 | 348 | |
| 349 | private func resolveTrackerLink(owner: String, tracker: String) { | |
| 350 | isResolvingDeepLink = true | |
| 351 | Task { | |
| 352 | defer { isResolvingDeepLink = false } | |
| 353 | do { | |
| 354 | let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker) | |
| 355 | ticketsPath = NavigationPath() | |
| 356 | appState.selectedTab = .tickets | |
| 357 | await settleNavigationTransition() | |
| 358 | ticketsPath.append(trackerSummary) | |
| 359 | } catch { | |
| 360 | appState.presentTicketDeepLinkError() | |
| 361 | } | |
| 362 | } | |
| 363 | } | |
| 364 | ||
| 313 | 365 | private func resolveMailingListLink(owner: String, list: String) { |
| 314 | 366 | isResolvingDeepLink = true |
| 315 | 367 | Task { |
| @@ -375,7 +427,7 @@ enum MoreDestination: Hashable { | ||
| 375 | 427 | } |
| 376 | 428 | |
| 377 | 429 | enum MoreRoute: Hashable { |
| 378 | case lookup | |
| 430 | case lookup(query: String?) | |
| 379 | 431 | case projects |
| 380 | 432 | case lists |
| 381 | 433 | case pastes |
| @@ -384,6 +436,7 @@ enum MoreRoute: Hashable { | ||
| 384 | 436 | case settings |
| 385 | 437 | case about |
| 386 | 438 | case userProfile(String) |
| 439 | case projectDashboard(id: String, title: String?) | |
| 387 | 440 | case mailingList(InboxMailingListReference) |
| 388 | 441 | case thread(InboxThreadSummary) |
| 389 | 442 | case manPageBrowser |
| @@ -397,8 +450,8 @@ private struct MoreNavigationRoot: View { | ||
| 397 | 450 | MoreView() |
| 398 | 451 | .navigationDestination(for: MoreRoute.self) { route in |
| 399 | 452 | switch route { |
| 400 | case .lookup: | |
| 401 | LookupView() | |
| 453 | case .lookup(let query): | |
| 454 | LookupView(initialQuery: query ?? "") | |
| 402 | 455 | case .projects: |
| 403 | 456 | ProjectsListView() |
| 404 | 457 | case .lists: |
| @@ -415,6 +468,8 @@ private struct MoreNavigationRoot: View { | ||
| 415 | 468 | AboutView() |
| 416 | 469 | case .userProfile(let owner): |
| 417 | 470 | UserProfileDeepLinkView(owner: owner) |
| 471 | case .projectDashboard(let id, let title): | |
| 472 | ProjectDashboardDeepLinkView(projectID: id, title: title) | |
| 418 | 473 | case .mailingList(let mailingList): |
| 419 | 474 | MailingListDetailView(mailingList: mailingList) |
| 420 | 475 | case .thread(let thread): |
| @@ -484,6 +539,45 @@ struct UserProfileDeepLinkView: View { | ||
| 484 | 539 | } |
| 485 | 540 | } |
| 486 | 541 | |
| 542 | struct ProjectDashboardDeepLinkView: View { | |
| 543 | @Environment(AppState.self) private var appState | |
| 544 | let projectID: String | |
| 545 | let title: String? | |
| 546 | @State private var project: Project? | |
| 547 | @State private var errorMessage: String? | |
| 548 | ||
| 549 | var body: some View { | |
| 550 | Group { | |
| 551 | if let project { | |
| 552 | ProjectDetailView(project: project) | |
| 553 | } else if let errorMessage { | |
| 554 | ContentUnavailableView( | |
| 555 | "Couldn't Open Project", | |
| 556 | systemImage: "square.stack.3d.up.slash", | |
| 557 | description: Text(errorMessage) | |
| 558 | ) | |
| 559 | } else { | |
| 560 | SRHTLoadingStateView(message: "Loading project...") | |
| 561 | } | |
| 562 | } | |
| 563 | .navigationTitle(title ?? "Project") | |
| 564 | .navigationBarTitleDisplayMode(.inline) | |
| 565 | .task(id: projectID) { | |
| 566 | await loadProject() | |
| 567 | } | |
| 568 | } | |
| 569 | ||
| 570 | @MainActor | |
| 571 | private func loadProject() async { | |
| 572 | errorMessage = nil | |
| 573 | do { | |
| 574 | project = try await ProjectService(client: appState.client).fetchProjectDetail(rid: projectID) | |
| 575 | } catch { | |
| 576 | errorMessage = "The project could not be found or is inaccessible." | |
| 577 | } | |
| 578 | } | |
| 579 | } | |
| 580 | ||
| 487 | 581 | // MARK: - Ticket Deep Link Navigation Target |
| 488 | 582 | |
| 489 | 583 | /// Hashable wrapper to push a ticket detail view from a deep link. |
Hutch/Views/Builds/BuildListView.swift +9
| @@ -124,6 +124,10 @@ struct BuildListView: View { | ||
| 124 | 124 | let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults) |
| 125 | 125 | vm.repoFilter = savedRepoFilter |
| 126 | 126 | vm.lookbackDays = lookbackDays |
| 127 | if let pendingFilter = appState.pendingBuildListFilter { | |
| 128 | vm.filter = pendingFilter | |
| 129 | appState.pendingBuildListFilter = nil | |
| 130 | } | |
| 127 | 131 | viewModel = vm |
| 128 | 132 | await vm.loadJobs() |
| 129 | 133 | } |
| @@ -134,6 +138,11 @@ struct BuildListView: View { | ||
| 134 | 138 | .onChange(of: lookbackDays) { _, newValue in |
| 135 | 139 | viewModel?.lookbackDays = newValue |
| 136 | 140 | } |
| 141 | .onChange(of: appState.pendingBuildListFilter) { _, newValue in | |
| 142 | guard let newValue else { return } | |
| 143 | viewModel?.filter = newValue | |
| 144 | appState.pendingBuildListFilter = nil | |
| 145 | } | |
| 137 | 146 | .onDisappear { |
| 138 | 147 | viewModel?.stopAutoRefresh() |
| 139 | 148 | } |
Hutch/Views/Builds/BuildListViewModel.swift +8
| @@ -21,6 +21,7 @@ private struct SubmittedJob: Decodable, Sendable { | ||
| 21 | 21 | |
| 22 | 22 | enum BuildListFilter: String, CaseIterable, Sendable { |
| 23 | 23 | case attention = "Attention" |
| 24 | case failed = "Failed" | |
| 24 | 25 | case active = "Active" |
| 25 | 26 | case all = "All" |
| 26 | 27 | } |
| @@ -413,6 +414,13 @@ final class BuildListViewModel { | ||
| 413 | 414 | case .success, .cancelled: |
| 414 | 415 | return false |
| 415 | 416 | } |
| 417 | case .failed: | |
| 418 | switch job.status { | |
| 419 | case .failed, .timeout: | |
| 420 | return true | |
| 421 | case .success, .cancelled, .running, .queued, .pending: | |
| 422 | return false | |
| 423 | } | |
| 416 | 424 | case .active: |
| 417 | 425 | switch job.status { |
| 418 | 426 | case .running, .queued, .pending: |
Hutch/Views/Home/HomeView.swift +2 −2
| @@ -106,7 +106,7 @@ struct HomeView: View { | ||
| 106 | 106 | |
| 107 | 107 | private func workSection(_ viewModel: HomeViewModel) -> some View { |
| 108 | 108 | Section("Work") { |
| 109 | NavigationLink(value: HomeRoute.work) { | |
| 109 | NavigationLink(value: HomeRoute.work(scope: .all)) { | |
| 110 | 110 | HomeSummaryRow( |
| 111 | 111 | title: workTitle(viewModel), |
| 112 | 112 | summary: workSummary(viewModel), |
| @@ -424,7 +424,7 @@ struct HomeView: View { | ||
| 424 | 424 | } |
| 425 | 425 | |
| 426 | 426 | enum HomeRoute: Hashable { |
| 427 | case work | |
| 427 | case work(scope: HutchWorkQueueScope) | |
| 428 | 428 | } |
| 429 | 429 | |
| 430 | 430 | private enum HomeSummaryEmphasis { |
Hutch/Views/Lookup/LookupView.swift +22 −4
| @@ -92,11 +92,17 @@ final class LookupViewModel { | ||
| 92 | 92 | ) |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | init(client: SRHTClient, appState: AppState, defaults: UserDefaults = .standard) { | |
| 95 | init( | |
| 96 | client: SRHTClient, | |
| 97 | appState: AppState, | |
| 98 | defaults: UserDefaults = .standard, | |
| 99 | initialQuery: String = "" | |
| 100 | ) { | |
| 96 | 101 | self.client = client |
| 97 | 102 | self.appState = appState |
| 98 | 103 | self.defaults = defaults |
| 99 | 104 | self.history = LookupHistoryStore.load(defaults: defaults) |
| 105 | self.inputText = initialQuery.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 100 | 106 | } |
| 101 | 107 | |
| 102 | 108 | func lookup() async { |
| @@ -329,6 +335,11 @@ final class LookupViewModel { | ||
| 329 | 335 | struct LookupView: View { |
| 330 | 336 | @Environment(AppState.self) private var appState |
| 331 | 337 | @State private var viewModel: LookupViewModel? |
| 338 | private let initialQuery: String | |
| 339 | ||
| 340 | init(initialQuery: String = "") { | |
| 341 | self.initialQuery = initialQuery | |
| 342 | } | |
| 332 | 343 | |
| 333 | 344 | var body: some View { |
| 334 | 345 | Group { |
| @@ -341,7 +352,12 @@ struct LookupView: View { | ||
| 341 | 352 | .navigationTitle("Look Up") |
| 342 | 353 | .task { |
| 343 | 354 | if viewModel == nil { |
| 344 | viewModel = LookupViewModel(client: appState.client, appState: appState, defaults: appState.accountDefaults) | |
| 355 | viewModel = LookupViewModel( | |
| 356 | client: appState.client, | |
| 357 | appState: appState, | |
| 358 | defaults: appState.accountDefaults, | |
| 359 | initialQuery: initialQuery | |
| 360 | ) | |
| 345 | 361 | } |
| 346 | 362 | } |
| 347 | 363 | } |
| @@ -427,8 +443,8 @@ struct LookupView: View { | ||
| 427 | 443 | } |
| 428 | 444 | .navigationDestination(for: MoreRoute.self) { route in |
| 429 | 445 | switch route { |
| 430 | case .lookup: | |
| 431 | LookupView() | |
| 446 | case .lookup(let query): | |
| 447 | LookupView(initialQuery: query ?? "") | |
| 432 | 448 | case .projects: |
| 433 | 449 | ProjectsListView() |
| 434 | 450 | case .lists: |
| @@ -445,6 +461,8 @@ struct LookupView: View { | ||
| 445 | 461 | AboutView() |
| 446 | 462 | case .userProfile(let owner): |
| 447 | 463 | UserProfileDeepLinkView(owner: owner) |
| 464 | case .projectDashboard(let id, let title): | |
| 465 | ProjectDashboardDeepLinkView(projectID: id, title: title) | |
| 448 | 466 | case .mailingList(let mailingList): |
| 449 | 467 | MailingListDetailView(mailingList: mailingList) |
| 450 | 468 | case .thread(let thread): |
Hutch/Views/More/MoreView.swift +1 −1
| @@ -13,7 +13,7 @@ struct MoreView: View { | ||
| 13 | 13 | var body: some View { |
| 14 | 14 | List { |
| 15 | 15 | Section("Search") { |
| 16 | NavigationLink(value: MoreRoute.lookup) { | |
| 16 | NavigationLink(value: MoreRoute.lookup(query: nil)) { | |
| 17 | 17 | Label("Look Up", systemImage: "magnifyingglass") |
| 18 | 18 | } |
| 19 | 19 | .themedRow() |
Hutch/Views/Work/WorkView.swift +18 −10
| @@ -1,20 +1,28 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | struct WorkView: View { | |
| 4 | private enum Scope: String, CaseIterable, Identifiable { | |
| 5 | case all = "All" | |
| 6 | case unread = "Unread" | |
| 7 | case assigned = "Assigned" | |
| 8 | ||
| 9 | var id: String { rawValue } | |
| 3 | extension HutchWorkQueueScope: Identifiable { | |
| 4 | var id: String { rawValue } | |
| 5 | ||
| 6 | var displayName: String { | |
| 7 | switch self { | |
| 8 | case .all: "All" | |
| 9 | case .unread: "Unread" | |
| 10 | case .assigned: "Assigned" | |
| 11 | } | |
| 10 | 12 | } |
| 13 | } | |
| 11 | 14 | |
| 15 | struct WorkView: View { | |
| 12 | 16 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 13 | 17 | @Environment(AppState.self) private var appState |
| 14 | 18 | @Environment(\.isAMOLEDTheme) private var isAMOLED |
| 15 | 19 | @Environment(\.scenePhase) private var scenePhase |
| 16 | 20 | @State private var viewModel: HomeViewModel? |
| 17 | @State private var scope: Scope = .all | |
| 21 | @State private var scope: HutchWorkQueueScope | |
| 22 | ||
| 23 | init(initialScope: HutchWorkQueueScope = .all) { | |
| 24 | _scope = State(initialValue: initialScope) | |
| 25 | } | |
| 18 | 26 | |
| 19 | 27 | var body: some View { |
| 20 | 28 | Group { |
| @@ -106,8 +114,8 @@ struct WorkView: View { | ||
| 106 | 114 | private var scopeSection: some View { |
| 107 | 115 | Section { |
| 108 | 116 | Picker("Scope", selection: $scope) { |
| 109 | ForEach(Scope.allCases) { scope in | |
| 110 | Text(scope.rawValue).tag(scope) | |
| 117 | ForEach(HutchWorkQueueScope.allCases) { scope in | |
| 118 | Text(scope.displayName).tag(scope) | |
| 111 | 119 | } |
| 112 | 120 | } |
| 113 | 121 | .pickerStyle(.segmented) |
HutchTests/BuildListViewModelTests.swift +6
| @@ -59,9 +59,15 @@ struct BuildListViewModelTests { | ||
| 59 | 59 | filter: .active, |
| 60 | 60 | lookbackDays: BuildListViewModel.defaultLookbackDays |
| 61 | 61 | ) |
| 62 | let failed = BuildListViewModel.filterJobs( | |
| 63 | jobs, | |
| 64 | filter: .failed, | |
| 65 | lookbackDays: BuildListViewModel.defaultLookbackDays | |
| 66 | ) | |
| 62 | 67 | |
| 63 | 68 | #expect(attention.map(\.id) == [2, 3]) |
| 64 | 69 | #expect(active.map(\.id) == [3]) |
| 70 | #expect(failed.map(\.id) == [2]) | |
| 65 | 71 | } |
| 66 | 72 | |
| 67 | 73 | @Test |
HutchTests/DeepLinkTests.swift +8
| @@ -88,6 +88,14 @@ struct DeepLinkTests { | ||
| 88 | 88 | #expect(link == .lookup) |
| 89 | 89 | } |
| 90 | 90 | |
| 91 | @Test | |
| 92 | func parsesRouteBackedNavigationLinks() { | |
| 93 | #expect(DeepLink(url: HutchRoute.workQueue(scope: .assigned).url) == .workQueue(scope: .assigned)) | |
| 94 | #expect(DeepLink(url: HutchRoute.failedBuilds.url) == .failedBuilds) | |
| 95 | #expect(DeepLink(url: HutchRoute.search(query: "patch queue").url) == .search(query: "patch queue")) | |
| 96 | #expect(DeepLink(url: HutchRoute.projectDashboard(id: "project-1", title: "Hutch").url) == .projectDashboard(id: "project-1", title: "Hutch")) | |
| 97 | } | |
| 98 | ||
| 91 | 99 | @Test |
| 92 | 100 | func parsesUserProfileLink() { |
| 93 | 101 | let link = DeepLink(url: HutchDeepLinkURL.userProfile) |
HutchTests/HutchIntentsTests.swift +9
| @@ -5,6 +5,15 @@ import Testing | ||
| 5 | 5 | @MainActor |
| 6 | 6 | struct HutchIntentsTests { |
| 7 | 7 | |
| 8 | @Test | |
| 9 | func navigationIntentsMapToCentralRoutes() { | |
| 10 | #expect(OpenWorkQueueIntent().route == .workQueue(scope: .all)) | |
| 11 | #expect(OpenRecentActivityIntent().route == .recentActivity) | |
| 12 | #expect(OpenSystemStatusIntent().route == .systemStatus) | |
| 13 | #expect(OpenFailedBuildsIntent().route == .failedBuilds) | |
| 14 | #expect(OpenAssignedTicketsIntent().route == .workQueue(scope: .assigned)) | |
| 15 | } | |
| 16 | ||
| 8 | 17 | @Test |
| 9 | 18 | func checkSystemStatusReturnsOperationalWhenNoDisruption() async throws { |
| 10 | 19 | let defaultsName = "HutchIntentsTests-status-\(UUID().uuidString)" |
Shared/NeedsAttentionSnapshot.swift +13 −8
| @@ -58,11 +58,11 @@ enum NeedsAttentionSnapshotStore { | ||
| 58 | 58 | private static let snapshotKey = "needsAttention.snapshot" |
| 59 | 59 | |
| 60 | 60 | static func load( |
| 61 | accountID: String? = ActiveAccountContextStore.load(), | |
| 61 | accountID: String? = nil, | |
| 62 | 62 | defaults: UserDefaults? = sharedDefaults() |
| 63 | 63 | ) -> NeedsAttentionSnapshot? { |
| 64 | 64 | guard let defaults, |
| 65 | let data = defaults.data(forKey: scopedKey(for: accountID)) else { | |
| 65 | let data = defaults.data(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) else { | |
| 66 | 66 | return nil |
| 67 | 67 | } |
| 68 | 68 | |
| @@ -71,7 +71,7 @@ enum NeedsAttentionSnapshotStore { | ||
| 71 | 71 | |
| 72 | 72 | static func save( |
| 73 | 73 | _ snapshot: NeedsAttentionSnapshot, |
| 74 | accountID: String? = ActiveAccountContextStore.load(), | |
| 74 | accountID: String? = nil, | |
| 75 | 75 | defaults: UserDefaults? = sharedDefaults() |
| 76 | 76 | ) { |
| 77 | 77 | guard let defaults, |
| @@ -79,7 +79,7 @@ enum NeedsAttentionSnapshotStore { | ||
| 79 | 79 | return |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | defaults.set(data, forKey: scopedKey(for: accountID)) | |
| 82 | defaults.set(data, forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) | |
| 83 | 83 | reloadWidgetTimelines() |
| 84 | 84 | } |
| 85 | 85 | |
| @@ -87,7 +87,7 @@ enum NeedsAttentionSnapshotStore { | ||
| 87 | 87 | unreadInboxThreads: Int? = nil, |
| 88 | 88 | assignedOpenTickets: Int? = nil, |
| 89 | 89 | failedBuilds: Int? = nil, |
| 90 | accountID: String? = ActiveAccountContextStore.load(), | |
| 90 | accountID: String? = nil, | |
| 91 | 91 | defaults: UserDefaults? = sharedDefaults() |
| 92 | 92 | ) { |
| 93 | 93 | let existing = load(accountID: accountID, defaults: defaults) |
| @@ -102,7 +102,7 @@ enum NeedsAttentionSnapshotStore { | ||
| 102 | 102 | |
| 103 | 103 | static func adjustUnreadInboxThreads( |
| 104 | 104 | by delta: Int, |
| 105 | accountID: String? = ActiveAccountContextStore.load(), | |
| 105 | accountID: String? = nil, | |
| 106 | 106 | defaults: UserDefaults? = sharedDefaults() |
| 107 | 107 | ) { |
| 108 | 108 | guard let existing = load(accountID: accountID, defaults: defaults), |
| @@ -123,10 +123,11 @@ enum NeedsAttentionSnapshotStore { | ||
| 123 | 123 | } |
| 124 | 124 | |
| 125 | 125 | static func clear( |
| 126 | accountID: String? = ActiveAccountContextStore.load(), | |
| 126 | accountID: String? = nil, | |
| 127 | 127 | defaults: UserDefaults? = sharedDefaults() |
| 128 | 128 | ) { |
| 129 | defaults?.removeObject(forKey: scopedKey(for: accountID)) | |
| 129 | guard let defaults else { return } | |
| 130 | defaults.removeObject(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) | |
| 130 | 131 | reloadWidgetTimelines() |
| 131 | 132 | } |
| 132 | 133 | |
| @@ -139,6 +140,10 @@ enum NeedsAttentionSnapshotStore { | ||
| 139 | 140 | return "\(snapshotKey).\(accountID)" |
| 140 | 141 | } |
| 141 | 142 | |
| 143 | private static func resolvedAccountID(_ accountID: String?, defaults: UserDefaults) -> String? { | |
| 144 | accountID ?? ActiveAccountContextStore.load(defaults: defaults) | |
| 145 | } | |
| 146 | ||
| 142 | 147 | private static func reloadWidgetTimelines() { |
| 143 | 148 | #if canImport(WidgetKit) |
| 144 | 149 | WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind) |
Shared/SystemStatusWidgetSnapshot.swift +11 −6
| @@ -34,11 +34,11 @@ enum SystemStatusWidgetSnapshotStore { | ||
| 34 | 34 | private static let snapshotKey = "systemStatus.widgetSnapshot" |
| 35 | 35 | |
| 36 | 36 | static func load( |
| 37 | accountID: String? = ActiveAccountContextStore.load(), | |
| 37 | accountID: String? = nil, | |
| 38 | 38 | defaults: UserDefaults? = sharedDefaults() |
| 39 | 39 | ) -> SystemStatusWidgetSnapshot? { |
| 40 | 40 | guard let defaults, |
| 41 | let data = defaults.data(forKey: scopedKey(for: accountID)) else { | |
| 41 | let data = defaults.data(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) else { | |
| 42 | 42 | return nil |
| 43 | 43 | } |
| 44 | 44 | return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data) |
| @@ -46,22 +46,23 @@ enum SystemStatusWidgetSnapshotStore { | ||
| 46 | 46 | |
| 47 | 47 | static func save( |
| 48 | 48 | _ snapshot: SystemStatusWidgetSnapshot, |
| 49 | accountID: String? = ActiveAccountContextStore.load(), | |
| 49 | accountID: String? = nil, | |
| 50 | 50 | defaults: UserDefaults? = sharedDefaults() |
| 51 | 51 | ) { |
| 52 | 52 | guard let defaults, |
| 53 | 53 | let data = try? JSONEncoder().encode(snapshot) else { |
| 54 | 54 | return |
| 55 | 55 | } |
| 56 | defaults.set(data, forKey: scopedKey(for: accountID)) | |
| 56 | defaults.set(data, forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) | |
| 57 | 57 | reloadWidgetTimelines() |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | 60 | static func clear( |
| 61 | accountID: String? = ActiveAccountContextStore.load(), | |
| 61 | accountID: String? = nil, | |
| 62 | 62 | defaults: UserDefaults? = sharedDefaults() |
| 63 | 63 | ) { |
| 64 | defaults?.removeObject(forKey: scopedKey(for: accountID)) | |
| 64 | guard let defaults else { return } | |
| 65 | defaults.removeObject(forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) | |
| 65 | 66 | reloadWidgetTimelines() |
| 66 | 67 | } |
| 67 | 68 | |
| @@ -74,6 +75,10 @@ enum SystemStatusWidgetSnapshotStore { | ||
| 74 | 75 | return "\(snapshotKey).\(accountID)" |
| 75 | 76 | } |
| 76 | 77 | |
| 78 | private static func resolvedAccountID(_ accountID: String?, defaults: UserDefaults) -> String? { | |
| 79 | accountID ?? ActiveAccountContextStore.load(defaults: defaults) | |
| 80 | } | |
| 81 | ||
| 77 | 82 | private static func reloadWidgetTimelines() { |
| 78 | 83 | #if canImport(WidgetKit) |
| 79 | 84 | WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind) |