krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

6898bc3fc00decee7224895ea75908daf0f97f59

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-07-16T05:29:34Z

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.
 Hutch/App/RootView.swift                        | 48 +++++++++++++----------
 Hutch/Views/Lists/MailingListListView.swift     |  3 +-
 Hutch/Views/Pastes/PasteListView.swift          |  3 +-
 Hutch/Views/Repositories/ArtifactsView.swift    | 52 +++++++++++++++----------
 Hutch/Views/Tickets/TrackerListView.swift       |  3 +-
 Hutch/Views/Tickets/TrackerManagementView.swift |  6 ++-
 6 files changed, 69 insertions(+), 46 deletions(-)

diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 32d1624..0194d63 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -291,39 +291,45 @@ struct RootView: View {
         }
     }
 
+    /// Replaces the target tab's path in one assignment.
+    ///
+    /// Resetting the path and appending to it afterwards races when the target tab
+    /// is already the one on screen: the reset starts an animated pop of the view
+    /// the user is standing on, and the appends land mid-animation, leaving a blank
+    /// screen. That is why opening a mailing list from a pinned project on Home
+    /// worked while the same tap under More → Projects did not — one changes tabs
+    /// and the other does not.
+    ///
+    /// Building the whole path first and assigning once gives SwiftUI a single
+    /// diff, with nothing to race.
     private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
         switch target {
         case .repository(let repository):
-            repoPath = NavigationPath()
+            var path = NavigationPath()
+            path.append(repository)
+            repoPath = path
             appState.selectedTab = .repositories
-            Task {
-                await settleNavigationTransition()
-                repoPath.append(repository)
-            }
 
         case .tracker(let tracker):
-            ticketsPath = NavigationPath()
+            var path = NavigationPath()
+            path.append(tracker)
+            ticketsPath = path
             appState.selectedTab = .tickets
-            Task {
-                await settleNavigationTransition()
-                ticketsPath.append(tracker)
-            }
 
         case .mailingList(let mailingList):
-            morePath = NavigationPath()
+            // .lists first so back lands on Mailing Lists rather than dead-ending.
+            var path = NavigationPath()
+            path.append(MoreRoute.lists)
+            path.append(MoreRoute.mailingList(mailingList))
+            morePath = path
             appState.selectedTab = .more
-            Task {
-                await settleNavigationTransition()
-                morePath.append(MoreRoute.lists)
-                morePath.append(MoreRoute.mailingList(mailingList))
-            }
+
         case .systemStatus:
-            morePath = NavigationPath()
+            var path = NavigationPath()
+            path.append(MoreRoute.systemStatus)
+            morePath = path
             appState.selectedTab = .more
-            Task {
-                await settleNavigationTransition()
-                morePath.append(MoreRoute.systemStatus)
-            }
+
         case .builds:
             buildsPath = NavigationPath()
             appState.selectedTab = .builds
diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift
index 77df89b..1b159bf 100644
--- a/Hutch/Views/Lists/MailingListListView.swift
+++ b/Hutch/Views/Lists/MailingListListView.swift
@@ -373,11 +373,12 @@ struct MailingListListView: View {
                 // the data has not actually changed.
                 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                     if isOwned(mailingList) {
-                        Button(role: .destructive) {
+                        Button {
                             pendingDeletion = mailingList
                         } label: {
                             SwiftUI.Label("Delete", systemImage: "trash")
                         }
+                        .tint(.red)
                         Button {
                             editingList = mailingList
                         } label: {
diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift
index b325153..b2c7838 100644
--- a/Hutch/Views/Pastes/PasteListView.swift
+++ b/Hutch/Views/Pastes/PasteListView.swift
@@ -90,11 +90,12 @@ struct PasteListView: View {
                 }
                 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                     if swipeActionsEnabled {
-                        Button(role: .destructive) {
+                        Button {
                             pasteToDelete = paste
                         } label: {
                             Label("Delete", systemImage: "trash")
                         }
+                        .tint(.red)
                     }
                 }
                 .task {
diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift
index 037c092..752c7c5 100644
--- a/Hutch/Views/Repositories/ArtifactsView.swift
+++ b/Hutch/Views/Repositories/ArtifactsView.swift
@@ -16,6 +16,21 @@ struct ArtifactsView: View {
 
     var body: some View {
         List {
+            // In the list rather than the toolbar: this view is a segment inside
+            // RepositoryDetailView's tab switch, not its own navigation
+            // destination, and a toolbar declared from there does not reliably
+            // reach the navigation bar. It also has to be reachable when there are
+            // no artifacts at all, which is the state a new tag is in.
+            if isOwnedByCurrentUser {
+                Button {
+                    showTagPicker = true
+                } label: {
+                    SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up")
+                }
+                .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty)
+                .themedRow()
+            }
+
             ForEach(viewModel.referenceArtifacts) { refArtifacts in
                 Section {
                     ForEach(refArtifacts.artifacts) { artifact in
@@ -26,11 +41,12 @@ struct ArtifactsView: View {
                         // action animates the row out before the confirmation.
                         .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                             if isOwnedByCurrentUser {
-                                Button(role: .destructive) {
+                                Button {
                                     pendingDeletion = artifact
                                 } label: {
                                     SwiftUI.Label("Delete", systemImage: "trash")
                                 }
+                                .tint(.red)
                             }
                         }
                     }
@@ -83,20 +99,6 @@ struct ArtifactsView: View {
         } message: { _ in
             Text("This permanently removes the artifact from the tag. This cannot be undone.")
         }
-        // The sections above only list tags that already have an artifact, so
-        // without this there would be no way to attach the first one to a tag.
-        .toolbar {
-            if isOwnedByCurrentUser {
-                ToolbarItem(placement: .topBarTrailing) {
-                    Button {
-                        showTagPicker = true
-                    } label: {
-                        SwiftUI.Label("Upload Artifact", systemImage: "square.and.arrow.up")
-                    }
-                    .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty)
-                }
-            }
-        }
         .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) {
             ForEach(viewModel.tags.prefix(12), id: \.name) { tag in
                 Button(RepositorySummary.displayBranchName(for: tag.name)) {
@@ -125,11 +127,21 @@ struct ArtifactsView: View {
                     retryAction: { await viewModel.loadArtifacts() }
                 )
             } else if viewModel.referenceArtifacts.isEmpty {
-                ContentUnavailableView(
-                    "No Artifacts",
-                    systemImage: "archivebox",
-                    description: Text("This repository has no release artifacts.")
-                )
+                // The overlay covers the whole list, so the upload row above is
+                // hidden underneath it — and a repository with no artifacts is
+                // exactly the one that needs uploading. Offer it here too.
+                ContentUnavailableView {
+                    SwiftUI.Label("No Artifacts", systemImage: "archivebox")
+                } description: {
+                    Text("This repository has no release artifacts.")
+                } actions: {
+                    if isOwnedByCurrentUser {
+                        Button("Upload Artifact…") {
+                            showTagPicker = true
+                        }
+                        .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty)
+                    }
+                }
             }
         }
         .task {
diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift
index 5deb513..cb4a59c 100644
--- a/Hutch/Views/Tickets/TrackerListView.swift
+++ b/Hutch/Views/Tickets/TrackerListView.swift
@@ -145,11 +145,12 @@ struct TrackerListView: View {
                     TrackerRowView(tracker: tracker)
                 }
                 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
-                    Button(role: .destructive) {
+                    Button {
                         pendingDeletion = tracker
                     } label: {
                         Label("Delete", systemImage: "trash")
                     }
+                    .tint(.red)
 
                     Button {
                         editingTracker = tracker
diff --git a/Hutch/Views/Tickets/TrackerManagementView.swift b/Hutch/Views/Tickets/TrackerManagementView.swift
index fad1ae8..73b2a08 100644
--- a/Hutch/Views/Tickets/TrackerManagementView.swift
+++ b/Hutch/Views/Tickets/TrackerManagementView.swift
@@ -751,11 +751,12 @@ struct TrackerACLManagementSheet: View {
                                 TrackerPermissionSummary(permissions: entry.permissions)
                             }
                             .swipeActions(edge: .trailing, allowsFullSwipe: false) {
-                                Button(role: .destructive) {
+                                Button {
                                     pendingDeletion = entry
                                 } label: {
                                     Label("Delete", systemImage: "trash")
                                 }
+                                .tint(.red)
 
                                 Button {
                                     editingACL = entry
@@ -1132,11 +1133,12 @@ struct TrackerLabelManagementSheet: View {
                             }
                             .tint(.blue)
 
-                            Button(role: .destructive) {
+                            Button {
                                 pendingDeletion = label
                             } label: {
                                 Label("Delete", systemImage: "trash")
                             }
+                            .tint(.red)
                         }
                     }
                     .themedRow()