Commit 6b2371ec5c
6b2371ec5ce038ea398c036e1b9626a97e69a680
parent: 24feba272c
Unsigned
cmc <hello@cleberg.net> · 2026-07-16 05:29 UTC
fix: blank mailing list from Projects, swipe flicker, hidden upload
Three problems from manual testing.
Opening a mailing list from More → Projects showed a blank screen, while the
same tap on a project pinned to Home worked. handleTabNavigation reset the
target path and appended to it two Task.yields later. When the target tab is
already on screen — Projects lives under More — the reset starts an animated pop
of the view the user is standing on and the appends land mid-animation. From
Home the tab actually changes, so the More stack is quiescent and the appends
land cleanly. Each case now builds its path and assigns it once, so SwiftUI gets
a single diff with nothing to race.
Destructive swipe actions made the row vanish and spring back while the
confirmation was still up. role: .destructive makes SwiftUI perform the row
removal on activation, which allowsFullSwipe: false does not prevent — the
report was a tap, not a full swipe. These buttons only record pending state and
wait for an answer, so they are plain buttons tinted red instead. Six sites: the
two added here, plus trackers, pastes, and tracker ACLs and labels, which had
the same flicker already.
The artifacts upload control was invisible. It was declared as a toolbar item
from a view that is a segment inside RepositoryDetailView's tab switch rather
than its own navigation destination, so it never reached the navigation bar. It
is a row in the list now, and also an action on the empty state — the overlay
covers the list, and a repository with no artifacts is precisely the one that
needs uploading.
Layout: unified · split
Hutch/App/RootView.swift
+27 −21
| @@ -291,39 +291,45 @@ struct RootView: View { |
| 291 | 291 | } |
| 292 | 292 | } |
| 293 | 293 | |
| 294 | /// Replaces the target tab's path in one assignment. |
| 295 | /// |
| 296 | /// Resetting the path and appending to it afterwards races when the target tab |
| 297 | /// is already the one on screen: the reset starts an animated pop of the view |
| 298 | /// the user is standing on, and the appends land mid-animation, leaving a blank |
| 299 | /// screen. That is why opening a mailing list from a pinned project on Home |
| 300 | /// worked while the same tap under More → Projects did not — one changes tabs |
| 301 | /// and the other does not. |
| 302 | /// |
| 303 | /// Building the whole path first and assigning once gives SwiftUI a single |
| 304 | /// diff, with nothing to race. |
| 294 | 305 | private func handleTabNavigation(_ target: AppState.TabNavigationTarget) { |
| 295 | 306 | switch target { |
| 296 | 307 | case .repository(let repository): |
| 297 | | repoPath = NavigationPath() |
| 308 | var path = NavigationPath() |
| 309 | path.append(repository) |
| 310 | repoPath = path |
| 298 | 311 | appState.selectedTab = .repositories |
| 299 | | Task { |
| 300 | | await settleNavigationTransition() |
| 301 | | repoPath.append(repository) |
| 302 | | } |
| 303 | 312 | |
| 304 | 313 | case .tracker(let tracker): |
| 305 | | ticketsPath = NavigationPath() |
| 314 | var path = NavigationPath() |
| 315 | path.append(tracker) |
| 316 | ticketsPath = path |
| 306 | 317 | appState.selectedTab = .tickets |
| 307 | | Task { |
| 308 | | await settleNavigationTransition() |
| 309 | | ticketsPath.append(tracker) |
| 310 | | } |
| 311 | 318 | |
| 312 | 319 | case .mailingList(let mailingList): |
| 313 | | morePath = NavigationPath() |
| 320 | // .lists first so back lands on Mailing Lists rather than dead-ending. |
| 321 | var path = NavigationPath() |
| 322 | path.append(MoreRoute.lists) |
| 323 | path.append(MoreRoute.mailingList(mailingList)) |
| 324 | morePath = path |
| 314 | 325 | appState.selectedTab = .more |
| 315 | | Task { |
| 316 | | await settleNavigationTransition() |
| 317 | | morePath.append(MoreRoute.lists) |
| 318 | | morePath.append(MoreRoute.mailingList(mailingList)) |
| 319 | | } |
| 326 | |
| 320 | 327 | case .systemStatus: |
| 321 | | morePath = NavigationPath() |
| 328 | var path = NavigationPath() |
| 329 | path.append(MoreRoute.systemStatus) |
| 330 | morePath = path |
| 322 | 331 | appState.selectedTab = .more |
| 323 | | Task { |
| 324 | | await settleNavigationTransition() |
| 325 | | morePath.append(MoreRoute.systemStatus) |
| 326 | | } |
| 332 | |
| 327 | 333 | case .builds: |
| 328 | 334 | buildsPath = NavigationPath() |
| 329 | 335 | appState.selectedTab = .builds |
Hutch/Views/Lists/MailingListListView.swift
+2 −1
| @@ -373,11 +373,12 @@ struct MailingListListView: View { |
| 373 | 373 | // the data has not actually changed. |
| 374 | 374 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 375 | 375 | if isOwned(mailingList) { |
| 376 | | Button(role: .destructive) { |
| 376 | Button { |
| 377 | 377 | pendingDeletion = mailingList |
| 378 | 378 | } label: { |
| 379 | 379 | SwiftUI.Label("Delete", systemImage: "trash") |
| 380 | 380 | } |
| 381 | .tint(.red) |
| 381 | 382 | Button { |
| 382 | 383 | editingList = mailingList |
| 383 | 384 | } label: { |
Hutch/Views/Pastes/PasteListView.swift
+2 −1
| @@ -90,11 +90,12 @@ struct PasteListView: View { |
| 90 | 90 | } |
| 91 | 91 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 92 | 92 | if swipeActionsEnabled { |
| 93 | | Button(role: .destructive) { |
| 93 | Button { |
| 94 | 94 | pasteToDelete = paste |
| 95 | 95 | } label: { |
| 96 | 96 | Label("Delete", systemImage: "trash") |
| 97 | 97 | } |
| 98 | .tint(.red) |
| 98 | 99 | } |
| 99 | 100 | } |
| 100 | 101 | .task { |
Hutch/Views/Repositories/ArtifactsView.swift
+32 −20
| @@ -16,6 +16,21 @@ struct ArtifactsView: View { |
| 16 | 16 | |
| 17 | 17 | var body: some View { |
| 18 | 18 | List { |
| 19 | // In the list rather than the toolbar: this view is a segment inside |
| 20 | // RepositoryDetailView's tab switch, not its own navigation |
| 21 | // destination, and a toolbar declared from there does not reliably |
| 22 | // reach the navigation bar. It also has to be reachable when there are |
| 23 | // no artifacts at all, which is the state a new tag is in. |
| 24 | if isOwnedByCurrentUser { |
| 25 | Button { |
| 26 | showTagPicker = true |
| 27 | } label: { |
| 28 | SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up") |
| 29 | } |
| 30 | .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) |
| 31 | .themedRow() |
| 32 | } |
| 33 | |
| 19 | 34 | ForEach(viewModel.referenceArtifacts) { refArtifacts in |
| 20 | 35 | Section { |
| 21 | 36 | ForEach(refArtifacts.artifacts) { artifact in |
| @@ -26,11 +41,12 @@ struct ArtifactsView: View { |
| 26 | 41 | // action animates the row out before the confirmation. |
| 27 | 42 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 28 | 43 | if isOwnedByCurrentUser { |
| 29 | | Button(role: .destructive) { |
| 44 | Button { |
| 30 | 45 | pendingDeletion = artifact |
| 31 | 46 | } label: { |
| 32 | 47 | SwiftUI.Label("Delete", systemImage: "trash") |
| 33 | 48 | } |
| 49 | .tint(.red) |
| 34 | 50 | } |
| 35 | 51 | } |
| 36 | 52 | } |
| @@ -83,20 +99,6 @@ struct ArtifactsView: View { |
| 83 | 99 | } message: { _ in |
| 84 | 100 | Text("This permanently removes the artifact from the tag. This cannot be undone.") |
| 85 | 101 | } |
| 86 | | // The sections above only list tags that already have an artifact, so |
| 87 | | // without this there would be no way to attach the first one to a tag. |
| 88 | | .toolbar { |
| 89 | | if isOwnedByCurrentUser { |
| 90 | | ToolbarItem(placement: .topBarTrailing) { |
| 91 | | Button { |
| 92 | | showTagPicker = true |
| 93 | | } label: { |
| 94 | | SwiftUI.Label("Upload Artifact", systemImage: "square.and.arrow.up") |
| 95 | | } |
| 96 | | .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) |
| 97 | | } |
| 98 | | } |
| 99 | | } |
| 100 | 102 | .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) { |
| 101 | 103 | ForEach(viewModel.tags.prefix(12), id: \.name) { tag in |
| 102 | 104 | Button(RepositorySummary.displayBranchName(for: tag.name)) { |
| @@ -125,11 +127,21 @@ struct ArtifactsView: View { |
| 125 | 127 | retryAction: { await viewModel.loadArtifacts() } |
| 126 | 128 | ) |
| 127 | 129 | } else if viewModel.referenceArtifacts.isEmpty { |
| 128 | | ContentUnavailableView( |
| 129 | | "No Artifacts", |
| 130 | | systemImage: "archivebox", |
| 131 | | description: Text("This repository has no release artifacts.") |
| 132 | | ) |
| 130 | // The overlay covers the whole list, so the upload row above is |
| 131 | // hidden underneath it — and a repository with no artifacts is |
| 132 | // exactly the one that needs uploading. Offer it here too. |
| 133 | ContentUnavailableView { |
| 134 | SwiftUI.Label("No Artifacts", systemImage: "archivebox") |
| 135 | } description: { |
| 136 | Text("This repository has no release artifacts.") |
| 137 | } actions: { |
| 138 | if isOwnedByCurrentUser { |
| 139 | Button("Upload Artifact…") { |
| 140 | showTagPicker = true |
| 141 | } |
| 142 | .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) |
| 143 | } |
| 144 | } |
| 133 | 145 | } |
| 134 | 146 | } |
| 135 | 147 | .task { |
Hutch/Views/Tickets/TrackerListView.swift
+2 −1
| @@ -145,11 +145,12 @@ struct TrackerListView: View { |
| 145 | 145 | TrackerRowView(tracker: tracker) |
| 146 | 146 | } |
| 147 | 147 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 148 | | Button(role: .destructive) { |
| 148 | Button { |
| 149 | 149 | pendingDeletion = tracker |
| 150 | 150 | } label: { |
| 151 | 151 | Label("Delete", systemImage: "trash") |
| 152 | 152 | } |
| 153 | .tint(.red) |
| 153 | 154 | |
| 154 | 155 | Button { |
| 155 | 156 | editingTracker = tracker |
Hutch/Views/Tickets/TrackerManagementView.swift
+4 −2
| @@ -751,11 +751,12 @@ struct TrackerACLManagementSheet: View { |
| 751 | 751 | TrackerPermissionSummary(permissions: entry.permissions) |
| 752 | 752 | } |
| 753 | 753 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 754 | | Button(role: .destructive) { |
| 754 | Button { |
| 755 | 755 | pendingDeletion = entry |
| 756 | 756 | } label: { |
| 757 | 757 | Label("Delete", systemImage: "trash") |
| 758 | 758 | } |
| 759 | .tint(.red) |
| 759 | 760 | |
| 760 | 761 | Button { |
| 761 | 762 | editingACL = entry |
| @@ -1132,11 +1133,12 @@ struct TrackerLabelManagementSheet: View { |
| 1132 | 1133 | } |
| 1133 | 1134 | .tint(.blue) |
| 1134 | 1135 | |
| 1135 | | Button(role: .destructive) { |
| 1136 | Button { |
| 1136 | 1137 | pendingDeletion = label |
| 1137 | 1138 | } label: { |
| 1138 | 1139 | Label("Delete", systemImage: "trash") |
| 1139 | 1140 | } |
| 1141 | .tint(.red) |
| 1140 | 1142 | } |
| 1141 | 1143 | } |
| 1142 | 1144 | .themedRow() |