Commit 431c255c28
Unsigned
Layout: unified · split
Hutch/App/AppState.swift +6
| @@ -84,6 +84,7 @@ final class AppState { | |||
| 84 | /// Set by the deep link handler; consumed by RootView to drive navigation. | 84 | /// Set by the deep link handler; consumed by RootView to drive navigation. |
| 85 | var pendingDeepLink: DeepLink? | 85 | var pendingDeepLink: DeepLink? |
| 86 | var pendingTabNavigation: TabNavigationTarget? | 86 | var pendingTabNavigation: TabNavigationTarget? |
| 87 | var pendingBuildListFilter: BuildListFilter? | ||
| 87 | var deepLinkError: String? | 88 | var deepLinkError: String? |
| 88 | 89 | ||
| 89 | // MARK: - Init | 90 | // MARK: - Init |
| @@ -373,6 +374,10 @@ final class AppState { | |||
| 373 | selectedTab = .builds | 374 | selectedTab = .builds |
| 374 | } | 375 | } |
| 375 | 376 | ||
| 377 | func open(_ route: HutchRoute) { | ||
| 378 | pendingDeepLink = DeepLink(route: route) | ||
| 379 | } | ||
| 380 | |||
| 376 | func presentRepositoryDeepLinkError() { | 381 | func presentRepositoryDeepLinkError() { |
| 377 | deepLinkError = "The repository could not be found or is inaccessible." | 382 | deepLinkError = "The repository could not be found or is inaccessible." |
| 378 | } | 383 | } |
| @@ -574,6 +579,7 @@ final class AppState { | |||
| 574 | private func resetNavigationState() { | 579 | private func resetNavigationState() { |
| 575 | pendingDeepLink = nil | 580 | pendingDeepLink = nil |
| 576 | pendingTabNavigation = nil | 581 | pendingTabNavigation = nil |
| 582 | pendingBuildListFilter = nil | ||
| 577 | deepLinkError = nil | 583 | deepLinkError = nil |
| 578 | selectedTab = .home | 584 | selectedTab = .home |
| 579 | } | 585 | } |
Hutch/App/DeepLink.swift +186 −24
| @@ -3,45 +3,50 @@ import os | |||
| 3 | 3 | ||
| 4 | private let deepLinkParserLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink") | 4 | private let deepLinkParserLogger = Logger(subsystem: "net.cleberg.Hutch", category: "DeepLink") |
| 5 | 5 | ||
| 6 | /// Represents a parsed `hutch://` deep link. | 6 | enum HutchWorkQueueScope: String, CaseIterable, Sendable { |
| 7 | enum DeepLink: Equatable { | 7 | case all |
| 8 | case unread | ||
| 9 | case assigned | ||
| 10 | } | ||
| 11 | |||
| 12 | enum HutchRoute: Equatable, Sendable { | ||
| 8 | case home | 13 | case home |
| 9 | case work | 14 | case workQueue(scope: HutchWorkQueueScope = .all) |
| 10 | /// hutch://git/<owner>/<repo> or hutch://hg/<owner>/<repo> | 15 | case recentActivity |
| 11 | case repository(service: SRHTService, owner: String, repo: String) | 16 | case repository(service: SRHTService, owner: String, repo: String) |
| 12 | /// hutch://todo/<owner>/<tracker>/<ticketId> | 17 | case tracker(owner: String, tracker: String) |
| 13 | case ticket(owner: String, tracker: String, ticketId: Int) | 18 | case ticket(owner: String, tracker: String, ticketId: Int) |
| 14 | /// hutch://builds/<jobId> or hutch://builds/<owner>/job/<jobId> | ||
| 15 | case build(jobId: Int) | 19 | case build(jobId: Int) |
| 16 | /// hutch://lists/<owner>/<list> | ||
| 17 | case mailingList(owner: String, list: String) | 20 | case mailingList(owner: String, list: String) |
| 18 | /// hutch://lookup/<owner> | ||
| 19 | case userProfile(owner: String) | 21 | case userProfile(owner: String) |
| 20 | /// hutch://builds (tab-level) | 22 | case builds |
| 21 | case buildsTab | 23 | case failedBuilds |
| 22 | /// hutch://repositories (tab-level) | 24 | case repositories |
| 23 | case repositoriesTab | 25 | case trackers |
| 24 | /// hutch://trackers (tab-level) | ||
| 25 | case trackersTab | ||
| 26 | /// hutch://status | ||
| 27 | case systemStatus | 26 | case systemStatus |
| 28 | /// hutch://lookup | ||
| 29 | case lookup | 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 | init?(url: URL) { | 31 | init?(url: URL) { |
| 34 | guard url.scheme == "hutch" else { return nil } | 32 | guard url.scheme == "hutch" else { return nil } |
| 35 | 33 | ||
| 36 | let components = url.deepLinkPathComponents | 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 | switch components.first { | 40 | switch components.first { |
| 40 | case "home", nil: | 41 | case "home", nil: |
| 41 | self = .home | 42 | self = .home |
| 42 | 43 | ||
| 44 | case "recent", "recent-activity", "activity": | ||
| 45 | self = .recentActivity | ||
| 46 | |||
| 43 | case "work", "inbox": | 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 | case let .some(serviceName) where ["git", "hg", "todo", "builds", "lists"].contains(serviceName) | 51 | case let .some(serviceName) where ["git", "hg", "todo", "builds", "lists"].contains(serviceName) |
| 47 | && components.count == 2 | 52 | && components.count == 2 |
| @@ -62,6 +67,9 @@ enum DeepLink: Equatable { | |||
| 62 | guard let ticketId = Int(components[3]) else { return nil } | 67 | guard let ticketId = Int(components[3]) else { return nil } |
| 63 | self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId) | 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 | case "builds" where components.count >= 2: | 73 | case "builds" where components.count >= 2: |
| 66 | let rawJobId: String | 74 | let rawJobId: String |
| 67 | if components.count >= 4, components[2] == "job" { | 75 | if components.count >= 4, components[2] == "job" { |
| @@ -73,16 +81,19 @@ enum DeepLink: Equatable { | |||
| 73 | self = .build(jobId: jobId) | 81 | self = .build(jobId: jobId) |
| 74 | 82 | ||
| 75 | case "builds": | 83 | case "builds": |
| 76 | self = .buildsTab | 84 | self = queryValue("filter") == "failed" ? .failedBuilds : .builds |
| 77 | 85 | ||
| 78 | case "lists" where components.count >= 3: | 86 | case "lists" where components.count >= 3: |
| 79 | self = .mailingList(owner: components[1], list: components[2]) | 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 | case "repositories": | 92 | case "repositories": |
| 82 | self = .repositoriesTab | 93 | self = .repositories |
| 83 | 94 | ||
| 84 | case "trackers": | 95 | case "trackers": |
| 85 | self = .trackersTab | 96 | self = .trackers |
| 86 | 97 | ||
| 87 | case "status": | 98 | case "status": |
| 88 | self = .systemStatus | 99 | self = .systemStatus |
| @@ -91,12 +102,163 @@ enum DeepLink: Equatable { | |||
| 91 | self = .userProfile(owner: components[1]) | 102 | self = .userProfile(owner: components[1]) |
| 92 | 103 | ||
| 93 | case "lookup": | 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 | default: | 111 | default: |
| 97 | return nil | 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 | private extension URL { | 264 | private extension URL { |
Hutch/App/HutchApp.swift +4 −14
| @@ -28,20 +28,10 @@ struct HutchApp: App { | |||
| 28 | deepLinkLogger.error("Rejected URL: \(url.absoluteString, privacy: .public)") | 28 | deepLinkLogger.error("Rejected URL: \(url.absoluteString, privacy: .public)") |
| 29 | } | 29 | } |
| 30 | } | 30 | } |
| 31 | .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in | 31 | .onChange(of: HutchIntentNavigator.shared.pendingRoute) { _, route in |
| 32 | guard let destination else { return } | 32 | guard let route else { return } |
| 33 | HutchIntentNavigator.shared.pendingDestination = nil | 33 | HutchIntentNavigator.shared.pendingRoute = nil |
| 34 | let link: DeepLink | 34 | appState.open(route) |
| 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 | ||
| 45 | } | 35 | } |
| 46 | } | 36 | } |
| 47 | } | 37 | } |
Hutch/App/HutchIntents.swift +335 −42
| @@ -1,46 +1,271 @@ | |||
| 1 | import AppIntents | 1 | import AppIntents |
| 2 | import Foundation | 2 | import Foundation |
| 3 | 3 | ||
| 4 | // MARK: - Open Hutch Intent | 4 | // MARK: - Navigation Intents |
| 5 | 5 | ||
| 6 | enum HutchDestination: String, AppEnum { | 6 | struct OpenWorkQueueIntent: AppIntent { |
| 7 | case home | 7 | static var title: LocalizedStringResource = "Open Work Queue" |
| 8 | case work | 8 | static var description = IntentDescription("Opens Hutch to your Work Queue.") |
| 9 | case builds | 9 | static var openAppWhenRun = true |
| 10 | case repositories | 10 | |
| 11 | case trackers | 11 | var route: HutchRoute { .workQueue(scope: .all) } |
| 12 | case systemStatus | 12 | |
| 13 | case lookup | 13 | @MainActor |
| 14 | 14 | func perform() async throws -> some IntentResult { | |
| 15 | static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Hutch Section") | 15 | HutchIntentNavigator.shared.open(route) |
| 16 | 16 | return .result() | |
| 17 | static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [ | 17 | } |
| 18 | .home: "Home", | 18 | } |
| 19 | .work: "Work", | 19 | |
| 20 | .builds: "Builds", | 20 | struct OpenRecentActivityIntent: AppIntent { |
| 21 | .repositories: "Repositories", | 21 | static var title: LocalizedStringResource = "Open Recent Activity" |
| 22 | .trackers: "Trackers", | 22 | static var description = IntentDescription("Opens Hutch to recent activity.") |
| 23 | .systemStatus: "System Status", | 23 | static var openAppWhenRun = true |
| 24 | .lookup: "Look Up" | 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 { | 93 | struct OpenFailedBuildsIntent: AppIntent { |
| 29 | static var title: LocalizedStringResource = "Open Hutch" | 94 | static var title: LocalizedStringResource = "Open Failed Builds" |
| 30 | static var description = IntentDescription("Opens Hutch to a specific section.") | 95 | static var description = IntentDescription("Opens Hutch to failed builds.") |
| 31 | static var openAppWhenRun = true | 96 | static var openAppWhenRun = true |
| 32 | 97 | ||
| 33 | @Parameter(title: "Section", default: .home) | 98 | @Parameter(title: "Scope", default: .all) |
| 34 | var destination: HutchDestination | 99 | var scope: HutchShortcutScope |
| 100 | |||
| 101 | var route: HutchRoute { .failedBuilds } | ||
| 35 | 102 | ||
| 36 | @MainActor | 103 | @MainActor |
| 37 | func perform() async throws -> some IntentResult { | 104 | func perform() async throws -> some IntentResult { |
| 38 | HutchIntentNavigator.shared.pendingDestination = destination | 105 | HutchIntentNavigator.shared.open(route) |
| 39 | return .result() | 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 | struct CheckSystemStatusIntent: AppIntent { | 270 | struct CheckSystemStatusIntent: AppIntent { |
| 46 | static var title: LocalizedStringResource = "Check SourceHut Status" | 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 | struct CheckBuildsIntent: AppIntent { | 292 | struct CheckBuildsIntent: AppIntent { |
| 70 | static var title: LocalizedStringResource = "Check Hutch Builds" | 293 | static var title: LocalizedStringResource = "Check Hutch Builds" |
| 71 | static var description = IntentDescription("Returns a summary of your recent build status.") | 294 | static var description = IntentDescription("Returns a summary of your recent build status.") |
| @@ -107,25 +330,92 @@ struct CheckBuildsIntent: AppIntent { | |||
| 107 | struct HutchShortcuts: AppShortcutsProvider { | 330 | struct HutchShortcuts: AppShortcutsProvider { |
| 108 | static var appShortcuts: [AppShortcut] { | 331 | static var appShortcuts: [AppShortcut] { |
| 109 | AppShortcut( | 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 | phrases: [ | 364 | phrases: [ |
| 112 | "Open \(.applicationName)", | 365 | "Open \(\.$pinnedResource) in \(.applicationName)", |
| 113 | "Open \(.applicationName) \(\.$destination)", | 366 | "Show my pinned \(\.$pinnedResource) in \(.applicationName)" |
| 114 | "Show my \(.applicationName) \(\.$destination)", | ||
| 115 | "Go to \(\.$destination) in \(.applicationName)" | ||
| 116 | ], | 367 | ], |
| 117 | shortTitle: "Open Hutch", | 368 | shortTitle: "Pinned Resource", |
| 118 | systemImageName: "house" | 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 | AppShortcut( | 412 | AppShortcut( |
| 122 | intent: CheckSystemStatusIntent(), | 413 | intent: CheckSystemStatusIntent(), |
| 123 | phrases: [ | 414 | phrases: [ |
| 124 | "Check \(.applicationName) status", | 415 | "Check \(.applicationName) status", |
| 125 | "Is SourceHut up in \(.applicationName)", | 416 | "Is SourceHut up in \(.applicationName)" |
| 126 | "SourceHut status in \(.applicationName)" | ||
| 127 | ], | 417 | ], |
| 128 | shortTitle: "Check SourceHut Status", | 418 | shortTitle: "Check Status", |
| 129 | systemImageName: "server.rack" | 419 | systemImageName: "server.rack" |
| 130 | ) | 420 | ) |
| 131 | 421 | ||
| @@ -133,7 +423,6 @@ struct HutchShortcuts: AppShortcutsProvider { | |||
| 133 | intent: CheckBuildsIntent(), | 423 | intent: CheckBuildsIntent(), |
| 134 | phrases: [ | 424 | phrases: [ |
| 135 | "Check my \(.applicationName) builds", | 425 | "Check my \(.applicationName) builds", |
| 136 | "How are my builds in \(.applicationName)", | ||
| 137 | "Build status in \(.applicationName)" | 426 | "Build status in \(.applicationName)" |
| 138 | ], | 427 | ], |
| 139 | shortTitle: "Check Builds", | 428 | shortTitle: "Check Builds", |
| @@ -148,9 +437,13 @@ struct HutchShortcuts: AppShortcutsProvider { | |||
| 148 | @Observable | 437 | @Observable |
| 149 | final class HutchIntentNavigator { | 438 | final class HutchIntentNavigator { |
| 150 | static let shared = HutchIntentNavigator() | 439 | static let shared = HutchIntentNavigator() |
| 151 | var pendingDestination: HutchDestination? | 440 | var pendingRoute: HutchRoute? |
| 152 | 441 | ||
| 153 | private init() { | 442 | private init() { |
| 154 | /* Singleton; external code uses `shared`. */ | 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 | HomeView() | 75 | HomeView() |
| 76 | .navigationDestination(for: HomeRoute.self) { route in | 76 | .navigationDestination(for: HomeRoute.self) { route in |
| 77 | switch route { | 77 | switch route { |
| 78 | case .work: | 78 | case .work(let scope): |
| 79 | WorkView() | 79 | WorkView(initialScope: scope) |
| 80 | } | 80 | } |
| 81 | } | 81 | } |
| 82 | } | 82 | } |
| @@ -202,9 +202,16 @@ struct RootView: View { | |||
| 202 | homePath = NavigationPath() | 202 | homePath = NavigationPath() |
| 203 | appState.selectedTab = .home | 203 | appState.selectedTab = .home |
| 204 | 204 | ||
| 205 | case .recentActivity: | ||
| 206 | homePath = NavigationPath() | ||
| 207 | appState.selectedTab = .home | ||
| 208 | |||
| 205 | case .repository(let service, let owner, let repo): | 209 | case .repository(let service, let owner, let repo): |
| 206 | resolveRepositoryLink(service: service, owner: owner, repo: repo) | 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 | case .build(let jobId): | 215 | case .build(let jobId): |
| 209 | buildsPath = NavigationPath() | 216 | buildsPath = NavigationPath() |
| 210 | appState.selectedTab = .builds | 217 | appState.selectedTab = .builds |
| @@ -223,11 +230,39 @@ struct RootView: View { | |||
| 223 | resolveUserProfileLink(owner: owner) | 230 | resolveUserProfileLink(owner: owner) |
| 224 | 231 | ||
| 225 | case .work: | 232 | case .work: |
| 226 | homePath = NavigationPath() | 233 | navigateToWork(scope: .all) |
| 227 | appState.selectedTab = .home | 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 | Task { | 255 | Task { |
| 229 | await settleNavigationTransition() | 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 | case .buildsTab: | 268 | case .buildsTab: |
| @@ -244,14 +279,15 @@ struct RootView: View { | |||
| 244 | 279 | ||
| 245 | case .systemStatus: | 280 | case .systemStatus: |
| 246 | appState.navigateToSystemStatus() | 281 | appState.navigateToSystemStatus() |
| 282 | } | ||
| 283 | } | ||
| 247 | 284 | ||
| 248 | case .lookup: | 285 | private func navigateToWork(scope: HutchWorkQueueScope) { |
| 249 | morePath = NavigationPath() | 286 | homePath = NavigationPath() |
| 250 | appState.selectedTab = .more | 287 | appState.selectedTab = .home |
| 251 | Task { | 288 | Task { |
| 252 | await settleNavigationTransition() | 289 | await settleNavigationTransition() |
| 253 | morePath.append(MoreRoute.lookup) | 290 | homePath.append(HomeRoute.work(scope: scope)) |
| 254 | } | ||
| 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 | private func resolveMailingListLink(owner: String, list: String) { | 365 | private func resolveMailingListLink(owner: String, list: String) { |
| 314 | isResolvingDeepLink = true | 366 | isResolvingDeepLink = true |
| 315 | Task { | 367 | Task { |
| @@ -375,7 +427,7 @@ enum MoreDestination: Hashable { | |||
| 375 | } | 427 | } |
| 376 | 428 | ||
| 377 | enum MoreRoute: Hashable { | 429 | enum MoreRoute: Hashable { |
| 378 | case lookup | 430 | case lookup(query: String?) |
| 379 | case projects | 431 | case projects |
| 380 | case lists | 432 | case lists |
| 381 | case pastes | 433 | case pastes |
| @@ -384,6 +436,7 @@ enum MoreRoute: Hashable { | |||
| 384 | case settings | 436 | case settings |
| 385 | case about | 437 | case about |
| 386 | case userProfile(String) | 438 | case userProfile(String) |
| 439 | case projectDashboard(id: String, title: String?) | ||
| 387 | case mailingList(InboxMailingListReference) | 440 | case mailingList(InboxMailingListReference) |
| 388 | case thread(InboxThreadSummary) | 441 | case thread(InboxThreadSummary) |
| 389 | case manPageBrowser | 442 | case manPageBrowser |
| @@ -397,8 +450,8 @@ private struct MoreNavigationRoot: View { | |||
| 397 | MoreView() | 450 | MoreView() |
| 398 | .navigationDestination(for: MoreRoute.self) { route in | 451 | .navigationDestination(for: MoreRoute.self) { route in |
| 399 | switch route { | 452 | switch route { |
| 400 | case .lookup: | 453 | case .lookup(let query): |
| 401 | LookupView() | 454 | LookupView(initialQuery: query ?? "") |
| 402 | case .projects: | 455 | case .projects: |
| 403 | ProjectsListView() | 456 | ProjectsListView() |
| 404 | case .lists: | 457 | case .lists: |
| @@ -415,6 +468,8 @@ private struct MoreNavigationRoot: View { | |||
| 415 | AboutView() | 468 | AboutView() |
| 416 | case .userProfile(let owner): | 469 | case .userProfile(let owner): |
| 417 | UserProfileDeepLinkView(owner: owner) | 470 | UserProfileDeepLinkView(owner: owner) |
| 471 | case .projectDashboard(let id, let title): | ||
| 472 | ProjectDashboardDeepLinkView(projectID: id, title: title) | ||
| 418 | case .mailingList(let mailingList): | 473 | case .mailingList(let mailingList): |
| 419 | MailingListDetailView(mailingList: mailingList) | 474 | MailingListDetailView(mailingList: mailingList) |
| 420 | case .thread(let thread): | 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 | // MARK: - Ticket Deep Link Navigation Target | 581 | // MARK: - Ticket Deep Link Navigation Target |
| 488 | 582 | ||
| 489 | /// Hashable wrapper to push a ticket detail view from a deep link. | 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 | let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults) | 124 | let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults) |
| 125 | vm.repoFilter = savedRepoFilter | 125 | vm.repoFilter = savedRepoFilter |
| 126 | vm.lookbackDays = lookbackDays | 126 | vm.lookbackDays = lookbackDays |
| 127 | if let pendingFilter = appState.pendingBuildListFilter { | ||
| 128 | vm.filter = pendingFilter | ||
| 129 | appState.pendingBuildListFilter = nil | ||
| 130 | } | ||
| 127 | viewModel = vm | 131 | viewModel = vm |
| 128 | await vm.loadJobs() | 132 | await vm.loadJobs() |
| 129 | } | 133 | } |
| @@ -134,6 +138,11 @@ struct BuildListView: View { | |||
| 134 | .onChange(of: lookbackDays) { _, newValue in | 138 | .onChange(of: lookbackDays) { _, newValue in |
| 135 | viewModel?.lookbackDays = newValue | 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 | .onDisappear { | 146 | .onDisappear { |
| 138 | viewModel?.stopAutoRefresh() | 147 | viewModel?.stopAutoRefresh() |
| 139 | } | 148 | } |
Hutch/Views/Builds/BuildListViewModel.swift +8
| @@ -21,6 +21,7 @@ private struct SubmittedJob: Decodable, Sendable { | |||
| 21 | 21 | ||
| 22 | enum BuildListFilter: String, CaseIterable, Sendable { | 22 | enum BuildListFilter: String, CaseIterable, Sendable { |
| 23 | case attention = "Attention" | 23 | case attention = "Attention" |
| 24 | case failed = "Failed" | ||
| 24 | case active = "Active" | 25 | case active = "Active" |
| 25 | case all = "All" | 26 | case all = "All" |
| 26 | } | 27 | } |
| @@ -413,6 +414,13 @@ final class BuildListViewModel { | |||
| 413 | case .success, .cancelled: | 414 | case .success, .cancelled: |
| 414 | return false | 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 | case .active: | 424 | case .active: |
| 417 | switch job.status { | 425 | switch job.status { |
| 418 | case .running, .queued, .pending: | 426 | case .running, .queued, .pending: |
Hutch/Views/Home/HomeView.swift +2 −2
| @@ -106,7 +106,7 @@ struct HomeView: View { | |||
| 106 | 106 | ||
| 107 | private func workSection(_ viewModel: HomeViewModel) -> some View { | 107 | private func workSection(_ viewModel: HomeViewModel) -> some View { |
| 108 | Section("Work") { | 108 | Section("Work") { |
| 109 | NavigationLink(value: HomeRoute.work) { | 109 | NavigationLink(value: HomeRoute.work(scope: .all)) { |
| 110 | HomeSummaryRow( | 110 | HomeSummaryRow( |
| 111 | title: workTitle(viewModel), | 111 | title: workTitle(viewModel), |
| 112 | summary: workSummary(viewModel), | 112 | summary: workSummary(viewModel), |
| @@ -424,7 +424,7 @@ struct HomeView: View { | |||
| 424 | } | 424 | } |
| 425 | 425 | ||
| 426 | enum HomeRoute: Hashable { | 426 | enum HomeRoute: Hashable { |
| 427 | case work | 427 | case work(scope: HutchWorkQueueScope) |
| 428 | } | 428 | } |
| 429 | 429 | ||
| 430 | private enum HomeSummaryEmphasis { | 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 | self.client = client | 101 | self.client = client |
| 97 | self.appState = appState | 102 | self.appState = appState |
| 98 | self.defaults = defaults | 103 | self.defaults = defaults |
| 99 | self.history = LookupHistoryStore.load(defaults: defaults) | 104 | self.history = LookupHistoryStore.load(defaults: defaults) |
| 105 | self.inputText = initialQuery.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 100 | } | 106 | } |
| 101 | 107 | ||
| 102 | func lookup() async { | 108 | func lookup() async { |
| @@ -329,6 +335,11 @@ final class LookupViewModel { | |||
| 329 | struct LookupView: View { | 335 | struct LookupView: View { |
| 330 | @Environment(AppState.self) private var appState | 336 | @Environment(AppState.self) private var appState |
| 331 | @State private var viewModel: LookupViewModel? | 337 | @State private var viewModel: LookupViewModel? |
| 338 | private let initialQuery: String | ||
| 339 | |||
| 340 | init(initialQuery: String = "") { | ||
| 341 | self.initialQuery = initialQuery | ||
| 342 | } | ||
| 332 | 343 | ||
| 333 | var body: some View { | 344 | var body: some View { |
| 334 | Group { | 345 | Group { |
| @@ -341,7 +352,12 @@ struct LookupView: View { | |||
| 341 | .navigationTitle("Look Up") | 352 | .navigationTitle("Look Up") |
| 342 | .task { | 353 | .task { |
| 343 | if viewModel == nil { | 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 | .navigationDestination(for: MoreRoute.self) { route in | 444 | .navigationDestination(for: MoreRoute.self) { route in |
| 429 | switch route { | 445 | switch route { |
| 430 | case .lookup: | 446 | case .lookup(let query): |
| 431 | LookupView() | 447 | LookupView(initialQuery: query ?? "") |
| 432 | case .projects: | 448 | case .projects: |
| 433 | ProjectsListView() | 449 | ProjectsListView() |
| 434 | case .lists: | 450 | case .lists: |
| @@ -445,6 +461,8 @@ struct LookupView: View { | |||
| 445 | AboutView() | 461 | AboutView() |
| 446 | case .userProfile(let owner): | 462 | case .userProfile(let owner): |
| 447 | UserProfileDeepLinkView(owner: owner) | 463 | UserProfileDeepLinkView(owner: owner) |
| 464 | case .projectDashboard(let id, let title): | ||
| 465 | ProjectDashboardDeepLinkView(projectID: id, title: title) | ||
| 448 | case .mailingList(let mailingList): | 466 | case .mailingList(let mailingList): |
| 449 | MailingListDetailView(mailingList: mailingList) | 467 | MailingListDetailView(mailingList: mailingList) |
| 450 | case .thread(let thread): | 468 | case .thread(let thread): |
Hutch/Views/More/MoreView.swift +1 −1
| @@ -13,7 +13,7 @@ struct MoreView: View { | |||
| 13 | var body: some View { | 13 | var body: some View { |
| 14 | List { | 14 | List { |
| 15 | Section("Search") { | 15 | Section("Search") { |
| 16 | NavigationLink(value: MoreRoute.lookup) { | 16 | NavigationLink(value: MoreRoute.lookup(query: nil)) { |
| 17 | Label("Look Up", systemImage: "magnifyingglass") | 17 | Label("Look Up", systemImage: "magnifyingglass") |
| 18 | } | 18 | } |
| 19 | .themedRow() | 19 | .themedRow() |
Hutch/Views/Work/WorkView.swift +18 −10
| @@ -1,20 +1,28 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | struct WorkView: View { | 3 | extension HutchWorkQueueScope: Identifiable { |
| 4 | private enum Scope: String, CaseIterable, Identifiable { | 4 | var id: String { rawValue } |
| 5 | case all = "All" | 5 | |
| 6 | case unread = "Unread" | 6 | var displayName: String { |
| 7 | case assigned = "Assigned" | 7 | switch self { |
| 8 | 8 | case .all: "All" | |
| 9 | var id: String { rawValue } | 9 | case .unread: "Unread" |
| 10 | case .assigned: "Assigned" | ||
| 11 | } | ||
| 10 | } | 12 | } |
| 13 | } | ||
| 11 | 14 | ||
| 15 | struct WorkView: View { | ||
| 12 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true | 16 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 13 | @Environment(AppState.self) private var appState | 17 | @Environment(AppState.self) private var appState |
| 14 | @Environment(\.isAMOLEDTheme) private var isAMOLED | 18 | @Environment(\.isAMOLEDTheme) private var isAMOLED |
| 15 | @Environment(\.scenePhase) private var scenePhase | 19 | @Environment(\.scenePhase) private var scenePhase |
| 16 | @State private var viewModel: HomeViewModel? | 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 | var body: some View { | 27 | var body: some View { |
| 20 | Group { | 28 | Group { |
| @@ -106,8 +114,8 @@ struct WorkView: View { | |||
| 106 | private var scopeSection: some View { | 114 | private var scopeSection: some View { |
| 107 | Section { | 115 | Section { |
| 108 | Picker("Scope", selection: $scope) { | 116 | Picker("Scope", selection: $scope) { |
| 109 | ForEach(Scope.allCases) { scope in | 117 | ForEach(HutchWorkQueueScope.allCases) { scope in |
| 110 | Text(scope.rawValue).tag(scope) | 118 | Text(scope.displayName).tag(scope) |
| 111 | } | 119 | } |
| 112 | } | 120 | } |
| 113 | .pickerStyle(.segmented) | 121 | .pickerStyle(.segmented) |
HutchTests/BuildListViewModelTests.swift +6
| @@ -59,9 +59,15 @@ struct BuildListViewModelTests { | |||
| 59 | filter: .active, | 59 | filter: .active, |
| 60 | lookbackDays: BuildListViewModel.defaultLookbackDays | 60 | lookbackDays: BuildListViewModel.defaultLookbackDays |
| 61 | ) | 61 | ) |
| 62 | let failed = BuildListViewModel.filterJobs( | ||
| 63 | jobs, | ||
| 64 | filter: .failed, | ||
| 65 | lookbackDays: BuildListViewModel.defaultLookbackDays | ||
| 66 | ) | ||
| 62 | 67 | ||
| 63 | #expect(attention.map(\.id) == [2, 3]) | 68 | #expect(attention.map(\.id) == [2, 3]) |
| 64 | #expect(active.map(\.id) == [3]) | 69 | #expect(active.map(\.id) == [3]) |
| 70 | #expect(failed.map(\.id) == [2]) | ||
| 65 | } | 71 | } |
| 66 | 72 | ||
| 67 | @Test | 73 | @Test |
HutchTests/DeepLinkTests.swift +8
| @@ -88,6 +88,14 @@ struct DeepLinkTests { | |||
| 88 | #expect(link == .lookup) | 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 | @Test | 99 | @Test |
| 92 | func parsesUserProfileLink() { | 100 | func parsesUserProfileLink() { |
| 93 | let link = DeepLink(url: HutchDeepLinkURL.userProfile) | 101 | let link = DeepLink(url: HutchDeepLinkURL.userProfile) |
HutchTests/HutchIntentsTests.swift +9
| @@ -5,6 +5,15 @@ import Testing | |||
| 5 | @MainActor | 5 | @MainActor |
| 6 | struct HutchIntentsTests { | 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 | @Test | 17 | @Test |
| 9 | func checkSystemStatusReturnsOperationalWhenNoDisruption() async throws { | 18 | func checkSystemStatusReturnsOperationalWhenNoDisruption() async throws { |
| 10 | let defaultsName = "HutchIntentsTests-status-\(UUID().uuidString)" | 19 | let defaultsName = "HutchIntentsTests-status-\(UUID().uuidString)" |
Shared/NeedsAttentionSnapshot.swift +13 −8
| @@ -58,11 +58,11 @@ enum NeedsAttentionSnapshotStore { | |||
| 58 | private static let snapshotKey = "needsAttention.snapshot" | 58 | private static let snapshotKey = "needsAttention.snapshot" |
| 59 | 59 | ||
| 60 | static func load( | 60 | static func load( |
| 61 | accountID: String? = ActiveAccountContextStore.load(), | 61 | accountID: String? = nil, |
| 62 | defaults: UserDefaults? = sharedDefaults() | 62 | defaults: UserDefaults? = sharedDefaults() |
| 63 | ) -> NeedsAttentionSnapshot? { | 63 | ) -> NeedsAttentionSnapshot? { |
| 64 | guard let defaults, | 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 | return nil | 66 | return nil |
| 67 | } | 67 | } |
| 68 | 68 | ||
| @@ -71,7 +71,7 @@ enum NeedsAttentionSnapshotStore { | |||
| 71 | 71 | ||
| 72 | static func save( | 72 | static func save( |
| 73 | _ snapshot: NeedsAttentionSnapshot, | 73 | _ snapshot: NeedsAttentionSnapshot, |
| 74 | accountID: String? = ActiveAccountContextStore.load(), | 74 | accountID: String? = nil, |
| 75 | defaults: UserDefaults? = sharedDefaults() | 75 | defaults: UserDefaults? = sharedDefaults() |
| 76 | ) { | 76 | ) { |
| 77 | guard let defaults, | 77 | guard let defaults, |
| @@ -79,7 +79,7 @@ enum NeedsAttentionSnapshotStore { | |||
| 79 | return | 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 | reloadWidgetTimelines() | 83 | reloadWidgetTimelines() |
| 84 | } | 84 | } |
| 85 | 85 | ||
| @@ -87,7 +87,7 @@ enum NeedsAttentionSnapshotStore { | |||
| 87 | unreadInboxThreads: Int? = nil, | 87 | unreadInboxThreads: Int? = nil, |
| 88 | assignedOpenTickets: Int? = nil, | 88 | assignedOpenTickets: Int? = nil, |
| 89 | failedBuilds: Int? = nil, | 89 | failedBuilds: Int? = nil, |
| 90 | accountID: String? = ActiveAccountContextStore.load(), | 90 | accountID: String? = nil, |
| 91 | defaults: UserDefaults? = sharedDefaults() | 91 | defaults: UserDefaults? = sharedDefaults() |
| 92 | ) { | 92 | ) { |
| 93 | let existing = load(accountID: accountID, defaults: defaults) | 93 | let existing = load(accountID: accountID, defaults: defaults) |
| @@ -102,7 +102,7 @@ enum NeedsAttentionSnapshotStore { | |||
| 102 | 102 | ||
| 103 | static func adjustUnreadInboxThreads( | 103 | static func adjustUnreadInboxThreads( |
| 104 | by delta: Int, | 104 | by delta: Int, |
| 105 | accountID: String? = ActiveAccountContextStore.load(), | 105 | accountID: String? = nil, |
| 106 | defaults: UserDefaults? = sharedDefaults() | 106 | defaults: UserDefaults? = sharedDefaults() |
| 107 | ) { | 107 | ) { |
| 108 | guard let existing = load(accountID: accountID, defaults: defaults), | 108 | guard let existing = load(accountID: accountID, defaults: defaults), |
| @@ -123,10 +123,11 @@ enum NeedsAttentionSnapshotStore { | |||
| 123 | } | 123 | } |
| 124 | 124 | ||
| 125 | static func clear( | 125 | static func clear( |
| 126 | accountID: String? = ActiveAccountContextStore.load(), | 126 | accountID: String? = nil, |
| 127 | defaults: UserDefaults? = sharedDefaults() | 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 | reloadWidgetTimelines() | 131 | reloadWidgetTimelines() |
| 131 | } | 132 | } |
| 132 | 133 | ||
| @@ -139,6 +140,10 @@ enum NeedsAttentionSnapshotStore { | |||
| 139 | return "\(snapshotKey).\(accountID)" | 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 | private static func reloadWidgetTimelines() { | 147 | private static func reloadWidgetTimelines() { |
| 143 | #if canImport(WidgetKit) | 148 | #if canImport(WidgetKit) |
| 144 | WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind) | 149 | WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind) |
Shared/SystemStatusWidgetSnapshot.swift +11 −6
| @@ -34,11 +34,11 @@ enum SystemStatusWidgetSnapshotStore { | |||
| 34 | private static let snapshotKey = "systemStatus.widgetSnapshot" | 34 | private static let snapshotKey = "systemStatus.widgetSnapshot" |
| 35 | 35 | ||
| 36 | static func load( | 36 | static func load( |
| 37 | accountID: String? = ActiveAccountContextStore.load(), | 37 | accountID: String? = nil, |
| 38 | defaults: UserDefaults? = sharedDefaults() | 38 | defaults: UserDefaults? = sharedDefaults() |
| 39 | ) -> SystemStatusWidgetSnapshot? { | 39 | ) -> SystemStatusWidgetSnapshot? { |
| 40 | guard let defaults, | 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 | return nil | 42 | return nil |
| 43 | } | 43 | } |
| 44 | return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data) | 44 | return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data) |
| @@ -46,22 +46,23 @@ enum SystemStatusWidgetSnapshotStore { | |||
| 46 | 46 | ||
| 47 | static func save( | 47 | static func save( |
| 48 | _ snapshot: SystemStatusWidgetSnapshot, | 48 | _ snapshot: SystemStatusWidgetSnapshot, |
| 49 | accountID: String? = ActiveAccountContextStore.load(), | 49 | accountID: String? = nil, |
| 50 | defaults: UserDefaults? = sharedDefaults() | 50 | defaults: UserDefaults? = sharedDefaults() |
| 51 | ) { | 51 | ) { |
| 52 | guard let defaults, | 52 | guard let defaults, |
| 53 | let data = try? JSONEncoder().encode(snapshot) else { | 53 | let data = try? JSONEncoder().encode(snapshot) else { |
| 54 | return | 54 | return |
| 55 | } | 55 | } |
| 56 | defaults.set(data, forKey: scopedKey(for: accountID)) | 56 | defaults.set(data, forKey: scopedKey(for: resolvedAccountID(accountID, defaults: defaults))) |
| 57 | reloadWidgetTimelines() | 57 | reloadWidgetTimelines() |
| 58 | } | 58 | } |
| 59 | 59 | ||
| 60 | static func clear( | 60 | static func clear( |
| 61 | accountID: String? = ActiveAccountContextStore.load(), | 61 | accountID: String? = nil, |
| 62 | defaults: UserDefaults? = sharedDefaults() | 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 | reloadWidgetTimelines() | 66 | reloadWidgetTimelines() |
| 66 | } | 67 | } |
| 67 | 68 | ||
| @@ -74,6 +75,10 @@ enum SystemStatusWidgetSnapshotStore { | |||
| 74 | return "\(snapshotKey).\(accountID)" | 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 | private static func reloadWidgetTimelines() { | 82 | private static func reloadWidgetTimelines() { |
| 78 | #if canImport(WidgetKit) | 83 | #if canImport(WidgetKit) |
| 79 | WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind) | 84 | WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind) |