krz/hutch

an ios client for sourcehut

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

d32ad837ac9eb153598c10c3cf47fbdb4e51e3ba

verified · cmc

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

fix: make artifact upload actually fire, and show it when it fails

Picking a file did nothing. The fileImporter's isPresented binding was derived
from uploadTargetRef and nilled it on dismissal, but dismissal happens before
the completion runs — so the completion read nil and returned without
uploading. Presentation state and payload state cannot be the same state. A
plain isImporting bool drives presentation now; the tag survives in
uploadTargetRef until the completion consumes it.

The upload menu was also disabled when the repository has no tags, while the
explanation for that state lived inside the menu — unreachable exactly when it
applies, so the tap died with no reason given. sr.ht requires revspec to match a
tag, so having none is a real state worth explaining rather than hiding.

Failures were invisible too. uploadArtifact and deleteArtifact set error, but
the overlay only renders it when the list is empty, so a rejection on a
repository that already has artifacts — a duplicate filename is the likely one,
since sr.ht requires filenames to be unique per repository — set an error nobody
saw. The tab carries an error banner now.
 Hutch/Views/Repositories/ArtifactsView.swift | 47 ++++++++++++++++++++++------
 1 file changed, 37 insertions(+), 10 deletions(-)

diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift
index 30ab0b1..8843c6f 100644
--- a/Hutch/Views/Repositories/ArtifactsView.swift
+++ b/Hutch/Views/Repositories/ArtifactsView.swift
@@ -1,5 +1,11 @@
 import SwiftUI
 import UniformTypeIdentifiers
+import os
+
+#if DEBUG
+/// Temporary: diagnosing why the upload menu swallows taps.
+private let artifactsLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Artifacts")
+#endif
 
 struct ArtifactsView: View {
     let viewModel: RepositoryDetailViewModel
@@ -9,6 +15,7 @@ struct ArtifactsView: View {
     @Environment(\.openURL) private var openURL
 
     @State private var uploadTargetRef: String?
+    @State private var isImporting = false
     @State private var pendingDeletion: ArtifactInfo?
 
     private var isOwnedByCurrentUser: Bool { canManage }
@@ -25,17 +32,23 @@ struct ArtifactsView: View {
                 ForEach(viewModel.tags.prefix(12), id: \.name) { tag in
                     Button(RepositorySummary.displayBranchName(for: tag.name)) {
                         uploadTargetRef = tag.name
+                        isImporting = true
                     }
                 }
             }
         } label: {
             SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up")
         }
-        .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty)
+        // Deliberately not disabled when there are no tags. The explanation for
+        // that state lives inside the menu, and disabling the control makes the
+        // explanation unreachable — the tap just dies with no reason given.
+        .disabled(viewModel.isMutatingArtifact)
     }
 
     var body: some View {
-        List {
+        @Bindable var vm = viewModel
+
+        return 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
@@ -75,6 +88,7 @@ struct ArtifactsView: View {
                             // on the tag rather than in the toolbar.
                             Button {
                                 uploadTargetRef = refArtifacts.name
+                                isImporting = true
                             } label: {
                                 SwiftUI.Label("Upload", systemImage: "plus.circle")
                                     .font(.caption)
@@ -85,18 +99,18 @@ struct ArtifactsView: View {
                 }
             }
         }
+        // isImporting drives presentation; uploadTargetRef carries the tag. They
+        // have to be separate: a binding derived from uploadTargetRef clears it on
+        // dismissal, and dismissal happens before the completion runs — so the
+        // completion read nil and returned without uploading anything.
         .fileImporter(
-            isPresented: .init(
-                get: { uploadTargetRef != nil },
-                set: { if !$0 { uploadTargetRef = nil } }
-            ),
+            isPresented: $isImporting,
             allowedContentTypes: [.data]
         ) { result in
-            guard let revspec = uploadTargetRef else { return }
+            let revspec = uploadTargetRef
             uploadTargetRef = nil
-            if case .success(let fileURL) = result {
-                Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) }
-            }
+            guard let revspec, case .success(let fileURL) = result else { return }
+            Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) }
         }
         .confirmationDialog(
             pendingDeletion.map { "Delete \($0.filename)?" } ?? "",
@@ -116,11 +130,24 @@ struct ArtifactsView: View {
         }
         .themedList()
         .listStyle(.insetGrouped)
+        .srhtErrorBanner(error: $vm.error)
         .task {
             // Tags drive the picker above and are not otherwise needed by this tab.
             if isOwnedByCurrentUser, viewModel.tags.isEmpty {
                 await viewModel.loadReferences()
             }
+            #if DEBUG
+            // Temporary: diagnosing why the upload menu swallows taps.
+            artifactsLogger.debug(
+                """
+                canManage=\(canManage, privacy: .public) \
+                tags=\(viewModel.tags.count, privacy: .public) \
+                isMutating=\(viewModel.isMutatingArtifact, privacy: .public) \
+                menuDisabled=\(viewModel.isMutatingArtifact || viewModel.tags.isEmpty, privacy: .public) \
+                error=\(viewModel.error ?? "nil", privacy: .public)
+                """
+            )
+            #endif
         }
         .overlay {
             if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty {