summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 00:55:42 -0500
committerChristian Cleberg <[email protected]>2026-04-12 00:55:42 -0500
commit9050405b4f33b2b9b70458fcf3e43b80c940eef7 (patch)
tree983c3a07a560258059cfe421beb3ffcf3c18baf4 /Hutch
parent5f6d545eb3455a9e4da5a3cb4460811e3a48d939 (diff)
downloadhutch-9050405b4f33b2b9b70458fcf3e43b80c940eef7.tar.gz
hutch-9050405b4f33b2b9b70458fcf3e43b80c940eef7.tar.bz2
hutch-9050405b4f33b2b9b70458fcf3e43b80c940eef7.zip
add shortcuts widgets and power-user workflowsv2.15.0
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/App/AppState.swift2
-rw-r--r--Hutch/App/DeepLink.swift25
-rw-r--r--Hutch/App/HutchApp.swift15
-rw-r--r--Hutch/App/HutchIntents.swift154
-rw-r--r--Hutch/App/RootView.swift71
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift22
6 files changed, 289 insertions, 0 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index c2e2744..35ac77f 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -203,6 +203,7 @@ final class AppState {
await clearWebData()
clearWebContentRenderCaches()
NeedsAttentionSnapshotStore.clear()
+ SystemStatusWidgetSnapshotStore.clear()
authPhase = .unauthenticated
selectedTab = .home
}
@@ -218,6 +219,7 @@ final class AppState {
await clearWebData()
clearWebContentRenderCaches()
NeedsAttentionSnapshotStore.clear()
+ SystemStatusWidgetSnapshotStore.clear()
authPhase = .unauthenticated
selectedTab = .home
diff --git a/Hutch/App/DeepLink.swift b/Hutch/App/DeepLink.swift
index e42bd6b..bbf8885 100644
--- a/Hutch/App/DeepLink.swift
+++ b/Hutch/App/DeepLink.swift
@@ -9,6 +9,16 @@ enum DeepLink: Equatable {
case ticket(owner: String, tracker: String, ticketId: Int)
/// hutch://builds/<jobId>
case build(jobId: Int)
+ /// hutch://builds (tab-level)
+ case buildsTab
+ /// hutch://repositories (tab-level)
+ case repositoriesTab
+ /// hutch://trackers (tab-level)
+ case trackersTab
+ /// hutch://status
+ case systemStatus
+ /// hutch://lookup
+ case lookup
/// Attempt to parse a URL into a DeepLink.
/// Expected format: hutch://<path>
@@ -38,6 +48,21 @@ enum DeepLink: Equatable {
guard let jobId = Int(components[1]) else { return nil }
self = .build(jobId: jobId)
+ case "builds":
+ self = .buildsTab
+
+ case "repositories":
+ self = .repositoriesTab
+
+ case "trackers":
+ self = .trackersTab
+
+ case "status":
+ self = .systemStatus
+
+ case "lookup":
+ self = .lookup
+
default:
return nil
}
diff --git a/Hutch/App/HutchApp.swift b/Hutch/App/HutchApp.swift
index fda0f06..e65833c 100644
--- a/Hutch/App/HutchApp.swift
+++ b/Hutch/App/HutchApp.swift
@@ -15,6 +15,21 @@ struct HutchApp: App {
appState.pendingDeepLink = link
}
}
+ .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in
+ guard let destination else { return }
+ HutchIntentNavigator.shared.pendingDestination = nil
+ let link: DeepLink
+ switch destination {
+ case .home: link = .home
+ case .inbox: link = .home
+ case .builds: link = .buildsTab
+ case .repositories: link = .repositoriesTab
+ case .trackers: link = .trackersTab
+ case .systemStatus: link = .systemStatus
+ case .lookup: link = .lookup
+ }
+ appState.pendingDeepLink = link
+ }
}
}
}
diff --git a/Hutch/App/HutchIntents.swift b/Hutch/App/HutchIntents.swift
new file mode 100644
index 0000000..652c07b
--- /dev/null
+++ b/Hutch/App/HutchIntents.swift
@@ -0,0 +1,154 @@
+import AppIntents
+import Foundation
+
+// MARK: - Open Hutch Intent
+
+enum HutchDestination: String, AppEnum {
+ case home
+ case inbox
+ case builds
+ case repositories
+ case trackers
+ case systemStatus
+ case lookup
+
+ static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Hutch Section")
+
+ static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [
+ .home: "Home",
+ .inbox: "Inbox",
+ .builds: "Builds",
+ .repositories: "Repositories",
+ .trackers: "Trackers",
+ .systemStatus: "System Status",
+ .lookup: "Look Up"
+ ]
+}
+
+struct OpenHutchIntent: AppIntent {
+ static var title: LocalizedStringResource = "Open Hutch"
+ static var description = IntentDescription("Opens Hutch to a specific section.")
+ static var openAppWhenRun = true
+
+ @Parameter(title: "Section", default: .home)
+ var destination: HutchDestination
+
+ @MainActor
+ func perform() async throws -> some IntentResult {
+ HutchIntentNavigator.shared.pendingDestination = destination
+ return .result()
+ }
+}
+
+// MARK: - Check System Status Intent
+
+struct CheckSystemStatusIntent: AppIntent {
+ static var title: LocalizedStringResource = "Check SourceHut Status"
+ 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)")
+ }
+
+ return .result(value: "All SourceHut services operational.")
+ }
+}
+
+// MARK: - Check Builds Intent
+
+struct CheckBuildsIntent: AppIntent {
+ static var title: LocalizedStringResource = "Check Hutch Builds"
+ 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.")
+ }
+
+ 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")
+ }
+ }
+
+ if let unread = snapshot.unreadInboxThreads, unread > 0 {
+ parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")")
+ }
+
+ if let assigned = snapshot.assignedOpenTickets, assigned > 0 {
+ parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")")
+ }
+
+ if parts.isEmpty {
+ return .result(value: "No recent data. Open Hutch to refresh.")
+ }
+
+ return .result(value: parts.joined(separator: ". ") + ".")
+ }
+}
+
+// MARK: - Shortcuts Provider
+
+struct HutchShortcuts: AppShortcutsProvider {
+ static var appShortcuts: [AppShortcut] {
+ AppShortcut(
+ intent: OpenHutchIntent(),
+ phrases: [
+ "Open \(.applicationName)",
+ "Open \(.applicationName) \(\.$destination)",
+ "Show my \(.applicationName) \(\.$destination)",
+ "Go to \(\.$destination) in \(.applicationName)"
+ ],
+ shortTitle: "Open Hutch",
+ systemImageName: "house"
+ )
+
+ AppShortcut(
+ intent: CheckSystemStatusIntent(),
+ phrases: [
+ "Check \(.applicationName) status",
+ "Is SourceHut up in \(.applicationName)",
+ "SourceHut status in \(.applicationName)"
+ ],
+ shortTitle: "Check SourceHut Status",
+ systemImageName: "server.rack"
+ )
+
+ AppShortcut(
+ intent: CheckBuildsIntent(),
+ phrases: [
+ "Check my \(.applicationName) builds",
+ "How are my builds in \(.applicationName)",
+ "Build status in \(.applicationName)"
+ ],
+ shortTitle: "Check Builds",
+ systemImageName: "hammer"
+ )
+ }
+}
+
+// MARK: - Intent Navigator
+
+@MainActor
+@Observable
+final class HutchIntentNavigator {
+ static let shared = HutchIntentNavigator()
+ var pendingDestination: HutchDestination?
+
+ private init() {}
+}
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index c210d9d..80532a0 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -113,6 +113,11 @@ struct RootView: View {
Label("More", systemImage: "ellipsis.circle")
}
}
+ .modifier(SidebarAdaptableTabStyle())
+ .modifier(TabKeyboardShortcuts(selectedTab: Binding(
+ get: { appState.selectedTab },
+ set: { appState.selectedTab = $0 }
+ )))
.overlay {
if isResolvingDeepLink {
ZStack {
@@ -178,6 +183,29 @@ struct RootView: View {
case .ticket(let owner, let tracker, let ticketId):
resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
+
+ case .buildsTab:
+ buildsPath = NavigationPath()
+ appState.selectedTab = .builds
+
+ case .repositoriesTab:
+ repoPath = NavigationPath()
+ appState.selectedTab = .repositories
+
+ case .trackersTab:
+ ticketsPath = NavigationPath()
+ appState.selectedTab = .tickets
+
+ case .systemStatus:
+ appState.navigateToSystemStatus()
+
+ case .lookup:
+ morePath = NavigationPath()
+ appState.selectedTab = .more
+ Task {
+ await settleNavigationTransition()
+ morePath.append(MoreRoute.lookup)
+ }
}
}
@@ -336,3 +364,46 @@ struct TicketDeepLinkTarget: Hashable {
let trackerRid: String
let ticketId: Int
}
+
+// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
+
+/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
+private struct TabKeyboardShortcuts: ViewModifier {
+ @Binding var selectedTab: AppState.Tab
+
+ private static let tabMap: [String: AppState.Tab] = [
+ "1": .home,
+ "2": .repositories,
+ "3": .tickets,
+ "4": .builds,
+ "5": .more,
+ ]
+
+ func body(content: Content) -> some View {
+ content
+ .onKeyPress(characters: .decimalDigits, phases: .down) { press in
+ guard press.modifiers == .command else { return .ignored }
+ let key = String(press.characters)
+ if let tab = Self.tabMap[key] {
+ selectedTab = tab
+ return .handled
+ }
+ return .ignored
+ }
+ }
+}
+
+// MARK: - iPad Sidebar Adaptable
+
+/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
+/// becomes a full sidebar on iPad, while falling back to the standard tab
+/// bar on earlier releases.
+private struct SidebarAdaptableTabStyle: ViewModifier {
+ func body(content: Content) -> some View {
+ if #available(iOS 18.0, *) {
+ content.tabViewStyle(.sidebarAdaptable)
+ } else {
+ content
+ }
+ }
+}
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index 8762393..ec2b324 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -388,6 +388,7 @@ final class HomeViewModel {
}
isLoadingSystemStatus = false
persistNeedsAttentionSnapshot()
+ persistSystemStatusWidgetSnapshot()
}
var hasDashboardContent: Bool {
@@ -928,6 +929,27 @@ final class HomeViewModel {
)
}
+ private func persistSystemStatusWidgetSnapshot() {
+ guard let snapshot = systemStatusSnapshot else {
+ return
+ }
+ let widgetSnapshot = SystemStatusWidgetSnapshot(
+ services: snapshot.services.map { service in
+ SystemStatusWidgetSnapshot.ServiceEntry(
+ id: service.id,
+ name: service.name,
+ status: service.status.displayName,
+ requiresAttention: service.status.requiresAttention
+ )
+ },
+ hasDisruption: snapshot.hasDisruption,
+ overallStatusText: snapshot.overallStatusText,
+ bannerSummary: snapshot.bannerSummary,
+ updatedAt: .now
+ )
+ SystemStatusWidgetSnapshotStore.save(widgetSnapshot)
+ }
+
nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
jobs.map { job in
let repository = primaryRepositoryReference(in: job.manifest)