Commit 258ce2b73b

258ce2b73b0ac848172283993c203936ac382819

parent: 165f0cf230

Unsigned

cmc <hello@cleberg.net> · 2026-04-12 05:55 UTC

add shortcuts widgets and power-user workflows

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517 CODE_SIGN_STYLE = Automatic; 517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 31; 518 CURRENT_PROJECT_VERSION = 32;
519 DEVELOPMENT_TEAM = ZCNAX3VL9D; 519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520 ENABLE_PREVIEWS = YES; 520 ENABLE_PREVIEWS = YES;
521 GENERATE_INFOPLIST_FILE = YES; 521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532 "$(inherited)", 532 "$(inherited)",
533 "@executable_path/Frameworks", 533 "@executable_path/Frameworks",
534 ); 534 );
535 MARKETING_VERSION = 2.14.0; 535 MARKETING_VERSION = 2.15.0;
536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537 PRODUCT_NAME = "$(TARGET_NAME)"; 537 PRODUCT_NAME = "$(TARGET_NAME)";
538 STRING_CATALOG_GENERATE_SYMBOLS = YES; 538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554 CODE_SIGN_STYLE = Automatic; 554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 31; 555 CURRENT_PROJECT_VERSION = 32;
556 DEVELOPMENT_TEAM = ZCNAX3VL9D; 556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557 ENABLE_PREVIEWS = YES; 557 ENABLE_PREVIEWS = YES;
558 GENERATE_INFOPLIST_FILE = YES; 558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569 "$(inherited)", 569 "$(inherited)",
570 "@executable_path/Frameworks", 570 "@executable_path/Frameworks",
571 ); 571 );
572 MARKETING_VERSION = 2.14.0; 572 MARKETING_VERSION = 2.15.0;
573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574 PRODUCT_NAME = "$(TARGET_NAME)"; 574 PRODUCT_NAME = "$(TARGET_NAME)";
575 STRING_CATALOG_GENERATE_SYMBOLS = YES; 575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632 APPLICATION_EXTENSION_API_ONLY = YES; 632 APPLICATION_EXTENSION_API_ONLY = YES;
633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634 CODE_SIGN_STYLE = Automatic; 634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 31; 635 CURRENT_PROJECT_VERSION = 32;
636 DEVELOPMENT_TEAM = ZCNAX3VL9D; 636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637 GENERATE_INFOPLIST_FILE = NO; 637 GENERATE_INFOPLIST_FILE = NO;
638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642 "@executable_path/Frameworks", 642 "@executable_path/Frameworks",
643 "@executable_path/../../Frameworks", 643 "@executable_path/../../Frameworks",
644 ); 644 );
645 MARKETING_VERSION = 2.14.0; 645 MARKETING_VERSION = 2.15.0;
646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647 PRODUCT_NAME = "$(TARGET_NAME)"; 647 PRODUCT_NAME = "$(TARGET_NAME)";
648 SKIP_INSTALL = YES; 648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661 APPLICATION_EXTENSION_API_ONLY = YES; 661 APPLICATION_EXTENSION_API_ONLY = YES;
662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663 CODE_SIGN_STYLE = Automatic; 663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 31; 664 CURRENT_PROJECT_VERSION = 32;
665 DEVELOPMENT_TEAM = ZCNAX3VL9D; 665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666 GENERATE_INFOPLIST_FILE = NO; 666 GENERATE_INFOPLIST_FILE = NO;
667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671 "@executable_path/Frameworks", 671 "@executable_path/Frameworks",
672 "@executable_path/../../Frameworks", 672 "@executable_path/../../Frameworks",
673 ); 673 );
674 MARKETING_VERSION = 2.14.0; 674 MARKETING_VERSION = 2.15.0;
675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676 PRODUCT_NAME = "$(TARGET_NAME)"; 676 PRODUCT_NAME = "$(TARGET_NAME)";
677 SKIP_INSTALL = YES; 677 SKIP_INSTALL = YES;
Hutch/App/AppState.swift +2
@@ -203,6 +203,7 @@ final class AppState {
203 await clearWebData() 203 await clearWebData()
204 clearWebContentRenderCaches() 204 clearWebContentRenderCaches()
205 NeedsAttentionSnapshotStore.clear() 205 NeedsAttentionSnapshotStore.clear()
206 SystemStatusWidgetSnapshotStore.clear()
206 authPhase = .unauthenticated 207 authPhase = .unauthenticated
207 selectedTab = .home 208 selectedTab = .home
208 } 209 }
@@ -218,6 +219,7 @@ final class AppState {
218 await clearWebData() 219 await clearWebData()
219 clearWebContentRenderCaches() 220 clearWebContentRenderCaches()
220 NeedsAttentionSnapshotStore.clear() 221 NeedsAttentionSnapshotStore.clear()
222 SystemStatusWidgetSnapshotStore.clear()
221 223
222 authPhase = .unauthenticated 224 authPhase = .unauthenticated
223 selectedTab = .home 225 selectedTab = .home
Hutch/App/DeepLink.swift +25
@@ -9,6 +9,16 @@ enum DeepLink: Equatable {
9 case ticket(owner: String, tracker: String, ticketId: Int) 9 case ticket(owner: String, tracker: String, ticketId: Int)
10 /// hutch://builds/<jobId> 10 /// hutch://builds/<jobId>
11 case build(jobId: Int) 11 case build(jobId: Int)
12 /// hutch://builds (tab-level)
13 case buildsTab
14 /// hutch://repositories (tab-level)
15 case repositoriesTab
16 /// hutch://trackers (tab-level)
17 case trackersTab
18 /// hutch://status
19 case systemStatus
20 /// hutch://lookup
21 case lookup
12 22
13 /// Attempt to parse a URL into a DeepLink. 23 /// Attempt to parse a URL into a DeepLink.
14 /// Expected format: hutch://<path> 24 /// Expected format: hutch://<path>
@@ -38,6 +48,21 @@ enum DeepLink: Equatable {
38 guard let jobId = Int(components[1]) else { return nil } 48 guard let jobId = Int(components[1]) else { return nil }
39 self = .build(jobId: jobId) 49 self = .build(jobId: jobId)
40 50
51 case "builds":
52 self = .buildsTab
53
54 case "repositories":
55 self = .repositoriesTab
56
57 case "trackers":
58 self = .trackersTab
59
60 case "status":
61 self = .systemStatus
62
63 case "lookup":
64 self = .lookup
65
41 default: 66 default:
42 return nil 67 return nil
43 } 68 }
Hutch/App/HutchApp.swift +15
@@ -15,6 +15,21 @@ struct HutchApp: App {
15 appState.pendingDeepLink = link 15 appState.pendingDeepLink = link
16 } 16 }
17 } 17 }
18 .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in
19 guard let destination else { return }
20 HutchIntentNavigator.shared.pendingDestination = nil
21 let link: DeepLink
22 switch destination {
23 case .home: link = .home
24 case .inbox: link = .home
25 case .builds: link = .buildsTab
26 case .repositories: link = .repositoriesTab
27 case .trackers: link = .trackersTab
28 case .systemStatus: link = .systemStatus
29 case .lookup: link = .lookup
30 }
31 appState.pendingDeepLink = link
32 }
18 } 33 }
19 } 34 }
20} 35}
Hutch/App/HutchIntents.swift added +154
@@ -0,0 +1,154 @@
1import AppIntents
2import Foundation
3
4// MARK: - Open Hutch Intent
5
6enum HutchDestination: String, AppEnum {
7 case home
8 case inbox
9 case builds
10 case repositories
11 case trackers
12 case systemStatus
13 case lookup
14
15 static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Hutch Section")
16
17 static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [
18 .home: "Home",
19 .inbox: "Inbox",
20 .builds: "Builds",
21 .repositories: "Repositories",
22 .trackers: "Trackers",
23 .systemStatus: "System Status",
24 .lookup: "Look Up"
25 ]
26}
27
28struct OpenHutchIntent: AppIntent {
29 static var title: LocalizedStringResource = "Open Hutch"
30 static var description = IntentDescription("Opens Hutch to a specific section.")
31 static var openAppWhenRun = true
32
33 @Parameter(title: "Section", default: .home)
34 var destination: HutchDestination
35
36 @MainActor
37 func perform() async throws -> some IntentResult {
38 HutchIntentNavigator.shared.pendingDestination = destination
39 return .result()
40 }
41}
42
43// MARK: - Check System Status Intent
44
45struct CheckSystemStatusIntent: AppIntent {
46 static var title: LocalizedStringResource = "Check SourceHut Status"
47 static var description = IntentDescription("Returns the current SourceHut system status.")
48
49 @MainActor
50 func perform() async throws -> some IntentResult & ReturnsValue<String> {
51 guard let snapshot = SystemStatusWidgetSnapshotStore.load() else {
52 return .result(value: "System status is unavailable. Open Hutch to refresh.")
53 }
54
55 if snapshot.hasDisruption {
56 let disrupted = snapshot.services
57 .filter { $0.requiresAttention }
58 .map { "\($0.name): \($0.status)" }
59 .joined(separator: ", ")
60 return .result(value: "SourceHut disruption detected: \(disrupted)")
61 }
62
63 return .result(value: "All SourceHut services operational.")
64 }
65}
66
67// MARK: - Check Builds Intent
68
69struct CheckBuildsIntent: AppIntent {
70 static var title: LocalizedStringResource = "Check Hutch Builds"
71 static var description = IntentDescription("Returns a summary of your recent build status.")
72
73 @MainActor
74 func perform() async throws -> some IntentResult & ReturnsValue<String> {
75 guard let snapshot = NeedsAttentionSnapshotStore.load() else {
76 return .result(value: "Build status unavailable. Open Hutch to refresh.")
77 }
78
79 var parts: [String] = []
80
81 if let failed = snapshot.failedBuilds {
82 if failed > 0 {
83 parts.append("\(failed) failed build\(failed == 1 ? "" : "s")")
84 } else {
85 parts.append("No failed builds")
86 }
87 }
88
89 if let unread = snapshot.unreadInboxThreads, unread > 0 {
90 parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")")
91 }
92
93 if let assigned = snapshot.assignedOpenTickets, assigned > 0 {
94 parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")")
95 }
96
97 if parts.isEmpty {
98 return .result(value: "No recent data. Open Hutch to refresh.")
99 }
100
101 return .result(value: parts.joined(separator: ". ") + ".")
102 }
103}
104
105// MARK: - Shortcuts Provider
106
107struct HutchShortcuts: AppShortcutsProvider {
108 static var appShortcuts: [AppShortcut] {
109 AppShortcut(
110 intent: OpenHutchIntent(),
111 phrases: [
112 "Open \(.applicationName)",
113 "Open \(.applicationName) \(\.$destination)",
114 "Show my \(.applicationName) \(\.$destination)",
115 "Go to \(\.$destination) in \(.applicationName)"
116 ],
117 shortTitle: "Open Hutch",
118 systemImageName: "house"
119 )
120
121 AppShortcut(
122 intent: CheckSystemStatusIntent(),
123 phrases: [
124 "Check \(.applicationName) status",
125 "Is SourceHut up in \(.applicationName)",
126 "SourceHut status in \(.applicationName)"
127 ],
128 shortTitle: "Check SourceHut Status",
129 systemImageName: "server.rack"
130 )
131
132 AppShortcut(
133 intent: CheckBuildsIntent(),
134 phrases: [
135 "Check my \(.applicationName) builds",
136 "How are my builds in \(.applicationName)",
137 "Build status in \(.applicationName)"
138 ],
139 shortTitle: "Check Builds",
140 systemImageName: "hammer"
141 )
142 }
143}
144
145// MARK: - Intent Navigator
146
147@MainActor
148@Observable
149final class HutchIntentNavigator {
150 static let shared = HutchIntentNavigator()
151 var pendingDestination: HutchDestination?
152
153 private init() {}
154}
Hutch/App/RootView.swift +71
@@ -113,6 +113,11 @@ struct RootView: View {
113 Label("More", systemImage: "ellipsis.circle") 113 Label("More", systemImage: "ellipsis.circle")
114 } 114 }
115 } 115 }
116 .modifier(SidebarAdaptableTabStyle())
117 .modifier(TabKeyboardShortcuts(selectedTab: Binding(
118 get: { appState.selectedTab },
119 set: { appState.selectedTab = $0 }
120 )))
116 .overlay { 121 .overlay {
117 if isResolvingDeepLink { 122 if isResolvingDeepLink {
118 ZStack { 123 ZStack {
@@ -178,6 +183,29 @@ struct RootView: View {
178 183
179 case .ticket(let owner, let tracker, let ticketId): 184 case .ticket(let owner, let tracker, let ticketId):
180 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId) 185 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
186
187 case .buildsTab:
188 buildsPath = NavigationPath()
189 appState.selectedTab = .builds
190
191 case .repositoriesTab:
192 repoPath = NavigationPath()
193 appState.selectedTab = .repositories
194
195 case .trackersTab:
196 ticketsPath = NavigationPath()
197 appState.selectedTab = .tickets
198
199 case .systemStatus:
200 appState.navigateToSystemStatus()
201
202 case .lookup:
203 morePath = NavigationPath()
204 appState.selectedTab = .more
205 Task {
206 await settleNavigationTransition()
207 morePath.append(MoreRoute.lookup)
208 }
181 } 209 }
182 } 210 }
183 211
@@ -336,3 +364,46 @@ struct TicketDeepLinkTarget: Hashable {
336 let trackerRid: String 364 let trackerRid: String
337 let ticketId: Int 365 let ticketId: Int
338} 366}
367
368// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
369
370/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
371private struct TabKeyboardShortcuts: ViewModifier {
372 @Binding var selectedTab: AppState.Tab
373
374 private static let tabMap: [String: AppState.Tab] = [
375 "1": .home,
376 "2": .repositories,
377 "3": .tickets,
378 "4": .builds,
379 "5": .more,
380 ]
381
382 func body(content: Content) -> some View {
383 content
384 .onKeyPress(characters: .decimalDigits, phases: .down) { press in
385 guard press.modifiers == .command else { return .ignored }
386 let key = String(press.characters)
387 if let tab = Self.tabMap[key] {
388 selectedTab = tab
389 return .handled
390 }
391 return .ignored
392 }
393 }
394}
395
396// MARK: - iPad Sidebar Adaptable
397
398/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
399/// becomes a full sidebar on iPad, while falling back to the standard tab
400/// bar on earlier releases.
401private struct SidebarAdaptableTabStyle: ViewModifier {
402 func body(content: Content) -> some View {
403 if #available(iOS 18.0, *) {
404 content.tabViewStyle(.sidebarAdaptable)
405 } else {
406 content
407 }
408 }
409}
Hutch/Views/Home/HomeViewModel.swift +22
@@ -388,6 +388,7 @@ final class HomeViewModel {
388 } 388 }
389 isLoadingSystemStatus = false 389 isLoadingSystemStatus = false
390 persistNeedsAttentionSnapshot() 390 persistNeedsAttentionSnapshot()
391 persistSystemStatusWidgetSnapshot()
391 } 392 }
392 393
393 var hasDashboardContent: Bool { 394 var hasDashboardContent: Bool {
@@ -928,6 +929,27 @@ final class HomeViewModel {
928 ) 929 )
929 } 930 }
930 931
932 private func persistSystemStatusWidgetSnapshot() {
933 guard let snapshot = systemStatusSnapshot else {
934 return
935 }
936 let widgetSnapshot = SystemStatusWidgetSnapshot(
937 services: snapshot.services.map { service in
938 SystemStatusWidgetSnapshot.ServiceEntry(
939 id: service.id,
940 name: service.name,
941 status: service.status.displayName,
942 requiresAttention: service.status.requiresAttention
943 )
944 },
945 hasDisruption: snapshot.hasDisruption,
946 overallStatusText: snapshot.overallStatusText,
947 bannerSummary: snapshot.bannerSummary,
948 updatedAt: .now
949 )
950 SystemStatusWidgetSnapshotStore.save(widgetSnapshot)
951 }
952
931 nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { 953 nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
932 jobs.map { job in 954 jobs.map { job in
933 let repository = primaryRepositoryReference(in: job.manifest) 955 let repository = primaryRepositoryReference(in: job.manifest)
HutchTests/DeepLinkTests.swift added +90
@@ -0,0 +1,90 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct DeepLinkTests {
6
7 @Test
8 func parsesHomeLink() {
9 let link = DeepLink(url: URL(string: "hutch://home")!)
10 #expect(link == .home)
11 }
12
13 @Test
14 func parsesNilPathAsHome() {
15 let link = DeepLink(url: URL(string: "hutch://")!)
16 #expect(link == .home)
17 }
18
19 @Test
20 func parsesRepositoryLink() {
21 let link = DeepLink(url: URL(string: "hutch://git/~user/repo")!)
22 #expect(link == .repository(owner: "~user", repo: "repo"))
23 }
24
25 @Test
26 func parsesTicketLink() {
27 let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/42")!)
28 #expect(link == .ticket(owner: "~owner", tracker: "tracker", ticketId: 42))
29 }
30
31 @Test
32 func parsesBuildJobLink() {
33 let link = DeepLink(url: URL(string: "hutch://builds/12345")!)
34 #expect(link == .build(jobId: 12345))
35 }
36
37 @Test
38 func parsesBuildsTabLink() {
39 let link = DeepLink(url: URL(string: "hutch://builds")!)
40 #expect(link == .buildsTab)
41 }
42
43 @Test
44 func parsesRepositoriesTabLink() {
45 let link = DeepLink(url: URL(string: "hutch://repositories")!)
46 #expect(link == .repositoriesTab)
47 }
48
49 @Test
50 func parsesTrackersTabLink() {
51 let link = DeepLink(url: URL(string: "hutch://trackers")!)
52 #expect(link == .trackersTab)
53 }
54
55 @Test
56 func parsesSystemStatusLink() {
57 let link = DeepLink(url: URL(string: "hutch://status")!)
58 #expect(link == .systemStatus)
59 }
60
61 @Test
62 func parsesLookupLink() {
63 let link = DeepLink(url: URL(string: "hutch://lookup")!)
64 #expect(link == .lookup)
65 }
66
67 @Test
68 func rejectsNonHutchScheme() {
69 let link = DeepLink(url: URL(string: "https://example.com/home")!)
70 #expect(link == nil)
71 }
72
73 @Test
74 func rejectsUnknownPath() {
75 let link = DeepLink(url: URL(string: "hutch://unknown")!)
76 #expect(link == nil)
77 }
78
79 @Test
80 func rejectsTicketLinkWithNonNumericId() {
81 let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/abc")!)
82 #expect(link == nil)
83 }
84
85 @Test
86 func rejectsBuildLinkWithNonNumericId() {
87 let link = DeepLink(url: URL(string: "hutch://builds/abc")!)
88 #expect(link == nil)
89 }
90}
HutchTests/HutchIntentsTests.swift added +75
@@ -0,0 +1,75 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5@MainActor
6struct HutchIntentsTests {
7
8 @Test
9 func checkSystemStatusReturnsOperationalWhenNoDisruption() async throws {
10 let defaultsName = "HutchIntentsTests-status-\(UUID().uuidString)"
11 let defaults = UserDefaults(suiteName: defaultsName)!
12 defer { defaults.removePersistentDomain(forName: defaultsName) }
13
14 let snapshot = SystemStatusWidgetSnapshot(
15 services: [
16 .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false),
17 ],
18 hasDisruption: false,
19 overallStatusText: "All monitored services operational",
20 bannerSummary: "",
21 updatedAt: .now
22 )
23 SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults)
24
25 let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults)
26 #expect(loaded != nil)
27 #expect(loaded?.hasDisruption == false)
28 }
29
30 @Test
31 func checkSystemStatusReturnsDisruptionDetails() async throws {
32 let defaultsName = "HutchIntentsTests-disruption-\(UUID().uuidString)"
33 let defaults = UserDefaults(suiteName: defaultsName)!
34 defer { defaults.removePersistentDomain(forName: defaultsName) }
35
36 let snapshot = SystemStatusWidgetSnapshot(
37 services: [
38 .init(id: "git", name: "git.sr.ht", status: "Degraded", requiresAttention: true),
39 .init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false),
40 ],
41 hasDisruption: true,
42 overallStatusText: "Experiencing disruptions",
43 bannerSummary: "git.sr.ht disrupted",
44 updatedAt: .now
45 )
46 SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults)
47
48 let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults)
49 #expect(loaded?.hasDisruption == true)
50
51 let disrupted = loaded!.services.filter(\.requiresAttention)
52 #expect(disrupted.count == 1)
53 #expect(disrupted.first?.name == "git.sr.ht")
54 }
55
56 @Test
57 func needsAttentionSnapshotSupportsBuildsCheck() {
58 let defaultsName = "HutchIntentsTests-builds-\(UUID().uuidString)"
59 let defaults = UserDefaults(suiteName: defaultsName)!
60 defer { defaults.removePersistentDomain(forName: defaultsName) }
61
62 let snapshot = NeedsAttentionSnapshot(
63 unreadInboxThreads: 5,
64 assignedOpenTickets: 3,
65 failedBuilds: 2,
66 updatedAt: .now
67 )
68 NeedsAttentionSnapshotStore.save(snapshot, defaults: defaults)
69
70 let loaded = NeedsAttentionSnapshotStore.load(defaults: defaults)
71 #expect(loaded?.failedBuilds == 2)
72 #expect(loaded?.unreadInboxThreads == 5)
73 #expect(loaded?.assignedOpenTickets == 3)
74 }
75}
HutchTests/SystemStatusWidgetSnapshotTests.swift added +73
@@ -0,0 +1,73 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5@MainActor
6struct SystemStatusWidgetSnapshotTests {
7
8 @Test
9 func saveAndLoadRoundTrips() {
10 let defaultsName = "SystemStatusWidgetSnapshotTests-\(UUID().uuidString)"
11 let defaults = UserDefaults(suiteName: defaultsName)!
12 defer { defaults.removePersistentDomain(forName: defaultsName) }
13
14 let snapshot = SystemStatusWidgetSnapshot(
15 services: [
16 .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false),
17 .init(id: "builds", name: "builds.sr.ht", status: "Degraded", requiresAttention: true),
18 ],
19 hasDisruption: true,
20 overallStatusText: "Experiencing disruptions",
21 bannerSummary: "builds.sr.ht disrupted",
22 updatedAt: Date(timeIntervalSince1970: 1000)
23 )
24
25 SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults)
26 let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults)
27
28 #expect(loaded != nil)
29 #expect(loaded?.services.count == 2)
30 #expect(loaded?.hasDisruption == true)
31 #expect(loaded?.bannerSummary == "builds.sr.ht disrupted")
32 #expect(loaded?.services[0].name == "git.sr.ht")
33 #expect(loaded?.services[1].requiresAttention == true)
34 }
35
36 @Test
37 func loadReturnsNilWhenEmpty() {
38 let defaultsName = "SystemStatusWidgetSnapshotTests-empty-\(UUID().uuidString)"
39 let defaults = UserDefaults(suiteName: defaultsName)!
40 defer { defaults.removePersistentDomain(forName: defaultsName) }
41
42 let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults)
43 #expect(loaded == nil)
44 }
45
46 @Test
47 func clearRemovesSnapshot() {
48 let defaultsName = "SystemStatusWidgetSnapshotTests-clear-\(UUID().uuidString)"
49 let defaults = UserDefaults(suiteName: defaultsName)!
50 defer { defaults.removePersistentDomain(forName: defaultsName) }
51
52 let snapshot = SystemStatusWidgetSnapshot(
53 services: [.init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false)],
54 hasDisruption: false,
55 overallStatusText: "All monitored services operational",
56 bannerSummary: "",
57 updatedAt: .now
58 )
59
60 SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults)
61 #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) != nil)
62
63 SystemStatusWidgetSnapshotStore.clear(defaults: defaults)
64 #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) == nil)
65 }
66
67 @Test
68 func unavailableSnapshotHasEmptyServices() {
69 let snapshot = SystemStatusWidgetSnapshot.unavailable
70 #expect(snapshot.services.isEmpty)
71 #expect(snapshot.hasDisruption == false)
72 }
73}
HutchWidgetExtension/NeedsAttentionWidget.swift +46 −33
@@ -71,7 +71,7 @@ private struct NeedsAttentionWidgetView: View {
71 fallbackState 71 fallbackState
72 } 72 }
73 } 73 }
74 .widgetURL(URL(string: "hutch://home")) 74 .widgetURL(family == .systemSmall ? URL(string: "hutch://home") : nil)
75 .containerBackground(for: .widget) { 75 .containerBackground(for: .widget) {
76 Color(.systemBackground) 76 Color(.systemBackground)
77 } 77 }
@@ -101,52 +101,64 @@ private struct NeedsAttentionWidgetView: View {
101 private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View { 101 private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
102 VStack(alignment: .leading, spacing: 0) { 102 VStack(alignment: .leading, spacing: 0) {
103 HStack(alignment: .top, spacing: 12) { 103 HStack(alignment: .top, spacing: 12) {
104 metricColumn( 104 Link(destination: URL(string: "hutch://home")!) {
105 metricColumn(
106 count: snapshot.unreadInboxThreads,
107 label: "Unread",
108 systemImage: "tray",
109 tint: unreadTint(for: snapshot.unreadInboxThreads)
110 )
111 }
112 Link(destination: URL(string: "hutch://trackers")!) {
113 metricColumn(
114 count: snapshot.assignedOpenTickets,
115 label: "Assigned",
116 systemImage: "ticket"
117 )
118 }
119 Link(destination: URL(string: "hutch://builds")!) {
120 metricColumn(
121 count: snapshot.failedBuilds,
122 label: "Failed",
123 systemImage: "hammer",
124 tint: failedTint(for: snapshot.failedBuilds)
125 )
126 }
127 }
128 Spacer(minLength: 0)
129 }
130 .padding(.horizontal, 16)
131 .padding(.top, 18)
132 .padding(.bottom, 16)
133 }
134
135 private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
136 VStack(alignment: .leading, spacing: 18) {
137 Link(destination: URL(string: "hutch://home")!) {
138 metricRow(
105 count: snapshot.unreadInboxThreads, 139 count: snapshot.unreadInboxThreads,
106 label: "Unread", 140 label: "Unread threads",
107 systemImage: "tray", 141 systemImage: "tray",
108 tint: unreadTint(for: snapshot.unreadInboxThreads) 142 tint: unreadTint(for: snapshot.unreadInboxThreads)
109 ) 143 )
110 metricColumn( 144 }
145 Link(destination: URL(string: "hutch://trackers")!) {
146 metricRow(
111 count: snapshot.assignedOpenTickets, 147 count: snapshot.assignedOpenTickets,
112 label: "Assigned", 148 label: "Assigned tickets",
113 systemImage: "ticket" 149 systemImage: "ticket"
114 ) 150 )
115 metricColumn( 151 }
152 Link(destination: URL(string: "hutch://builds")!) {
153 metricRow(
116 count: snapshot.failedBuilds, 154 count: snapshot.failedBuilds,
117 label: "Failed", 155 label: "Failed builds",
118 systemImage: "hammer", 156 systemImage: "hammer",
119 tint: failedTint(for: snapshot.failedBuilds) 157 tint: failedTint(for: snapshot.failedBuilds)
120 ) 158 )
121 } 159 }
122 Spacer(minLength: 0) 160 Spacer(minLength: 0)
123 } 161 }
124 .padding(.horizontal, 16)
125 .padding(.top, 18)
126 .padding(.bottom, 16)
127 }
128
129 private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
130 VStack(alignment: .leading, spacing: 18) {
131 metricRow(
132 count: snapshot.unreadInboxThreads,
133 label: "Unread threads",
134 systemImage: "tray",
135 tint: unreadTint(for: snapshot.unreadInboxThreads)
136 )
137 metricRow(
138 count: snapshot.assignedOpenTickets,
139 label: "Assigned tickets",
140 systemImage: "ticket"
141 )
142 metricRow(
143 count: snapshot.failedBuilds,
144 label: "Failed builds",
145 systemImage: "hammer",
146 tint: failedTint(for: snapshot.failedBuilds)
147 )
148 Spacer(minLength: 0)
149 }
150 .padding(20) 162 .padding(20)
151 } 163 }
152 164
@@ -303,5 +315,6 @@ struct HutchWidgets: WidgetBundle {
303 var body: some Widget { 315 var body: some Widget {
304 NeedsAttentionWidget() 316 NeedsAttentionWidget()
305 ContributionGraphWidget() 317 ContributionGraphWidget()
318 SystemStatusWidget()
306 } 319 }
307} 320}
HutchWidgetExtension/SystemStatusWidget.swift added +177
@@ -0,0 +1,177 @@
1import SwiftUI
2import WidgetKit
3
4struct SystemStatusEntry: TimelineEntry {
5 let date: Date
6 let snapshot: SystemStatusWidgetSnapshot?
7}
8
9struct SystemStatusTimelineProvider: TimelineProvider {
10 func placeholder(in _: Context) -> SystemStatusEntry {
11 SystemStatusEntry(
12 date: .now,
13 snapshot: SystemStatusWidgetSnapshot(
14 services: [
15 .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false),
16 .init(id: "builds", name: "builds.sr.ht", status: "Operational", requiresAttention: false),
17 .init(id: "lists", name: "lists.sr.ht", status: "Operational", requiresAttention: false),
18 .init(id: "todo", name: "todo.sr.ht", status: "Operational", requiresAttention: false),
19 .init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false),
20 .init(id: "hg", name: "hg.sr.ht", status: "Operational", requiresAttention: false),
21 ],
22 hasDisruption: false,
23 overallStatusText: "All monitored services operational",
24 bannerSummary: "",
25 updatedAt: .now
26 )
27 )
28 }
29
30 func getSnapshot(in _: Context, completion: @escaping (SystemStatusEntry) -> Void) {
31 completion(
32 SystemStatusEntry(
33 date: .now,
34 snapshot: SystemStatusWidgetSnapshotStore.load()
35 )
36 )
37 }
38
39 func getTimeline(in _: Context, completion: @escaping (Timeline<SystemStatusEntry>) -> Void) {
40 let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: .now) ?? .now.addingTimeInterval(900)
41 let entry = SystemStatusEntry(date: .now, snapshot: SystemStatusWidgetSnapshotStore.load())
42 completion(Timeline(entries: [entry], policy: .after(refreshDate)))
43 }
44}
45
46struct SystemStatusWidget: Widget {
47 static let kind = SystemStatusWidgetConfiguration.kind
48
49 var body: some WidgetConfiguration {
50 StaticConfiguration(kind: Self.kind, provider: SystemStatusTimelineProvider()) { entry in
51 SystemStatusWidgetView(entry: entry)
52 }
53 .configurationDisplayName("SourceHut Status")
54 .description("Current status of SourceHut services at a glance.")
55 .supportedFamilies([.systemSmall, .systemMedium])
56 }
57}
58
59private struct SystemStatusWidgetView: View {
60 let entry: SystemStatusEntry
61 @Environment(\.widgetFamily) private var family
62
63 var body: some View {
64 Group {
65 if let snapshot = entry.snapshot, !snapshot.services.isEmpty {
66 switch family {
67 case .systemMedium:
68 mediumView(snapshot: snapshot)
69 default:
70 smallView(snapshot: snapshot)
71 }
72 } else {
73 fallbackView
74 }
75 }
76 .widgetURL(URL(string: "hutch://status"))
77 .containerBackground(for: .widget) {
78 Color(.systemBackground)
79 }
80 }
81
82 private func smallView(snapshot: SystemStatusWidgetSnapshot) -> some View {
83 VStack(alignment: .leading, spacing: 8) {
84 HStack(spacing: 6) {
85 Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
86 .font(.title3.weight(.semibold))
87 .foregroundStyle(snapshot.hasDisruption ? .orange : .green)
88 Spacer()
89 }
90
91 Text(snapshot.hasDisruption ? snapshot.bannerSummary : "All Clear")
92 .font(.subheadline.weight(.semibold))
93 .lineLimit(2)
94
95 Spacer(minLength: 0)
96
97 if snapshot.hasDisruption {
98 let disrupted = snapshot.services.filter(\.requiresAttention)
99 Text(disrupted.map(\.name).joined(separator: ", "))
100 .font(.caption2)
101 .foregroundStyle(.secondary)
102 .lineLimit(2)
103 } else {
104 Text("\(snapshot.services.count) services monitored")
105 .font(.caption2)
106 .foregroundStyle(.secondary)
107 }
108 }
109 .padding()
110 }
111
112 private func mediumView(snapshot: SystemStatusWidgetSnapshot) -> some View {
113 VStack(alignment: .leading, spacing: 6) {
114 HStack(spacing: 8) {
115 Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
116 .font(.title3.weight(.semibold))
117 .foregroundStyle(snapshot.hasDisruption ? .orange : .green)
118
119 Text(snapshot.hasDisruption ? snapshot.bannerSummary : "All Services Operational")
120 .font(.subheadline.weight(.semibold))
121 .lineLimit(1)
122
123 Spacer()
124 }
125
126 Divider()
127
128 let columns = Array(snapshot.services.prefix(8))
129 let half = (columns.count + 1) / 2
130 let leftColumn = Array(columns.prefix(half))
131 let rightColumn = Array(columns.suffix(from: half))
132
133 HStack(alignment: .top, spacing: 16) {
134 VStack(alignment: .leading, spacing: 4) {
135 ForEach(leftColumn) { service in
136 serviceRow(service)
137 }
138 }
139 VStack(alignment: .leading, spacing: 4) {
140 ForEach(rightColumn) { service in
141 serviceRow(service)
142 }
143 }
144 Spacer(minLength: 0)
145 }
146
147 Spacer(minLength: 0)
148 }
149 .padding(.horizontal, 16)
150 .padding(.top, 14)
151 .padding(.bottom, 12)
152 }
153
154 private func serviceRow(_ service: SystemStatusWidgetSnapshot.ServiceEntry) -> some View {
155 HStack(spacing: 6) {
156 Circle()
157 .fill(service.requiresAttention ? Color.orange : Color.green)
158 .frame(width: 6, height: 6)
159 Text(service.name)
160 .font(.caption2)
161 .foregroundStyle(service.requiresAttention ? .primary : .secondary)
162 .lineLimit(1)
163 }
164 }
165
166 private var fallbackView: some View {
167 VStack(alignment: .leading, spacing: 8) {
168 Spacer(minLength: 0)
169 Text("Open Hutch")
170 .font(.title3.weight(.semibold))
171 Text("Sign in to load system status.")
172 .font(.caption)
173 .foregroundStyle(.secondary)
174 }
175 .padding()
176 }
177}
Shared/SystemStatusWidgetSnapshot.swift added +67
@@ -0,0 +1,67 @@
1import Foundation
2#if canImport(WidgetKit)
3import WidgetKit
4#endif
5
6enum SystemStatusWidgetConfiguration {
7 static let kind = "SystemStatusWidget"
8}
9
10struct SystemStatusWidgetSnapshot: Codable, Sendable {
11 let services: [ServiceEntry]
12 let hasDisruption: Bool
13 let overallStatusText: String
14 let bannerSummary: String
15 let updatedAt: Date
16
17 struct ServiceEntry: Codable, Sendable, Identifiable {
18 let id: String
19 let name: String
20 let status: String
21 let requiresAttention: Bool
22 }
23
24 static let unavailable = SystemStatusWidgetSnapshot(
25 services: [],
26 hasDisruption: false,
27 overallStatusText: "Unavailable",
28 bannerSummary: "",
29 updatedAt: .now
30 )
31}
32
33enum SystemStatusWidgetSnapshotStore {
34 private static let snapshotKey = "systemStatus.widgetSnapshot"
35
36 static func load(defaults: UserDefaults? = sharedDefaults()) -> SystemStatusWidgetSnapshot? {
37 guard let defaults,
38 let data = defaults.data(forKey: snapshotKey) else {
39 return nil
40 }
41 return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data)
42 }
43
44 static func save(_ snapshot: SystemStatusWidgetSnapshot, defaults: UserDefaults? = sharedDefaults()) {
45 guard let defaults,
46 let data = try? JSONEncoder().encode(snapshot) else {
47 return
48 }
49 defaults.set(data, forKey: snapshotKey)
50 reloadWidgetTimelines()
51 }
52
53 static func clear(defaults: UserDefaults? = sharedDefaults()) {
54 defaults?.removeObject(forKey: snapshotKey)
55 reloadWidgetTimelines()
56 }
57
58 private static func sharedDefaults() -> UserDefaults? {
59 UserDefaults(suiteName: HutchAppGroup.identifier)
60 }
61
62 private static func reloadWidgetTimelines() {
63 #if canImport(WidgetKit)
64 WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind)
65 #endif
66 }
67}