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 private func handleTabNavigation(_ target: AppState.TabNavigationTarget) { 305 private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
295 switch target { 306 switch target {
296 case .repository(let repository): 307 case .repository(let repository):
297 repoPath = NavigationPath() 308 var path = NavigationPath()
309 path.append(repository)
310 repoPath = path
298 appState.selectedTab = .repositories 311 appState.selectedTab = .repositories
299 Task {
300 await settleNavigationTransition()
301 repoPath.append(repository)
302 }
303 312
304 case .tracker(let tracker): 313 case .tracker(let tracker):
305 ticketsPath = NavigationPath() 314 var path = NavigationPath()
315 path.append(tracker)
316 ticketsPath = path
306 appState.selectedTab = .tickets 317 appState.selectedTab = .tickets
307 Task {
308 await settleNavigationTransition()
309 ticketsPath.append(tracker)
310 }
311 318
312 case .mailingList(let mailingList): 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 appState.selectedTab = .more 325 appState.selectedTab = .more
315 Task { 326
316 await settleNavigationTransition()
317 morePath.append(MoreRoute.lists)
318 morePath.append(MoreRoute.mailingList(mailingList))
319 }
320 case .systemStatus: 327 case .systemStatus:
321 morePath = NavigationPath() 328 var path = NavigationPath()
329 path.append(MoreRoute.systemStatus)
330 morePath = path
322 appState.selectedTab = .more 331 appState.selectedTab = .more
323 Task { 332
324 await settleNavigationTransition()
325 morePath.append(MoreRoute.systemStatus)
326 }
327 case .builds: 333 case .builds:
328 buildsPath = NavigationPath() 334 buildsPath = NavigationPath()
329 appState.selectedTab = .builds 335 appState.selectedTab = .builds
Hutch/Views/Lists/MailingListListView.swift +2 −1
@@ -373,11 +373,12 @@ struct MailingListListView: View {
373 // the data has not actually changed. 373 // the data has not actually changed.
374 .swipeActions(edge: .trailing, allowsFullSwipe: false) { 374 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
375 if isOwned(mailingList) { 375 if isOwned(mailingList) {
376 Button(role: .destructive) { 376 Button {
377 pendingDeletion = mailingList 377 pendingDeletion = mailingList
378 } label: { 378 } label: {
379 SwiftUI.Label("Delete", systemImage: "trash") 379 SwiftUI.Label("Delete", systemImage: "trash")
380 } 380 }
381 .tint(.red)
381 Button { 382 Button {
382 editingList = mailingList 383 editingList = mailingList
383 } label: { 384 } label: {
Hutch/Views/Pastes/PasteListView.swift +2 −1
@@ -90,11 +90,12 @@ struct PasteListView: View {
90 } 90 }
91 .swipeActions(edge: .trailing, allowsFullSwipe: false) { 91 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
92 if swipeActionsEnabled { 92 if swipeActionsEnabled {
93 Button(role: .destructive) { 93 Button {
94 pasteToDelete = paste 94 pasteToDelete = paste
95 } label: { 95 } label: {
96 Label("Delete", systemImage: "trash") 96 Label("Delete", systemImage: "trash")
97 } 97 }
98 .tint(.red)
98 } 99 }
99 } 100 }
100 .task { 101 .task {
Hutch/Views/Repositories/ArtifactsView.swift +32 −20
@@ -16,6 +16,21 @@ struct ArtifactsView: View {
16 16
17 var body: some View { 17 var body: some View {
18 List { 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 ForEach(viewModel.referenceArtifacts) { refArtifacts in 34 ForEach(viewModel.referenceArtifacts) { refArtifacts in
20 Section { 35 Section {
21 ForEach(refArtifacts.artifacts) { artifact in 36 ForEach(refArtifacts.artifacts) { artifact in
@@ -26,11 +41,12 @@ struct ArtifactsView: View {
26 // action animates the row out before the confirmation. 41 // action animates the row out before the confirmation.
27 .swipeActions(edge: .trailing, allowsFullSwipe: false) { 42 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
28 if isOwnedByCurrentUser { 43 if isOwnedByCurrentUser {
29 Button(role: .destructive) { 44 Button {
30 pendingDeletion = artifact 45 pendingDeletion = artifact
31 } label: { 46 } label: {
32 SwiftUI.Label("Delete", systemImage: "trash") 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 } message: { _ in 99 } message: { _ in
84 Text("This permanently removes the artifact from the tag. This cannot be undone.") 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 .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) { 102 .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) {
101 ForEach(viewModel.tags.prefix(12), id: \.name) { tag in 103 ForEach(viewModel.tags.prefix(12), id: \.name) { tag in
102 Button(RepositorySummary.displayBranchName(for: tag.name)) { 104 Button(RepositorySummary.displayBranchName(for: tag.name)) {
@@ -125,11 +127,21 @@ struct ArtifactsView: View {
125 retryAction: { await viewModel.loadArtifacts() } 127 retryAction: { await viewModel.loadArtifacts() }
126 ) 128 )
127 } else if viewModel.referenceArtifacts.isEmpty { 129 } else if viewModel.referenceArtifacts.isEmpty {
128 ContentUnavailableView( 130 // The overlay covers the whole list, so the upload row above is
129 "No Artifacts", 131 // hidden underneath it — and a repository with no artifacts is
130 systemImage: "archivebox", 132 // exactly the one that needs uploading. Offer it here too.
131 description: Text("This repository has no release artifacts.") 133 ContentUnavailableView {
132 ) 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 .task { 147 .task {
Hutch/Views/Tickets/TrackerListView.swift +2 −1
@@ -145,11 +145,12 @@ struct TrackerListView: View {
145 TrackerRowView(tracker: tracker) 145 TrackerRowView(tracker: tracker)
146 } 146 }
147 .swipeActions(edge: .trailing, allowsFullSwipe: false) { 147 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
148 Button(role: .destructive) { 148 Button {
149 pendingDeletion = tracker 149 pendingDeletion = tracker
150 } label: { 150 } label: {
151 Label("Delete", systemImage: "trash") 151 Label("Delete", systemImage: "trash")
152 } 152 }
153 .tint(.red)
153 154
154 Button { 155 Button {
155 editingTracker = tracker 156 editingTracker = tracker
Hutch/Views/Tickets/TrackerManagementView.swift +4 −2
@@ -751,11 +751,12 @@ struct TrackerACLManagementSheet: View {
751 TrackerPermissionSummary(permissions: entry.permissions) 751 TrackerPermissionSummary(permissions: entry.permissions)
752 } 752 }
753 .swipeActions(edge: .trailing, allowsFullSwipe: false) { 753 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
754 Button(role: .destructive) { 754 Button {
755 pendingDeletion = entry 755 pendingDeletion = entry
756 } label: { 756 } label: {
757 Label("Delete", systemImage: "trash") 757 Label("Delete", systemImage: "trash")
758 } 758 }
759 .tint(.red)
759 760
760 Button { 761 Button {
761 editingACL = entry 762 editingACL = entry
@@ -1132,11 +1133,12 @@ struct TrackerLabelManagementSheet: View {
1132 } 1133 }
1133 .tint(.blue) 1134 .tint(.blue)
1134 1135
1135 Button(role: .destructive) { 1136 Button {
1136 pendingDeletion = label 1137 pendingDeletion = label
1137 } label: { 1138 } label: {
1138 Label("Delete", systemImage: "trash") 1139 Label("Delete", systemImage: "trash")
1139 } 1140 }
1141 .tint(.red)
1140 } 1142 }
1141 } 1143 }
1142 .themedRow() 1144 .themedRow()