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 {
291291 }
292292 }
293293
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.
294305 private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
295306 switch target {
296307 case .repository(let repository):
297 repoPath = NavigationPath()
308 var path = NavigationPath()
309 path.append(repository)
310 repoPath = path
298311 appState.selectedTab = .repositories
299 Task {
300 await settleNavigationTransition()
301 repoPath.append(repository)
302 }
303312
304313 case .tracker(let tracker):
305 ticketsPath = NavigationPath()
314 var path = NavigationPath()
315 path.append(tracker)
316 ticketsPath = path
306317 appState.selectedTab = .tickets
307 Task {
308 await settleNavigationTransition()
309 ticketsPath.append(tracker)
310 }
311318
312319 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
314325 appState.selectedTab = .more
315 Task {
316 await settleNavigationTransition()
317 morePath.append(MoreRoute.lists)
318 morePath.append(MoreRoute.mailingList(mailingList))
319 }
326
320327 case .systemStatus:
321 morePath = NavigationPath()
328 var path = NavigationPath()
329 path.append(MoreRoute.systemStatus)
330 morePath = path
322331 appState.selectedTab = .more
323 Task {
324 await settleNavigationTransition()
325 morePath.append(MoreRoute.systemStatus)
326 }
332
327333 case .builds:
328334 buildsPath = NavigationPath()
329335 appState.selectedTab = .builds
Hutch/Views/Lists/MailingListListView.swift +2 −1
@@ -373,11 +373,12 @@ struct MailingListListView: View {
373373 // the data has not actually changed.
374374 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
375375 if isOwned(mailingList) {
376 Button(role: .destructive) {
376 Button {
377377 pendingDeletion = mailingList
378378 } label: {
379379 SwiftUI.Label("Delete", systemImage: "trash")
380380 }
381 .tint(.red)
381382 Button {
382383 editingList = mailingList
383384 } label: {
Hutch/Views/Pastes/PasteListView.swift +2 −1
@@ -90,11 +90,12 @@ struct PasteListView: View {
9090 }
9191 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
9292 if swipeActionsEnabled {
93 Button(role: .destructive) {
93 Button {
9494 pasteToDelete = paste
9595 } label: {
9696 Label("Delete", systemImage: "trash")
9797 }
98 .tint(.red)
9899 }
99100 }
100101 .task {
Hutch/Views/Repositories/ArtifactsView.swift +32 −20
@@ -16,6 +16,21 @@ struct ArtifactsView: View {
1616
1717 var body: some View {
1818 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
1934 ForEach(viewModel.referenceArtifacts) { refArtifacts in
2035 Section {
2136 ForEach(refArtifacts.artifacts) { artifact in
@@ -26,11 +41,12 @@ struct ArtifactsView: View {
2641 // action animates the row out before the confirmation.
2742 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
2843 if isOwnedByCurrentUser {
29 Button(role: .destructive) {
44 Button {
3045 pendingDeletion = artifact
3146 } label: {
3247 SwiftUI.Label("Delete", systemImage: "trash")
3348 }
49 .tint(.red)
3450 }
3551 }
3652 }
@@ -83,20 +99,6 @@ struct ArtifactsView: View {
8399 } message: { _ in
84100 Text("This permanently removes the artifact from the tag. This cannot be undone.")
85101 }
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 }
100102 .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) {
101103 ForEach(viewModel.tags.prefix(12), id: \.name) { tag in
102104 Button(RepositorySummary.displayBranchName(for: tag.name)) {
@@ -125,11 +127,21 @@ struct ArtifactsView: View {
125127 retryAction: { await viewModel.loadArtifacts() }
126128 )
127129 } 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 }
133145 }
134146 }
135147 .task {
Hutch/Views/Tickets/TrackerListView.swift +2 −1
@@ -145,11 +145,12 @@ struct TrackerListView: View {
145145 TrackerRowView(tracker: tracker)
146146 }
147147 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
148 Button(role: .destructive) {
148 Button {
149149 pendingDeletion = tracker
150150 } label: {
151151 Label("Delete", systemImage: "trash")
152152 }
153 .tint(.red)
153154
154155 Button {
155156 editingTracker = tracker
Hutch/Views/Tickets/TrackerManagementView.swift +4 −2
@@ -751,11 +751,12 @@ struct TrackerACLManagementSheet: View {
751751 TrackerPermissionSummary(permissions: entry.permissions)
752752 }
753753 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
754 Button(role: .destructive) {
754 Button {
755755 pendingDeletion = entry
756756 } label: {
757757 Label("Delete", systemImage: "trash")
758758 }
759 .tint(.red)
759760
760761 Button {
761762 editingACL = entry
@@ -1132,11 +1133,12 @@ struct TrackerLabelManagementSheet: View {
11321133 }
11331134 .tint(.blue)
11341135
1135 Button(role: .destructive) {
1136 Button {
11361137 pendingDeletion = label
11371138 } label: {
11381139 Label("Delete", systemImage: "trash")
11391140 }
1141 .tint(.red)
11401142 }
11411143 }
11421144 .themedRow()