diff options
| author | Christian Cleberg <[email protected]> | 2026-08-07 03:06:34 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-08-07 03:06:34 -0500 |
| commit | 9c11d69002d71556fea1e2a938e02d4f4d503ea5 (patch) | |
| tree | 22c3280dca5f85923ddc3cc85bff8751ad46b7fe /Hutch/App/HutchIntents.swift | |
| parent | 044c33f7f43db1f38d6c44dd8f483298d9efcc06 (diff) | |
| download | hutch-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.
Diffstat (limited to 'Hutch/App/HutchIntents.swift')
| -rw-r--r-- | Hutch/App/HutchIntents.swift | 147 |
1 files changed, 104 insertions, 43 deletions
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).") } } |
