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