Commit 303f741e56

303f741e567d632553ebfa93f0ce39084b0f634e

parent: 558828a21c

Unsigned

cmc <hello@cleberg.net> · 2026-04-14 00:44 UTC

fix: centralize URLs, tighten models, and polish SwiftUI bindings

- Add shared HutchDeepLinkURL constants for app, widgets, and tests.
- Bump marketing version to 3.1.2 and build to 70 (app + widget extension).
- Point Hutch stats default base URL at HutchStatsAPI; add SRHTWebURL status
  incident feed and reuse it from SystemStatusService.
- Group Project into Metadata and Resources; split ContributionStatsResponse
  into StatsWindow and StatsTotals with updated decoding and tests.
- Replace @Bindable usage with explicit Bindings in Profile, Projects list,
  and repository ACL flows; simplify Home pinned-item helper; add no-op
  bodies on cancel alert buttons where the compiler requires a statement.
- Move repository row build-status indicator next to the relative-updated
  caption and reserve a fixed 8×8 slot so the row does not jump when status
  loads.
- Use NSString.lastPathComponent for build artifact filenames; collapse
  duplicate ACL error branches; minor HutchStats HTTP and XMLParserDelegate
  cleanups.
- Point widgets at HutchDeepLinkURL helpers; align tests with the new
  response and URL types.

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 69;
518 CURRENT_PROJECT_VERSION = 71;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 3.1.1;
535 MARKETING_VERSION = 3.1.3;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 69;
555 CURRENT_PROJECT_VERSION = 71;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 3.1.1;
572 MARKETING_VERSION = 3.1.3;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 69;
635 CURRENT_PROJECT_VERSION = 71;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 3.1.1;
645 MARKETING_VERSION = 3.1.3;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 69;
664 CURRENT_PROJECT_VERSION = 71;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 3.1.1;
674 MARKETING_VERSION = 3.1.3;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/App/AppConfiguration.swift +1 −1
@@ -1,7 +1,7 @@
11import Foundation
22
33struct AppConfiguration: Sendable {
4 static let defaultHutchStatsBaseURL = URL(string: "https://hutch-stats.zerolabs.sh")!
4 static let defaultHutchStatsBaseURL = HutchStatsAPI.defaultBaseURL
55 static let hutchStatsBaseURLEnvironmentKey = "HUTCH_STATS_BASE_URL"
66
77 let hutchStatsBaseURL: URL
Hutch/App/HutchIntents.swift +3 −1
@@ -150,5 +150,7 @@ final class HutchIntentNavigator {
150150 static let shared = HutchIntentNavigator()
151151 var pendingDestination: HutchDestination?
152152
153 private init() {}
153 private init() {
154 /* Singleton; external code uses `shared`. */
155 }
154156}
Hutch/Extensions/SRHTWebURL.swift +2
@@ -3,6 +3,8 @@ import Foundation
33enum SRHTWebURL {
44 static let chat = URL(string: "https://chat.sr.ht")!
55 static let status = URL(string: "https://status.sr.ht")!
6 /// RSS/Atom feed for status incidents (HTML snapshot uses `status`).
7 static let statusIncidentFeed = URL(string: "https://status.sr.ht/index.xml")!
68
79 static func repository(_ repository: RepositorySummary) -> URL? {
810 userScopedURL(
Hutch/Models/Builds.swift +1 −2
@@ -136,8 +136,7 @@ struct BuildArtifact: Codable, Sendable, Identifiable, Equatable {
136136 let url: URL?
137137
138138 var filename: String {
139 let pathComponents = path.split(separator: "/")
140 return pathComponents.last.map(String.init) ?? path
139 (path as NSString).lastPathComponent
141140 }
142141
143142 var isDownloadable: Bool {
Hutch/Models/ContributionCalendar.swift +57 −35
@@ -117,45 +117,67 @@ struct ContributionStatsResponse: Decodable, Sendable, Hashable {
117117 case currentStreak = "current_streak"
118118 }
119119
120 init(
121 actor: String,
122 from: Date,
123 to: Date,
124 isIndexed: Bool,
125 lastPolledAt: Date?,
126 indexingState: ContributionIndexingState,
127 totalEvents: Int,
128 totalScore: Double,
129 activeDays: Int,
130 longestStreak: Int,
131 currentStreak: Int
132 ) {
133 self.actor = actor
134 self.from = from
135 self.to = to
136 self.isIndexed = isIndexed
137 self.lastPolledAt = lastPolledAt
138 self.indexingState = indexingState
139 self.totalEvents = totalEvents
140 self.totalScore = totalScore
141 self.activeDays = activeDays
142 self.longestStreak = longestStreak
143 self.currentStreak = currentStreak
120 struct StatsWindow: Sendable, Hashable {
121 let actor: String
122 let from: Date
123 let to: Date
124 let isIndexed: Bool
125 let lastPolledAt: Date?
126 let indexingState: ContributionIndexingState
127 }
128
129 struct StatsTotals: Sendable, Hashable {
130 let totalEvents: Int
131 let totalScore: Double
132 let activeDays: Int
133 let longestStreak: Int
134 let currentStreak: Int
135 }
136
137 init(window: StatsWindow, totals: StatsTotals) {
138 actor = window.actor
139 from = window.from
140 to = window.to
141 isIndexed = window.isIndexed
142 lastPolledAt = window.lastPolledAt
143 indexingState = window.indexingState
144 totalEvents = totals.totalEvents
145 totalScore = totals.totalScore
146 activeDays = totals.activeDays
147 longestStreak = totals.longestStreak
148 currentStreak = totals.currentStreak
144149 }
145150
146151 init(from decoder: any Decoder) throws {
147152 let container = try decoder.container(keyedBy: CodingKeys.self)
148 actor = try container.decode(String.self, forKey: .actor)
149 from = try ContributionDateParser.decodeDateString(from: container, forKey: .from)
150 to = try ContributionDateParser.decodeDateString(from: container, forKey: .to)
151 isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false
152 lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt)
153 indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed
154 totalEvents = try container.decode(Int.self, forKey: .totalEvents)
155 totalScore = try container.decode(Double.self, forKey: .totalScore)
156 activeDays = try container.decode(Int.self, forKey: .activeDays)
157 longestStreak = try container.decode(Int.self, forKey: .longestStreak)
158 currentStreak = try container.decode(Int.self, forKey: .currentStreak)
153 let actor = try container.decode(String.self, forKey: .actor)
154 let from = try ContributionDateParser.decodeDateString(from: container, forKey: .from)
155 let to = try ContributionDateParser.decodeDateString(from: container, forKey: .to)
156 let isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false
157 let lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt)
158 let indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed
159 let totalEvents = try container.decode(Int.self, forKey: .totalEvents)
160 let totalScore = try container.decode(Double.self, forKey: .totalScore)
161 let activeDays = try container.decode(Int.self, forKey: .activeDays)
162 let longestStreak = try container.decode(Int.self, forKey: .longestStreak)
163 let currentStreak = try container.decode(Int.self, forKey: .currentStreak)
164 self.init(
165 window: .init(
166 actor: actor,
167 from: from,
168 to: to,
169 isIndexed: isIndexed,
170 lastPolledAt: lastPolledAt,
171 indexingState: indexingState
172 ),
173 totals: .init(
174 totalEvents: totalEvents,
175 totalScore: totalScore,
176 activeDays: activeDays,
177 longestStreak: longestStreak,
178 currentStreak: currentStreak
179 )
180 )
159181 }
160182}
161183
Hutch/Models/Project.swift +30 −24
@@ -71,30 +71,36 @@ struct Project: Identifiable, Hashable, Sendable {
7171 let trackers: [Tracker]
7272 let isFullyLoaded: Bool
7373
74 init(
75 id: String,
76 name: String,
77 description: String?,
78 website: String?,
79 visibility: Visibility,
80 tags: [String],
81 updated: Date,
82 mailingLists: [MailingList],
83 sources: [SourceRepo],
84 trackers: [Tracker],
85 isFullyLoaded: Bool = true
86 ) {
87 self.id = id
88 self.name = name
89 self.description = description
90 self.website = website
91 self.visibility = visibility
92 self.tags = tags
93 self.updated = updated
94 self.mailingLists = mailingLists
95 self.sources = sources
96 self.trackers = trackers
97 self.isFullyLoaded = isFullyLoaded
74 /// Identity and display fields for a project.
75 struct Metadata: Sendable, Hashable {
76 let id: String
77 let name: String
78 let description: String?
79 let website: String?
80 let visibility: Visibility
81 let tags: [String]
82 let updated: Date
83 }
84
85 struct Resources: Sendable, Hashable {
86 let mailingLists: [MailingList]
87 let sources: [SourceRepo]
88 let trackers: [Tracker]
89 let isFullyLoaded: Bool
90 }
91
92 init(metadata: Metadata, resources: Resources) {
93 id = metadata.id
94 name = metadata.name
95 description = metadata.description
96 website = metadata.website
97 visibility = metadata.visibility
98 tags = metadata.tags
99 updated = metadata.updated
100 mailingLists = resources.mailingLists
101 sources = resources.sources
102 trackers = resources.trackers
103 isFullyLoaded = resources.isFullyLoaded
98104 }
99105
100106 var resourceSummary: String? {
Hutch/Networking/HutchStatsService.swift +3 −4
@@ -61,10 +61,9 @@ struct HutchStatsService: ContributionCalendarServing {
6161 throw SRHTError.networkError(error)
6262 }
6363
64 if let httpResponse = response as? HTTPURLResponse {
65 if !(200...299).contains(httpResponse.statusCode) {
66 throw SRHTError.httpError(httpResponse.statusCode)
67 }
64 if let httpResponse = response as? HTTPURLResponse,
65 !(200...299).contains(httpResponse.statusCode) {
66 throw SRHTError.httpError(httpResponse.statusCode)
6867 }
6968
7069 do {
Hutch/Networking/ProjectService.swift +25 −22
@@ -372,17 +372,21 @@ struct ProjectService: Sendable {
372372
373373 if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil {
374374 return Project(
375 id: project.rid,
376 name: project.name,
377 description: project.description,
378 website: project.website,
379 visibility: project.visibility,
380 tags: project.tags,
381 updated: project.updated,
382 mailingLists: deduplicate(mailingLists),
383 sources: deduplicate(sources),
384 trackers: deduplicate(trackers),
385 isFullyLoaded: true
375 metadata: .init(
376 id: project.rid,
377 name: project.name,
378 description: project.description,
379 website: project.website,
380 visibility: project.visibility,
381 tags: project.tags,
382 updated: project.updated
383 ),
384 resources: .init(
385 mailingLists: deduplicate(mailingLists),
386 sources: deduplicate(sources),
387 trackers: deduplicate(trackers),
388 isFullyLoaded: true
389 )
386390 )
387391 }
388392 }
@@ -390,17 +394,16 @@ struct ProjectService: Sendable {
390394
391395 private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project {
392396 Project(
393 id: summary.rid,
394 name: summary.name,
395 description: summary.description,
396 website: summary.website,
397 visibility: summary.visibility,
398 tags: summary.tags,
399 updated: summary.updated,
400 mailingLists: [],
401 sources: [],
402 trackers: [],
403 isFullyLoaded: false
397 metadata: .init(
398 id: summary.rid,
399 name: summary.name,
400 description: summary.description,
401 website: summary.website,
402 visibility: summary.visibility,
403 tags: summary.tags,
404 updated: summary.updated
405 ),
406 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
404407 )
405408 }
406409
Hutch/Networking/SystemStatusService.swift +7 −7
@@ -1,8 +1,8 @@
11import Foundation
22
33struct SystemStatusService: Sendable {
4 nonisolated static let statusURL = URL(string: "https://status.sr.ht/")!
5 nonisolated static let feedURL = URL(string: "https://status.sr.ht/index.xml")!
4 nonisolated static let statusURL = SRHTWebURL.status
5 nonisolated static let feedURL = SRHTWebURL.statusIncidentFeed
66
77 private let session: URLSession
88 private let now: @Sendable () -> Date
@@ -340,24 +340,24 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec
340340 return incidents.sorted { $0.publishedAt > $1.publishedAt }
341341 }
342342
343 func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) {
343 func parser(_: XMLParser, didStartElement elementName: String, namespaceURI _: String?, qualifiedName _: String?, attributes _: [String: String] = [:]) {
344344 textBuffer = ""
345345 if elementName == "item" {
346346 currentItem = FeedItem()
347347 }
348348 }
349349
350 func parser(_ parser: XMLParser, foundCharacters string: String) {
350 func parser(_: XMLParser, foundCharacters string: String) {
351351 textBuffer += string
352352 }
353353
354 func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
355 if let string = String(data: CDATABlock, encoding: .utf8) {
354 func parser(_: XMLParser, foundCDATA cdata: Data) {
355 if let string = String(data: cdata, encoding: .utf8) {
356356 textBuffer += string
357357 }
358358 }
359359
360 func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
360 func parser(_: XMLParser, didEndElement elementName: String, namespaceURI _: String?, qualifiedName _: String?) {
361361 guard var currentItem else {
362362 textBuffer = ""
363363 return
Hutch/Views/Home/HomeView.swift +2 −2
@@ -171,7 +171,7 @@ struct HomeView: View {
171171 ) {
172172 ForEach(items) { item in
173173 Button {
174 openPinnedItem(item, viewModel: viewModel)
174 openPinnedItem(item)
175175 } label: {
176176 HomePinnedCard(item: item)
177177 }
@@ -313,7 +313,7 @@ struct HomeView: View {
313313 }
314314 }
315315
316 private func openPinnedItem(_ item: HomePinnedItem, viewModel: HomeViewModel) {
316 private func openPinnedItem(_ item: HomePinnedItem) {
317317 switch item.pin.kind {
318318 case .project:
319319 guard let project = item.project else { return }
Hutch/Views/More/AccountSwitcherView.swift +3 −1
@@ -98,7 +98,9 @@ struct AccountSwitcherView: View {
9898 }
9999 )
100100 ) {
101 Button("Cancel", role: .cancel) {}
101 Button("Cancel", role: .cancel) {
102 /* Dismiss only; removal uses the destructive button. */
103 }
102104 Button("Remove", role: .destructive) {
103105 guard let pendingRemoval else { return }
104106 Task { await appState.removeAccount(id: pendingRemoval.id) }
Hutch/Views/More/ProfileView.swift +30 −17
@@ -61,8 +61,6 @@ struct ProfileView: View {
6161
6262 @ViewBuilder
6363 private func profileContent(_ viewModel: SettingsViewModel) -> some View {
64 @Bindable var vm = viewModel
65
6664 Form {
6765 if let profile = viewModel.profile {
6866 profileSection(profile, viewModel: viewModel)
@@ -100,7 +98,10 @@ struct ProfileView: View {
10098 )
10199 }
102100 }
103 .sheet(isPresented: $vm.isEditingProfile) {
101 .sheet(isPresented: Binding(
102 get: { viewModel.isEditingProfile },
103 set: { viewModel.isEditingProfile = $0 }
104 )) {
104105 if let profile = viewModel.profile {
105106 EditProfileSheet(profile: profile, viewModel: viewModel)
106107 }
@@ -130,7 +131,9 @@ struct ProfileView: View {
130131 }
131132 )
132133 ) {
133 Button("Cancel", role: .cancel) {}
134 Button("Cancel", role: .cancel) {
135 /* Dismiss only; destructive action is separate. */
136 }
134137 Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
135138 guard let action = pendingDestructiveAction else { return }
136139 pendingDestructiveAction = nil
@@ -158,12 +161,11 @@ struct ProfileView: View {
158161 Section("Profile") {
159162 HStack(spacing: 12) {
160163 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
161 switch phase {
162 case .success(let image):
164 if case .success(let image) = phase {
163165 image
164166 .resizable()
165167 .scaledToFill()
166 default:
168 } else {
167169 Image(systemName: "person.crop.circle.fill")
168170 .resizable()
169171 .foregroundStyle(.secondary)
@@ -238,8 +240,6 @@ struct ProfileView: View {
238240
239241 @ViewBuilder
240242 private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
241 @Bindable var vm = viewModel
242
243243 Section {
244244 ForEach(viewModel.sshKeys) { key in
245245 VStack(alignment: .leading, spacing: 2) {
@@ -275,7 +275,14 @@ struct ProfileView: View {
275275 .themedRow()
276276
277277 if viewModel.isAddingSSHKey {
278 TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical)
278 TextField(
279 "Paste SSH public key",
280 text: Binding(
281 get: { viewModel.newSSHKey },
282 set: { viewModel.newSSHKey = $0 }
283 ),
284 axis: .vertical
285 )
279286 .font(.caption.monospaced())
280287 .lineLimit(3...6)
281288 .themedRow()
@@ -310,8 +317,6 @@ struct ProfileView: View {
310317
311318 @ViewBuilder
312319 private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
313 @Bindable var vm = viewModel
314
315320 Section {
316321 ForEach(viewModel.pgpKeys) { key in
317322 VStack(alignment: .leading, spacing: 2) {
@@ -333,7 +338,14 @@ struct ProfileView: View {
333338 .themedRow()
334339
335340 if viewModel.isAddingPGPKey {
336 TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical)
341 TextField(
342 "Paste PGP public key",
343 text: Binding(
344 get: { viewModel.newPGPKey },
345 set: { viewModel.newPGPKey = $0 }
346 ),
347 axis: .vertical
348 )
337349 .font(.caption.monospaced())
338350 .lineLimit(3...6)
339351 .themedRow()
@@ -482,12 +494,11 @@ private struct EditProfileSheet: View {
482494 .scaledToFill()
483495 } else {
484496 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
485 switch phase {
486 case .success(let image):
497 if case .success(let image) = phase {
487498 image
488499 .resizable()
489500 .scaledToFill()
490 default:
501 } else {
491502 Image(systemName: "person.crop.circle.fill")
492503 .resizable()
493504 .foregroundStyle(.secondary)
@@ -592,7 +603,9 @@ private struct EditProfileSheet: View {
592603 }
593604 }
594605 .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
595 Button("Cancel", role: .cancel) {}
606 Button("Cancel", role: .cancel) {
607 /* Dismiss only; removal uses the destructive button. */
608 }
596609 Button("Remove Avatar", role: .destructive) {
597610 Task {
598611 await viewModel.removeAvatar()
Hutch/Views/Projects/ProjectsListView.swift +10 −4
@@ -67,8 +67,6 @@ struct ProjectsListView: View {
6767
6868 @ViewBuilder
6969 private func content(_ viewModel: ProjectsListViewModel) -> some View {
70 @Bindable var vm = viewModel
71
7270 List {
7371 ForEach(viewModel.filteredProjects) { project in
7472 NavigationLink {
@@ -84,7 +82,10 @@ struct ProjectsListView: View {
8482 .themedList()
8583 .listStyle(.plain)
8684 .searchable(
87 text: $vm.searchText,
85 text: Binding(
86 get: { viewModel.searchText },
87 set: { viewModel.searchText = $0 }
88 ),
8889 placement: .navigationBarDrawer(displayMode: .always),
8990 prompt: "Search projects"
9091 )
@@ -107,7 +108,12 @@ struct ProjectsListView: View {
107108 )
108109 }
109110 }
110 .srhtErrorBanner(error: $vm.error)
111 .srhtErrorBanner(
112 error: Binding(
113 get: { viewModel.error },
114 set: { viewModel.error = $0 }
115 )
116 )
111117 .refreshable {
112118 await viewModel.loadProjects()
113119 }
Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift +1 −5
@@ -261,11 +261,7 @@ final class HgRepositorySettingsViewModel {
261261 }
262262 newACLEntity = ""
263263 } catch {
264 if error.matchesGraphQLErrorClassification(.serviceNotProvisioned) {
265 self.error = error.userFacingMessage
266 } else {
267 self.error = error.userFacingMessage
268 }
264 self.error = error.userFacingMessage
269265 }
270266 }
271267
Hutch/Views/Repositories/RepositoryACLView.swift +9 −4
@@ -51,8 +51,6 @@ struct RepositoryACLView: View {
5151
5252 @ViewBuilder
5353 private func content(_ viewModel: RepositoryACLViewModel) -> some View {
54 @Bindable var vm = viewModel
55
5654 Group {
5755 if viewModel.isLoading && !viewModel.hasEntries && viewModel.loadError == nil {
5856 SRHTLoadingStateView(message: "Loading access…")
@@ -98,7 +96,12 @@ struct RepositoryACLView: View {
9896 }
9997 }
10098 }
101 .srhtErrorBanner(error: $vm.error)
99 .srhtErrorBanner(
100 error: Binding(
101 get: { viewModel.error },
102 set: { viewModel.error = $0 }
103 )
104 )
102105 .alert("Remove Access?", isPresented: Binding(
103106 get: { pendingDeletion != nil },
104107 set: { isPresented in
@@ -107,7 +110,9 @@ struct RepositoryACLView: View {
107110 }
108111 }
109112 )) {
110 Button("Cancel", role: .cancel) {}
113 Button("Cancel", role: .cancel) {
114 /* Dismiss only; removal is confirmed separately. */
115 }
111116 Button("Remove Access", role: .destructive) {
112117 guard let entry = pendingDeletion else { return }
113118 Task {
Hutch/Views/Repositories/RepositoryRowView.swift +13 −6
@@ -17,9 +17,6 @@ struct RepositoryRowView: View {
1717
1818 RepositoryForgeBadge(service: repository.service)
1919
20 if buildStatus != .none {
21 RepositoryBuildStatusIndicator(status: buildStatus)
22 }
2320 VisibilityBadge(visibility: repository.visibility)
2421 }
2522
@@ -44,9 +41,19 @@ struct RepositoryRowView: View {
4441
4542 Spacer()
4643
47 Text(repository.updated.relativeDescription)
48 .font(.caption)
49 .foregroundStyle(.tertiary)
44 HStack(spacing: 6) {
45 ZStack {
46 if buildStatus != .none {
47 RepositoryBuildStatusIndicator(status: buildStatus)
48 }
49 }
50 .frame(width: 8, height: 8)
51 .accessibilityHidden(buildStatus == .none)
52
53 Text(repository.updated.relativeDescription)
54 .font(.caption)
55 .foregroundStyle(.tertiary)
56 }
5057 }
5158 }
5259 .padding(.vertical, 2)
Hutch/Views/Settings/SettingsView.swift +3 −1
@@ -32,7 +32,9 @@ struct SettingsView: View {
3232 }
3333 )
3434 ) {
35 Button("Cancel", role: .cancel) {}
35 Button("Cancel", role: .cancel) {
36 /* Dismiss only; destructive action is separate. */
37 }
3638 Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
3739 guard let action = pendingDestructiveAction else { return }
3840 pendingDestructiveAction = nil
HutchTests/ContributionCalendarTests.swift +62 −46
@@ -259,12 +259,12 @@ private final class MockContributionCalendarService: ContributionCalendarServing
259259 self.statsResponses = statsResponses
260260 }
261261
262 func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse {
262 func fetchContributionCalendar(actor _: String, endingOn _: Date) async throws -> ContributionCalendarResponse {
263263 fetchCalendarCallCount += 1
264264 return calendarResponses[min(fetchCalendarCallCount - 1, calendarResponses.count - 1)]
265265 }
266266
267 func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse {
267 func fetchContributionStats(actor _: String, endingOn _: Date) async throws -> ContributionStatsResponse {
268268 fetchStatsCallCount += 1
269269 return statsResponses[min(fetchStatsCallCount - 1, statsResponses.count - 1)]
270270 }
@@ -348,65 +348,81 @@ private extension ContributionCalendarResponse {
348348private extension ContributionStatsResponse {
349349 static func empty(actor: String, year: Int) -> Self {
350350 ContributionStatsResponse(
351 actor: actor,
352 from: ContributionDateParser.parse("\(year)-01-01")!,
353 to: ContributionDateParser.parse("\(year)-01-07")!,
354 isIndexed: true,
355 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
356 indexingState: .indexed,
357 totalEvents: 0,
358 totalScore: 0,
359 activeDays: 0,
360 longestStreak: 0,
361 currentStreak: 0
351 window: .init(
352 actor: actor,
353 from: ContributionDateParser.parse("\(year)-01-01")!,
354 to: ContributionDateParser.parse("\(year)-01-07")!,
355 isIndexed: true,
356 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
357 indexingState: .indexed
358 ),
359 totals: .init(
360 totalEvents: 0,
361 totalScore: 0,
362 activeDays: 0,
363 longestStreak: 0,
364 currentStreak: 0
365 )
362366 )
363367 }
364368
365369 static func active(actor: String, year: Int, totalEvents: Int, activeDays: Int, longestStreak: Int) -> Self {
366370 ContributionStatsResponse(
367 actor: actor,
368 from: ContributionDateParser.parse("\(year)-01-01")!,
369 to: ContributionDateParser.parse("\(year)-01-07")!,
370 isIndexed: true,
371 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
372 indexingState: .indexed,
373 totalEvents: totalEvents,
374 totalScore: Double(totalEvents),
375 activeDays: activeDays,
376 longestStreak: longestStreak,
377 currentStreak: 0
371 window: .init(
372 actor: actor,
373 from: ContributionDateParser.parse("\(year)-01-01")!,
374 to: ContributionDateParser.parse("\(year)-01-07")!,
375 isIndexed: true,
376 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
377 indexingState: .indexed
378 ),
379 totals: .init(
380 totalEvents: totalEvents,
381 totalScore: Double(totalEvents),
382 activeDays: activeDays,
383 longestStreak: longestStreak,
384 currentStreak: 0
385 )
378386 )
379387 }
380388
381389 static func pending(actor: String, year: Int) -> Self {
382390 ContributionStatsResponse(
383 actor: actor,
384 from: ContributionDateParser.parse("\(year)-01-01")!,
385 to: ContributionDateParser.parse("\(year)-01-07")!,
386 isIndexed: false,
387 lastPolledAt: nil,
388 indexingState: .pending,
389 totalEvents: 0,
390 totalScore: 0,
391 activeDays: 0,
392 longestStreak: 0,
393 currentStreak: 0
391 window: .init(
392 actor: actor,
393 from: ContributionDateParser.parse("\(year)-01-01")!,
394 to: ContributionDateParser.parse("\(year)-01-07")!,
395 isIndexed: false,
396 lastPolledAt: nil,
397 indexingState: .pending
398 ),
399 totals: .init(
400 totalEvents: 0,
401 totalScore: 0,
402 activeDays: 0,
403 longestStreak: 0,
404 currentStreak: 0
405 )
394406 )
395407 }
396408
397409 static func error(actor: String, year: Int) -> Self {
398410 ContributionStatsResponse(
399 actor: actor,
400 from: ContributionDateParser.parse("\(year)-01-01")!,
401 to: ContributionDateParser.parse("\(year)-01-07")!,
402 isIndexed: false,
403 lastPolledAt: nil,
404 indexingState: .error,
405 totalEvents: 0,
406 totalScore: 0,
407 activeDays: 0,
408 longestStreak: 0,
409 currentStreak: 0
411 window: .init(
412 actor: actor,
413 from: ContributionDateParser.parse("\(year)-01-01")!,
414 to: ContributionDateParser.parse("\(year)-01-07")!,
415 isIndexed: false,
416 lastPolledAt: nil,
417 indexingState: .error
418 ),
419 totals: .init(
420 totalEvents: 0,
421 totalScore: 0,
422 activeDays: 0,
423 longestStreak: 0,
424 currentStreak: 0
425 )
410426 )
411427 }
412428}
HutchTests/DeepLinkTests.swift +13 −13
@@ -6,61 +6,61 @@ struct DeepLinkTests {
66
77 @Test
88 func parsesHomeLink() {
9 let link = DeepLink(url: URL(string: "hutch://home")!)
9 let link = DeepLink(url: HutchDeepLinkURL.home)
1010 #expect(link == .home)
1111 }
1212
1313 @Test
1414 func parsesNilPathAsHome() {
15 let link = DeepLink(url: URL(string: "hutch://")!)
15 let link = DeepLink(url: HutchDeepLinkURL.emptyHost)
1616 #expect(link == .home)
1717 }
1818
1919 @Test
2020 func parsesRepositoryLink() {
21 let link = DeepLink(url: URL(string: "hutch://git/~user/repo")!)
21 let link = DeepLink(url: HutchDeepLinkURL.repositoryGit)
2222 #expect(link == .repository(owner: "~user", repo: "repo"))
2323 }
2424
2525 @Test
2626 func parsesTicketLink() {
27 let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/42")!)
27 let link = DeepLink(url: HutchDeepLinkURL.ticket)
2828 #expect(link == .ticket(owner: "~owner", tracker: "tracker", ticketId: 42))
2929 }
3030
3131 @Test
3232 func parsesBuildJobLink() {
33 let link = DeepLink(url: URL(string: "hutch://builds/12345")!)
33 let link = DeepLink(url: HutchDeepLinkURL.buildJob)
3434 #expect(link == .build(jobId: 12345))
3535 }
3636
3737 @Test
3838 func parsesBuildsTabLink() {
39 let link = DeepLink(url: URL(string: "hutch://builds")!)
39 let link = DeepLink(url: HutchDeepLinkURL.builds)
4040 #expect(link == .buildsTab)
4141 }
4242
4343 @Test
4444 func parsesRepositoriesTabLink() {
45 let link = DeepLink(url: URL(string: "hutch://repositories")!)
45 let link = DeepLink(url: HutchDeepLinkURL.repositories)
4646 #expect(link == .repositoriesTab)
4747 }
4848
4949 @Test
5050 func parsesTrackersTabLink() {
51 let link = DeepLink(url: URL(string: "hutch://trackers")!)
51 let link = DeepLink(url: HutchDeepLinkURL.trackers)
5252 #expect(link == .trackersTab)
5353 }
5454
5555 @Test
5656 func parsesSystemStatusLink() {
57 let link = DeepLink(url: URL(string: "hutch://status")!)
57 let link = DeepLink(url: HutchDeepLinkURL.status)
5858 #expect(link == .systemStatus)
5959 }
6060
6161 @Test
6262 func parsesLookupLink() {
63 let link = DeepLink(url: URL(string: "hutch://lookup")!)
63 let link = DeepLink(url: HutchDeepLinkURL.lookup)
6464 #expect(link == .lookup)
6565 }
6666
@@ -72,19 +72,19 @@ struct DeepLinkTests {
7272
7373 @Test
7474 func rejectsUnknownPath() {
75 let link = DeepLink(url: URL(string: "hutch://unknown")!)
75 let link = DeepLink(url: HutchDeepLinkURL.unknown)
7676 #expect(link == nil)
7777 }
7878
7979 @Test
8080 func rejectsTicketLinkWithNonNumericId() {
81 let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/abc")!)
81 let link = DeepLink(url: HutchDeepLinkURL.invalidTicketId)
8282 #expect(link == nil)
8383 }
8484
8585 @Test
8686 func rejectsBuildLinkWithNonNumericId() {
87 let link = DeepLink(url: URL(string: "hutch://builds/abc")!)
87 let link = DeepLink(url: HutchDeepLinkURL.invalidBuildId)
8888 #expect(link == nil)
8989 }
9090}
HutchTests/ProjectPinStoreTests.swift +2 −2
@@ -27,11 +27,11 @@ struct ProjectPinStoreTests {
2727 }
2828
2929 @Test
30 func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() {
30 func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() throws {
3131 let defaults = UserDefaults(suiteName: #function)!
3232 defaults.removePersistentDomain(forName: #function)
3333
34 let encoded = try! JSONEncoder().encode([
34 let encoded = try JSONEncoder().encode([
3535 "~alice": [" project-1 ", "", "project-1", "project-2"]
3636 ])
3737 defaults.set(encoded, forKey: AppStorageKeys.pinnedHomeProjects)
HutchTests/ProjectTests.swift +72 −63
@@ -3,52 +3,61 @@ import Testing
33@testable import Hutch
44
55struct ProjectTests {
6 private enum Fixture {
7 static let exampleWebsite = "https://example.com"
8 }
9
610 @Test
711 func resourceSummaryIncludesCounts() {
812 let project = Project(
9 id: "project-1",
10 name: "Hutch",
11 description: nil,
12 website: nil,
13 visibility: .public,
14 tags: [],
15 updated: Date(timeIntervalSince1970: 0),
16 mailingLists: [
17 Project.MailingList(
18 id: "list-1",
19 name: "hutch-devel",
20 description: nil,
21 visibility: .public,
22 owner: Entity(canonicalName: "~owner")
23 )
24 ],
25 sources: [
26 Project.SourceRepo(
27 id: "repo-1",
28 name: "hutch",
29 description: nil,
30 visibility: .public,
31 owner: Entity(canonicalName: "~owner"),
32 repoType: .git
33 ),
34 Project.SourceRepo(
35 id: "repo-2",
36 name: "hutch-web",
37 description: nil,
38 visibility: .public,
39 owner: Entity(canonicalName: "~owner"),
40 repoType: .git
41 )
42 ],
43 trackers: [
44 Project.Tracker(
45 id: "tracker-1",
46 name: "bugs",
47 description: nil,
48 visibility: .public,
49 owner: Entity(canonicalName: "~owner")
50 )
51 ]
13 metadata: .init(
14 id: "project-1",
15 name: "Hutch",
16 description: nil,
17 website: nil,
18 visibility: .public,
19 tags: [],
20 updated: Date(timeIntervalSince1970: 0)
21 ),
22 resources: .init(
23 mailingLists: [
24 Project.MailingList(
25 id: "list-1",
26 name: "hutch-devel",
27 description: nil,
28 visibility: .public,
29 owner: Entity(canonicalName: "~owner")
30 )
31 ],
32 sources: [
33 Project.SourceRepo(
34 id: "repo-1",
35 name: "hutch",
36 description: nil,
37 visibility: .public,
38 owner: Entity(canonicalName: "~owner"),
39 repoType: .git
40 ),
41 Project.SourceRepo(
42 id: "repo-2",
43 name: "hutch-web",
44 description: nil,
45 visibility: .public,
46 owner: Entity(canonicalName: "~owner"),
47 repoType: .git
48 )
49 ],
50 trackers: [
51 Project.Tracker(
52 id: "tracker-1",
53 name: "bugs",
54 description: nil,
55 visibility: .public,
56 owner: Entity(canonicalName: "~owner")
57 )
58 ],
59 isFullyLoaded: true
60 )
5261 )
5362
5463 #expect(project.resourceSummary == "2 repos • 1 tracker • 1 list")
@@ -57,16 +66,16 @@ struct ProjectTests {
5766 @Test
5867 func resourceSummaryFallsBackToWebsite() {
5968 let project = Project(
60 id: "project-1",
61 name: "Docs",
62 description: nil,
63 website: "https://example.com",
64 visibility: .public,
65 tags: [],
66 updated: Date(timeIntervalSince1970: 0),
67 mailingLists: [],
68 sources: [],
69 trackers: []
69 metadata: .init(
70 id: "project-1",
71 name: "Docs",
72 description: nil,
73 website: Fixture.exampleWebsite,
74 visibility: .public,
75 tags: [],
76 updated: Date(timeIntervalSince1970: 0)
77 ),
78 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: true)
7079 )
7180
7281 #expect(project.resourceSummary == "Website linked")
@@ -75,16 +84,16 @@ struct ProjectTests {
7584 @Test
7685 func displayHelpersNormalizeBlankValues() {
7786 let project = Project(
78 id: "project-1",
79 name: " ",
80 description: "\n",
81 website: "https://example.com",
82 visibility: .unlisted,
83 tags: [" docs ", "", "Docs", "ios"],
84 updated: Date(timeIntervalSince1970: 0),
85 mailingLists: [],
86 sources: [],
87 trackers: []
87 metadata: .init(
88 id: "project-1",
89 name: " ",
90 description: "\n",
91 website: Fixture.exampleWebsite,
92 visibility: .unlisted,
93 tags: [" docs ", "", "Docs", "ios"],
94 updated: Date(timeIntervalSince1970: 0)
95 ),
96 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: true)
8897 )
8998
9099 #expect(project.displayName == "Untitled Project")
HutchTests/SRHTWebURLTests.swift +18 −8
@@ -3,6 +3,16 @@ import Testing
33@testable import Hutch
44
55struct SRHTWebURLTests {
6 private enum Expected {
7 static let chatOrigin = "https://chat.sr.ht"
8 static let statusOrigin = "https://status.sr.ht"
9 static let gitRepoHTTPS = "https://git.sr.ht/~ccleberg/hutch"
10 static let gitSSH = "git@git.sr.ht:~ccleberg/hutch"
11 static let tracker = "https://todo.sr.ht/~ccleberg/todo"
12 static let ticket = "https://todo.sr.ht/~ccleberg/todo/42"
13 static let buildJob = "https://builds.sr.ht/~ccleberg/job/12"
14 }
15
616 private let repository = RepositorySummary(
717 id: 1,
818 rid: "repo-1",
@@ -26,21 +36,21 @@ struct SRHTWebURLTests {
2636
2737 @Test
2838 func browserOnlyServiceURLsUseCanonicalHosts() {
29 #expect(SRHTWebURL.chat.absoluteString == "https://chat.sr.ht")
30 #expect(SRHTWebURL.status.absoluteString == "https://status.sr.ht")
39 #expect(SRHTWebURL.chat.absoluteString == Expected.chatOrigin)
40 #expect(SRHTWebURL.status.absoluteString == Expected.statusOrigin)
3141 }
3242
3343 @Test
3444 func repositoryAndCloneURLsUseStableUserScopedPaths() {
35 #expect(SRHTWebURL.repository(repository)?.absoluteString == "https://git.sr.ht/~ccleberg/hutch")
36 #expect(SRHTWebURL.httpsCloneURL(repository) == "https://git.sr.ht/~ccleberg/hutch")
37 #expect(SRHTWebURL.sshCloneURL(repository) == "git@git.sr.ht:~ccleberg/hutch")
45 #expect(SRHTWebURL.repository(repository)?.absoluteString == Expected.gitRepoHTTPS)
46 #expect(SRHTWebURL.httpsCloneURL(repository) == Expected.gitRepoHTTPS)
47 #expect(SRHTWebURL.sshCloneURL(repository) == Expected.gitSSH)
3848 }
3949
4050 @Test
4151 func trackerTicketAndBuildURLsUseStableUserScopedPaths() {
42 #expect(SRHTWebURL.tracker(tracker)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo")
43 #expect(SRHTWebURL.ticket(ownerUsername: "ccleberg", trackerName: "todo", ticketId: 42)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo/42")
44 #expect(SRHTWebURL.build(jobId: 12, ownerCanonicalName: "~ccleberg")?.absoluteString == "https://builds.sr.ht/~ccleberg/job/12")
52 #expect(SRHTWebURL.tracker(tracker)?.absoluteString == Expected.tracker)
53 #expect(SRHTWebURL.ticket(ownerUsername: "ccleberg", trackerName: "todo", ticketId: 42)?.absoluteString == Expected.ticket)
54 #expect(SRHTWebURL.build(jobId: 12, ownerCanonicalName: "~ccleberg")?.absoluteString == Expected.buildJob)
4555 }
4656}
HutchTests/SystemStatusServiceTests.swift +4 −1
@@ -3,6 +3,9 @@ import Testing
33@testable import Hutch
44
55struct SystemStatusServiceTests {
6 private enum Fixture {
7 static let ddosIssueURL = "https://status.sr.ht/issues/2026-04-06-ddos-attack/"
8 }
69
710 @Test
811 func parsesCurrentStatusHTMLIntoServicesAndActiveIncidents() throws {
@@ -16,7 +19,7 @@ struct SystemStatusServiceTests {
1619 #expect(snapshot.activeIncidents.count == 1)
1720 #expect(snapshot.activeIncidents[0].title == "SourceHut disrupted due to DDoS attack")
1821 #expect(snapshot.activeIncidents[0].summary == "SourceHut was disrupted by a DDoS attack.")
19 #expect(snapshot.activeIncidents[0].url?.absoluteString == "https://status.sr.ht/issues/2026-04-06-ddos-attack/")
22 #expect(snapshot.activeIncidents[0].url?.absoluteString == Fixture.ddosIssueURL)
2023 }
2124
2225 @Test
HutchWidgetExtension/ContributionGraphWidget.swift +2 −2
@@ -112,7 +112,7 @@ private struct ContributionGraphWidgetView: View {
112112 graphView
113113 }
114114 }
115 .widgetURL(URL(string: "hutch://home"))
115 .widgetURL(HutchDeepLinkURL.home)
116116 .containerBackground(for: .widget) {
117117 Color(.systemBackground)
118118 }
@@ -235,7 +235,7 @@ private enum ContributionGraphSizing {
235235}
236236
237237private struct ContributionGraphWidgetService {
238 private let baseURL = URL(string: "https://hutch-stats.zerolabs.sh")!
238 private let baseURL = HutchStatsAPI.defaultBaseURL
239239
240240 func fetchCalendar(actor: String) async throws -> ContributionGraphResponse {
241241 guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
HutchWidgetExtension/NeedsAttentionWidget.swift +7 −7
@@ -71,7 +71,7 @@ private struct NeedsAttentionWidgetView: View {
7171 fallbackState
7272 }
7373 }
74 .widgetURL(family == .systemSmall ? URL(string: "hutch://home") : nil)
74 .widgetURL(family == .systemSmall ? HutchDeepLinkURL.home : nil)
7575 .containerBackground(for: .widget) {
7676 Color(.systemBackground)
7777 }
@@ -101,7 +101,7 @@ private struct NeedsAttentionWidgetView: View {
101101 private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
102102 VStack(alignment: .leading, spacing: 0) {
103103 HStack(alignment: .top, spacing: 12) {
104 Link(destination: URL(string: "hutch://home")!) {
104 Link(destination: HutchDeepLinkURL.home) {
105105 metricColumn(
106106 count: snapshot.unreadInboxThreads,
107107 label: "Unread",
@@ -109,14 +109,14 @@ private struct NeedsAttentionWidgetView: View {
109109 tint: unreadTint(for: snapshot.unreadInboxThreads)
110110 )
111111 }
112 Link(destination: URL(string: "hutch://trackers")!) {
112 Link(destination: HutchDeepLinkURL.trackers) {
113113 metricColumn(
114114 count: snapshot.assignedOpenTickets,
115115 label: "Assigned",
116116 systemImage: "ticket"
117117 )
118118 }
119 Link(destination: URL(string: "hutch://builds")!) {
119 Link(destination: HutchDeepLinkURL.builds) {
120120 metricColumn(
121121 count: snapshot.failedBuilds,
122122 label: "Failed",
@@ -134,7 +134,7 @@ private struct NeedsAttentionWidgetView: View {
134134
135135 private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
136136 VStack(alignment: .leading, spacing: 18) {
137 Link(destination: URL(string: "hutch://home")!) {
137 Link(destination: HutchDeepLinkURL.home) {
138138 metricRow(
139139 count: snapshot.unreadInboxThreads,
140140 label: "Unread threads",
@@ -142,14 +142,14 @@ private struct NeedsAttentionWidgetView: View {
142142 tint: unreadTint(for: snapshot.unreadInboxThreads)
143143 )
144144 }
145 Link(destination: URL(string: "hutch://trackers")!) {
145 Link(destination: HutchDeepLinkURL.trackers) {
146146 metricRow(
147147 count: snapshot.assignedOpenTickets,
148148 label: "Assigned tickets",
149149 systemImage: "ticket"
150150 )
151151 }
152 Link(destination: URL(string: "hutch://builds")!) {
152 Link(destination: HutchDeepLinkURL.builds) {
153153 metricRow(
154154 count: snapshot.failedBuilds,
155155 label: "Failed builds",
HutchWidgetExtension/SystemStatusWidget.swift +3 −4
@@ -63,17 +63,16 @@ private struct SystemStatusWidgetView: View {
6363 var body: some View {
6464 Group {
6565 if let snapshot = entry.snapshot, !snapshot.services.isEmpty {
66 switch family {
67 case .systemMedium:
66 if family == .systemMedium {
6867 mediumView(snapshot: snapshot)
69 default:
68 } else {
7069 smallView(snapshot: snapshot)
7170 }
7271 } else {
7372 fallbackView
7473 }
7574 }
76 .widgetURL(URL(string: "hutch://status"))
75 .widgetURL(HutchDeepLinkURL.status)
7776 .containerBackground(for: .widget) {
7877 Color(.systemBackground)
7978 }
Shared/HutchDeepLinkURLs.swift added +23
@@ -0,0 +1,23 @@
1import Foundation
2
3/// Canonical `hutch://` URLs for widgets, tests, and in-app links.
4enum HutchDeepLinkURL {
5 static let home = URL(string: "hutch://home")!
6 static let emptyHost = URL(string: "hutch://")!
7 static let repositoryGit = URL(string: "hutch://git/~user/repo")!
8 static let ticket = URL(string: "hutch://todo/~owner/tracker/42")!
9 static let buildJob = URL(string: "hutch://builds/12345")!
10 static let trackers = URL(string: "hutch://trackers")!
11 static let builds = URL(string: "hutch://builds")!
12 static let repositories = URL(string: "hutch://repositories")!
13 static let status = URL(string: "hutch://status")!
14 static let lookup = URL(string: "hutch://lookup")!
15 static let unknown = URL(string: "hutch://unknown")!
16 static let invalidTicketId = URL(string: "hutch://todo/~owner/tracker/abc")!
17 static let invalidBuildId = URL(string: "hutch://builds/abc")!
18}
19
20/// Default Hutch Stats API base URL (mirrors `AppConfiguration` fallback).
21enum HutchStatsAPI {
22 static let defaultBaseURL = URL(string: "https://hutch-stats.zerolabs.sh")!
23}