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 @@
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 = 69; 518 CURRENT_PROJECT_VERSION = 71;
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 = 3.1.1; 535 MARKETING_VERSION = 3.1.3;
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 = 69; 555 CURRENT_PROJECT_VERSION = 71;
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 = 3.1.1; 572 MARKETING_VERSION = 3.1.3;
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 = 69; 635 CURRENT_PROJECT_VERSION = 71;
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 = 3.1.1; 645 MARKETING_VERSION = 3.1.3;
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 = 69; 664 CURRENT_PROJECT_VERSION = 71;
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 = 3.1.1; 674 MARKETING_VERSION = 3.1.3;
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/AppConfiguration.swift +1 −1
@@ -1,7 +1,7 @@
1import Foundation 1import Foundation
2 2
3struct AppConfiguration: Sendable { 3struct AppConfiguration: Sendable {
4 static let defaultHutchStatsBaseURL = URL(string: "https://hutch-stats.zerolabs.sh")! 4 static let defaultHutchStatsBaseURL = HutchStatsAPI.defaultBaseURL
5 static let hutchStatsBaseURLEnvironmentKey = "HUTCH_STATS_BASE_URL" 5 static let hutchStatsBaseURLEnvironmentKey = "HUTCH_STATS_BASE_URL"
6 6
7 let hutchStatsBaseURL: URL 7 let hutchStatsBaseURL: URL
Hutch/App/HutchIntents.swift +3 −1
@@ -150,5 +150,7 @@ final class HutchIntentNavigator {
150 static let shared = HutchIntentNavigator() 150 static let shared = HutchIntentNavigator()
151 var pendingDestination: HutchDestination? 151 var pendingDestination: HutchDestination?
152 152
153 private init() {} 153 private init() {
154 /* Singleton; external code uses `shared`. */
155 }
154} 156}
Hutch/Extensions/SRHTWebURL.swift +2
@@ -3,6 +3,8 @@ import Foundation
3enum SRHTWebURL { 3enum SRHTWebURL {
4 static let chat = URL(string: "https://chat.sr.ht")! 4 static let chat = URL(string: "https://chat.sr.ht")!
5 static let status = URL(string: "https://status.sr.ht")! 5 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")!
6 8
7 static func repository(_ repository: RepositorySummary) -> URL? { 9 static func repository(_ repository: RepositorySummary) -> URL? {
8 userScopedURL( 10 userScopedURL(
Hutch/Models/Builds.swift +1 −2
@@ -136,8 +136,7 @@ struct BuildArtifact: Codable, Sendable, Identifiable, Equatable {
136 let url: URL? 136 let url: URL?
137 137
138 var filename: String { 138 var filename: String {
139 let pathComponents = path.split(separator: "/") 139 (path as NSString).lastPathComponent
140 return pathComponents.last.map(String.init) ?? path
141 } 140 }
142 141
143 var isDownloadable: Bool { 142 var isDownloadable: Bool {
Hutch/Models/ContributionCalendar.swift +57 −35
@@ -117,45 +117,67 @@ struct ContributionStatsResponse: Decodable, Sendable, Hashable {
117 case currentStreak = "current_streak" 117 case currentStreak = "current_streak"
118 } 118 }
119 119
120 init( 120 struct StatsWindow: Sendable, Hashable {
121 actor: String, 121 let actor: String
122 from: Date, 122 let from: Date
123 to: Date, 123 let to: Date
124 isIndexed: Bool, 124 let isIndexed: Bool
125 lastPolledAt: Date?, 125 let lastPolledAt: Date?
126 indexingState: ContributionIndexingState, 126 let indexingState: ContributionIndexingState
127 totalEvents: Int, 127 }
128 totalScore: Double, 128
129 activeDays: Int, 129 struct StatsTotals: Sendable, Hashable {
130 longestStreak: Int, 130 let totalEvents: Int
131 currentStreak: Int 131 let totalScore: Double
132 ) { 132 let activeDays: Int
133 self.actor = actor 133 let longestStreak: Int
134 self.from = from 134 let currentStreak: Int
135 self.to = to 135 }
136 self.isIndexed = isIndexed 136
137 self.lastPolledAt = lastPolledAt 137 init(window: StatsWindow, totals: StatsTotals) {
138 self.indexingState = indexingState 138 actor = window.actor
139 self.totalEvents = totalEvents 139 from = window.from
140 self.totalScore = totalScore 140 to = window.to
141 self.activeDays = activeDays 141 isIndexed = window.isIndexed
142 self.longestStreak = longestStreak 142 lastPolledAt = window.lastPolledAt
143 self.currentStreak = currentStreak 143 indexingState = window.indexingState
144 totalEvents = totals.totalEvents
145 totalScore = totals.totalScore
146 activeDays = totals.activeDays
147 longestStreak = totals.longestStreak
148 currentStreak = totals.currentStreak
144 } 149 }
145 150
146 init(from decoder: any Decoder) throws { 151 init(from decoder: any Decoder) throws {
147 let container = try decoder.container(keyedBy: CodingKeys.self) 152 let container = try decoder.container(keyedBy: CodingKeys.self)
148 actor = try container.decode(String.self, forKey: .actor) 153 let actor = try container.decode(String.self, forKey: .actor)
149 from = try ContributionDateParser.decodeDateString(from: container, forKey: .from) 154 let from = try ContributionDateParser.decodeDateString(from: container, forKey: .from)
150 to = try ContributionDateParser.decodeDateString(from: container, forKey: .to) 155 let to = try ContributionDateParser.decodeDateString(from: container, forKey: .to)
151 isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false 156 let isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false
152 lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt) 157 let lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt)
153 indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed 158 let indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed
154 totalEvents = try container.decode(Int.self, forKey: .totalEvents) 159 let totalEvents = try container.decode(Int.self, forKey: .totalEvents)
155 totalScore = try container.decode(Double.self, forKey: .totalScore) 160 let totalScore = try container.decode(Double.self, forKey: .totalScore)
156 activeDays = try container.decode(Int.self, forKey: .activeDays) 161 let activeDays = try container.decode(Int.self, forKey: .activeDays)
157 longestStreak = try container.decode(Int.self, forKey: .longestStreak) 162 let longestStreak = try container.decode(Int.self, forKey: .longestStreak)
158 currentStreak = try container.decode(Int.self, forKey: .currentStreak) 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 )
159 } 181 }
160} 182}
161 183
Hutch/Models/Project.swift +30 −24
@@ -71,30 +71,36 @@ struct Project: Identifiable, Hashable, Sendable {
71 let trackers: [Tracker] 71 let trackers: [Tracker]
72 let isFullyLoaded: Bool 72 let isFullyLoaded: Bool
73 73
74 init( 74 /// Identity and display fields for a project.
75 id: String, 75 struct Metadata: Sendable, Hashable {
76 name: String, 76 let id: String
77 description: String?, 77 let name: String
78 website: String?, 78 let description: String?
79 visibility: Visibility, 79 let website: String?
80 tags: [String], 80 let visibility: Visibility
81 updated: Date, 81 let tags: [String]
82 mailingLists: [MailingList], 82 let updated: Date
83 sources: [SourceRepo], 83 }
84 trackers: [Tracker], 84
85 isFullyLoaded: Bool = true 85 struct Resources: Sendable, Hashable {
86 ) { 86 let mailingLists: [MailingList]
87 self.id = id 87 let sources: [SourceRepo]
88 self.name = name 88 let trackers: [Tracker]
89 self.description = description 89 let isFullyLoaded: Bool
90 self.website = website 90 }
91 self.visibility = visibility 91
92 self.tags = tags 92 init(metadata: Metadata, resources: Resources) {
93 self.updated = updated 93 id = metadata.id
94 self.mailingLists = mailingLists 94 name = metadata.name
95 self.sources = sources 95 description = metadata.description
96 self.trackers = trackers 96 website = metadata.website
97 self.isFullyLoaded = isFullyLoaded 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
98 } 104 }
99 105
100 var resourceSummary: String? { 106 var resourceSummary: String? {
Hutch/Networking/HutchStatsService.swift +3 −4
@@ -61,10 +61,9 @@ struct HutchStatsService: ContributionCalendarServing {
61 throw SRHTError.networkError(error) 61 throw SRHTError.networkError(error)
62 } 62 }
63 63
64 if let httpResponse = response as? HTTPURLResponse { 64 if let httpResponse = response as? HTTPURLResponse,
65 if !(200...299).contains(httpResponse.statusCode) { 65 !(200...299).contains(httpResponse.statusCode) {
66 throw SRHTError.httpError(httpResponse.statusCode) 66 throw SRHTError.httpError(httpResponse.statusCode)
67 }
68 } 67 }
69 68
70 do { 69 do {
Hutch/Networking/ProjectService.swift +25 −22
@@ -372,17 +372,21 @@ struct ProjectService: Sendable {
372 372
373 if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil { 373 if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil {
374 return Project( 374 return Project(
375 id: project.rid, 375 metadata: .init(
376 name: project.name, 376 id: project.rid,
377 description: project.description, 377 name: project.name,
378 website: project.website, 378 description: project.description,
379 visibility: project.visibility, 379 website: project.website,
380 tags: project.tags, 380 visibility: project.visibility,
381 updated: project.updated, 381 tags: project.tags,
382 mailingLists: deduplicate(mailingLists), 382 updated: project.updated
383 sources: deduplicate(sources), 383 ),
384 trackers: deduplicate(trackers), 384 resources: .init(
385 isFullyLoaded: true 385 mailingLists: deduplicate(mailingLists),
386 sources: deduplicate(sources),
387 trackers: deduplicate(trackers),
388 isFullyLoaded: true
389 )
386 ) 390 )
387 } 391 }
388 } 392 }
@@ -390,17 +394,16 @@ struct ProjectService: Sendable {
390 394
391 private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project { 395 private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project {
392 Project( 396 Project(
393 id: summary.rid, 397 metadata: .init(
394 name: summary.name, 398 id: summary.rid,
395 description: summary.description, 399 name: summary.name,
396 website: summary.website, 400 description: summary.description,
397 visibility: summary.visibility, 401 website: summary.website,
398 tags: summary.tags, 402 visibility: summary.visibility,
399 updated: summary.updated, 403 tags: summary.tags,
400 mailingLists: [], 404 updated: summary.updated
401 sources: [], 405 ),
402 trackers: [], 406 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
403 isFullyLoaded: false
404 ) 407 )
405 } 408 }
406 409
Hutch/Networking/SystemStatusService.swift +7 −7
@@ -1,8 +1,8 @@
1import Foundation 1import Foundation
2 2
3struct SystemStatusService: Sendable { 3struct SystemStatusService: Sendable {
4 nonisolated static let statusURL = URL(string: "https://status.sr.ht/")! 4 nonisolated static let statusURL = SRHTWebURL.status
5 nonisolated static let feedURL = URL(string: "https://status.sr.ht/index.xml")! 5 nonisolated static let feedURL = SRHTWebURL.statusIncidentFeed
6 6
7 private let session: URLSession 7 private let session: URLSession
8 private let now: @Sendable () -> Date 8 private let now: @Sendable () -> Date
@@ -340,24 +340,24 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec
340 return incidents.sorted { $0.publishedAt > $1.publishedAt } 340 return incidents.sorted { $0.publishedAt > $1.publishedAt }
341 } 341 }
342 342
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] = [:]) {
344 textBuffer = "" 344 textBuffer = ""
345 if elementName == "item" { 345 if elementName == "item" {
346 currentItem = FeedItem() 346 currentItem = FeedItem()
347 } 347 }
348 } 348 }
349 349
350 func parser(_ parser: XMLParser, foundCharacters string: String) { 350 func parser(_: XMLParser, foundCharacters string: String) {
351 textBuffer += string 351 textBuffer += string
352 } 352 }
353 353
354 func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) { 354 func parser(_: XMLParser, foundCDATA cdata: Data) {
355 if let string = String(data: CDATABlock, encoding: .utf8) { 355 if let string = String(data: cdata, encoding: .utf8) {
356 textBuffer += string 356 textBuffer += string
357 } 357 }
358 } 358 }
359 359
360 func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { 360 func parser(_: XMLParser, didEndElement elementName: String, namespaceURI _: String?, qualifiedName _: String?) {
361 guard var currentItem else { 361 guard var currentItem else {
362 textBuffer = "" 362 textBuffer = ""
363 return 363 return
Hutch/Views/Home/HomeView.swift +2 −2
@@ -171,7 +171,7 @@ struct HomeView: View {
171 ) { 171 ) {
172 ForEach(items) { item in 172 ForEach(items) { item in
173 Button { 173 Button {
174 openPinnedItem(item, viewModel: viewModel) 174 openPinnedItem(item)
175 } label: { 175 } label: {
176 HomePinnedCard(item: item) 176 HomePinnedCard(item: item)
177 } 177 }
@@ -313,7 +313,7 @@ struct HomeView: View {
313 } 313 }
314 } 314 }
315 315
316 private func openPinnedItem(_ item: HomePinnedItem, viewModel: HomeViewModel) { 316 private func openPinnedItem(_ item: HomePinnedItem) {
317 switch item.pin.kind { 317 switch item.pin.kind {
318 case .project: 318 case .project:
319 guard let project = item.project else { return } 319 guard let project = item.project else { return }
Hutch/Views/More/AccountSwitcherView.swift +3 −1
@@ -98,7 +98,9 @@ struct AccountSwitcherView: View {
98 } 98 }
99 ) 99 )
100 ) { 100 ) {
101 Button("Cancel", role: .cancel) {} 101 Button("Cancel", role: .cancel) {
102 /* Dismiss only; removal uses the destructive button. */
103 }
102 Button("Remove", role: .destructive) { 104 Button("Remove", role: .destructive) {
103 guard let pendingRemoval else { return } 105 guard let pendingRemoval else { return }
104 Task { await appState.removeAccount(id: pendingRemoval.id) } 106 Task { await appState.removeAccount(id: pendingRemoval.id) }
Hutch/Views/More/ProfileView.swift +30 −17
@@ -61,8 +61,6 @@ struct ProfileView: View {
61 61
62 @ViewBuilder 62 @ViewBuilder
63 private func profileContent(_ viewModel: SettingsViewModel) -> some View { 63 private func profileContent(_ viewModel: SettingsViewModel) -> some View {
64 @Bindable var vm = viewModel
65
66 Form { 64 Form {
67 if let profile = viewModel.profile { 65 if let profile = viewModel.profile {
68 profileSection(profile, viewModel: viewModel) 66 profileSection(profile, viewModel: viewModel)
@@ -100,7 +98,10 @@ struct ProfileView: View {
100 ) 98 )
101 } 99 }
102 } 100 }
103 .sheet(isPresented: $vm.isEditingProfile) { 101 .sheet(isPresented: Binding(
102 get: { viewModel.isEditingProfile },
103 set: { viewModel.isEditingProfile = $0 }
104 )) {
104 if let profile = viewModel.profile { 105 if let profile = viewModel.profile {
105 EditProfileSheet(profile: profile, viewModel: viewModel) 106 EditProfileSheet(profile: profile, viewModel: viewModel)
106 } 107 }
@@ -130,7 +131,9 @@ struct ProfileView: View {
130 } 131 }
131 ) 132 )
132 ) { 133 ) {
133 Button("Cancel", role: .cancel) {} 134 Button("Cancel", role: .cancel) {
135 /* Dismiss only; destructive action is separate. */
136 }
134 Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) { 137 Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
135 guard let action = pendingDestructiveAction else { return } 138 guard let action = pendingDestructiveAction else { return }
136 pendingDestructiveAction = nil 139 pendingDestructiveAction = nil
@@ -158,12 +161,11 @@ struct ProfileView: View {
158 Section("Profile") { 161 Section("Profile") {
159 HStack(spacing: 12) { 162 HStack(spacing: 12) {
160 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in 163 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
161 switch phase { 164 if case .success(let image) = phase {
162 case .success(let image):
163 image 165 image
164 .resizable() 166 .resizable()
165 .scaledToFill() 167 .scaledToFill()
166 default: 168 } else {
167 Image(systemName: "person.crop.circle.fill") 169 Image(systemName: "person.crop.circle.fill")
168 .resizable() 170 .resizable()
169 .foregroundStyle(.secondary) 171 .foregroundStyle(.secondary)
@@ -238,8 +240,6 @@ struct ProfileView: View {
238 240
239 @ViewBuilder 241 @ViewBuilder
240 private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View { 242 private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
241 @Bindable var vm = viewModel
242
243 Section { 243 Section {
244 ForEach(viewModel.sshKeys) { key in 244 ForEach(viewModel.sshKeys) { key in
245 VStack(alignment: .leading, spacing: 2) { 245 VStack(alignment: .leading, spacing: 2) {
@@ -275,7 +275,14 @@ struct ProfileView: View {
275 .themedRow() 275 .themedRow()
276 276
277 if viewModel.isAddingSSHKey { 277 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 )
279 .font(.caption.monospaced()) 286 .font(.caption.monospaced())
280 .lineLimit(3...6) 287 .lineLimit(3...6)
281 .themedRow() 288 .themedRow()
@@ -310,8 +317,6 @@ struct ProfileView: View {
310 317
311 @ViewBuilder 318 @ViewBuilder
312 private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View { 319 private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
313 @Bindable var vm = viewModel
314
315 Section { 320 Section {
316 ForEach(viewModel.pgpKeys) { key in 321 ForEach(viewModel.pgpKeys) { key in
317 VStack(alignment: .leading, spacing: 2) { 322 VStack(alignment: .leading, spacing: 2) {
@@ -333,7 +338,14 @@ struct ProfileView: View {
333 .themedRow() 338 .themedRow()
334 339
335 if viewModel.isAddingPGPKey { 340 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 )
337 .font(.caption.monospaced()) 349 .font(.caption.monospaced())
338 .lineLimit(3...6) 350 .lineLimit(3...6)
339 .themedRow() 351 .themedRow()
@@ -482,12 +494,11 @@ private struct EditProfileSheet: View {
482 .scaledToFill() 494 .scaledToFill()
483 } else { 495 } else {
484 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in 496 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
485 switch phase { 497 if case .success(let image) = phase {
486 case .success(let image):
487 image 498 image
488 .resizable() 499 .resizable()
489 .scaledToFill() 500 .scaledToFill()
490 default: 501 } else {
491 Image(systemName: "person.crop.circle.fill") 502 Image(systemName: "person.crop.circle.fill")
492 .resizable() 503 .resizable()
493 .foregroundStyle(.secondary) 504 .foregroundStyle(.secondary)
@@ -592,7 +603,9 @@ private struct EditProfileSheet: View {
592 } 603 }
593 } 604 }
594 .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) { 605 .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
595 Button("Cancel", role: .cancel) {} 606 Button("Cancel", role: .cancel) {
607 /* Dismiss only; removal uses the destructive button. */
608 }
596 Button("Remove Avatar", role: .destructive) { 609 Button("Remove Avatar", role: .destructive) {
597 Task { 610 Task {
598 await viewModel.removeAvatar() 611 await viewModel.removeAvatar()
Hutch/Views/Projects/ProjectsListView.swift +10 −4
@@ -67,8 +67,6 @@ struct ProjectsListView: View {
67 67
68 @ViewBuilder 68 @ViewBuilder
69 private func content(_ viewModel: ProjectsListViewModel) -> some View { 69 private func content(_ viewModel: ProjectsListViewModel) -> some View {
70 @Bindable var vm = viewModel
71
72 List { 70 List {
73 ForEach(viewModel.filteredProjects) { project in 71 ForEach(viewModel.filteredProjects) { project in
74 NavigationLink { 72 NavigationLink {
@@ -84,7 +82,10 @@ struct ProjectsListView: View {
84 .themedList() 82 .themedList()
85 .listStyle(.plain) 83 .listStyle(.plain)
86 .searchable( 84 .searchable(
87 text: $vm.searchText, 85 text: Binding(
86 get: { viewModel.searchText },
87 set: { viewModel.searchText = $0 }
88 ),
88 placement: .navigationBarDrawer(displayMode: .always), 89 placement: .navigationBarDrawer(displayMode: .always),
89 prompt: "Search projects" 90 prompt: "Search projects"
90 ) 91 )
@@ -107,7 +108,12 @@ struct ProjectsListView: View {
107 ) 108 )
108 } 109 }
109 } 110 }
110 .srhtErrorBanner(error: $vm.error) 111 .srhtErrorBanner(
112 error: Binding(
113 get: { viewModel.error },
114 set: { viewModel.error = $0 }
115 )
116 )
111 .refreshable { 117 .refreshable {
112 await viewModel.loadProjects() 118 await viewModel.loadProjects()
113 } 119 }
Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift +1 −5
@@ -261,11 +261,7 @@ final class HgRepositorySettingsViewModel {
261 } 261 }
262 newACLEntity = "" 262 newACLEntity = ""
263 } catch { 263 } catch {
264 if error.matchesGraphQLErrorClassification(.serviceNotProvisioned) { 264 self.error = error.userFacingMessage
265 self.error = error.userFacingMessage
266 } else {
267 self.error = error.userFacingMessage
268 }
269 } 265 }
270 } 266 }
271 267
Hutch/Views/Repositories/RepositoryACLView.swift +9 −4
@@ -51,8 +51,6 @@ struct RepositoryACLView: View {
51 51
52 @ViewBuilder 52 @ViewBuilder
53 private func content(_ viewModel: RepositoryACLViewModel) -> some View { 53 private func content(_ viewModel: RepositoryACLViewModel) -> some View {
54 @Bindable var vm = viewModel
55
56 Group { 54 Group {
57 if viewModel.isLoading && !viewModel.hasEntries && viewModel.loadError == nil { 55 if viewModel.isLoading && !viewModel.hasEntries && viewModel.loadError == nil {
58 SRHTLoadingStateView(message: "Loading access…") 56 SRHTLoadingStateView(message: "Loading access…")
@@ -98,7 +96,12 @@ struct RepositoryACLView: View {
98 } 96 }
99 } 97 }
100 } 98 }
101 .srhtErrorBanner(error: $vm.error) 99 .srhtErrorBanner(
100 error: Binding(
101 get: { viewModel.error },
102 set: { viewModel.error = $0 }
103 )
104 )
102 .alert("Remove Access?", isPresented: Binding( 105 .alert("Remove Access?", isPresented: Binding(
103 get: { pendingDeletion != nil }, 106 get: { pendingDeletion != nil },
104 set: { isPresented in 107 set: { isPresented in
@@ -107,7 +110,9 @@ struct RepositoryACLView: View {
107 } 110 }
108 } 111 }
109 )) { 112 )) {
110 Button("Cancel", role: .cancel) {} 113 Button("Cancel", role: .cancel) {
114 /* Dismiss only; removal is confirmed separately. */
115 }
111 Button("Remove Access", role: .destructive) { 116 Button("Remove Access", role: .destructive) {
112 guard let entry = pendingDeletion else { return } 117 guard let entry = pendingDeletion else { return }
113 Task { 118 Task {
Hutch/Views/Repositories/RepositoryRowView.swift +13 −6
@@ -17,9 +17,6 @@ struct RepositoryRowView: View {
17 17
18 RepositoryForgeBadge(service: repository.service) 18 RepositoryForgeBadge(service: repository.service)
19 19
20 if buildStatus != .none {
21 RepositoryBuildStatusIndicator(status: buildStatus)
22 }
23 VisibilityBadge(visibility: repository.visibility) 20 VisibilityBadge(visibility: repository.visibility)
24 } 21 }
25 22
@@ -44,9 +41,19 @@ struct RepositoryRowView: View {
44 41
45 Spacer() 42 Spacer()
46 43
47 Text(repository.updated.relativeDescription) 44 HStack(spacing: 6) {
48 .font(.caption) 45 ZStack {
49 .foregroundStyle(.tertiary) 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 }
50 } 57 }
51 } 58 }
52 .padding(.vertical, 2) 59 .padding(.vertical, 2)
Hutch/Views/Settings/SettingsView.swift +3 −1
@@ -32,7 +32,9 @@ struct SettingsView: View {
32 } 32 }
33 ) 33 )
34 ) { 34 ) {
35 Button("Cancel", role: .cancel) {} 35 Button("Cancel", role: .cancel) {
36 /* Dismiss only; destructive action is separate. */
37 }
36 Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) { 38 Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
37 guard let action = pendingDestructiveAction else { return } 39 guard let action = pendingDestructiveAction else { return }
38 pendingDestructiveAction = nil 40 pendingDestructiveAction = nil
HutchTests/ContributionCalendarTests.swift +62 −46
@@ -259,12 +259,12 @@ private final class MockContributionCalendarService: ContributionCalendarServing
259 self.statsResponses = statsResponses 259 self.statsResponses = statsResponses
260 } 260 }
261 261
262 func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse { 262 func fetchContributionCalendar(actor _: String, endingOn _: Date) async throws -> ContributionCalendarResponse {
263 fetchCalendarCallCount += 1 263 fetchCalendarCallCount += 1
264 return calendarResponses[min(fetchCalendarCallCount - 1, calendarResponses.count - 1)] 264 return calendarResponses[min(fetchCalendarCallCount - 1, calendarResponses.count - 1)]
265 } 265 }
266 266
267 func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse { 267 func fetchContributionStats(actor _: String, endingOn _: Date) async throws -> ContributionStatsResponse {
268 fetchStatsCallCount += 1 268 fetchStatsCallCount += 1
269 return statsResponses[min(fetchStatsCallCount - 1, statsResponses.count - 1)] 269 return statsResponses[min(fetchStatsCallCount - 1, statsResponses.count - 1)]
270 } 270 }
@@ -348,65 +348,81 @@ private extension ContributionCalendarResponse {
348private extension ContributionStatsResponse { 348private extension ContributionStatsResponse {
349 static func empty(actor: String, year: Int) -> Self { 349 static func empty(actor: String, year: Int) -> Self {
350 ContributionStatsResponse( 350 ContributionStatsResponse(
351 actor: actor, 351 window: .init(
352 from: ContributionDateParser.parse("\(year)-01-01")!, 352 actor: actor,
353 to: ContributionDateParser.parse("\(year)-01-07")!, 353 from: ContributionDateParser.parse("\(year)-01-01")!,
354 isIndexed: true, 354 to: ContributionDateParser.parse("\(year)-01-07")!,
355 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"), 355 isIndexed: true,
356 indexingState: .indexed, 356 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
357 totalEvents: 0, 357 indexingState: .indexed
358 totalScore: 0, 358 ),
359 activeDays: 0, 359 totals: .init(
360 longestStreak: 0, 360 totalEvents: 0,
361 currentStreak: 0 361 totalScore: 0,
362 activeDays: 0,
363 longestStreak: 0,
364 currentStreak: 0
365 )
362 ) 366 )
363 } 367 }
364 368
365 static func active(actor: String, year: Int, totalEvents: Int, activeDays: Int, longestStreak: Int) -> Self { 369 static func active(actor: String, year: Int, totalEvents: Int, activeDays: Int, longestStreak: Int) -> Self {
366 ContributionStatsResponse( 370 ContributionStatsResponse(
367 actor: actor, 371 window: .init(
368 from: ContributionDateParser.parse("\(year)-01-01")!, 372 actor: actor,
369 to: ContributionDateParser.parse("\(year)-01-07")!, 373 from: ContributionDateParser.parse("\(year)-01-01")!,
370 isIndexed: true, 374 to: ContributionDateParser.parse("\(year)-01-07")!,
371 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"), 375 isIndexed: true,
372 indexingState: .indexed, 376 lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
373 totalEvents: totalEvents, 377 indexingState: .indexed
374 totalScore: Double(totalEvents), 378 ),
375 activeDays: activeDays, 379 totals: .init(
376 longestStreak: longestStreak, 380 totalEvents: totalEvents,
377 currentStreak: 0 381 totalScore: Double(totalEvents),
382 activeDays: activeDays,
383 longestStreak: longestStreak,
384 currentStreak: 0
385 )
378 ) 386 )
379 } 387 }
380 388
381 static func pending(actor: String, year: Int) -> Self { 389 static func pending(actor: String, year: Int) -> Self {
382 ContributionStatsResponse( 390 ContributionStatsResponse(
383 actor: actor, 391 window: .init(
384 from: ContributionDateParser.parse("\(year)-01-01")!, 392 actor: actor,
385 to: ContributionDateParser.parse("\(year)-01-07")!, 393 from: ContributionDateParser.parse("\(year)-01-01")!,
386 isIndexed: false, 394 to: ContributionDateParser.parse("\(year)-01-07")!,
387 lastPolledAt: nil, 395 isIndexed: false,
388 indexingState: .pending, 396 lastPolledAt: nil,
389 totalEvents: 0, 397 indexingState: .pending
390 totalScore: 0, 398 ),
391 activeDays: 0, 399 totals: .init(
392 longestStreak: 0, 400 totalEvents: 0,
393 currentStreak: 0 401 totalScore: 0,
402 activeDays: 0,
403 longestStreak: 0,
404 currentStreak: 0
405 )
394 ) 406 )
395 } 407 }
396 408
397 static func error(actor: String, year: Int) -> Self { 409 static func error(actor: String, year: Int) -> Self {
398 ContributionStatsResponse( 410 ContributionStatsResponse(
399 actor: actor, 411 window: .init(
400 from: ContributionDateParser.parse("\(year)-01-01")!, 412 actor: actor,
401 to: ContributionDateParser.parse("\(year)-01-07")!, 413 from: ContributionDateParser.parse("\(year)-01-01")!,
402 isIndexed: false, 414 to: ContributionDateParser.parse("\(year)-01-07")!,
403 lastPolledAt: nil, 415 isIndexed: false,
404 indexingState: .error, 416 lastPolledAt: nil,
405 totalEvents: 0, 417 indexingState: .error
406 totalScore: 0, 418 ),
407 activeDays: 0, 419 totals: .init(
408 longestStreak: 0, 420 totalEvents: 0,
409 currentStreak: 0 421 totalScore: 0,
422 activeDays: 0,
423 longestStreak: 0,
424 currentStreak: 0
425 )
410 ) 426 )
411 } 427 }
412} 428}
HutchTests/DeepLinkTests.swift +13 −13
@@ -6,61 +6,61 @@ struct DeepLinkTests {
6 6
7 @Test 7 @Test
8 func parsesHomeLink() { 8 func parsesHomeLink() {
9 let link = DeepLink(url: URL(string: "hutch://home")!) 9 let link = DeepLink(url: HutchDeepLinkURL.home)
10 #expect(link == .home) 10 #expect(link == .home)
11 } 11 }
12 12
13 @Test 13 @Test
14 func parsesNilPathAsHome() { 14 func parsesNilPathAsHome() {
15 let link = DeepLink(url: URL(string: "hutch://")!) 15 let link = DeepLink(url: HutchDeepLinkURL.emptyHost)
16 #expect(link == .home) 16 #expect(link == .home)
17 } 17 }
18 18
19 @Test 19 @Test
20 func parsesRepositoryLink() { 20 func parsesRepositoryLink() {
21 let link = DeepLink(url: URL(string: "hutch://git/~user/repo")!) 21 let link = DeepLink(url: HutchDeepLinkURL.repositoryGit)
22 #expect(link == .repository(owner: "~user", repo: "repo")) 22 #expect(link == .repository(owner: "~user", repo: "repo"))
23 } 23 }
24 24
25 @Test 25 @Test
26 func parsesTicketLink() { 26 func parsesTicketLink() {
27 let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/42")!) 27 let link = DeepLink(url: HutchDeepLinkURL.ticket)
28 #expect(link == .ticket(owner: "~owner", tracker: "tracker", ticketId: 42)) 28 #expect(link == .ticket(owner: "~owner", tracker: "tracker", ticketId: 42))
29 } 29 }
30 30
31 @Test 31 @Test
32 func parsesBuildJobLink() { 32 func parsesBuildJobLink() {
33 let link = DeepLink(url: URL(string: "hutch://builds/12345")!) 33 let link = DeepLink(url: HutchDeepLinkURL.buildJob)
34 #expect(link == .build(jobId: 12345)) 34 #expect(link == .build(jobId: 12345))
35 } 35 }
36 36
37 @Test 37 @Test
38 func parsesBuildsTabLink() { 38 func parsesBuildsTabLink() {
39 let link = DeepLink(url: URL(string: "hutch://builds")!) 39 let link = DeepLink(url: HutchDeepLinkURL.builds)
40 #expect(link == .buildsTab) 40 #expect(link == .buildsTab)
41 } 41 }
42 42
43 @Test 43 @Test
44 func parsesRepositoriesTabLink() { 44 func parsesRepositoriesTabLink() {
45 let link = DeepLink(url: URL(string: "hutch://repositories")!) 45 let link = DeepLink(url: HutchDeepLinkURL.repositories)
46 #expect(link == .repositoriesTab) 46 #expect(link == .repositoriesTab)
47 } 47 }
48 48
49 @Test 49 @Test
50 func parsesTrackersTabLink() { 50 func parsesTrackersTabLink() {
51 let link = DeepLink(url: URL(string: "hutch://trackers")!) 51 let link = DeepLink(url: HutchDeepLinkURL.trackers)
52 #expect(link == .trackersTab) 52 #expect(link == .trackersTab)
53 } 53 }
54 54
55 @Test 55 @Test
56 func parsesSystemStatusLink() { 56 func parsesSystemStatusLink() {
57 let link = DeepLink(url: URL(string: "hutch://status")!) 57 let link = DeepLink(url: HutchDeepLinkURL.status)
58 #expect(link == .systemStatus) 58 #expect(link == .systemStatus)
59 } 59 }
60 60
61 @Test 61 @Test
62 func parsesLookupLink() { 62 func parsesLookupLink() {
63 let link = DeepLink(url: URL(string: "hutch://lookup")!) 63 let link = DeepLink(url: HutchDeepLinkURL.lookup)
64 #expect(link == .lookup) 64 #expect(link == .lookup)
65 } 65 }
66 66
@@ -72,19 +72,19 @@ struct DeepLinkTests {
72 72
73 @Test 73 @Test
74 func rejectsUnknownPath() { 74 func rejectsUnknownPath() {
75 let link = DeepLink(url: URL(string: "hutch://unknown")!) 75 let link = DeepLink(url: HutchDeepLinkURL.unknown)
76 #expect(link == nil) 76 #expect(link == nil)
77 } 77 }
78 78
79 @Test 79 @Test
80 func rejectsTicketLinkWithNonNumericId() { 80 func rejectsTicketLinkWithNonNumericId() {
81 let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/abc")!) 81 let link = DeepLink(url: HutchDeepLinkURL.invalidTicketId)
82 #expect(link == nil) 82 #expect(link == nil)
83 } 83 }
84 84
85 @Test 85 @Test
86 func rejectsBuildLinkWithNonNumericId() { 86 func rejectsBuildLinkWithNonNumericId() {
87 let link = DeepLink(url: URL(string: "hutch://builds/abc")!) 87 let link = DeepLink(url: HutchDeepLinkURL.invalidBuildId)
88 #expect(link == nil) 88 #expect(link == nil)
89 } 89 }
90} 90}
HutchTests/ProjectPinStoreTests.swift +2 −2
@@ -27,11 +27,11 @@ struct ProjectPinStoreTests {
27 } 27 }
28 28
29 @Test 29 @Test
30 func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() { 30 func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() throws {
31 let defaults = UserDefaults(suiteName: #function)! 31 let defaults = UserDefaults(suiteName: #function)!
32 defaults.removePersistentDomain(forName: #function) 32 defaults.removePersistentDomain(forName: #function)
33 33
34 let encoded = try! JSONEncoder().encode([ 34 let encoded = try JSONEncoder().encode([
35 "~alice": [" project-1 ", "", "project-1", "project-2"] 35 "~alice": [" project-1 ", "", "project-1", "project-2"]
36 ]) 36 ])
37 defaults.set(encoded, forKey: AppStorageKeys.pinnedHomeProjects) 37 defaults.set(encoded, forKey: AppStorageKeys.pinnedHomeProjects)
HutchTests/ProjectTests.swift +72 −63
@@ -3,52 +3,61 @@ import Testing
3@testable import Hutch 3@testable import Hutch
4 4
5struct ProjectTests { 5struct ProjectTests {
6 private enum Fixture {
7 static let exampleWebsite = "https://example.com"
8 }
9
6 @Test 10 @Test
7 func resourceSummaryIncludesCounts() { 11 func resourceSummaryIncludesCounts() {
8 let project = Project( 12 let project = Project(
9 id: "project-1", 13 metadata: .init(
10 name: "Hutch", 14 id: "project-1",
11 description: nil, 15 name: "Hutch",
12 website: nil, 16 description: nil,
13 visibility: .public, 17 website: nil,
14 tags: [], 18 visibility: .public,
15 updated: Date(timeIntervalSince1970: 0), 19 tags: [],
16 mailingLists: [ 20 updated: Date(timeIntervalSince1970: 0)
17 Project.MailingList( 21 ),
18 id: "list-1", 22 resources: .init(
19 name: "hutch-devel", 23 mailingLists: [
20 description: nil, 24 Project.MailingList(
21 visibility: .public, 25 id: "list-1",
22 owner: Entity(canonicalName: "~owner") 26 name: "hutch-devel",
23 ) 27 description: nil,
24 ], 28 visibility: .public,
25 sources: [ 29 owner: Entity(canonicalName: "~owner")
26 Project.SourceRepo( 30 )
27 id: "repo-1", 31 ],
28 name: "hutch", 32 sources: [
29 description: nil, 33 Project.SourceRepo(
30 visibility: .public, 34 id: "repo-1",
31 owner: Entity(canonicalName: "~owner"), 35 name: "hutch",
32 repoType: .git 36 description: nil,
33 ), 37 visibility: .public,
34 Project.SourceRepo( 38 owner: Entity(canonicalName: "~owner"),
35 id: "repo-2", 39 repoType: .git
36 name: "hutch-web", 40 ),
37 description: nil, 41 Project.SourceRepo(
38 visibility: .public, 42 id: "repo-2",
39 owner: Entity(canonicalName: "~owner"), 43 name: "hutch-web",
40 repoType: .git 44 description: nil,
41 ) 45 visibility: .public,
42 ], 46 owner: Entity(canonicalName: "~owner"),
43 trackers: [ 47 repoType: .git
44 Project.Tracker( 48 )
45 id: "tracker-1", 49 ],
46 name: "bugs", 50 trackers: [
47 description: nil, 51 Project.Tracker(
48 visibility: .public, 52 id: "tracker-1",
49 owner: Entity(canonicalName: "~owner") 53 name: "bugs",
50 ) 54 description: nil,
51 ] 55 visibility: .public,
56 owner: Entity(canonicalName: "~owner")
57 )
58 ],
59 isFullyLoaded: true
60 )
52 ) 61 )
53 62
54 #expect(project.resourceSummary == "2 repos • 1 tracker • 1 list") 63 #expect(project.resourceSummary == "2 repos • 1 tracker • 1 list")
@@ -57,16 +66,16 @@ struct ProjectTests {
57 @Test 66 @Test
58 func resourceSummaryFallsBackToWebsite() { 67 func resourceSummaryFallsBackToWebsite() {
59 let project = Project( 68 let project = Project(
60 id: "project-1", 69 metadata: .init(
61 name: "Docs", 70 id: "project-1",
62 description: nil, 71 name: "Docs",
63 website: "https://example.com", 72 description: nil,
64 visibility: .public, 73 website: Fixture.exampleWebsite,
65 tags: [], 74 visibility: .public,
66 updated: Date(timeIntervalSince1970: 0), 75 tags: [],
67 mailingLists: [], 76 updated: Date(timeIntervalSince1970: 0)
68 sources: [], 77 ),
69 trackers: [] 78 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: true)
70 ) 79 )
71 80
72 #expect(project.resourceSummary == "Website linked") 81 #expect(project.resourceSummary == "Website linked")
@@ -75,16 +84,16 @@ struct ProjectTests {
75 @Test 84 @Test
76 func displayHelpersNormalizeBlankValues() { 85 func displayHelpersNormalizeBlankValues() {
77 let project = Project( 86 let project = Project(
78 id: "project-1", 87 metadata: .init(
79 name: " ", 88 id: "project-1",
80 description: "\n", 89 name: " ",
81 website: "https://example.com", 90 description: "\n",
82 visibility: .unlisted, 91 website: Fixture.exampleWebsite,
83 tags: [" docs ", "", "Docs", "ios"], 92 visibility: .unlisted,
84 updated: Date(timeIntervalSince1970: 0), 93 tags: [" docs ", "", "Docs", "ios"],
85 mailingLists: [], 94 updated: Date(timeIntervalSince1970: 0)
86 sources: [], 95 ),
87 trackers: [] 96 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: true)
88 ) 97 )
89 98
90 #expect(project.displayName == "Untitled Project") 99 #expect(project.displayName == "Untitled Project")
HutchTests/SRHTWebURLTests.swift +18 −8
@@ -3,6 +3,16 @@ import Testing
3@testable import Hutch 3@testable import Hutch
4 4
5struct SRHTWebURLTests { 5struct 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
6 private let repository = RepositorySummary( 16 private let repository = RepositorySummary(
7 id: 1, 17 id: 1,
8 rid: "repo-1", 18 rid: "repo-1",
@@ -26,21 +36,21 @@ struct SRHTWebURLTests {
26 36
27 @Test 37 @Test
28 func browserOnlyServiceURLsUseCanonicalHosts() { 38 func browserOnlyServiceURLsUseCanonicalHosts() {
29 #expect(SRHTWebURL.chat.absoluteString == "https://chat.sr.ht") 39 #expect(SRHTWebURL.chat.absoluteString == Expected.chatOrigin)
30 #expect(SRHTWebURL.status.absoluteString == "https://status.sr.ht") 40 #expect(SRHTWebURL.status.absoluteString == Expected.statusOrigin)
31 } 41 }
32 42
33 @Test 43 @Test
34 func repositoryAndCloneURLsUseStableUserScopedPaths() { 44 func repositoryAndCloneURLsUseStableUserScopedPaths() {
35 #expect(SRHTWebURL.repository(repository)?.absoluteString == "https://git.sr.ht/~ccleberg/hutch") 45 #expect(SRHTWebURL.repository(repository)?.absoluteString == Expected.gitRepoHTTPS)
36 #expect(SRHTWebURL.httpsCloneURL(repository) == "https://git.sr.ht/~ccleberg/hutch") 46 #expect(SRHTWebURL.httpsCloneURL(repository) == Expected.gitRepoHTTPS)
37 #expect(SRHTWebURL.sshCloneURL(repository) == "git@git.sr.ht:~ccleberg/hutch") 47 #expect(SRHTWebURL.sshCloneURL(repository) == Expected.gitSSH)
38 } 48 }
39 49
40 @Test 50 @Test
41 func trackerTicketAndBuildURLsUseStableUserScopedPaths() { 51 func trackerTicketAndBuildURLsUseStableUserScopedPaths() {
42 #expect(SRHTWebURL.tracker(tracker)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo") 52 #expect(SRHTWebURL.tracker(tracker)?.absoluteString == Expected.tracker)
43 #expect(SRHTWebURL.ticket(ownerUsername: "ccleberg", trackerName: "todo", ticketId: 42)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo/42") 53 #expect(SRHTWebURL.ticket(ownerUsername: "ccleberg", trackerName: "todo", ticketId: 42)?.absoluteString == Expected.ticket)
44 #expect(SRHTWebURL.build(jobId: 12, ownerCanonicalName: "~ccleberg")?.absoluteString == "https://builds.sr.ht/~ccleberg/job/12") 54 #expect(SRHTWebURL.build(jobId: 12, ownerCanonicalName: "~ccleberg")?.absoluteString == Expected.buildJob)
45 } 55 }
46} 56}
HutchTests/SystemStatusServiceTests.swift +4 −1
@@ -3,6 +3,9 @@ import Testing
3@testable import Hutch 3@testable import Hutch
4 4
5struct SystemStatusServiceTests { 5struct SystemStatusServiceTests {
6 private enum Fixture {
7 static let ddosIssueURL = "https://status.sr.ht/issues/2026-04-06-ddos-attack/"
8 }
6 9
7 @Test 10 @Test
8 func parsesCurrentStatusHTMLIntoServicesAndActiveIncidents() throws { 11 func parsesCurrentStatusHTMLIntoServicesAndActiveIncidents() throws {
@@ -16,7 +19,7 @@ struct SystemStatusServiceTests {
16 #expect(snapshot.activeIncidents.count == 1) 19 #expect(snapshot.activeIncidents.count == 1)
17 #expect(snapshot.activeIncidents[0].title == "SourceHut disrupted due to DDoS attack") 20 #expect(snapshot.activeIncidents[0].title == "SourceHut disrupted due to DDoS attack")
18 #expect(snapshot.activeIncidents[0].summary == "SourceHut was disrupted by a DDoS attack.") 21 #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)
20 } 23 }
21 24
22 @Test 25 @Test
HutchWidgetExtension/ContributionGraphWidget.swift +2 −2
@@ -112,7 +112,7 @@ private struct ContributionGraphWidgetView: View {
112 graphView 112 graphView
113 } 113 }
114 } 114 }
115 .widgetURL(URL(string: "hutch://home")) 115 .widgetURL(HutchDeepLinkURL.home)
116 .containerBackground(for: .widget) { 116 .containerBackground(for: .widget) {
117 Color(.systemBackground) 117 Color(.systemBackground)
118 } 118 }
@@ -235,7 +235,7 @@ private enum ContributionGraphSizing {
235} 235}
236 236
237private struct ContributionGraphWidgetService { 237private struct ContributionGraphWidgetService {
238 private let baseURL = URL(string: "https://hutch-stats.zerolabs.sh")! 238 private let baseURL = HutchStatsAPI.defaultBaseURL
239 239
240 func fetchCalendar(actor: String) async throws -> ContributionGraphResponse { 240 func fetchCalendar(actor: String) async throws -> ContributionGraphResponse {
241 guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { 241 guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
HutchWidgetExtension/NeedsAttentionWidget.swift +7 −7
@@ -71,7 +71,7 @@ private struct NeedsAttentionWidgetView: View {
71 fallbackState 71 fallbackState
72 } 72 }
73 } 73 }
74 .widgetURL(family == .systemSmall ? URL(string: "hutch://home") : nil) 74 .widgetURL(family == .systemSmall ? HutchDeepLinkURL.home : nil)
75 .containerBackground(for: .widget) { 75 .containerBackground(for: .widget) {
76 Color(.systemBackground) 76 Color(.systemBackground)
77 } 77 }
@@ -101,7 +101,7 @@ 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 Link(destination: URL(string: "hutch://home")!) { 104 Link(destination: HutchDeepLinkURL.home) {
105 metricColumn( 105 metricColumn(
106 count: snapshot.unreadInboxThreads, 106 count: snapshot.unreadInboxThreads,
107 label: "Unread", 107 label: "Unread",
@@ -109,14 +109,14 @@ private struct NeedsAttentionWidgetView: View {
109 tint: unreadTint(for: snapshot.unreadInboxThreads) 109 tint: unreadTint(for: snapshot.unreadInboxThreads)
110 ) 110 )
111 } 111 }
112 Link(destination: URL(string: "hutch://trackers")!) { 112 Link(destination: HutchDeepLinkURL.trackers) {
113 metricColumn( 113 metricColumn(
114 count: snapshot.assignedOpenTickets, 114 count: snapshot.assignedOpenTickets,
115 label: "Assigned", 115 label: "Assigned",
116 systemImage: "ticket" 116 systemImage: "ticket"
117 ) 117 )
118 } 118 }
119 Link(destination: URL(string: "hutch://builds")!) { 119 Link(destination: HutchDeepLinkURL.builds) {
120 metricColumn( 120 metricColumn(
121 count: snapshot.failedBuilds, 121 count: snapshot.failedBuilds,
122 label: "Failed", 122 label: "Failed",
@@ -134,7 +134,7 @@ private struct NeedsAttentionWidgetView: View {
134 134
135 private func largeView(snapshot: NeedsAttentionSnapshot) -> some View { 135 private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
136 VStack(alignment: .leading, spacing: 18) { 136 VStack(alignment: .leading, spacing: 18) {
137 Link(destination: URL(string: "hutch://home")!) { 137 Link(destination: HutchDeepLinkURL.home) {
138 metricRow( 138 metricRow(
139 count: snapshot.unreadInboxThreads, 139 count: snapshot.unreadInboxThreads,
140 label: "Unread threads", 140 label: "Unread threads",
@@ -142,14 +142,14 @@ private struct NeedsAttentionWidgetView: View {
142 tint: unreadTint(for: snapshot.unreadInboxThreads) 142 tint: unreadTint(for: snapshot.unreadInboxThreads)
143 ) 143 )
144 } 144 }
145 Link(destination: URL(string: "hutch://trackers")!) { 145 Link(destination: HutchDeepLinkURL.trackers) {
146 metricRow( 146 metricRow(
147 count: snapshot.assignedOpenTickets, 147 count: snapshot.assignedOpenTickets,
148 label: "Assigned tickets", 148 label: "Assigned tickets",
149 systemImage: "ticket" 149 systemImage: "ticket"
150 ) 150 )
151 } 151 }
152 Link(destination: URL(string: "hutch://builds")!) { 152 Link(destination: HutchDeepLinkURL.builds) {
153 metricRow( 153 metricRow(
154 count: snapshot.failedBuilds, 154 count: snapshot.failedBuilds,
155 label: "Failed builds", 155 label: "Failed builds",
HutchWidgetExtension/SystemStatusWidget.swift +3 −4
@@ -63,17 +63,16 @@ private struct SystemStatusWidgetView: View {
63 var body: some View { 63 var body: some View {
64 Group { 64 Group {
65 if let snapshot = entry.snapshot, !snapshot.services.isEmpty { 65 if let snapshot = entry.snapshot, !snapshot.services.isEmpty {
66 switch family { 66 if family == .systemMedium {
67 case .systemMedium:
68 mediumView(snapshot: snapshot) 67 mediumView(snapshot: snapshot)
69 default: 68 } else {
70 smallView(snapshot: snapshot) 69 smallView(snapshot: snapshot)
71 } 70 }
72 } else { 71 } else {
73 fallbackView 72 fallbackView
74 } 73 }
75 } 74 }
76 .widgetURL(URL(string: "hutch://status")) 75 .widgetURL(HutchDeepLinkURL.status)
77 .containerBackground(for: .widget) { 76 .containerBackground(for: .widget) {
78 Color(.systemBackground) 77 Color(.systemBackground)
79 } 78 }
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}