Commit 303f741e56
Unsigned
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 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | 2 | ||
| 3 | struct AppConfiguration: Sendable { | 3 | struct 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 | |||
| 3 | enum SRHTWebURL { | 3 | enum 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 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | 2 | ||
| 3 | struct SystemStatusService: Sendable { | 3 | struct 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 { | |||
| 348 | private extension ContributionStatsResponse { | 348 | private 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 | ||
| 5 | struct ProjectTests { | 5 | struct 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 | ||
| 5 | struct SRHTWebURLTests { | 5 | struct 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 | ||
| 5 | struct SystemStatusServiceTests { | 5 | struct 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 | ||
| 237 | private struct ContributionGraphWidgetService { | 237 | private 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Canonical `hutch://` URLs for widgets, tests, and in-app links. | ||
| 4 | enum 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). | ||
| 21 | enum HutchStatsAPI { | ||
| 22 | static let defaultBaseURL = URL(string: "https://hutch-stats.zerolabs.sh")! | ||
| 23 | } | ||