krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

9c11d69002d71556fea1e2a938e02d4f4d503ea5

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-07T08:06:34Z

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.
 Hutch/App/DeepLink.swift            |  21 ++++--
 Hutch/App/HutchIntents.swift        | 147 +++++++++++++++++++++++++-----------
 Hutch/App/RootView.swift            |  12 +--
 Hutch/Views/Home/HomePinStore.swift |  15 ++++
 Hutch/Views/Lookup/LookupView.swift |  17 +++--
 Hutch/Views/More/MoreView.swift     |   2 +-
 HutchTests/DeepLinkTests.swift      |   3 +-
 HutchTests/HutchIntentsTests.swift  |  33 ++++++++
 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])
+    }
 }