summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-13 19:44:44 -0500
committerChristian Cleberg <[email protected]>2026-04-13 19:44:44 -0500
commitfe1ccc69661603d419a642a450e4ac9e1258adb0 (patch)
tree386455b6b5468abdd9dfa46dd4802b10da8b77a2
parent6ec9754ce34f331641b000eddef3d44bca2631a5 (diff)
downloadhutch-fe1ccc69661603d419a642a450e4ac9e1258adb0.tar.gz
hutch-fe1ccc69661603d419a642a450e4ac9e1258adb0.tar.bz2
hutch-fe1ccc69661603d419a642a450e4ac9e1258adb0.zip
fix: centralize URLs, tighten models, and polish SwiftUI bindingsv3.1.3
- 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.
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/App/AppConfiguration.swift2
-rw-r--r--Hutch/App/HutchIntents.swift4
-rw-r--r--Hutch/Extensions/SRHTWebURL.swift2
-rw-r--r--Hutch/Models/Builds.swift3
-rw-r--r--Hutch/Models/ContributionCalendar.swift92
-rw-r--r--Hutch/Models/Project.swift54
-rw-r--r--Hutch/Networking/HutchStatsService.swift7
-rw-r--r--Hutch/Networking/ProjectService.swift47
-rw-r--r--Hutch/Networking/SystemStatusService.swift14
-rw-r--r--Hutch/Views/Home/HomeView.swift4
-rw-r--r--Hutch/Views/More/AccountSwitcherView.swift4
-rw-r--r--Hutch/Views/More/ProfileView.swift47
-rw-r--r--Hutch/Views/Projects/ProjectsListView.swift14
-rw-r--r--Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift6
-rw-r--r--Hutch/Views/Repositories/RepositoryACLView.swift13
-rw-r--r--Hutch/Views/Repositories/RepositoryRowView.swift19
-rw-r--r--Hutch/Views/Settings/SettingsView.swift4
-rw-r--r--HutchTests/ContributionCalendarTests.swift108
-rw-r--r--HutchTests/DeepLinkTests.swift26
-rw-r--r--HutchTests/ProjectPinStoreTests.swift4
-rw-r--r--HutchTests/ProjectTests.swift135
-rw-r--r--HutchTests/SRHTWebURLTests.swift26
-rw-r--r--HutchTests/SystemStatusServiceTests.swift5
-rw-r--r--HutchWidgetExtension/ContributionGraphWidget.swift4
-rw-r--r--HutchWidgetExtension/NeedsAttentionWidget.swift14
-rw-r--r--HutchWidgetExtension/SystemStatusWidget.swift7
-rw-r--r--Shared/HutchDeepLinkURLs.swift23
28 files changed, 414 insertions, 290 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 8f98e1b..bbdb9e3 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 69;
+ CURRENT_PROJECT_VERSION = 71;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.1.1;
+ MARKETING_VERSION = 3.1.3;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 69;
+ CURRENT_PROJECT_VERSION = 71;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.1.1;
+ MARKETING_VERSION = 3.1.3;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 69;
+ CURRENT_PROJECT_VERSION = 71;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.1.1;
+ MARKETING_VERSION = 3.1.3;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 69;
+ CURRENT_PROJECT_VERSION = 71;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.1.1;
+ MARKETING_VERSION = 3.1.3;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/Hutch/App/AppConfiguration.swift b/Hutch/App/AppConfiguration.swift
index 7824371..c733799 100644
--- a/Hutch/App/AppConfiguration.swift
+++ b/Hutch/App/AppConfiguration.swift
@@ -1,7 +1,7 @@
import Foundation
struct AppConfiguration: Sendable {
- static let defaultHutchStatsBaseURL = URL(string: "https://hutch-stats.zerolabs.sh")!
+ static let defaultHutchStatsBaseURL = HutchStatsAPI.defaultBaseURL
static let hutchStatsBaseURLEnvironmentKey = "HUTCH_STATS_BASE_URL"
let hutchStatsBaseURL: URL
diff --git a/Hutch/App/HutchIntents.swift b/Hutch/App/HutchIntents.swift
index a7a33b3..5751ef5 100644
--- a/Hutch/App/HutchIntents.swift
+++ b/Hutch/App/HutchIntents.swift
@@ -150,5 +150,7 @@ final class HutchIntentNavigator {
static let shared = HutchIntentNavigator()
var pendingDestination: HutchDestination?
- private init() {}
+ private init() {
+ /* Singleton; external code uses `shared`. */
+ }
}
diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift
index bfed48b..fc5a1d5 100644
--- a/Hutch/Extensions/SRHTWebURL.swift
+++ b/Hutch/Extensions/SRHTWebURL.swift
@@ -3,6 +3,8 @@ import Foundation
enum SRHTWebURL {
static let chat = URL(string: "https://chat.sr.ht")!
static let status = URL(string: "https://status.sr.ht")!
+ /// RSS/Atom feed for status incidents (HTML snapshot uses `status`).
+ static let statusIncidentFeed = URL(string: "https://status.sr.ht/index.xml")!
static func repository(_ repository: RepositorySummary) -> URL? {
userScopedURL(
diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift
index d666398..3926888 100644
--- a/Hutch/Models/Builds.swift
+++ b/Hutch/Models/Builds.swift
@@ -136,8 +136,7 @@ struct BuildArtifact: Codable, Sendable, Identifiable, Equatable {
let url: URL?
var filename: String {
- let pathComponents = path.split(separator: "/")
- return pathComponents.last.map(String.init) ?? path
+ (path as NSString).lastPathComponent
}
var isDownloadable: Bool {
diff --git a/Hutch/Models/ContributionCalendar.swift b/Hutch/Models/ContributionCalendar.swift
index e2942dd..a429fa2 100644
--- a/Hutch/Models/ContributionCalendar.swift
+++ b/Hutch/Models/ContributionCalendar.swift
@@ -117,45 +117,67 @@ struct ContributionStatsResponse: Decodable, Sendable, Hashable {
case currentStreak = "current_streak"
}
- init(
- actor: String,
- from: Date,
- to: Date,
- isIndexed: Bool,
- lastPolledAt: Date?,
- indexingState: ContributionIndexingState,
- totalEvents: Int,
- totalScore: Double,
- activeDays: Int,
- longestStreak: Int,
- currentStreak: Int
- ) {
- self.actor = actor
- self.from = from
- self.to = to
- self.isIndexed = isIndexed
- self.lastPolledAt = lastPolledAt
- self.indexingState = indexingState
- self.totalEvents = totalEvents
- self.totalScore = totalScore
- self.activeDays = activeDays
- self.longestStreak = longestStreak
- self.currentStreak = currentStreak
+ struct StatsWindow: Sendable, Hashable {
+ let actor: String
+ let from: Date
+ let to: Date
+ let isIndexed: Bool
+ let lastPolledAt: Date?
+ let indexingState: ContributionIndexingState
+ }
+
+ struct StatsTotals: Sendable, Hashable {
+ let totalEvents: Int
+ let totalScore: Double
+ let activeDays: Int
+ let longestStreak: Int
+ let currentStreak: Int
+ }
+
+ init(window: StatsWindow, totals: StatsTotals) {
+ actor = window.actor
+ from = window.from
+ to = window.to
+ isIndexed = window.isIndexed
+ lastPolledAt = window.lastPolledAt
+ indexingState = window.indexingState
+ totalEvents = totals.totalEvents
+ totalScore = totals.totalScore
+ activeDays = totals.activeDays
+ longestStreak = totals.longestStreak
+ currentStreak = totals.currentStreak
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
- actor = try container.decode(String.self, forKey: .actor)
- from = try ContributionDateParser.decodeDateString(from: container, forKey: .from)
- to = try ContributionDateParser.decodeDateString(from: container, forKey: .to)
- isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false
- lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt)
- indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed
- totalEvents = try container.decode(Int.self, forKey: .totalEvents)
- totalScore = try container.decode(Double.self, forKey: .totalScore)
- activeDays = try container.decode(Int.self, forKey: .activeDays)
- longestStreak = try container.decode(Int.self, forKey: .longestStreak)
- currentStreak = try container.decode(Int.self, forKey: .currentStreak)
+ let actor = try container.decode(String.self, forKey: .actor)
+ let from = try ContributionDateParser.decodeDateString(from: container, forKey: .from)
+ let to = try ContributionDateParser.decodeDateString(from: container, forKey: .to)
+ let isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false
+ let lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt)
+ let indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed
+ let totalEvents = try container.decode(Int.self, forKey: .totalEvents)
+ let totalScore = try container.decode(Double.self, forKey: .totalScore)
+ let activeDays = try container.decode(Int.self, forKey: .activeDays)
+ let longestStreak = try container.decode(Int.self, forKey: .longestStreak)
+ let currentStreak = try container.decode(Int.self, forKey: .currentStreak)
+ self.init(
+ window: .init(
+ actor: actor,
+ from: from,
+ to: to,
+ isIndexed: isIndexed,
+ lastPolledAt: lastPolledAt,
+ indexingState: indexingState
+ ),
+ totals: .init(
+ totalEvents: totalEvents,
+ totalScore: totalScore,
+ activeDays: activeDays,
+ longestStreak: longestStreak,
+ currentStreak: currentStreak
+ )
+ )
}
}
diff --git a/Hutch/Models/Project.swift b/Hutch/Models/Project.swift
index 678d52e..bbca7d6 100644
--- a/Hutch/Models/Project.swift
+++ b/Hutch/Models/Project.swift
@@ -71,30 +71,36 @@ struct Project: Identifiable, Hashable, Sendable {
let trackers: [Tracker]
let isFullyLoaded: Bool
- init(
- id: String,
- name: String,
- description: String?,
- website: String?,
- visibility: Visibility,
- tags: [String],
- updated: Date,
- mailingLists: [MailingList],
- sources: [SourceRepo],
- trackers: [Tracker],
- isFullyLoaded: Bool = true
- ) {
- self.id = id
- self.name = name
- self.description = description
- self.website = website
- self.visibility = visibility
- self.tags = tags
- self.updated = updated
- self.mailingLists = mailingLists
- self.sources = sources
- self.trackers = trackers
- self.isFullyLoaded = isFullyLoaded
+ /// Identity and display fields for a project.
+ struct Metadata: Sendable, Hashable {
+ let id: String
+ let name: String
+ let description: String?
+ let website: String?
+ let visibility: Visibility
+ let tags: [String]
+ let updated: Date
+ }
+
+ struct Resources: Sendable, Hashable {
+ let mailingLists: [MailingList]
+ let sources: [SourceRepo]
+ let trackers: [Tracker]
+ let isFullyLoaded: Bool
+ }
+
+ init(metadata: Metadata, resources: Resources) {
+ id = metadata.id
+ name = metadata.name
+ description = metadata.description
+ website = metadata.website
+ visibility = metadata.visibility
+ tags = metadata.tags
+ updated = metadata.updated
+ mailingLists = resources.mailingLists
+ sources = resources.sources
+ trackers = resources.trackers
+ isFullyLoaded = resources.isFullyLoaded
}
var resourceSummary: String? {
diff --git a/Hutch/Networking/HutchStatsService.swift b/Hutch/Networking/HutchStatsService.swift
index 52d86b9..aead9c6 100644
--- a/Hutch/Networking/HutchStatsService.swift
+++ b/Hutch/Networking/HutchStatsService.swift
@@ -61,10 +61,9 @@ struct HutchStatsService: ContributionCalendarServing {
throw SRHTError.networkError(error)
}
- if let httpResponse = response as? HTTPURLResponse {
- if !(200...299).contains(httpResponse.statusCode) {
- throw SRHTError.httpError(httpResponse.statusCode)
- }
+ if let httpResponse = response as? HTTPURLResponse,
+ !(200...299).contains(httpResponse.statusCode) {
+ throw SRHTError.httpError(httpResponse.statusCode)
}
do {
diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift
index 93ce71f..bd8ccdc 100644
--- a/Hutch/Networking/ProjectService.swift
+++ b/Hutch/Networking/ProjectService.swift
@@ -372,17 +372,21 @@ struct ProjectService: Sendable {
if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil {
return Project(
- id: project.rid,
- name: project.name,
- description: project.description,
- website: project.website,
- visibility: project.visibility,
- tags: project.tags,
- updated: project.updated,
- mailingLists: deduplicate(mailingLists),
- sources: deduplicate(sources),
- trackers: deduplicate(trackers),
- isFullyLoaded: true
+ metadata: .init(
+ id: project.rid,
+ name: project.name,
+ description: project.description,
+ website: project.website,
+ visibility: project.visibility,
+ tags: project.tags,
+ updated: project.updated
+ ),
+ resources: .init(
+ mailingLists: deduplicate(mailingLists),
+ sources: deduplicate(sources),
+ trackers: deduplicate(trackers),
+ isFullyLoaded: true
+ )
)
}
}
@@ -390,17 +394,16 @@ struct ProjectService: Sendable {
private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project {
Project(
- id: summary.rid,
- name: summary.name,
- description: summary.description,
- website: summary.website,
- visibility: summary.visibility,
- tags: summary.tags,
- updated: summary.updated,
- mailingLists: [],
- sources: [],
- trackers: [],
- isFullyLoaded: false
+ metadata: .init(
+ id: summary.rid,
+ name: summary.name,
+ description: summary.description,
+ website: summary.website,
+ visibility: summary.visibility,
+ tags: summary.tags,
+ updated: summary.updated
+ ),
+ resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
)
}
diff --git a/Hutch/Networking/SystemStatusService.swift b/Hutch/Networking/SystemStatusService.swift
index 5dd27b5..ec2d233 100644
--- a/Hutch/Networking/SystemStatusService.swift
+++ b/Hutch/Networking/SystemStatusService.swift
@@ -1,8 +1,8 @@
import Foundation
struct SystemStatusService: Sendable {
- nonisolated static let statusURL = URL(string: "https://status.sr.ht/")!
- nonisolated static let feedURL = URL(string: "https://status.sr.ht/index.xml")!
+ nonisolated static let statusURL = SRHTWebURL.status
+ nonisolated static let feedURL = SRHTWebURL.statusIncidentFeed
private let session: URLSession
private let now: @Sendable () -> Date
@@ -340,24 +340,24 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec
return incidents.sorted { $0.publishedAt > $1.publishedAt }
}
- func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) {
+ func parser(_: XMLParser, didStartElement elementName: String, namespaceURI _: String?, qualifiedName _: String?, attributes _: [String: String] = [:]) {
textBuffer = ""
if elementName == "item" {
currentItem = FeedItem()
}
}
- func parser(_ parser: XMLParser, foundCharacters string: String) {
+ func parser(_: XMLParser, foundCharacters string: String) {
textBuffer += string
}
- func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
- if let string = String(data: CDATABlock, encoding: .utf8) {
+ func parser(_: XMLParser, foundCDATA cdata: Data) {
+ if let string = String(data: cdata, encoding: .utf8) {
textBuffer += string
}
}
- func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
+ func parser(_: XMLParser, didEndElement elementName: String, namespaceURI _: String?, qualifiedName _: String?) {
guard var currentItem else {
textBuffer = ""
return
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index b10926a..b4b809d 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -171,7 +171,7 @@ struct HomeView: View {
) {
ForEach(items) { item in
Button {
- openPinnedItem(item, viewModel: viewModel)
+ openPinnedItem(item)
} label: {
HomePinnedCard(item: item)
}
@@ -313,7 +313,7 @@ struct HomeView: View {
}
}
- private func openPinnedItem(_ item: HomePinnedItem, viewModel: HomeViewModel) {
+ private func openPinnedItem(_ item: HomePinnedItem) {
switch item.pin.kind {
case .project:
guard let project = item.project else { return }
diff --git a/Hutch/Views/More/AccountSwitcherView.swift b/Hutch/Views/More/AccountSwitcherView.swift
index ce69279..83871a8 100644
--- a/Hutch/Views/More/AccountSwitcherView.swift
+++ b/Hutch/Views/More/AccountSwitcherView.swift
@@ -98,7 +98,9 @@ struct AccountSwitcherView: View {
}
)
) {
- Button("Cancel", role: .cancel) {}
+ Button("Cancel", role: .cancel) {
+ /* Dismiss only; removal uses the destructive button. */
+ }
Button("Remove", role: .destructive) {
guard let pendingRemoval else { return }
Task { await appState.removeAccount(id: pendingRemoval.id) }
diff --git a/Hutch/Views/More/ProfileView.swift b/Hutch/Views/More/ProfileView.swift
index c53a89d..8f353ee 100644
--- a/Hutch/Views/More/ProfileView.swift
+++ b/Hutch/Views/More/ProfileView.swift
@@ -61,8 +61,6 @@ struct ProfileView: View {
@ViewBuilder
private func profileContent(_ viewModel: SettingsViewModel) -> some View {
- @Bindable var vm = viewModel
-
Form {
if let profile = viewModel.profile {
profileSection(profile, viewModel: viewModel)
@@ -100,7 +98,10 @@ struct ProfileView: View {
)
}
}
- .sheet(isPresented: $vm.isEditingProfile) {
+ .sheet(isPresented: Binding(
+ get: { viewModel.isEditingProfile },
+ set: { viewModel.isEditingProfile = $0 }
+ )) {
if let profile = viewModel.profile {
EditProfileSheet(profile: profile, viewModel: viewModel)
}
@@ -130,7 +131,9 @@ struct ProfileView: View {
}
)
) {
- Button("Cancel", role: .cancel) {}
+ Button("Cancel", role: .cancel) {
+ /* Dismiss only; destructive action is separate. */
+ }
Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
guard let action = pendingDestructiveAction else { return }
pendingDestructiveAction = nil
@@ -158,12 +161,11 @@ struct ProfileView: View {
Section("Profile") {
HStack(spacing: 12) {
AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
- switch phase {
- case .success(let image):
+ if case .success(let image) = phase {
image
.resizable()
.scaledToFill()
- default:
+ } else {
Image(systemName: "person.crop.circle.fill")
.resizable()
.foregroundStyle(.secondary)
@@ -238,8 +240,6 @@ struct ProfileView: View {
@ViewBuilder
private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
- @Bindable var vm = viewModel
-
Section {
ForEach(viewModel.sshKeys) { key in
VStack(alignment: .leading, spacing: 2) {
@@ -275,7 +275,14 @@ struct ProfileView: View {
.themedRow()
if viewModel.isAddingSSHKey {
- TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical)
+ TextField(
+ "Paste SSH public key",
+ text: Binding(
+ get: { viewModel.newSSHKey },
+ set: { viewModel.newSSHKey = $0 }
+ ),
+ axis: .vertical
+ )
.font(.caption.monospaced())
.lineLimit(3...6)
.themedRow()
@@ -310,8 +317,6 @@ struct ProfileView: View {
@ViewBuilder
private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
- @Bindable var vm = viewModel
-
Section {
ForEach(viewModel.pgpKeys) { key in
VStack(alignment: .leading, spacing: 2) {
@@ -333,7 +338,14 @@ struct ProfileView: View {
.themedRow()
if viewModel.isAddingPGPKey {
- TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical)
+ TextField(
+ "Paste PGP public key",
+ text: Binding(
+ get: { viewModel.newPGPKey },
+ set: { viewModel.newPGPKey = $0 }
+ ),
+ axis: .vertical
+ )
.font(.caption.monospaced())
.lineLimit(3...6)
.themedRow()
@@ -482,12 +494,11 @@ private struct EditProfileSheet: View {
.scaledToFill()
} else {
AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
- switch phase {
- case .success(let image):
+ if case .success(let image) = phase {
image
.resizable()
.scaledToFill()
- default:
+ } else {
Image(systemName: "person.crop.circle.fill")
.resizable()
.foregroundStyle(.secondary)
@@ -592,7 +603,9 @@ private struct EditProfileSheet: View {
}
}
.alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
- Button("Cancel", role: .cancel) {}
+ Button("Cancel", role: .cancel) {
+ /* Dismiss only; removal uses the destructive button. */
+ }
Button("Remove Avatar", role: .destructive) {
Task {
await viewModel.removeAvatar()
diff --git a/Hutch/Views/Projects/ProjectsListView.swift b/Hutch/Views/Projects/ProjectsListView.swift
index 12a5149..f6d5766 100644
--- a/Hutch/Views/Projects/ProjectsListView.swift
+++ b/Hutch/Views/Projects/ProjectsListView.swift
@@ -67,8 +67,6 @@ struct ProjectsListView: View {
@ViewBuilder
private func content(_ viewModel: ProjectsListViewModel) -> some View {
- @Bindable var vm = viewModel
-
List {
ForEach(viewModel.filteredProjects) { project in
NavigationLink {
@@ -84,7 +82,10 @@ struct ProjectsListView: View {
.themedList()
.listStyle(.plain)
.searchable(
- text: $vm.searchText,
+ text: Binding(
+ get: { viewModel.searchText },
+ set: { viewModel.searchText = $0 }
+ ),
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search projects"
)
@@ -107,7 +108,12 @@ struct ProjectsListView: View {
)
}
}
- .srhtErrorBanner(error: $vm.error)
+ .srhtErrorBanner(
+ error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ )
+ )
.refreshable {
await viewModel.loadProjects()
}
diff --git a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
index 7b8d7f7..6df40db 100644
--- a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
@@ -261,11 +261,7 @@ final class HgRepositorySettingsViewModel {
}
newACLEntity = ""
} catch {
- if error.matchesGraphQLErrorClassification(.serviceNotProvisioned) {
- self.error = error.userFacingMessage
- } else {
- self.error = error.userFacingMessage
- }
+ self.error = error.userFacingMessage
}
}
diff --git a/Hutch/Views/Repositories/RepositoryACLView.swift b/Hutch/Views/Repositories/RepositoryACLView.swift
index 29ded84..ede770c 100644
--- a/Hutch/Views/Repositories/RepositoryACLView.swift
+++ b/Hutch/Views/Repositories/RepositoryACLView.swift
@@ -51,8 +51,6 @@ struct RepositoryACLView: View {
@ViewBuilder
private func content(_ viewModel: RepositoryACLViewModel) -> some View {
- @Bindable var vm = viewModel
-
Group {
if viewModel.isLoading && !viewModel.hasEntries && viewModel.loadError == nil {
SRHTLoadingStateView(message: "Loading access…")
@@ -98,7 +96,12 @@ struct RepositoryACLView: View {
}
}
}
- .srhtErrorBanner(error: $vm.error)
+ .srhtErrorBanner(
+ error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ )
+ )
.alert("Remove Access?", isPresented: Binding(
get: { pendingDeletion != nil },
set: { isPresented in
@@ -107,7 +110,9 @@ struct RepositoryACLView: View {
}
}
)) {
- Button("Cancel", role: .cancel) {}
+ Button("Cancel", role: .cancel) {
+ /* Dismiss only; removal is confirmed separately. */
+ }
Button("Remove Access", role: .destructive) {
guard let entry = pendingDeletion else { return }
Task {
diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift
index afbd740..a2d120b 100644
--- a/Hutch/Views/Repositories/RepositoryRowView.swift
+++ b/Hutch/Views/Repositories/RepositoryRowView.swift
@@ -17,9 +17,6 @@ struct RepositoryRowView: View {
RepositoryForgeBadge(service: repository.service)
- if buildStatus != .none {
- RepositoryBuildStatusIndicator(status: buildStatus)
- }
VisibilityBadge(visibility: repository.visibility)
}
@@ -44,9 +41,19 @@ struct RepositoryRowView: View {
Spacer()
- Text(repository.updated.relativeDescription)
- .font(.caption)
- .foregroundStyle(.tertiary)
+ HStack(spacing: 6) {
+ ZStack {
+ if buildStatus != .none {
+ RepositoryBuildStatusIndicator(status: buildStatus)
+ }
+ }
+ .frame(width: 8, height: 8)
+ .accessibilityHidden(buildStatus == .none)
+
+ Text(repository.updated.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
}
}
.padding(.vertical, 2)
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index 4ed9216..577de8a 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -32,7 +32,9 @@ struct SettingsView: View {
}
)
) {
- Button("Cancel", role: .cancel) {}
+ Button("Cancel", role: .cancel) {
+ /* Dismiss only; destructive action is separate. */
+ }
Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
guard let action = pendingDestructiveAction else { return }
pendingDestructiveAction = nil
diff --git a/HutchTests/ContributionCalendarTests.swift b/HutchTests/ContributionCalendarTests.swift
index 9e76e42..0328ad5 100644
--- a/HutchTests/ContributionCalendarTests.swift
+++ b/HutchTests/ContributionCalendarTests.swift
@@ -259,12 +259,12 @@ private final class MockContributionCalendarService: ContributionCalendarServing
self.statsResponses = statsResponses
}
- func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse {
+ func fetchContributionCalendar(actor _: String, endingOn _: Date) async throws -> ContributionCalendarResponse {
fetchCalendarCallCount += 1
return calendarResponses[min(fetchCalendarCallCount - 1, calendarResponses.count - 1)]
}
- func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse {
+ func fetchContributionStats(actor _: String, endingOn _: Date) async throws -> ContributionStatsResponse {
fetchStatsCallCount += 1
return statsResponses[min(fetchStatsCallCount - 1, statsResponses.count - 1)]
}
@@ -348,65 +348,81 @@ private extension ContributionCalendarResponse {
private extension ContributionStatsResponse {
static func empty(actor: String, year: Int) -> Self {
ContributionStatsResponse(
- actor: actor,
- from: ContributionDateParser.parse("\(year)-01-01")!,
- to: ContributionDateParser.parse("\(year)-01-07")!,
- isIndexed: true,
- lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
- indexingState: .indexed,
- totalEvents: 0,
- totalScore: 0,
- activeDays: 0,
- longestStreak: 0,
- currentStreak: 0
+ window: .init(
+ actor: actor,
+ from: ContributionDateParser.parse("\(year)-01-01")!,
+ to: ContributionDateParser.parse("\(year)-01-07")!,
+ isIndexed: true,
+ lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
+ indexingState: .indexed
+ ),
+ totals: .init(
+ totalEvents: 0,
+ totalScore: 0,
+ activeDays: 0,
+ longestStreak: 0,
+ currentStreak: 0
+ )
)
}
static func active(actor: String, year: Int, totalEvents: Int, activeDays: Int, longestStreak: Int) -> Self {
ContributionStatsResponse(
- actor: actor,
- from: ContributionDateParser.parse("\(year)-01-01")!,
- to: ContributionDateParser.parse("\(year)-01-07")!,
- isIndexed: true,
- lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
- indexingState: .indexed,
- totalEvents: totalEvents,
- totalScore: Double(totalEvents),
- activeDays: activeDays,
- longestStreak: longestStreak,
- currentStreak: 0
+ window: .init(
+ actor: actor,
+ from: ContributionDateParser.parse("\(year)-01-01")!,
+ to: ContributionDateParser.parse("\(year)-01-07")!,
+ isIndexed: true,
+ lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
+ indexingState: .indexed
+ ),
+ totals: .init(
+ totalEvents: totalEvents,
+ totalScore: Double(totalEvents),
+ activeDays: activeDays,
+ longestStreak: longestStreak,
+ currentStreak: 0
+ )
)
}
static func pending(actor: String, year: Int) -> Self {
ContributionStatsResponse(
- actor: actor,
- from: ContributionDateParser.parse("\(year)-01-01")!,
- to: ContributionDateParser.parse("\(year)-01-07")!,
- isIndexed: false,
- lastPolledAt: nil,
- indexingState: .pending,
- totalEvents: 0,
- totalScore: 0,
- activeDays: 0,
- longestStreak: 0,
- currentStreak: 0
+ window: .init(
+ actor: actor,
+ from: ContributionDateParser.parse("\(year)-01-01")!,
+ to: ContributionDateParser.parse("\(year)-01-07")!,
+ isIndexed: false,
+ lastPolledAt: nil,
+ indexingState: .pending
+ ),
+ totals: .init(
+ totalEvents: 0,
+ totalScore: 0,
+ activeDays: 0,
+ longestStreak: 0,
+ currentStreak: 0
+ )
)
}
static func error(actor: String, year: Int) -> Self {
ContributionStatsResponse(
- actor: actor,
- from: ContributionDateParser.parse("\(year)-01-01")!,
- to: ContributionDateParser.parse("\(year)-01-07")!,
- isIndexed: false,
- lastPolledAt: nil,
- indexingState: .error,
- totalEvents: 0,
- totalScore: 0,
- activeDays: 0,
- longestStreak: 0,
- currentStreak: 0
+ window: .init(
+ actor: actor,
+ from: ContributionDateParser.parse("\(year)-01-01")!,
+ to: ContributionDateParser.parse("\(year)-01-07")!,
+ isIndexed: false,
+ lastPolledAt: nil,
+ indexingState: .error
+ ),
+ totals: .init(
+ totalEvents: 0,
+ totalScore: 0,
+ activeDays: 0,
+ longestStreak: 0,
+ currentStreak: 0
+ )
)
}
}
diff --git a/HutchTests/DeepLinkTests.swift b/HutchTests/DeepLinkTests.swift
index 12f759b..c57cfce 100644
--- a/HutchTests/DeepLinkTests.swift
+++ b/HutchTests/DeepLinkTests.swift
@@ -6,61 +6,61 @@ struct DeepLinkTests {
@Test
func parsesHomeLink() {
- let link = DeepLink(url: URL(string: "hutch://home")!)
+ let link = DeepLink(url: HutchDeepLinkURL.home)
#expect(link == .home)
}
@Test
func parsesNilPathAsHome() {
- let link = DeepLink(url: URL(string: "hutch://")!)
+ let link = DeepLink(url: HutchDeepLinkURL.emptyHost)
#expect(link == .home)
}
@Test
func parsesRepositoryLink() {
- let link = DeepLink(url: URL(string: "hutch://git/~user/repo")!)
+ let link = DeepLink(url: HutchDeepLinkURL.repositoryGit)
#expect(link == .repository(owner: "~user", repo: "repo"))
}
@Test
func parsesTicketLink() {
- let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/42")!)
+ let link = DeepLink(url: HutchDeepLinkURL.ticket)
#expect(link == .ticket(owner: "~owner", tracker: "tracker", ticketId: 42))
}
@Test
func parsesBuildJobLink() {
- let link = DeepLink(url: URL(string: "hutch://builds/12345")!)
+ let link = DeepLink(url: HutchDeepLinkURL.buildJob)
#expect(link == .build(jobId: 12345))
}
@Test
func parsesBuildsTabLink() {
- let link = DeepLink(url: URL(string: "hutch://builds")!)
+ let link = DeepLink(url: HutchDeepLinkURL.builds)
#expect(link == .buildsTab)
}
@Test
func parsesRepositoriesTabLink() {
- let link = DeepLink(url: URL(string: "hutch://repositories")!)
+ let link = DeepLink(url: HutchDeepLinkURL.repositories)
#expect(link == .repositoriesTab)
}
@Test
func parsesTrackersTabLink() {
- let link = DeepLink(url: URL(string: "hutch://trackers")!)
+ let link = DeepLink(url: HutchDeepLinkURL.trackers)
#expect(link == .trackersTab)
}
@Test
func parsesSystemStatusLink() {
- let link = DeepLink(url: URL(string: "hutch://status")!)
+ let link = DeepLink(url: HutchDeepLinkURL.status)
#expect(link == .systemStatus)
}
@Test
func parsesLookupLink() {
- let link = DeepLink(url: URL(string: "hutch://lookup")!)
+ let link = DeepLink(url: HutchDeepLinkURL.lookup)
#expect(link == .lookup)
}
@@ -72,19 +72,19 @@ struct DeepLinkTests {
@Test
func rejectsUnknownPath() {
- let link = DeepLink(url: URL(string: "hutch://unknown")!)
+ let link = DeepLink(url: HutchDeepLinkURL.unknown)
#expect(link == nil)
}
@Test
func rejectsTicketLinkWithNonNumericId() {
- let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/abc")!)
+ let link = DeepLink(url: HutchDeepLinkURL.invalidTicketId)
#expect(link == nil)
}
@Test
func rejectsBuildLinkWithNonNumericId() {
- let link = DeepLink(url: URL(string: "hutch://builds/abc")!)
+ let link = DeepLink(url: HutchDeepLinkURL.invalidBuildId)
#expect(link == nil)
}
}
diff --git a/HutchTests/ProjectPinStoreTests.swift b/HutchTests/ProjectPinStoreTests.swift
index 17b4397..13df539 100644
--- a/HutchTests/ProjectPinStoreTests.swift
+++ b/HutchTests/ProjectPinStoreTests.swift
@@ -27,11 +27,11 @@ struct ProjectPinStoreTests {
}
@Test
- func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() {
+ func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() throws {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
- let encoded = try! JSONEncoder().encode([
+ let encoded = try JSONEncoder().encode([
"~alice": [" project-1 ", "", "project-1", "project-2"]
])
defaults.set(encoded, forKey: AppStorageKeys.pinnedHomeProjects)
diff --git a/HutchTests/ProjectTests.swift b/HutchTests/ProjectTests.swift
index eff800f..0323f25 100644
--- a/HutchTests/ProjectTests.swift
+++ b/HutchTests/ProjectTests.swift
@@ -3,52 +3,61 @@ import Testing
@testable import Hutch
struct ProjectTests {
+ private enum Fixture {
+ static let exampleWebsite = "https://example.com"
+ }
+
@Test
func resourceSummaryIncludesCounts() {
let project = Project(
- id: "project-1",
- name: "Hutch",
- description: nil,
- website: nil,
- visibility: .public,
- tags: [],
- updated: Date(timeIntervalSince1970: 0),
- mailingLists: [
- Project.MailingList(
- id: "list-1",
- name: "hutch-devel",
- description: nil,
- visibility: .public,
- owner: Entity(canonicalName: "~owner")
- )
- ],
- sources: [
- Project.SourceRepo(
- id: "repo-1",
- name: "hutch",
- description: nil,
- visibility: .public,
- owner: Entity(canonicalName: "~owner"),
- repoType: .git
- ),
- Project.SourceRepo(
- id: "repo-2",
- name: "hutch-web",
- description: nil,
- visibility: .public,
- owner: Entity(canonicalName: "~owner"),
- repoType: .git
- )
- ],
- trackers: [
- Project.Tracker(
- id: "tracker-1",
- name: "bugs",
- description: nil,
- visibility: .public,
- owner: Entity(canonicalName: "~owner")
- )
- ]
+ metadata: .init(
+ id: "project-1",
+ name: "Hutch",
+ description: nil,
+ website: nil,
+ visibility: .public,
+ tags: [],
+ updated: Date(timeIntervalSince1970: 0)
+ ),
+ resources: .init(
+ mailingLists: [
+ Project.MailingList(
+ id: "list-1",
+ name: "hutch-devel",
+ description: nil,
+ visibility: .public,
+ owner: Entity(canonicalName: "~owner")
+ )
+ ],
+ sources: [
+ Project.SourceRepo(
+ id: "repo-1",
+ name: "hutch",
+ description: nil,
+ visibility: .public,
+ owner: Entity(canonicalName: "~owner"),
+ repoType: .git
+ ),
+ Project.SourceRepo(
+ id: "repo-2",
+ name: "hutch-web",
+ description: nil,
+ visibility: .public,
+ owner: Entity(canonicalName: "~owner"),
+ repoType: .git
+ )
+ ],
+ trackers: [
+ Project.Tracker(
+ id: "tracker-1",
+ name: "bugs",
+ description: nil,
+ visibility: .public,
+ owner: Entity(canonicalName: "~owner")
+ )
+ ],
+ isFullyLoaded: true
+ )
)
#expect(project.resourceSummary == "2 repos • 1 tracker • 1 list")
@@ -57,16 +66,16 @@ struct ProjectTests {
@Test
func resourceSummaryFallsBackToWebsite() {
let project = Project(
- id: "project-1",
- name: "Docs",
- description: nil,
- website: "https://example.com",
- visibility: .public,
- tags: [],
- updated: Date(timeIntervalSince1970: 0),
- mailingLists: [],
- sources: [],
- trackers: []
+ metadata: .init(
+ id: "project-1",
+ name: "Docs",
+ description: nil,
+ website: Fixture.exampleWebsite,
+ visibility: .public,
+ tags: [],
+ updated: Date(timeIntervalSince1970: 0)
+ ),
+ resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: true)
)
#expect(project.resourceSummary == "Website linked")
@@ -75,16 +84,16 @@ struct ProjectTests {
@Test
func displayHelpersNormalizeBlankValues() {
let project = Project(
- id: "project-1",
- name: " ",
- description: "\n",
- website: "https://example.com",
- visibility: .unlisted,
- tags: [" docs ", "", "Docs", "ios"],
- updated: Date(timeIntervalSince1970: 0),
- mailingLists: [],
- sources: [],
- trackers: []
+ metadata: .init(
+ id: "project-1",
+ name: " ",
+ description: "\n",
+ website: Fixture.exampleWebsite,
+ visibility: .unlisted,
+ tags: [" docs ", "", "Docs", "ios"],
+ updated: Date(timeIntervalSince1970: 0)
+ ),
+ resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: true)
)
#expect(project.displayName == "Untitled Project")
diff --git a/HutchTests/SRHTWebURLTests.swift b/HutchTests/SRHTWebURLTests.swift
index a183f00..c1f0dc0 100644
--- a/HutchTests/SRHTWebURLTests.swift
+++ b/HutchTests/SRHTWebURLTests.swift
@@ -3,6 +3,16 @@ import Testing
@testable import Hutch
struct SRHTWebURLTests {
+ private enum Expected {
+ static let chatOrigin = "https://chat.sr.ht"
+ static let statusOrigin = "https://status.sr.ht"
+ static let gitRepoHTTPS = "https://git.sr.ht/~ccleberg/hutch"
+ static let gitSSH = "[email protected]:~ccleberg/hutch"
+ static let tracker = "https://todo.sr.ht/~ccleberg/todo"
+ static let ticket = "https://todo.sr.ht/~ccleberg/todo/42"
+ static let buildJob = "https://builds.sr.ht/~ccleberg/job/12"
+ }
+
private let repository = RepositorySummary(
id: 1,
rid: "repo-1",
@@ -26,21 +36,21 @@ struct SRHTWebURLTests {
@Test
func browserOnlyServiceURLsUseCanonicalHosts() {
- #expect(SRHTWebURL.chat.absoluteString == "https://chat.sr.ht")
- #expect(SRHTWebURL.status.absoluteString == "https://status.sr.ht")
+ #expect(SRHTWebURL.chat.absoluteString == Expected.chatOrigin)
+ #expect(SRHTWebURL.status.absoluteString == Expected.statusOrigin)
}
@Test
func repositoryAndCloneURLsUseStableUserScopedPaths() {
- #expect(SRHTWebURL.repository(repository)?.absoluteString == "https://git.sr.ht/~ccleberg/hutch")
- #expect(SRHTWebURL.httpsCloneURL(repository) == "https://git.sr.ht/~ccleberg/hutch")
- #expect(SRHTWebURL.sshCloneURL(repository) == "[email protected]:~ccleberg/hutch")
+ #expect(SRHTWebURL.repository(repository)?.absoluteString == Expected.gitRepoHTTPS)
+ #expect(SRHTWebURL.httpsCloneURL(repository) == Expected.gitRepoHTTPS)
+ #expect(SRHTWebURL.sshCloneURL(repository) == Expected.gitSSH)
}
@Test
func trackerTicketAndBuildURLsUseStableUserScopedPaths() {
- #expect(SRHTWebURL.tracker(tracker)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo")
- #expect(SRHTWebURL.ticket(ownerUsername: "ccleberg", trackerName: "todo", ticketId: 42)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo/42")
- #expect(SRHTWebURL.build(jobId: 12, ownerCanonicalName: "~ccleberg")?.absoluteString == "https://builds.sr.ht/~ccleberg/job/12")
+ #expect(SRHTWebURL.tracker(tracker)?.absoluteString == Expected.tracker)
+ #expect(SRHTWebURL.ticket(ownerUsername: "ccleberg", trackerName: "todo", ticketId: 42)?.absoluteString == Expected.ticket)
+ #expect(SRHTWebURL.build(jobId: 12, ownerCanonicalName: "~ccleberg")?.absoluteString == Expected.buildJob)
}
}
diff --git a/HutchTests/SystemStatusServiceTests.swift b/HutchTests/SystemStatusServiceTests.swift
index d1577b6..d963fa4 100644
--- a/HutchTests/SystemStatusServiceTests.swift
+++ b/HutchTests/SystemStatusServiceTests.swift
@@ -3,6 +3,9 @@ import Testing
@testable import Hutch
struct SystemStatusServiceTests {
+ private enum Fixture {
+ static let ddosIssueURL = "https://status.sr.ht/issues/2026-04-06-ddos-attack/"
+ }
@Test
func parsesCurrentStatusHTMLIntoServicesAndActiveIncidents() throws {
@@ -16,7 +19,7 @@ struct SystemStatusServiceTests {
#expect(snapshot.activeIncidents.count == 1)
#expect(snapshot.activeIncidents[0].title == "SourceHut disrupted due to DDoS attack")
#expect(snapshot.activeIncidents[0].summary == "SourceHut was disrupted by a DDoS attack.")
- #expect(snapshot.activeIncidents[0].url?.absoluteString == "https://status.sr.ht/issues/2026-04-06-ddos-attack/")
+ #expect(snapshot.activeIncidents[0].url?.absoluteString == Fixture.ddosIssueURL)
}
@Test
diff --git a/HutchWidgetExtension/ContributionGraphWidget.swift b/HutchWidgetExtension/ContributionGraphWidget.swift
index 4245230..2fc07f2 100644
--- a/HutchWidgetExtension/ContributionGraphWidget.swift
+++ b/HutchWidgetExtension/ContributionGraphWidget.swift
@@ -112,7 +112,7 @@ private struct ContributionGraphWidgetView: View {
graphView
}
}
- .widgetURL(URL(string: "hutch://home"))
+ .widgetURL(HutchDeepLinkURL.home)
.containerBackground(for: .widget) {
Color(.systemBackground)
}
@@ -235,7 +235,7 @@ private enum ContributionGraphSizing {
}
private struct ContributionGraphWidgetService {
- private let baseURL = URL(string: "https://hutch-stats.zerolabs.sh")!
+ private let baseURL = HutchStatsAPI.defaultBaseURL
func fetchCalendar(actor: String) async throws -> ContributionGraphResponse {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
diff --git a/HutchWidgetExtension/NeedsAttentionWidget.swift b/HutchWidgetExtension/NeedsAttentionWidget.swift
index 574e9eb..016f1c9 100644
--- a/HutchWidgetExtension/NeedsAttentionWidget.swift
+++ b/HutchWidgetExtension/NeedsAttentionWidget.swift
@@ -71,7 +71,7 @@ private struct NeedsAttentionWidgetView: View {
fallbackState
}
}
- .widgetURL(family == .systemSmall ? URL(string: "hutch://home") : nil)
+ .widgetURL(family == .systemSmall ? HutchDeepLinkURL.home : nil)
.containerBackground(for: .widget) {
Color(.systemBackground)
}
@@ -101,7 +101,7 @@ private struct NeedsAttentionWidgetView: View {
private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 12) {
- Link(destination: URL(string: "hutch://home")!) {
+ Link(destination: HutchDeepLinkURL.home) {
metricColumn(
count: snapshot.unreadInboxThreads,
label: "Unread",
@@ -109,14 +109,14 @@ private struct NeedsAttentionWidgetView: View {
tint: unreadTint(for: snapshot.unreadInboxThreads)
)
}
- Link(destination: URL(string: "hutch://trackers")!) {
+ Link(destination: HutchDeepLinkURL.trackers) {
metricColumn(
count: snapshot.assignedOpenTickets,
label: "Assigned",
systemImage: "ticket"
)
}
- Link(destination: URL(string: "hutch://builds")!) {
+ Link(destination: HutchDeepLinkURL.builds) {
metricColumn(
count: snapshot.failedBuilds,
label: "Failed",
@@ -134,7 +134,7 @@ private struct NeedsAttentionWidgetView: View {
private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
VStack(alignment: .leading, spacing: 18) {
- Link(destination: URL(string: "hutch://home")!) {
+ Link(destination: HutchDeepLinkURL.home) {
metricRow(
count: snapshot.unreadInboxThreads,
label: "Unread threads",
@@ -142,14 +142,14 @@ private struct NeedsAttentionWidgetView: View {
tint: unreadTint(for: snapshot.unreadInboxThreads)
)
}
- Link(destination: URL(string: "hutch://trackers")!) {
+ Link(destination: HutchDeepLinkURL.trackers) {
metricRow(
count: snapshot.assignedOpenTickets,
label: "Assigned tickets",
systemImage: "ticket"
)
}
- Link(destination: URL(string: "hutch://builds")!) {
+ Link(destination: HutchDeepLinkURL.builds) {
metricRow(
count: snapshot.failedBuilds,
label: "Failed builds",
diff --git a/HutchWidgetExtension/SystemStatusWidget.swift b/HutchWidgetExtension/SystemStatusWidget.swift
index 4a3c1e5..a0908bb 100644
--- a/HutchWidgetExtension/SystemStatusWidget.swift
+++ b/HutchWidgetExtension/SystemStatusWidget.swift
@@ -63,17 +63,16 @@ private struct SystemStatusWidgetView: View {
var body: some View {
Group {
if let snapshot = entry.snapshot, !snapshot.services.isEmpty {
- switch family {
- case .systemMedium:
+ if family == .systemMedium {
mediumView(snapshot: snapshot)
- default:
+ } else {
smallView(snapshot: snapshot)
}
} else {
fallbackView
}
}
- .widgetURL(URL(string: "hutch://status"))
+ .widgetURL(HutchDeepLinkURL.status)
.containerBackground(for: .widget) {
Color(.systemBackground)
}
diff --git a/Shared/HutchDeepLinkURLs.swift b/Shared/HutchDeepLinkURLs.swift
new file mode 100644
index 0000000..9b60086
--- /dev/null
+++ b/Shared/HutchDeepLinkURLs.swift
@@ -0,0 +1,23 @@
+import Foundation
+
+/// Canonical `hutch://` URLs for widgets, tests, and in-app links.
+enum HutchDeepLinkURL {
+ static let home = URL(string: "hutch://home")!
+ static let emptyHost = URL(string: "hutch://")!
+ static let repositoryGit = URL(string: "hutch://git/~user/repo")!
+ static let ticket = URL(string: "hutch://todo/~owner/tracker/42")!
+ static let buildJob = URL(string: "hutch://builds/12345")!
+ static let trackers = URL(string: "hutch://trackers")!
+ static let builds = URL(string: "hutch://builds")!
+ static let repositories = URL(string: "hutch://repositories")!
+ static let status = URL(string: "hutch://status")!
+ static let lookup = URL(string: "hutch://lookup")!
+ static let unknown = URL(string: "hutch://unknown")!
+ static let invalidTicketId = URL(string: "hutch://todo/~owner/tracker/abc")!
+ static let invalidBuildId = URL(string: "hutch://builds/abc")!
+}
+
+/// Default Hutch Stats API base URL (mirrors `AppConfiguration` fallback).
+enum HutchStatsAPI {
+ static let defaultBaseURL = URL(string: "https://hutch-stats.zerolabs.sh")!
+}