summaryrefslogtreecommitdiff
path: root/Hutch/App
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/App')
-rw-r--r--Hutch/App/DeepLink.swift21
-rw-r--r--Hutch/App/HutchIntents.swift147
-rw-r--r--Hutch/App/RootView.swift12
3 files changed, 123 insertions, 57 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: