summaryrefslogtreecommitdiff
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
parent5f6d545eb3455a9e4da5a3cb4460811e3a48d939 (diff)
downloadhutch-9050405b4f33b2b9b70458fcf3e43b80c940eef7.tar.gz
hutch-9050405b4f33b2b9b70458fcf3e43b80c940eef7.tar.bz2
hutch-9050405b4f33b2b9b70458fcf3e43b80c940eef7.zip
add shortcuts widgets and power-user workflowsv2.15.0
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-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
-rw-r--r--HutchTests/DeepLinkTests.swift90
-rw-r--r--HutchTests/HutchIntentsTests.swift75
-rw-r--r--HutchTests/SystemStatusWidgetSnapshotTests.swift73
-rw-r--r--HutchWidgetExtension/NeedsAttentionWidget.swift79
-rw-r--r--HutchWidgetExtension/SystemStatusWidget.swift177
-rw-r--r--Shared/SystemStatusWidgetSnapshot.swift67
13 files changed, 825 insertions, 41 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index ca3476e..1020bb0 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 31;
+ CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.14.0;
+ MARKETING_VERSION = 2.15.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 31;
+ CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.14.0;
+ MARKETING_VERSION = 2.15.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 31;
+ CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.14.0;
+ MARKETING_VERSION = 2.15.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 31;
+ CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.14.0;
+ MARKETING_VERSION = 2.15.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
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)
diff --git a/HutchTests/DeepLinkTests.swift b/HutchTests/DeepLinkTests.swift
new file mode 100644
index 0000000..12f759b
--- /dev/null
+++ b/HutchTests/DeepLinkTests.swift
@@ -0,0 +1,90 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct DeepLinkTests {
+
+ @Test
+ func parsesHomeLink() {
+ let link = DeepLink(url: URL(string: "hutch://home")!)
+ #expect(link == .home)
+ }
+
+ @Test
+ func parsesNilPathAsHome() {
+ let link = DeepLink(url: URL(string: "hutch://")!)
+ #expect(link == .home)
+ }
+
+ @Test
+ func parsesRepositoryLink() {
+ let link = DeepLink(url: URL(string: "hutch://git/~user/repo")!)
+ #expect(link == .repository(owner: "~user", repo: "repo"))
+ }
+
+ @Test
+ func parsesTicketLink() {
+ let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/42")!)
+ #expect(link == .ticket(owner: "~owner", tracker: "tracker", ticketId: 42))
+ }
+
+ @Test
+ func parsesBuildJobLink() {
+ let link = DeepLink(url: URL(string: "hutch://builds/12345")!)
+ #expect(link == .build(jobId: 12345))
+ }
+
+ @Test
+ func parsesBuildsTabLink() {
+ let link = DeepLink(url: URL(string: "hutch://builds")!)
+ #expect(link == .buildsTab)
+ }
+
+ @Test
+ func parsesRepositoriesTabLink() {
+ let link = DeepLink(url: URL(string: "hutch://repositories")!)
+ #expect(link == .repositoriesTab)
+ }
+
+ @Test
+ func parsesTrackersTabLink() {
+ let link = DeepLink(url: URL(string: "hutch://trackers")!)
+ #expect(link == .trackersTab)
+ }
+
+ @Test
+ func parsesSystemStatusLink() {
+ let link = DeepLink(url: URL(string: "hutch://status")!)
+ #expect(link == .systemStatus)
+ }
+
+ @Test
+ func parsesLookupLink() {
+ let link = DeepLink(url: URL(string: "hutch://lookup")!)
+ #expect(link == .lookup)
+ }
+
+ @Test
+ func rejectsNonHutchScheme() {
+ let link = DeepLink(url: URL(string: "https://example.com/home")!)
+ #expect(link == nil)
+ }
+
+ @Test
+ func rejectsUnknownPath() {
+ let link = DeepLink(url: URL(string: "hutch://unknown")!)
+ #expect(link == nil)
+ }
+
+ @Test
+ func rejectsTicketLinkWithNonNumericId() {
+ let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/abc")!)
+ #expect(link == nil)
+ }
+
+ @Test
+ func rejectsBuildLinkWithNonNumericId() {
+ let link = DeepLink(url: URL(string: "hutch://builds/abc")!)
+ #expect(link == nil)
+ }
+}
diff --git a/HutchTests/HutchIntentsTests.swift b/HutchTests/HutchIntentsTests.swift
new file mode 100644
index 0000000..94ecbf0
--- /dev/null
+++ b/HutchTests/HutchIntentsTests.swift
@@ -0,0 +1,75 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+@MainActor
+struct HutchIntentsTests {
+
+ @Test
+ func checkSystemStatusReturnsOperationalWhenNoDisruption() async throws {
+ let defaultsName = "HutchIntentsTests-status-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: defaultsName)!
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+ let snapshot = SystemStatusWidgetSnapshot(
+ services: [
+ .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false),
+ ],
+ hasDisruption: false,
+ overallStatusText: "All monitored services operational",
+ bannerSummary: "",
+ updatedAt: .now
+ )
+ SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults)
+
+ let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults)
+ #expect(loaded != nil)
+ #expect(loaded?.hasDisruption == false)
+ }
+
+ @Test
+ func checkSystemStatusReturnsDisruptionDetails() async throws {
+ let defaultsName = "HutchIntentsTests-disruption-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: defaultsName)!
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+ let snapshot = SystemStatusWidgetSnapshot(
+ services: [
+ .init(id: "git", name: "git.sr.ht", status: "Degraded", requiresAttention: true),
+ .init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false),
+ ],
+ hasDisruption: true,
+ overallStatusText: "Experiencing disruptions",
+ bannerSummary: "git.sr.ht disrupted",
+ updatedAt: .now
+ )
+ SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults)
+
+ let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults)
+ #expect(loaded?.hasDisruption == true)
+
+ let disrupted = loaded!.services.filter(\.requiresAttention)
+ #expect(disrupted.count == 1)
+ #expect(disrupted.first?.name == "git.sr.ht")
+ }
+
+ @Test
+ func needsAttentionSnapshotSupportsBuildsCheck() {
+ let defaultsName = "HutchIntentsTests-builds-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: defaultsName)!
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+ let snapshot = NeedsAttentionSnapshot(
+ unreadInboxThreads: 5,
+ assignedOpenTickets: 3,
+ failedBuilds: 2,
+ updatedAt: .now
+ )
+ NeedsAttentionSnapshotStore.save(snapshot, defaults: defaults)
+
+ let loaded = NeedsAttentionSnapshotStore.load(defaults: defaults)
+ #expect(loaded?.failedBuilds == 2)
+ #expect(loaded?.unreadInboxThreads == 5)
+ #expect(loaded?.assignedOpenTickets == 3)
+ }
+}
diff --git a/HutchTests/SystemStatusWidgetSnapshotTests.swift b/HutchTests/SystemStatusWidgetSnapshotTests.swift
new file mode 100644
index 0000000..ddb74b1
--- /dev/null
+++ b/HutchTests/SystemStatusWidgetSnapshotTests.swift
@@ -0,0 +1,73 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+@MainActor
+struct SystemStatusWidgetSnapshotTests {
+
+ @Test
+ func saveAndLoadRoundTrips() {
+ let defaultsName = "SystemStatusWidgetSnapshotTests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: defaultsName)!
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+ let snapshot = SystemStatusWidgetSnapshot(
+ services: [
+ .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false),
+ .init(id: "builds", name: "builds.sr.ht", status: "Degraded", requiresAttention: true),
+ ],
+ hasDisruption: true,
+ overallStatusText: "Experiencing disruptions",
+ bannerSummary: "builds.sr.ht disrupted",
+ updatedAt: Date(timeIntervalSince1970: 1000)
+ )
+
+ SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults)
+ let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults)
+
+ #expect(loaded != nil)
+ #expect(loaded?.services.count == 2)
+ #expect(loaded?.hasDisruption == true)
+ #expect(loaded?.bannerSummary == "builds.sr.ht disrupted")
+ #expect(loaded?.services[0].name == "git.sr.ht")
+ #expect(loaded?.services[1].requiresAttention == true)
+ }
+
+ @Test
+ func loadReturnsNilWhenEmpty() {
+ let defaultsName = "SystemStatusWidgetSnapshotTests-empty-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: defaultsName)!
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+ let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults)
+ #expect(loaded == nil)
+ }
+
+ @Test
+ func clearRemovesSnapshot() {
+ let defaultsName = "SystemStatusWidgetSnapshotTests-clear-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: defaultsName)!
+ defer { defaults.removePersistentDomain(forName: defaultsName) }
+
+ let snapshot = SystemStatusWidgetSnapshot(
+ services: [.init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false)],
+ hasDisruption: false,
+ overallStatusText: "All monitored services operational",
+ bannerSummary: "",
+ updatedAt: .now
+ )
+
+ SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults)
+ #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) != nil)
+
+ SystemStatusWidgetSnapshotStore.clear(defaults: defaults)
+ #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) == nil)
+ }
+
+ @Test
+ func unavailableSnapshotHasEmptyServices() {
+ let snapshot = SystemStatusWidgetSnapshot.unavailable
+ #expect(snapshot.services.isEmpty)
+ #expect(snapshot.hasDisruption == false)
+ }
+}
diff --git a/HutchWidgetExtension/NeedsAttentionWidget.swift b/HutchWidgetExtension/NeedsAttentionWidget.swift
index e212af6..574e9eb 100644
--- a/HutchWidgetExtension/NeedsAttentionWidget.swift
+++ b/HutchWidgetExtension/NeedsAttentionWidget.swift
@@ -71,7 +71,7 @@ private struct NeedsAttentionWidgetView: View {
fallbackState
}
}
- .widgetURL(URL(string: "hutch://home"))
+ .widgetURL(family == .systemSmall ? URL(string: "hutch://home") : nil)
.containerBackground(for: .widget) {
Color(.systemBackground)
}
@@ -101,52 +101,64 @@ private struct NeedsAttentionWidgetView: View {
private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 12) {
- metricColumn(
+ Link(destination: URL(string: "hutch://home")!) {
+ metricColumn(
+ count: snapshot.unreadInboxThreads,
+ label: "Unread",
+ systemImage: "tray",
+ tint: unreadTint(for: snapshot.unreadInboxThreads)
+ )
+ }
+ Link(destination: URL(string: "hutch://trackers")!) {
+ metricColumn(
+ count: snapshot.assignedOpenTickets,
+ label: "Assigned",
+ systemImage: "ticket"
+ )
+ }
+ Link(destination: URL(string: "hutch://builds")!) {
+ metricColumn(
+ count: snapshot.failedBuilds,
+ label: "Failed",
+ systemImage: "hammer",
+ tint: failedTint(for: snapshot.failedBuilds)
+ )
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 18)
+ .padding(.bottom, 16)
+ }
+
+ private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
+ VStack(alignment: .leading, spacing: 18) {
+ Link(destination: URL(string: "hutch://home")!) {
+ metricRow(
count: snapshot.unreadInboxThreads,
- label: "Unread",
+ label: "Unread threads",
systemImage: "tray",
tint: unreadTint(for: snapshot.unreadInboxThreads)
)
- metricColumn(
+ }
+ Link(destination: URL(string: "hutch://trackers")!) {
+ metricRow(
count: snapshot.assignedOpenTickets,
- label: "Assigned",
+ label: "Assigned tickets",
systemImage: "ticket"
)
- metricColumn(
+ }
+ Link(destination: URL(string: "hutch://builds")!) {
+ metricRow(
count: snapshot.failedBuilds,
- label: "Failed",
+ label: "Failed builds",
systemImage: "hammer",
tint: failedTint(for: snapshot.failedBuilds)
)
}
Spacer(minLength: 0)
}
- .padding(.horizontal, 16)
- .padding(.top, 18)
- .padding(.bottom, 16)
- }
-
- private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
- VStack(alignment: .leading, spacing: 18) {
- metricRow(
- count: snapshot.unreadInboxThreads,
- label: "Unread threads",
- systemImage: "tray",
- tint: unreadTint(for: snapshot.unreadInboxThreads)
- )
- metricRow(
- count: snapshot.assignedOpenTickets,
- label: "Assigned tickets",
- systemImage: "ticket"
- )
- metricRow(
- count: snapshot.failedBuilds,
- label: "Failed builds",
- systemImage: "hammer",
- tint: failedTint(for: snapshot.failedBuilds)
- )
- Spacer(minLength: 0)
- }
.padding(20)
}
@@ -303,5 +315,6 @@ struct HutchWidgets: WidgetBundle {
var body: some Widget {
NeedsAttentionWidget()
ContributionGraphWidget()
+ SystemStatusWidget()
}
}
diff --git a/HutchWidgetExtension/SystemStatusWidget.swift b/HutchWidgetExtension/SystemStatusWidget.swift
new file mode 100644
index 0000000..4a3c1e5
--- /dev/null
+++ b/HutchWidgetExtension/SystemStatusWidget.swift
@@ -0,0 +1,177 @@
+import SwiftUI
+import WidgetKit
+
+struct SystemStatusEntry: TimelineEntry {
+ let date: Date
+ let snapshot: SystemStatusWidgetSnapshot?
+}
+
+struct SystemStatusTimelineProvider: TimelineProvider {
+ func placeholder(in _: Context) -> SystemStatusEntry {
+ SystemStatusEntry(
+ date: .now,
+ snapshot: SystemStatusWidgetSnapshot(
+ services: [
+ .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false),
+ .init(id: "builds", name: "builds.sr.ht", status: "Operational", requiresAttention: false),
+ .init(id: "lists", name: "lists.sr.ht", status: "Operational", requiresAttention: false),
+ .init(id: "todo", name: "todo.sr.ht", status: "Operational", requiresAttention: false),
+ .init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false),
+ .init(id: "hg", name: "hg.sr.ht", status: "Operational", requiresAttention: false),
+ ],
+ hasDisruption: false,
+ overallStatusText: "All monitored services operational",
+ bannerSummary: "",
+ updatedAt: .now
+ )
+ )
+ }
+
+ func getSnapshot(in _: Context, completion: @escaping (SystemStatusEntry) -> Void) {
+ completion(
+ SystemStatusEntry(
+ date: .now,
+ snapshot: SystemStatusWidgetSnapshotStore.load()
+ )
+ )
+ }
+
+ func getTimeline(in _: Context, completion: @escaping (Timeline<SystemStatusEntry>) -> Void) {
+ let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: .now) ?? .now.addingTimeInterval(900)
+ let entry = SystemStatusEntry(date: .now, snapshot: SystemStatusWidgetSnapshotStore.load())
+ completion(Timeline(entries: [entry], policy: .after(refreshDate)))
+ }
+}
+
+struct SystemStatusWidget: Widget {
+ static let kind = SystemStatusWidgetConfiguration.kind
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: Self.kind, provider: SystemStatusTimelineProvider()) { entry in
+ SystemStatusWidgetView(entry: entry)
+ }
+ .configurationDisplayName("SourceHut Status")
+ .description("Current status of SourceHut services at a glance.")
+ .supportedFamilies([.systemSmall, .systemMedium])
+ }
+}
+
+private struct SystemStatusWidgetView: View {
+ let entry: SystemStatusEntry
+ @Environment(\.widgetFamily) private var family
+
+ var body: some View {
+ Group {
+ if let snapshot = entry.snapshot, !snapshot.services.isEmpty {
+ switch family {
+ case .systemMedium:
+ mediumView(snapshot: snapshot)
+ default:
+ smallView(snapshot: snapshot)
+ }
+ } else {
+ fallbackView
+ }
+ }
+ .widgetURL(URL(string: "hutch://status"))
+ .containerBackground(for: .widget) {
+ Color(.systemBackground)
+ }
+ }
+
+ private func smallView(snapshot: SystemStatusWidgetSnapshot) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 6) {
+ Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(snapshot.hasDisruption ? .orange : .green)
+ Spacer()
+ }
+
+ Text(snapshot.hasDisruption ? snapshot.bannerSummary : "All Clear")
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(2)
+
+ Spacer(minLength: 0)
+
+ if snapshot.hasDisruption {
+ let disrupted = snapshot.services.filter(\.requiresAttention)
+ Text(disrupted.map(\.name).joined(separator: ", "))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ } else {
+ Text("\(snapshot.services.count) services monitored")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding()
+ }
+
+ private func mediumView(snapshot: SystemStatusWidgetSnapshot) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 8) {
+ Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(snapshot.hasDisruption ? .orange : .green)
+
+ Text(snapshot.hasDisruption ? snapshot.bannerSummary : "All Services Operational")
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(1)
+
+ Spacer()
+ }
+
+ Divider()
+
+ let columns = Array(snapshot.services.prefix(8))
+ let half = (columns.count + 1) / 2
+ let leftColumn = Array(columns.prefix(half))
+ let rightColumn = Array(columns.suffix(from: half))
+
+ HStack(alignment: .top, spacing: 16) {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(leftColumn) { service in
+ serviceRow(service)
+ }
+ }
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(rightColumn) { service in
+ serviceRow(service)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 14)
+ .padding(.bottom, 12)
+ }
+
+ private func serviceRow(_ service: SystemStatusWidgetSnapshot.ServiceEntry) -> some View {
+ HStack(spacing: 6) {
+ Circle()
+ .fill(service.requiresAttention ? Color.orange : Color.green)
+ .frame(width: 6, height: 6)
+ Text(service.name)
+ .font(.caption2)
+ .foregroundStyle(service.requiresAttention ? .primary : .secondary)
+ .lineLimit(1)
+ }
+ }
+
+ private var fallbackView: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Spacer(minLength: 0)
+ Text("Open Hutch")
+ .font(.title3.weight(.semibold))
+ Text("Sign in to load system status.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding()
+ }
+}
diff --git a/Shared/SystemStatusWidgetSnapshot.swift b/Shared/SystemStatusWidgetSnapshot.swift
new file mode 100644
index 0000000..8489547
--- /dev/null
+++ b/Shared/SystemStatusWidgetSnapshot.swift
@@ -0,0 +1,67 @@
+import Foundation
+#if canImport(WidgetKit)
+import WidgetKit
+#endif
+
+enum SystemStatusWidgetConfiguration {
+ static let kind = "SystemStatusWidget"
+}
+
+struct SystemStatusWidgetSnapshot: Codable, Sendable {
+ let services: [ServiceEntry]
+ let hasDisruption: Bool
+ let overallStatusText: String
+ let bannerSummary: String
+ let updatedAt: Date
+
+ struct ServiceEntry: Codable, Sendable, Identifiable {
+ let id: String
+ let name: String
+ let status: String
+ let requiresAttention: Bool
+ }
+
+ static let unavailable = SystemStatusWidgetSnapshot(
+ services: [],
+ hasDisruption: false,
+ overallStatusText: "Unavailable",
+ bannerSummary: "",
+ updatedAt: .now
+ )
+}
+
+enum SystemStatusWidgetSnapshotStore {
+ private static let snapshotKey = "systemStatus.widgetSnapshot"
+
+ static func load(defaults: UserDefaults? = sharedDefaults()) -> SystemStatusWidgetSnapshot? {
+ guard let defaults,
+ let data = defaults.data(forKey: snapshotKey) else {
+ return nil
+ }
+ return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data)
+ }
+
+ static func save(_ snapshot: SystemStatusWidgetSnapshot, defaults: UserDefaults? = sharedDefaults()) {
+ guard let defaults,
+ let data = try? JSONEncoder().encode(snapshot) else {
+ return
+ }
+ defaults.set(data, forKey: snapshotKey)
+ reloadWidgetTimelines()
+ }
+
+ static func clear(defaults: UserDefaults? = sharedDefaults()) {
+ defaults?.removeObject(forKey: snapshotKey)
+ reloadWidgetTimelines()
+ }
+
+ private static func sharedDefaults() -> UserDefaults? {
+ UserDefaults(suiteName: HutchAppGroup.identifier)
+ }
+
+ private static func reloadWidgetTimelines() {
+ #if canImport(WidgetKit)
+ WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind)
+ #endif
+ }
+}