summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-08-07 03:06:34 -0500
committerChristian Cleberg <[email protected]>2026-08-07 03:06:34 -0500
commit9c11d69002d71556fea1e2a938e02d4f4d503ea5 (patch)
tree22c3280dca5f85923ddc3cc85bff8751ad46b7fe
parent044c33f7f43db1f38d6c44dd8f483298d9efcc06 (diff)
downloadhutch-9c11d69002d71556fea1e2a938e02d4f4d503ea5.tar.gz
hutch-9c11d69002d71556fea1e2a938e02d4f4d503ea5.tar.bz2
hutch-9c11d69002d71556fea1e2a938e02d4f4d503ea5.zip
Expand App Intents: dialogs, search type, mutating intents (#17)
Addresses the three open items on the App Intents ticket: - Check Status / Check Builds now return a spoken/visible dialog alongside their value, so they surface a result in Siri instead of appearing to do nothing. Logic consolidated to always produce a message. - Search Hutch gains a Search Type parameter (user, git/hg repo, mailing list, tracker, build job) threaded through the .search route into Lookup, which pre-selects the type. LookupType now conforms to AppEnum. - Adds mutating intents (not just navigation): Clear Recent Activity and Unpin Resource, backed by RecentActivityStore.clear and a new HomePinStore.removePin. Both act on the active account's storage. Tests cover search-type routing, the blank-query fallback, and pin removal.
-rw-r--r--Hutch/App/DeepLink.swift21
-rw-r--r--Hutch/App/HutchIntents.swift147
-rw-r--r--Hutch/App/RootView.swift12
-rw-r--r--Hutch/Views/Home/HomePinStore.swift15
-rw-r--r--Hutch/Views/Lookup/LookupView.swift17
-rw-r--r--Hutch/Views/More/MoreView.swift2
-rw-r--r--HutchTests/DeepLinkTests.swift3
-rw-r--r--HutchTests/HutchIntentsTests.swift33
8 files changed, 186 insertions, 64 deletions
diff --git a/Hutch/App/DeepLink.swift b/Hutch/App/DeepLink.swift
index 7467fc8..a0331a0 100644
--- a/Hutch/App/DeepLink.swift
+++ b/Hutch/App/DeepLink.swift
@@ -25,7 +25,7 @@ enum HutchRoute: Equatable, Sendable {
case trackers
case systemStatus
case lookup
- case search(query: String)
+ case search(query: String, type: LookupType?)
case projectDashboard(id: String, title: String?)
init?(url: URL) {
@@ -103,7 +103,8 @@ enum HutchRoute: Equatable, Sendable {
case "lookup":
if let query = queryValue("q"), !query.isEmpty {
- self = .search(query: query)
+ let type = queryValue("type").flatMap(LookupType.init(rawValue:))
+ self = .search(query: query, type: type)
} else {
self = .lookup
}
@@ -148,8 +149,12 @@ enum HutchRoute: Equatable, Sendable {
return Self.makeURL(host: "status")
case .lookup:
return Self.makeURL(host: "lookup")
- case .search(let query):
- return Self.makeURL(host: "lookup", queryItems: [URLQueryItem(name: "q", value: query)])
+ case .search(let query, let type):
+ var items = [URLQueryItem(name: "q", value: query)]
+ if let type {
+ items.append(URLQueryItem(name: "type", value: type.rawValue))
+ }
+ return Self.makeURL(host: "lookup", queryItems: items)
case .projectDashboard(let id, let title):
return Self.makeURL(
host: "projects",
@@ -205,8 +210,8 @@ enum DeepLink: Equatable {
case systemStatus
/// hutch://lookup
case lookup
- /// hutch://lookup?q=<query>
- case search(query: String)
+ /// hutch://lookup?q=<query>&type=<type>
+ case search(query: String, type: LookupType?)
/// hutch://builds?filter=failed
case failedBuilds
/// hutch://projects/<rid>
@@ -253,8 +258,8 @@ enum DeepLink: Equatable {
self = .systemStatus
case .lookup:
self = .lookup
- case .search(let query):
- self = .search(query: query)
+ case .search(let query, let type):
+ self = .search(query: query, type: type)
case .projectDashboard(let id, let title):
self = .projectDashboard(id: id, title: title)
}
diff --git a/Hutch/App/HutchIntents.swift b/Hutch/App/HutchIntents.swift
index 947ec04..fe4ebdc 100644
--- a/Hutch/App/HutchIntents.swift
+++ b/Hutch/App/HutchIntents.swift
@@ -132,11 +132,14 @@ struct SearchHutchIntent: AppIntent {
@Parameter(title: "Query")
var query: String
+ @Parameter(title: "Search Type", default: .user)
+ var searchType: LookupType
+
var route: HutchRoute {
let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines)
- // Routes to Lookup for now; repoint at a global content search when Hutch
- // gains one — tracked in ROADMAP.md § "App Intent gaps".
- return normalized.isEmpty ? .lookup : .search(query: normalized)
+ // Routes to Lookup, pre-selecting the search type, until Hutch gains a
+ // global content search.
+ return normalized.isEmpty ? .lookup : .search(query: normalized, type: searchType)
}
@MainActor
@@ -147,7 +150,7 @@ struct SearchHutchIntent: AppIntent {
}
// An OpenSavedSearchIntent belongs here once Hutch has global saved-search
-// persistence — tracked in ROADMAP.md § "App Intent gaps".
+// persistence.
// MARK: - App Entities
@@ -201,7 +204,7 @@ struct ProjectEntityQuery: EntityQuery {
}
}
-private enum HutchIntentEntityStore {
+enum HutchIntentEntityStore {
static func pinnedResources() -> [PinnedResourceEntity] {
pins().compactMap { makePinnedResource(from: $0) }
}
@@ -213,22 +216,27 @@ private enum HutchIntentEntityStore {
}
}
- private static func pins() -> [HomePinRecord] {
+ /// The active account's key, or `nil` when no account is signed in.
+ static func currentUserKey() -> String? {
guard let userKey = ContributionWidgetContextStore.loadActor(),
!userKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
- return []
+ return nil
}
-
- return HomePinStore.loadPins(for: userKey, defaults: activeAccountDefaults)
+ return userKey
}
- private static var activeAccountDefaults: UserDefaults {
+ static var accountDefaults: UserDefaults {
let activeID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
guard !activeID.isEmpty else { return .standard }
return AccountDefaultsStore.userDefaults(for: activeID)
}
+ private static func pins() -> [HomePinRecord] {
+ guard let userKey = currentUserKey() else { return [] }
+ return HomePinStore.loadPins(for: userKey, defaults: accountDefaults)
+ }
+
private static func makePinnedResource(from pin: HomePinRecord) -> PinnedResourceEntity? {
guard let route = route(for: pin) else { return nil }
return PinnedResourceEntity(
@@ -274,20 +282,22 @@ struct CheckSystemStatusIntent: AppIntent {
static var description = IntentDescription("Returns the current SourceHut system status.")
@MainActor
- func perform() async throws -> some IntentResult & ReturnsValue<String> {
- guard let snapshot = SystemStatusWidgetSnapshotStore.load() else {
- return .result(value: "System status is unavailable. Open Hutch to refresh.")
- }
-
- if snapshot.hasDisruption {
- let disrupted = snapshot.services
- .filter { $0.requiresAttention }
- .map { "\($0.name): \($0.status)" }
- .joined(separator: ", ")
- return .result(value: "SourceHut disruption detected: \(disrupted)")
+ func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
+ let message: String
+ if let snapshot = SystemStatusWidgetSnapshotStore.load() {
+ if snapshot.hasDisruption {
+ let disrupted = snapshot.services
+ .filter { $0.requiresAttention }
+ .map { "\($0.name): \($0.status)" }
+ .joined(separator: ", ")
+ message = "SourceHut disruption detected: \(disrupted)"
+ } else {
+ message = "All SourceHut services operational."
+ }
+ } else {
+ message = "System status is unavailable. Open Hutch to refresh."
}
-
- return .result(value: "All SourceHut services operational.")
+ return .result(value: message, dialog: IntentDialog(stringLiteral: message))
}
}
@@ -296,34 +306,85 @@ struct CheckBuildsIntent: AppIntent {
static var description = IntentDescription("Returns a summary of your recent build status.")
@MainActor
- func perform() async throws -> some IntentResult & ReturnsValue<String> {
- guard let snapshot = NeedsAttentionSnapshotStore.load() else {
- return .result(value: "Build status unavailable. Open Hutch to refresh.")
- }
+ func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
+ let message: String
+ if let snapshot = NeedsAttentionSnapshotStore.load() {
+ var parts: [String] = []
+
+ if let failed = snapshot.failedBuilds {
+ if failed > 0 {
+ parts.append("\(failed) failed build\(failed == 1 ? "" : "s")")
+ } else {
+ parts.append("No failed builds")
+ }
+ }
- var parts: [String] = []
+ if let unread = snapshot.unreadInboxThreads, unread > 0 {
+ parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")")
+ }
- if let failed = snapshot.failedBuilds {
- if failed > 0 {
- parts.append("\(failed) failed build\(failed == 1 ? "" : "s")")
- } else {
- parts.append("No failed builds")
+ if let assigned = snapshot.assignedOpenTickets, assigned > 0 {
+ parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")")
}
- }
- if let unread = snapshot.unreadInboxThreads, unread > 0 {
- parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")")
+ message = parts.isEmpty ? "No recent data. Open Hutch to refresh." : parts.joined(separator: ". ") + "."
+ } else {
+ message = "Build status unavailable. Open Hutch to refresh."
}
+ return .result(value: message, dialog: IntentDialog(stringLiteral: message))
+ }
+}
- if let assigned = snapshot.assignedOpenTickets, assigned > 0 {
- parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")")
- }
+// MARK: - Search Type
- if parts.isEmpty {
- return .result(value: "No recent data. Open Hutch to refresh.")
- }
+extension LookupType: @retroactive AppEnum {
+ public nonisolated static var typeDisplayRepresentation: TypeDisplayRepresentation {
+ TypeDisplayRepresentation(name: "Search Type")
+ }
+
+ public nonisolated static var caseDisplayRepresentations: [LookupType: DisplayRepresentation] {
+ [
+ .user: "User",
+ .gitRepo: "Git Repository",
+ .hgRepo: "Mercurial Repository",
+ .mailingList: "Mailing List",
+ .tracker: "Tracker",
+ .buildJob: "Build Job"
+ ]
+ }
+}
+
+// MARK: - Mutating Intents
+
+struct ClearRecentActivityIntent: AppIntent {
+ static var title: LocalizedStringResource = "Clear Recent Activity"
+ static var description = IntentDescription("Clears the Recent list on the Hutch Home tab.")
+
+ @MainActor
+ func perform() async throws -> some IntentResult & ProvidesDialog {
+ RecentActivityStore.clear(defaults: HutchIntentEntityStore.accountDefaults)
+ return .result(dialog: "Cleared recent activity.")
+ }
+}
+
+struct UnpinResourceIntent: AppIntent {
+ static var title: LocalizedStringResource = "Unpin Resource"
+ static var description = IntentDescription("Removes a pinned resource from the Hutch Home tab.")
- return .result(value: parts.joined(separator: ". ") + ".")
+ @Parameter(title: "Pinned Resource")
+ var pinnedResource: PinnedResourceEntity
+
+ @MainActor
+ func perform() async throws -> some IntentResult & ProvidesDialog {
+ guard let userKey = HutchIntentEntityStore.currentUserKey() else {
+ return .result(dialog: "No active Hutch account.")
+ }
+ HomePinStore.removePin(
+ id: pinnedResource.id,
+ for: userKey,
+ defaults: HutchIntentEntityStore.accountDefaults
+ )
+ return .result(dialog: "Unpinned \(pinnedResource.name).")
}
}
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 2e16718..a3f9e7e 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -247,12 +247,12 @@ struct RootView: View {
appState.pendingBuildListFilter = .failed
appState.selectedTab = .builds
- case .search(let query):
+ case .search(let query, let type):
morePath = NavigationPath()
appState.selectedTab = .more
Task {
await settleNavigationTransition()
- morePath.append(MoreRoute.lookup(query: query))
+ morePath.append(MoreRoute.lookup(query: query, type: type))
}
case .lookup:
@@ -260,7 +260,7 @@ struct RootView: View {
appState.selectedTab = .more
Task {
await settleNavigationTransition()
- morePath.append(MoreRoute.lookup(query: nil))
+ morePath.append(MoreRoute.lookup(query: nil, type: nil))
}
case .buildsTab:
@@ -431,7 +431,7 @@ enum MoreDestination: Hashable {
}
enum MoreRoute: Hashable {
- case lookup(query: String?)
+ case lookup(query: String?, type: LookupType?)
case projects
case lists
case pastes
@@ -454,8 +454,8 @@ private struct MoreNavigationRoot: View {
MoreView()
.navigationDestination(for: MoreRoute.self) { route in
switch route {
- case .lookup(let query):
- LookupView(initialQuery: query ?? "")
+ case .lookup(let query, let type):
+ LookupView(initialQuery: query ?? "", initialType: type)
case .projects:
ProjectsListView()
case .lists:
diff --git a/Hutch/Views/Home/HomePinStore.swift b/Hutch/Views/Home/HomePinStore.swift
index e9bffd1..3b00843 100644
--- a/Hutch/Views/Home/HomePinStore.swift
+++ b/Hutch/Views/Home/HomePinStore.swift
@@ -144,6 +144,21 @@ enum HomePinStore {
saveAll(pinsByUser, defaults: defaults)
}
+ static func removePin(
+ id: String,
+ for userKey: String,
+ defaults: UserDefaults = .standard
+ ) {
+ let normalizedUserKey = normalizedUserKey(userKey)
+ guard !normalizedUserKey.isEmpty else { return }
+
+ var pinsByUser = loadAll(defaults: defaults)
+ var pins = normalizedPins(pinsByUser[normalizedUserKey] ?? loadPins(for: normalizedUserKey, defaults: defaults))
+ pins.removeAll { $0.id == id }
+ pinsByUser[normalizedUserKey] = pins
+ saveAll(pinsByUser, defaults: defaults)
+ }
+
static func pinnedProjectIDs(
for userKey: String,
defaults: UserDefaults = .standard
diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift
index 2a26282..89fce5b 100644
--- a/Hutch/Views/Lookup/LookupView.swift
+++ b/Hutch/Views/Lookup/LookupView.swift
@@ -96,13 +96,17 @@ final class LookupViewModel {
client: SRHTClient,
appState: AppState,
defaults: UserDefaults = .standard,
- initialQuery: String = ""
+ initialQuery: String = "",
+ initialType: LookupType? = nil
) {
self.client = client
self.appState = appState
self.defaults = defaults
self.history = LookupHistoryStore.load(defaults: defaults)
self.inputText = initialQuery.trimmingCharacters(in: .whitespacesAndNewlines)
+ if let initialType {
+ self.selectedType = initialType
+ }
}
func lookup() async {
@@ -336,9 +340,11 @@ struct LookupView: View {
@Environment(AppState.self) private var appState
@State private var viewModel: LookupViewModel?
private let initialQuery: String
+ private let initialType: LookupType?
- init(initialQuery: String = "") {
+ init(initialQuery: String = "", initialType: LookupType? = nil) {
self.initialQuery = initialQuery
+ self.initialType = initialType
}
var body: some View {
@@ -356,7 +362,8 @@ struct LookupView: View {
client: appState.client,
appState: appState,
defaults: appState.accountDefaults,
- initialQuery: initialQuery
+ initialQuery: initialQuery,
+ initialType: initialType
)
}
}
@@ -443,8 +450,8 @@ struct LookupView: View {
}
.navigationDestination(for: MoreRoute.self) { route in
switch route {
- case .lookup(let query):
- LookupView(initialQuery: query ?? "")
+ case .lookup(let query, let type):
+ LookupView(initialQuery: query ?? "", initialType: type)
case .projects:
ProjectsListView()
case .lists:
diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift
index ad02077..d6f7d8a 100644
--- a/Hutch/Views/More/MoreView.swift
+++ b/Hutch/Views/More/MoreView.swift
@@ -13,7 +13,7 @@ struct MoreView: View {
var body: some View {
List {
Section("Search") {
- NavigationLink(value: MoreRoute.lookup(query: nil)) {
+ NavigationLink(value: MoreRoute.lookup(query: nil, type: nil)) {
Label("Look Up", systemImage: "magnifyingglass")
}
.themedRow()
diff --git a/HutchTests/DeepLinkTests.swift b/HutchTests/DeepLinkTests.swift
index d62cfef..cdc5bab 100644
--- a/HutchTests/DeepLinkTests.swift
+++ b/HutchTests/DeepLinkTests.swift
@@ -92,7 +92,8 @@ struct DeepLinkTests {
func parsesRouteBackedNavigationLinks() {
#expect(DeepLink(url: HutchRoute.workQueue(scope: .assigned).url) == .workQueue(scope: .assigned))
#expect(DeepLink(url: HutchRoute.failedBuilds.url) == .failedBuilds)
- #expect(DeepLink(url: HutchRoute.search(query: "patch queue").url) == .search(query: "patch queue"))
+ #expect(DeepLink(url: HutchRoute.search(query: "patch queue", type: nil).url) == .search(query: "patch queue", type: nil))
+ #expect(DeepLink(url: HutchRoute.search(query: "~alice", type: .user).url) == .search(query: "~alice", type: .user))
#expect(DeepLink(url: HutchRoute.projectDashboard(id: "project-1", title: "Hutch").url) == .projectDashboard(id: "project-1", title: "Hutch"))
}
diff --git a/HutchTests/HutchIntentsTests.swift b/HutchTests/HutchIntentsTests.swift
index ddbe973..e201f42 100644
--- a/HutchTests/HutchIntentsTests.swift
+++ b/HutchTests/HutchIntentsTests.swift
@@ -110,4 +110,37 @@ struct HutchIntentsTests {
ActiveAccountContextStore.save("account-a", defaults: defaults)
#expect(NeedsAttentionSnapshotStore.load(defaults: defaults)?.failedBuilds == 3)
}
+
+ @Test
+ func searchIntentCarriesQueryAndType() {
+ let intent = SearchHutchIntent()
+ intent.query = "~alice/hutch"
+ intent.searchType = .tracker
+ #expect(intent.route == .search(query: "~alice/hutch", type: .tracker))
+ }
+
+ @Test
+ func searchIntentWithBlankQueryFallsBackToLookup() {
+ let intent = SearchHutchIntent()
+ intent.query = " "
+ intent.searchType = .user
+ #expect(intent.route == .lookup)
+ }
+
+ @Test
+ func removePinDropsMatchingResource() {
+ let defaultsName = "HutchIntentsTests-unpin-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: defaultsName)!
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+ let alice = HomePinRecord(kind: .user, value: "~alice", title: "~alice", subtitle: "User", ownerUsername: "~alice", service: nil)
+ let bob = HomePinRecord(kind: .user, value: "~bob", title: "~bob", subtitle: "User", ownerUsername: "~bob", service: nil)
+ HomePinStore.togglePin(alice, for: "~me", defaults: defaults)
+ HomePinStore.togglePin(bob, for: "~me", defaults: defaults)
+
+ HomePinStore.removePin(id: alice.id, for: "~me", defaults: defaults)
+
+ let remaining = HomePinStore.loadPins(for: "~me", defaults: defaults)
+ #expect(remaining.map(\.id) == [bob.id])
+ }
}