Commit 7e9802570d
Unsigned
Layout: unified · split
Hutch/App/DeepLink.swift +13 −8
| @@ -25,7 +25,7 @@ enum HutchRoute: Equatable, Sendable { | ||
| 25 | 25 | case trackers |
| 26 | 26 | case systemStatus |
| 27 | 27 | case lookup |
| 28 | case search(query: String) | |
| 28 | case search(query: String, type: LookupType?) | |
| 29 | 29 | case projectDashboard(id: String, title: String?) |
| 30 | 30 | |
| 31 | 31 | init?(url: URL) { |
| @@ -103,7 +103,8 @@ enum HutchRoute: Equatable, Sendable { | ||
| 103 | 103 | |
| 104 | 104 | case "lookup": |
| 105 | 105 | if let query = queryValue("q"), !query.isEmpty { |
| 106 | self = .search(query: query) | |
| 106 | let type = queryValue("type").flatMap(LookupType.init(rawValue:)) | |
| 107 | self = .search(query: query, type: type) | |
| 107 | 108 | } else { |
| 108 | 109 | self = .lookup |
| 109 | 110 | } |
| @@ -148,8 +149,12 @@ enum HutchRoute: Equatable, Sendable { | ||
| 148 | 149 | return Self.makeURL(host: "status") |
| 149 | 150 | case .lookup: |
| 150 | 151 | return Self.makeURL(host: "lookup") |
| 151 | case .search(let query): | |
| 152 | return Self.makeURL(host: "lookup", queryItems: [URLQueryItem(name: "q", value: query)]) | |
| 152 | case .search(let query, let type): | |
| 153 | var items = [URLQueryItem(name: "q", value: query)] | |
| 154 | if let type { | |
| 155 | items.append(URLQueryItem(name: "type", value: type.rawValue)) | |
| 156 | } | |
| 157 | return Self.makeURL(host: "lookup", queryItems: items) | |
| 153 | 158 | case .projectDashboard(let id, let title): |
| 154 | 159 | return Self.makeURL( |
| 155 | 160 | host: "projects", |
| @@ -205,8 +210,8 @@ enum DeepLink: Equatable { | ||
| 205 | 210 | case systemStatus |
| 206 | 211 | /// hutch://lookup |
| 207 | 212 | case lookup |
| 208 | /// hutch://lookup?q=<query> | |
| 209 | case search(query: String) | |
| 213 | /// hutch://lookup?q=<query>&type=<type> | |
| 214 | case search(query: String, type: LookupType?) | |
| 210 | 215 | /// hutch://builds?filter=failed |
| 211 | 216 | case failedBuilds |
| 212 | 217 | /// hutch://projects/<rid> |
| @@ -253,8 +258,8 @@ enum DeepLink: Equatable { | ||
| 253 | 258 | self = .systemStatus |
| 254 | 259 | case .lookup: |
| 255 | 260 | self = .lookup |
| 256 | case .search(let query): | |
| 257 | self = .search(query: query) | |
| 261 | case .search(let query, let type): | |
| 262 | self = .search(query: query, type: type) | |
| 258 | 263 | case .projectDashboard(let id, let title): |
| 259 | 264 | self = .projectDashboard(id: id, title: title) |
| 260 | 265 | } |
Hutch/App/HutchIntents.swift +104 −43
| @@ -132,11 +132,14 @@ struct SearchHutchIntent: AppIntent { | ||
| 132 | 132 | @Parameter(title: "Query") |
| 133 | 133 | var query: String |
| 134 | 134 | |
| 135 | @Parameter(title: "Search Type", default: .user) | |
| 136 | var searchType: LookupType | |
| 137 | ||
| 135 | 138 | var route: HutchRoute { |
| 136 | 139 | let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines) |
| 137 | // Routes to Lookup for now; repoint at a global content search when Hutch | |
| 138 | // gains one — tracked in ROADMAP.md § "App Intent gaps". | |
| 139 | return normalized.isEmpty ? .lookup : .search(query: normalized) | |
| 140 | // Routes to Lookup, pre-selecting the search type, until Hutch gains a | |
| 141 | // global content search. | |
| 142 | return normalized.isEmpty ? .lookup : .search(query: normalized, type: searchType) | |
| 140 | 143 | } |
| 141 | 144 | |
| 142 | 145 | @MainActor |
| @@ -147,7 +150,7 @@ struct SearchHutchIntent: AppIntent { | ||
| 147 | 150 | } |
| 148 | 151 | |
| 149 | 152 | // An OpenSavedSearchIntent belongs here once Hutch has global saved-search |
| 150 | // persistence — tracked in ROADMAP.md § "App Intent gaps". | |
| 153 | // persistence. | |
| 151 | 154 | |
| 152 | 155 | // MARK: - App Entities |
| 153 | 156 | |
| @@ -201,7 +204,7 @@ struct ProjectEntityQuery: EntityQuery { | ||
| 201 | 204 | } |
| 202 | 205 | } |
| 203 | 206 | |
| 204 | private enum HutchIntentEntityStore { | |
| 207 | enum HutchIntentEntityStore { | |
| 205 | 208 | static func pinnedResources() -> [PinnedResourceEntity] { |
| 206 | 209 | pins().compactMap { makePinnedResource(from: $0) } |
| 207 | 210 | } |
| @@ -213,22 +216,27 @@ private enum HutchIntentEntityStore { | ||
| 213 | 216 | } |
| 214 | 217 | } |
| 215 | 218 | |
| 216 | private static func pins() -> [HomePinRecord] { | |
| 219 | /// The active account's key, or `nil` when no account is signed in. | |
| 220 | static func currentUserKey() -> String? { | |
| 217 | 221 | guard let userKey = ContributionWidgetContextStore.loadActor(), |
| 218 | 222 | !userKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| 219 | 223 | else { |
| 220 | return [] | |
| 224 | return nil | |
| 221 | 225 | } |
| 222 | ||
| 223 | return HomePinStore.loadPins(for: userKey, defaults: activeAccountDefaults) | |
| 226 | return userKey | |
| 224 | 227 | } |
| 225 | 228 | |
| 226 | private static var activeAccountDefaults: UserDefaults { | |
| 229 | static var accountDefaults: UserDefaults { | |
| 227 | 230 | let activeID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? "" |
| 228 | 231 | guard !activeID.isEmpty else { return .standard } |
| 229 | 232 | return AccountDefaultsStore.userDefaults(for: activeID) |
| 230 | 233 | } |
| 231 | 234 | |
| 235 | private static func pins() -> [HomePinRecord] { | |
| 236 | guard let userKey = currentUserKey() else { return [] } | |
| 237 | return HomePinStore.loadPins(for: userKey, defaults: accountDefaults) | |
| 238 | } | |
| 239 | ||
| 232 | 240 | private static func makePinnedResource(from pin: HomePinRecord) -> PinnedResourceEntity? { |
| 233 | 241 | guard let route = route(for: pin) else { return nil } |
| 234 | 242 | return PinnedResourceEntity( |
| @@ -274,20 +282,22 @@ struct CheckSystemStatusIntent: AppIntent { | ||
| 274 | 282 | static var description = IntentDescription("Returns the current SourceHut system status.") |
| 275 | 283 | |
| 276 | 284 | @MainActor |
| 277 | func perform() async throws -> some IntentResult & ReturnsValue<String> { | |
| 278 | guard let snapshot = SystemStatusWidgetSnapshotStore.load() else { | |
| 279 | return .result(value: "System status is unavailable. Open Hutch to refresh.") | |
| 280 | } | |
| 281 | ||
| 282 | if snapshot.hasDisruption { | |
| 283 | let disrupted = snapshot.services | |
| 284 | .filter { $0.requiresAttention } | |
| 285 | .map { "\($0.name): \($0.status)" } | |
| 286 | .joined(separator: ", ") | |
| 287 | return .result(value: "SourceHut disruption detected: \(disrupted)") | |
| 285 | func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog { | |
| 286 | let message: String | |
| 287 | if let snapshot = SystemStatusWidgetSnapshotStore.load() { | |
| 288 | if snapshot.hasDisruption { | |
| 289 | let disrupted = snapshot.services | |
| 290 | .filter { $0.requiresAttention } | |
| 291 | .map { "\($0.name): \($0.status)" } | |
| 292 | .joined(separator: ", ") | |
| 293 | message = "SourceHut disruption detected: \(disrupted)" | |
| 294 | } else { | |
| 295 | message = "All SourceHut services operational." | |
| 296 | } | |
| 297 | } else { | |
| 298 | message = "System status is unavailable. Open Hutch to refresh." | |
| 288 | 299 | } |
| 289 | ||
| 290 | return .result(value: "All SourceHut services operational.") | |
| 300 | return .result(value: message, dialog: IntentDialog(stringLiteral: message)) | |
| 291 | 301 | } |
| 292 | 302 | } |
| 293 | 303 | |
| @@ -296,34 +306,85 @@ struct CheckBuildsIntent: AppIntent { | ||
| 296 | 306 | static var description = IntentDescription("Returns a summary of your recent build status.") |
| 297 | 307 | |
| 298 | 308 | @MainActor |
| 299 | func perform() async throws -> some IntentResult & ReturnsValue<String> { | |
| 300 | guard let snapshot = NeedsAttentionSnapshotStore.load() else { | |
| 301 | return .result(value: "Build status unavailable. Open Hutch to refresh.") | |
| 302 | } | |
| 309 | func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog { | |
| 310 | let message: String | |
| 311 | if let snapshot = NeedsAttentionSnapshotStore.load() { | |
| 312 | var parts: [String] = [] | |
| 313 | ||
| 314 | if let failed = snapshot.failedBuilds { | |
| 315 | if failed > 0 { | |
| 316 | parts.append("\(failed) failed build\(failed == 1 ? "" : "s")") | |
| 317 | } else { | |
| 318 | parts.append("No failed builds") | |
| 319 | } | |
| 320 | } | |
| 303 | 321 | |
| 304 | var parts: [String] = [] | |
| 322 | if let unread = snapshot.unreadInboxThreads, unread > 0 { | |
| 323 | parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")") | |
| 324 | } | |
| 305 | 325 | |
| 306 | if let failed = snapshot.failedBuilds { | |
| 307 | if failed > 0 { | |
| 308 | parts.append("\(failed) failed build\(failed == 1 ? "" : "s")") | |
| 309 | } else { | |
| 310 | parts.append("No failed builds") | |
| 326 | if let assigned = snapshot.assignedOpenTickets, assigned > 0 { | |
| 327 | parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")") | |
| 311 | 328 | } |
| 312 | } | |
| 313 | 329 | |
| 314 | if let unread = snapshot.unreadInboxThreads, unread > 0 { | |
| 315 | parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")") | |
| 330 | message = parts.isEmpty ? "No recent data. Open Hutch to refresh." : parts.joined(separator: ". ") + "." | |
| 331 | } else { | |
| 332 | message = "Build status unavailable. Open Hutch to refresh." | |
| 316 | 333 | } |
| 334 | return .result(value: message, dialog: IntentDialog(stringLiteral: message)) | |
| 335 | } | |
| 336 | } | |
| 317 | 337 | |
| 318 | if let assigned = snapshot.assignedOpenTickets, assigned > 0 { | |
| 319 | parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")") | |
| 320 | } | |
| 338 | // MARK: - Search Type | |
| 321 | 339 | |
| 322 | if parts.isEmpty { | |
| 323 | return .result(value: "No recent data. Open Hutch to refresh.") | |
| 324 | } | |
| 340 | extension LookupType: @retroactive AppEnum { | |
| 341 | public nonisolated static var typeDisplayRepresentation: TypeDisplayRepresentation { | |
| 342 | TypeDisplayRepresentation(name: "Search Type") | |
| 343 | } | |
| 344 | ||
| 345 | public nonisolated static var caseDisplayRepresentations: [LookupType: DisplayRepresentation] { | |
| 346 | [ | |
| 347 | .user: "User", | |
| 348 | .gitRepo: "Git Repository", | |
| 349 | .hgRepo: "Mercurial Repository", | |
| 350 | .mailingList: "Mailing List", | |
| 351 | .tracker: "Tracker", | |
| 352 | .buildJob: "Build Job" | |
| 353 | ] | |
| 354 | } | |
| 355 | } | |
| 356 | ||
| 357 | // MARK: - Mutating Intents | |
| 358 | ||
| 359 | struct ClearRecentActivityIntent: AppIntent { | |
| 360 | static var title: LocalizedStringResource = "Clear Recent Activity" | |
| 361 | static var description = IntentDescription("Clears the Recent list on the Hutch Home tab.") | |
| 362 | ||
| 363 | @MainActor | |
| 364 | func perform() async throws -> some IntentResult & ProvidesDialog { | |
| 365 | RecentActivityStore.clear(defaults: HutchIntentEntityStore.accountDefaults) | |
| 366 | return .result(dialog: "Cleared recent activity.") | |
| 367 | } | |
| 368 | } | |
| 369 | ||
| 370 | struct UnpinResourceIntent: AppIntent { | |
| 371 | static var title: LocalizedStringResource = "Unpin Resource" | |
| 372 | static var description = IntentDescription("Removes a pinned resource from the Hutch Home tab.") | |
| 325 | 373 | |
| 326 | return .result(value: parts.joined(separator: ". ") + ".") | |
| 374 | @Parameter(title: "Pinned Resource") | |
| 375 | var pinnedResource: PinnedResourceEntity | |
| 376 | ||
| 377 | @MainActor | |
| 378 | func perform() async throws -> some IntentResult & ProvidesDialog { | |
| 379 | guard let userKey = HutchIntentEntityStore.currentUserKey() else { | |
| 380 | return .result(dialog: "No active Hutch account.") | |
| 381 | } | |
| 382 | HomePinStore.removePin( | |
| 383 | id: pinnedResource.id, | |
| 384 | for: userKey, | |
| 385 | defaults: HutchIntentEntityStore.accountDefaults | |
| 386 | ) | |
| 387 | return .result(dialog: "Unpinned \(pinnedResource.name).") | |
| 327 | 388 | } |
| 328 | 389 | } |
| 329 | 390 | |
Hutch/App/RootView.swift +6 −6
| @@ -247,12 +247,12 @@ struct RootView: View { | ||
| 247 | 247 | appState.pendingBuildListFilter = .failed |
| 248 | 248 | appState.selectedTab = .builds |
| 249 | 249 | |
| 250 | case .search(let query): | |
| 250 | case .search(let query, let type): | |
| 251 | 251 | morePath = NavigationPath() |
| 252 | 252 | appState.selectedTab = .more |
| 253 | 253 | Task { |
| 254 | 254 | await settleNavigationTransition() |
| 255 | morePath.append(MoreRoute.lookup(query: query)) | |
| 255 | morePath.append(MoreRoute.lookup(query: query, type: type)) | |
| 256 | 256 | } |
| 257 | 257 | |
| 258 | 258 | case .lookup: |
| @@ -260,7 +260,7 @@ struct RootView: View { | ||
| 260 | 260 | appState.selectedTab = .more |
| 261 | 261 | Task { |
| 262 | 262 | await settleNavigationTransition() |
| 263 | morePath.append(MoreRoute.lookup(query: nil)) | |
| 263 | morePath.append(MoreRoute.lookup(query: nil, type: nil)) | |
| 264 | 264 | } |
| 265 | 265 | |
| 266 | 266 | case .buildsTab: |
| @@ -431,7 +431,7 @@ enum MoreDestination: Hashable { | ||
| 431 | 431 | } |
| 432 | 432 | |
| 433 | 433 | enum MoreRoute: Hashable { |
| 434 | case lookup(query: String?) | |
| 434 | case lookup(query: String?, type: LookupType?) | |
| 435 | 435 | case projects |
| 436 | 436 | case lists |
| 437 | 437 | case pastes |
| @@ -454,8 +454,8 @@ private struct MoreNavigationRoot: View { | ||
| 454 | 454 | MoreView() |
| 455 | 455 | .navigationDestination(for: MoreRoute.self) { route in |
| 456 | 456 | switch route { |
| 457 | case .lookup(let query): | |
| 458 | LookupView(initialQuery: query ?? "") | |
| 457 | case .lookup(let query, let type): | |
| 458 | LookupView(initialQuery: query ?? "", initialType: type) | |
| 459 | 459 | case .projects: |
| 460 | 460 | ProjectsListView() |
| 461 | 461 | case .lists: |
Hutch/Views/Home/HomePinStore.swift +15
| @@ -144,6 +144,21 @@ enum HomePinStore { | ||
| 144 | 144 | saveAll(pinsByUser, defaults: defaults) |
| 145 | 145 | } |
| 146 | 146 | |
| 147 | static func removePin( | |
| 148 | id: String, | |
| 149 | for userKey: String, | |
| 150 | defaults: UserDefaults = .standard | |
| 151 | ) { | |
| 152 | let normalizedUserKey = normalizedUserKey(userKey) | |
| 153 | guard !normalizedUserKey.isEmpty else { return } | |
| 154 | ||
| 155 | var pinsByUser = loadAll(defaults: defaults) | |
| 156 | var pins = normalizedPins(pinsByUser[normalizedUserKey] ?? loadPins(for: normalizedUserKey, defaults: defaults)) | |
| 157 | pins.removeAll { $0.id == id } | |
| 158 | pinsByUser[normalizedUserKey] = pins | |
| 159 | saveAll(pinsByUser, defaults: defaults) | |
| 160 | } | |
| 161 | ||
| 147 | 162 | static func pinnedProjectIDs( |
| 148 | 163 | for userKey: String, |
| 149 | 164 | defaults: UserDefaults = .standard |
Hutch/Views/Lookup/LookupView.swift +12 −5
| @@ -96,13 +96,17 @@ final class LookupViewModel { | ||
| 96 | 96 | client: SRHTClient, |
| 97 | 97 | appState: AppState, |
| 98 | 98 | defaults: UserDefaults = .standard, |
| 99 | initialQuery: String = "" | |
| 99 | initialQuery: String = "", | |
| 100 | initialType: LookupType? = nil | |
| 100 | 101 | ) { |
| 101 | 102 | self.client = client |
| 102 | 103 | self.appState = appState |
| 103 | 104 | self.defaults = defaults |
| 104 | 105 | self.history = LookupHistoryStore.load(defaults: defaults) |
| 105 | 106 | self.inputText = initialQuery.trimmingCharacters(in: .whitespacesAndNewlines) |
| 107 | if let initialType { | |
| 108 | self.selectedType = initialType | |
| 109 | } | |
| 106 | 110 | } |
| 107 | 111 | |
| 108 | 112 | func lookup() async { |
| @@ -336,9 +340,11 @@ struct LookupView: View { | ||
| 336 | 340 | @Environment(AppState.self) private var appState |
| 337 | 341 | @State private var viewModel: LookupViewModel? |
| 338 | 342 | private let initialQuery: String |
| 343 | private let initialType: LookupType? | |
| 339 | 344 | |
| 340 | init(initialQuery: String = "") { | |
| 345 | init(initialQuery: String = "", initialType: LookupType? = nil) { | |
| 341 | 346 | self.initialQuery = initialQuery |
| 347 | self.initialType = initialType | |
| 342 | 348 | } |
| 343 | 349 | |
| 344 | 350 | var body: some View { |
| @@ -356,7 +362,8 @@ struct LookupView: View { | ||
| 356 | 362 | client: appState.client, |
| 357 | 363 | appState: appState, |
| 358 | 364 | defaults: appState.accountDefaults, |
| 359 | initialQuery: initialQuery | |
| 365 | initialQuery: initialQuery, | |
| 366 | initialType: initialType | |
| 360 | 367 | ) |
| 361 | 368 | } |
| 362 | 369 | } |
| @@ -443,8 +450,8 @@ struct LookupView: View { | ||
| 443 | 450 | } |
| 444 | 451 | .navigationDestination(for: MoreRoute.self) { route in |
| 445 | 452 | switch route { |
| 446 | case .lookup(let query): | |
| 447 | LookupView(initialQuery: query ?? "") | |
| 453 | case .lookup(let query, let type): | |
| 454 | LookupView(initialQuery: query ?? "", initialType: type) | |
| 448 | 455 | case .projects: |
| 449 | 456 | ProjectsListView() |
| 450 | 457 | case .lists: |
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(query: nil)) { | |
| 16 | NavigationLink(value: MoreRoute.lookup(query: nil, type: nil)) { | |
| 17 | 17 | Label("Look Up", systemImage: "magnifyingglass") |
| 18 | 18 | } |
| 19 | 19 | .themedRow() |
HutchTests/DeepLinkTests.swift +2 −1
| @@ -92,7 +92,8 @@ struct DeepLinkTests { | ||
| 92 | 92 | func parsesRouteBackedNavigationLinks() { |
| 93 | 93 | #expect(DeepLink(url: HutchRoute.workQueue(scope: .assigned).url) == .workQueue(scope: .assigned)) |
| 94 | 94 | #expect(DeepLink(url: HutchRoute.failedBuilds.url) == .failedBuilds) |
| 95 | #expect(DeepLink(url: HutchRoute.search(query: "patch queue").url) == .search(query: "patch queue")) | |
| 95 | #expect(DeepLink(url: HutchRoute.search(query: "patch queue", type: nil).url) == .search(query: "patch queue", type: nil)) | |
| 96 | #expect(DeepLink(url: HutchRoute.search(query: "~alice", type: .user).url) == .search(query: "~alice", type: .user)) | |
| 96 | 97 | #expect(DeepLink(url: HutchRoute.projectDashboard(id: "project-1", title: "Hutch").url) == .projectDashboard(id: "project-1", title: "Hutch")) |
| 97 | 98 | } |
| 98 | 99 | |
HutchTests/HutchIntentsTests.swift +33
| @@ -110,4 +110,37 @@ struct HutchIntentsTests { | ||
| 110 | 110 | ActiveAccountContextStore.save("account-a", defaults: defaults) |
| 111 | 111 | #expect(NeedsAttentionSnapshotStore.load(defaults: defaults)?.failedBuilds == 3) |
| 112 | 112 | } |
| 113 | ||
| 114 | @Test | |
| 115 | func searchIntentCarriesQueryAndType() { | |
| 116 | let intent = SearchHutchIntent() | |
| 117 | intent.query = "~alice/hutch" | |
| 118 | intent.searchType = .tracker | |
| 119 | #expect(intent.route == .search(query: "~alice/hutch", type: .tracker)) | |
| 120 | } | |
| 121 | ||
| 122 | @Test | |
| 123 | func searchIntentWithBlankQueryFallsBackToLookup() { | |
| 124 | let intent = SearchHutchIntent() | |
| 125 | intent.query = " " | |
| 126 | intent.searchType = .user | |
| 127 | #expect(intent.route == .lookup) | |
| 128 | } | |
| 129 | ||
| 130 | @Test | |
| 131 | func removePinDropsMatchingResource() { | |
| 132 | let defaultsName = "HutchIntentsTests-unpin-\(UUID().uuidString)" | |
| 133 | let defaults = UserDefaults(suiteName: defaultsName)! | |
| 134 | defer { defaults.removePersistentDomain(forName: defaultsName) } | |
| 135 | ||
| 136 | let alice = HomePinRecord(kind: .user, value: "~alice", title: "~alice", subtitle: "User", ownerUsername: "~alice", service: nil) | |
| 137 | let bob = HomePinRecord(kind: .user, value: "~bob", title: "~bob", subtitle: "User", ownerUsername: "~bob", service: nil) | |
| 138 | HomePinStore.togglePin(alice, for: "~me", defaults: defaults) | |
| 139 | HomePinStore.togglePin(bob, for: "~me", defaults: defaults) | |
| 140 | ||
| 141 | HomePinStore.removePin(id: alice.id, for: "~me", defaults: defaults) | |
| 142 | ||
| 143 | let remaining = HomePinStore.loadPins(for: "~me", defaults: defaults) | |
| 144 | #expect(remaining.map(\.id) == [bob.id]) | |
| 145 | } | |
| 113 | 146 | } |