diff options
| author | Christian Cleberg <[email protected]> | 2026-04-13 19:44:44 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-13 19:44:44 -0500 |
| commit | fe1ccc69661603d419a642a450e4ac9e1258adb0 (patch) | |
| tree | 386455b6b5468abdd9dfa46dd4802b10da8b77a2 /Hutch | |
| parent | 6ec9754ce34f331641b000eddef3d44bca2631a5 (diff) | |
| download | hutch-fe1ccc69661603d419a642a450e4ac9e1258adb0.tar.gz hutch-fe1ccc69661603d419a642a450e4ac9e1258adb0.tar.bz2 hutch-fe1ccc69661603d419a642a450e4ac9e1258adb0.zip | |
fix: centralize URLs, tighten models, and polish SwiftUI bindingsv3.1.3
- Add shared HutchDeepLinkURL constants for app, widgets, and tests.
- Bump marketing version to 3.1.2 and build to 70 (app + widget extension).
- Point Hutch stats default base URL at HutchStatsAPI; add SRHTWebURL status
incident feed and reuse it from SystemStatusService.
- Group Project into Metadata and Resources; split ContributionStatsResponse
into StatsWindow and StatsTotals with updated decoding and tests.
- Replace @Bindable usage with explicit Bindings in Profile, Projects list,
and repository ACL flows; simplify Home pinned-item helper; add no-op
bodies on cancel alert buttons where the compiler requires a statement.
- Move repository row build-status indicator next to the relative-updated
caption and reserve a fixed 8×8 slot so the row does not jump when status
loads.
- Use NSString.lastPathComponent for build artifact filenames; collapse
duplicate ACL error branches; minor HutchStats HTTP and XMLParserDelegate
cleanups.
- Point widgets at HutchDeepLinkURL helpers; align tests with the new
response and URL types.
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/App/AppConfiguration.swift | 2 | ||||
| -rw-r--r-- | Hutch/App/HutchIntents.swift | 4 | ||||
| -rw-r--r-- | Hutch/Extensions/SRHTWebURL.swift | 2 | ||||
| -rw-r--r-- | Hutch/Models/Builds.swift | 3 | ||||
| -rw-r--r-- | Hutch/Models/ContributionCalendar.swift | 92 | ||||
| -rw-r--r-- | Hutch/Models/Project.swift | 54 | ||||
| -rw-r--r-- | Hutch/Networking/HutchStatsService.swift | 7 | ||||
| -rw-r--r-- | Hutch/Networking/ProjectService.swift | 47 | ||||
| -rw-r--r-- | Hutch/Networking/SystemStatusService.swift | 14 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 4 | ||||
| -rw-r--r-- | Hutch/Views/More/AccountSwitcherView.swift | 4 | ||||
| -rw-r--r-- | Hutch/Views/More/ProfileView.swift | 47 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectsListView.swift | 14 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift | 6 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryACLView.swift | 13 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryRowView.swift | 19 | ||||
| -rw-r--r-- | Hutch/Views/Settings/SettingsView.swift | 4 |
17 files changed, 200 insertions, 136 deletions
diff --git a/Hutch/App/AppConfiguration.swift b/Hutch/App/AppConfiguration.swift index 7824371..c733799 100644 --- a/Hutch/App/AppConfiguration.swift +++ b/Hutch/App/AppConfiguration.swift @@ -1,7 +1,7 @@ import Foundation struct AppConfiguration: Sendable { - static let defaultHutchStatsBaseURL = URL(string: "https://hutch-stats.zerolabs.sh")! + static let defaultHutchStatsBaseURL = HutchStatsAPI.defaultBaseURL static let hutchStatsBaseURLEnvironmentKey = "HUTCH_STATS_BASE_URL" let hutchStatsBaseURL: URL diff --git a/Hutch/App/HutchIntents.swift b/Hutch/App/HutchIntents.swift index a7a33b3..5751ef5 100644 --- a/Hutch/App/HutchIntents.swift +++ b/Hutch/App/HutchIntents.swift @@ -150,5 +150,7 @@ final class HutchIntentNavigator { static let shared = HutchIntentNavigator() var pendingDestination: HutchDestination? - private init() {} + private init() { + /* Singleton; external code uses `shared`. */ + } } diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift index bfed48b..fc5a1d5 100644 --- a/Hutch/Extensions/SRHTWebURL.swift +++ b/Hutch/Extensions/SRHTWebURL.swift @@ -3,6 +3,8 @@ import Foundation enum SRHTWebURL { static let chat = URL(string: "https://chat.sr.ht")! static let status = URL(string: "https://status.sr.ht")! + /// RSS/Atom feed for status incidents (HTML snapshot uses `status`). + static let statusIncidentFeed = URL(string: "https://status.sr.ht/index.xml")! static func repository(_ repository: RepositorySummary) -> URL? { userScopedURL( diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift index d666398..3926888 100644 --- a/Hutch/Models/Builds.swift +++ b/Hutch/Models/Builds.swift @@ -136,8 +136,7 @@ struct BuildArtifact: Codable, Sendable, Identifiable, Equatable { let url: URL? var filename: String { - let pathComponents = path.split(separator: "/") - return pathComponents.last.map(String.init) ?? path + (path as NSString).lastPathComponent } var isDownloadable: Bool { diff --git a/Hutch/Models/ContributionCalendar.swift b/Hutch/Models/ContributionCalendar.swift index e2942dd..a429fa2 100644 --- a/Hutch/Models/ContributionCalendar.swift +++ b/Hutch/Models/ContributionCalendar.swift @@ -117,45 +117,67 @@ struct ContributionStatsResponse: Decodable, Sendable, Hashable { case currentStreak = "current_streak" } - init( - actor: String, - from: Date, - to: Date, - isIndexed: Bool, - lastPolledAt: Date?, - indexingState: ContributionIndexingState, - totalEvents: Int, - totalScore: Double, - activeDays: Int, - longestStreak: Int, - currentStreak: Int - ) { - self.actor = actor - self.from = from - self.to = to - self.isIndexed = isIndexed - self.lastPolledAt = lastPolledAt - self.indexingState = indexingState - self.totalEvents = totalEvents - self.totalScore = totalScore - self.activeDays = activeDays - self.longestStreak = longestStreak - self.currentStreak = currentStreak + struct StatsWindow: Sendable, Hashable { + let actor: String + let from: Date + let to: Date + let isIndexed: Bool + let lastPolledAt: Date? + let indexingState: ContributionIndexingState + } + + struct StatsTotals: Sendable, Hashable { + let totalEvents: Int + let totalScore: Double + let activeDays: Int + let longestStreak: Int + let currentStreak: Int + } + + init(window: StatsWindow, totals: StatsTotals) { + actor = window.actor + from = window.from + to = window.to + isIndexed = window.isIndexed + lastPolledAt = window.lastPolledAt + indexingState = window.indexingState + totalEvents = totals.totalEvents + totalScore = totals.totalScore + activeDays = totals.activeDays + longestStreak = totals.longestStreak + currentStreak = totals.currentStreak } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - actor = try container.decode(String.self, forKey: .actor) - from = try ContributionDateParser.decodeDateString(from: container, forKey: .from) - to = try ContributionDateParser.decodeDateString(from: container, forKey: .to) - isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false - lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt) - indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed - totalEvents = try container.decode(Int.self, forKey: .totalEvents) - totalScore = try container.decode(Double.self, forKey: .totalScore) - activeDays = try container.decode(Int.self, forKey: .activeDays) - longestStreak = try container.decode(Int.self, forKey: .longestStreak) - currentStreak = try container.decode(Int.self, forKey: .currentStreak) + let actor = try container.decode(String.self, forKey: .actor) + let from = try ContributionDateParser.decodeDateString(from: container, forKey: .from) + let to = try ContributionDateParser.decodeDateString(from: container, forKey: .to) + let isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false + let lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt) + let indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed + let totalEvents = try container.decode(Int.self, forKey: .totalEvents) + let totalScore = try container.decode(Double.self, forKey: .totalScore) + let activeDays = try container.decode(Int.self, forKey: .activeDays) + let longestStreak = try container.decode(Int.self, forKey: .longestStreak) + let currentStreak = try container.decode(Int.self, forKey: .currentStreak) + self.init( + window: .init( + actor: actor, + from: from, + to: to, + isIndexed: isIndexed, + lastPolledAt: lastPolledAt, + indexingState: indexingState + ), + totals: .init( + totalEvents: totalEvents, + totalScore: totalScore, + activeDays: activeDays, + longestStreak: longestStreak, + currentStreak: currentStreak + ) + ) } } diff --git a/Hutch/Models/Project.swift b/Hutch/Models/Project.swift index 678d52e..bbca7d6 100644 --- a/Hutch/Models/Project.swift +++ b/Hutch/Models/Project.swift @@ -71,30 +71,36 @@ struct Project: Identifiable, Hashable, Sendable { let trackers: [Tracker] let isFullyLoaded: Bool - init( - id: String, - name: String, - description: String?, - website: String?, - visibility: Visibility, - tags: [String], - updated: Date, - mailingLists: [MailingList], - sources: [SourceRepo], - trackers: [Tracker], - isFullyLoaded: Bool = true - ) { - self.id = id - self.name = name - self.description = description - self.website = website - self.visibility = visibility - self.tags = tags - self.updated = updated - self.mailingLists = mailingLists - self.sources = sources - self.trackers = trackers - self.isFullyLoaded = isFullyLoaded + /// Identity and display fields for a project. + struct Metadata: Sendable, Hashable { + let id: String + let name: String + let description: String? + let website: String? + let visibility: Visibility + let tags: [String] + let updated: Date + } + + struct Resources: Sendable, Hashable { + let mailingLists: [MailingList] + let sources: [SourceRepo] + let trackers: [Tracker] + let isFullyLoaded: Bool + } + + init(metadata: Metadata, resources: Resources) { + id = metadata.id + name = metadata.name + description = metadata.description + website = metadata.website + visibility = metadata.visibility + tags = metadata.tags + updated = metadata.updated + mailingLists = resources.mailingLists + sources = resources.sources + trackers = resources.trackers + isFullyLoaded = resources.isFullyLoaded } var resourceSummary: String? { diff --git a/Hutch/Networking/HutchStatsService.swift b/Hutch/Networking/HutchStatsService.swift index 52d86b9..aead9c6 100644 --- a/Hutch/Networking/HutchStatsService.swift +++ b/Hutch/Networking/HutchStatsService.swift @@ -61,10 +61,9 @@ struct HutchStatsService: ContributionCalendarServing { throw SRHTError.networkError(error) } - if let httpResponse = response as? HTTPURLResponse { - if !(200...299).contains(httpResponse.statusCode) { - throw SRHTError.httpError(httpResponse.statusCode) - } + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + throw SRHTError.httpError(httpResponse.statusCode) } do { diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift index 93ce71f..bd8ccdc 100644 --- a/Hutch/Networking/ProjectService.swift +++ b/Hutch/Networking/ProjectService.swift @@ -372,17 +372,21 @@ struct ProjectService: Sendable { if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil { return Project( - id: project.rid, - name: project.name, - description: project.description, - website: project.website, - visibility: project.visibility, - tags: project.tags, - updated: project.updated, - mailingLists: deduplicate(mailingLists), - sources: deduplicate(sources), - trackers: deduplicate(trackers), - isFullyLoaded: true + metadata: .init( + id: project.rid, + name: project.name, + description: project.description, + website: project.website, + visibility: project.visibility, + tags: project.tags, + updated: project.updated + ), + resources: .init( + mailingLists: deduplicate(mailingLists), + sources: deduplicate(sources), + trackers: deduplicate(trackers), + isFullyLoaded: true + ) ) } } @@ -390,17 +394,16 @@ struct ProjectService: Sendable { private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project { Project( - id: summary.rid, - name: summary.name, - description: summary.description, - website: summary.website, - visibility: summary.visibility, - tags: summary.tags, - updated: summary.updated, - mailingLists: [], - sources: [], - trackers: [], - isFullyLoaded: false + metadata: .init( + id: summary.rid, + name: summary.name, + description: summary.description, + website: summary.website, + visibility: summary.visibility, + tags: summary.tags, + updated: summary.updated + ), + resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false) ) } diff --git a/Hutch/Networking/SystemStatusService.swift b/Hutch/Networking/SystemStatusService.swift index 5dd27b5..ec2d233 100644 --- a/Hutch/Networking/SystemStatusService.swift +++ b/Hutch/Networking/SystemStatusService.swift @@ -1,8 +1,8 @@ import Foundation struct SystemStatusService: Sendable { - nonisolated static let statusURL = URL(string: "https://status.sr.ht/")! - nonisolated static let feedURL = URL(string: "https://status.sr.ht/index.xml")! + nonisolated static let statusURL = SRHTWebURL.status + nonisolated static let feedURL = SRHTWebURL.statusIncidentFeed private let session: URLSession private let now: @Sendable () -> Date @@ -340,24 +340,24 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec return incidents.sorted { $0.publishedAt > $1.publishedAt } } - func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { + func parser(_: XMLParser, didStartElement elementName: String, namespaceURI _: String?, qualifiedName _: String?, attributes _: [String: String] = [:]) { textBuffer = "" if elementName == "item" { currentItem = FeedItem() } } - func parser(_ parser: XMLParser, foundCharacters string: String) { + func parser(_: XMLParser, foundCharacters string: String) { textBuffer += string } - func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) { - if let string = String(data: CDATABlock, encoding: .utf8) { + func parser(_: XMLParser, foundCDATA cdata: Data) { + if let string = String(data: cdata, encoding: .utf8) { textBuffer += string } } - func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { + func parser(_: XMLParser, didEndElement elementName: String, namespaceURI _: String?, qualifiedName _: String?) { guard var currentItem else { textBuffer = "" return diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index b10926a..b4b809d 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -171,7 +171,7 @@ struct HomeView: View { ) { ForEach(items) { item in Button { - openPinnedItem(item, viewModel: viewModel) + openPinnedItem(item) } label: { HomePinnedCard(item: item) } @@ -313,7 +313,7 @@ struct HomeView: View { } } - private func openPinnedItem(_ item: HomePinnedItem, viewModel: HomeViewModel) { + private func openPinnedItem(_ item: HomePinnedItem) { switch item.pin.kind { case .project: guard let project = item.project else { return } diff --git a/Hutch/Views/More/AccountSwitcherView.swift b/Hutch/Views/More/AccountSwitcherView.swift index ce69279..83871a8 100644 --- a/Hutch/Views/More/AccountSwitcherView.swift +++ b/Hutch/Views/More/AccountSwitcherView.swift @@ -98,7 +98,9 @@ struct AccountSwitcherView: View { } ) ) { - Button("Cancel", role: .cancel) {} + Button("Cancel", role: .cancel) { + /* Dismiss only; removal uses the destructive button. */ + } Button("Remove", role: .destructive) { guard let pendingRemoval else { return } Task { await appState.removeAccount(id: pendingRemoval.id) } diff --git a/Hutch/Views/More/ProfileView.swift b/Hutch/Views/More/ProfileView.swift index c53a89d..8f353ee 100644 --- a/Hutch/Views/More/ProfileView.swift +++ b/Hutch/Views/More/ProfileView.swift @@ -61,8 +61,6 @@ struct ProfileView: View { @ViewBuilder private func profileContent(_ viewModel: SettingsViewModel) -> some View { - @Bindable var vm = viewModel - Form { if let profile = viewModel.profile { profileSection(profile, viewModel: viewModel) @@ -100,7 +98,10 @@ struct ProfileView: View { ) } } - .sheet(isPresented: $vm.isEditingProfile) { + .sheet(isPresented: Binding( + get: { viewModel.isEditingProfile }, + set: { viewModel.isEditingProfile = $0 } + )) { if let profile = viewModel.profile { EditProfileSheet(profile: profile, viewModel: viewModel) } @@ -130,7 +131,9 @@ struct ProfileView: View { } ) ) { - Button("Cancel", role: .cancel) {} + Button("Cancel", role: .cancel) { + /* Dismiss only; destructive action is separate. */ + } Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) { guard let action = pendingDestructiveAction else { return } pendingDestructiveAction = nil @@ -158,12 +161,11 @@ struct ProfileView: View { Section("Profile") { HStack(spacing: 12) { AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in - switch phase { - case .success(let image): + if case .success(let image) = phase { image .resizable() .scaledToFill() - default: + } else { Image(systemName: "person.crop.circle.fill") .resizable() .foregroundStyle(.secondary) @@ -238,8 +240,6 @@ struct ProfileView: View { @ViewBuilder private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View { - @Bindable var vm = viewModel - Section { ForEach(viewModel.sshKeys) { key in VStack(alignment: .leading, spacing: 2) { @@ -275,7 +275,14 @@ struct ProfileView: View { .themedRow() if viewModel.isAddingSSHKey { - TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical) + TextField( + "Paste SSH public key", + text: Binding( + get: { viewModel.newSSHKey }, + set: { viewModel.newSSHKey = $0 } + ), + axis: .vertical + ) .font(.caption.monospaced()) .lineLimit(3...6) .themedRow() @@ -310,8 +317,6 @@ struct ProfileView: View { @ViewBuilder private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View { - @Bindable var vm = viewModel - Section { ForEach(viewModel.pgpKeys) { key in VStack(alignment: .leading, spacing: 2) { @@ -333,7 +338,14 @@ struct ProfileView: View { .themedRow() if viewModel.isAddingPGPKey { - TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical) + TextField( + "Paste PGP public key", + text: Binding( + get: { viewModel.newPGPKey }, + set: { viewModel.newPGPKey = $0 } + ), + axis: .vertical + ) .font(.caption.monospaced()) .lineLimit(3...6) .themedRow() @@ -482,12 +494,11 @@ private struct EditProfileSheet: View { .scaledToFill() } else { AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in - switch phase { - case .success(let image): + if case .success(let image) = phase { image .resizable() .scaledToFill() - default: + } else { Image(systemName: "person.crop.circle.fill") .resizable() .foregroundStyle(.secondary) @@ -592,7 +603,9 @@ private struct EditProfileSheet: View { } } .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) { - Button("Cancel", role: .cancel) {} + Button("Cancel", role: .cancel) { + /* Dismiss only; removal uses the destructive button. */ + } Button("Remove Avatar", role: .destructive) { Task { await viewModel.removeAvatar() diff --git a/Hutch/Views/Projects/ProjectsListView.swift b/Hutch/Views/Projects/ProjectsListView.swift index 12a5149..f6d5766 100644 --- a/Hutch/Views/Projects/ProjectsListView.swift +++ b/Hutch/Views/Projects/ProjectsListView.swift @@ -67,8 +67,6 @@ struct ProjectsListView: View { @ViewBuilder private func content(_ viewModel: ProjectsListViewModel) -> some View { - @Bindable var vm = viewModel - List { ForEach(viewModel.filteredProjects) { project in NavigationLink { @@ -84,7 +82,10 @@ struct ProjectsListView: View { .themedList() .listStyle(.plain) .searchable( - text: $vm.searchText, + text: Binding( + get: { viewModel.searchText }, + set: { viewModel.searchText = $0 } + ), placement: .navigationBarDrawer(displayMode: .always), prompt: "Search projects" ) @@ -107,7 +108,12 @@ struct ProjectsListView: View { ) } } - .srhtErrorBanner(error: $vm.error) + .srhtErrorBanner( + error: Binding( + get: { viewModel.error }, + set: { viewModel.error = $0 } + ) + ) .refreshable { await viewModel.loadProjects() } diff --git a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift index 7b8d7f7..6df40db 100644 --- a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift +++ b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift @@ -261,11 +261,7 @@ final class HgRepositorySettingsViewModel { } newACLEntity = "" } catch { - if error.matchesGraphQLErrorClassification(.serviceNotProvisioned) { - self.error = error.userFacingMessage - } else { - self.error = error.userFacingMessage - } + self.error = error.userFacingMessage } } diff --git a/Hutch/Views/Repositories/RepositoryACLView.swift b/Hutch/Views/Repositories/RepositoryACLView.swift index 29ded84..ede770c 100644 --- a/Hutch/Views/Repositories/RepositoryACLView.swift +++ b/Hutch/Views/Repositories/RepositoryACLView.swift @@ -51,8 +51,6 @@ struct RepositoryACLView: View { @ViewBuilder private func content(_ viewModel: RepositoryACLViewModel) -> some View { - @Bindable var vm = viewModel - Group { if viewModel.isLoading && !viewModel.hasEntries && viewModel.loadError == nil { SRHTLoadingStateView(message: "Loading access…") @@ -98,7 +96,12 @@ struct RepositoryACLView: View { } } } - .srhtErrorBanner(error: $vm.error) + .srhtErrorBanner( + error: Binding( + get: { viewModel.error }, + set: { viewModel.error = $0 } + ) + ) .alert("Remove Access?", isPresented: Binding( get: { pendingDeletion != nil }, set: { isPresented in @@ -107,7 +110,9 @@ struct RepositoryACLView: View { } } )) { - Button("Cancel", role: .cancel) {} + Button("Cancel", role: .cancel) { + /* Dismiss only; removal is confirmed separately. */ + } Button("Remove Access", role: .destructive) { guard let entry = pendingDeletion else { return } Task { diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift index afbd740..a2d120b 100644 --- a/Hutch/Views/Repositories/RepositoryRowView.swift +++ b/Hutch/Views/Repositories/RepositoryRowView.swift @@ -17,9 +17,6 @@ struct RepositoryRowView: View { RepositoryForgeBadge(service: repository.service) - if buildStatus != .none { - RepositoryBuildStatusIndicator(status: buildStatus) - } VisibilityBadge(visibility: repository.visibility) } @@ -44,9 +41,19 @@ struct RepositoryRowView: View { Spacer() - Text(repository.updated.relativeDescription) - .font(.caption) - .foregroundStyle(.tertiary) + HStack(spacing: 6) { + ZStack { + if buildStatus != .none { + RepositoryBuildStatusIndicator(status: buildStatus) + } + } + .frame(width: 8, height: 8) + .accessibilityHidden(buildStatus == .none) + + Text(repository.updated.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + } } } .padding(.vertical, 2) diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift index 4ed9216..577de8a 100644 --- a/Hutch/Views/Settings/SettingsView.swift +++ b/Hutch/Views/Settings/SettingsView.swift @@ -32,7 +32,9 @@ struct SettingsView: View { } ) ) { - Button("Cancel", role: .cancel) {} + Button("Cancel", role: .cancel) { + /* Dismiss only; destructive action is separate. */ + } Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) { guard let action = pendingDestructiveAction else { return } pendingDestructiveAction = nil |
