Commit ff141c0e34

ff141c0e34ff432b26079406c0faa50d80266266

parent: d37246d764

Unsigned

cmc <hello@cleberg.net> · 2026-07-16 05:54 UTC

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.

Layout: unified · split

Hutch/Views/Repositories/ArtifactsView.swift +37 −10
@@ -1,5 +1,11 @@
11import SwiftUI
22import UniformTypeIdentifiers
3import os
4
5#if DEBUG
6/// Temporary: diagnosing why the upload menu swallows taps.
7private let artifactsLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Artifacts")
8#endif
39
410struct ArtifactsView: View {
511 let viewModel: RepositoryDetailViewModel
@@ -9,6 +15,7 @@ struct ArtifactsView: View {
915 @Environment(\.openURL) private var openURL
1016
1117 @State private var uploadTargetRef: String?
18 @State private var isImporting = false
1219 @State private var pendingDeletion: ArtifactInfo?
1320
1421 private var isOwnedByCurrentUser: Bool { canManage }
@@ -25,17 +32,23 @@ struct ArtifactsView: View {
2532 ForEach(viewModel.tags.prefix(12), id: \.name) { tag in
2633 Button(RepositorySummary.displayBranchName(for: tag.name)) {
2734 uploadTargetRef = tag.name
35 isImporting = true
2836 }
2937 }
3038 }
3139 } label: {
3240 SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up")
3341 }
34 .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty)
42 // Deliberately not disabled when there are no tags. The explanation for
43 // that state lives inside the menu, and disabling the control makes the
44 // explanation unreachable — the tap just dies with no reason given.
45 .disabled(viewModel.isMutatingArtifact)
3546 }
3647
3748 var body: some View {
38 List {
49 @Bindable var vm = viewModel
50
51 return List {
3952 // In the list rather than the toolbar: this view is a segment inside
4053 // RepositoryDetailView's tab switch, not its own navigation
4154 // destination, and a toolbar declared from there does not reliably
@@ -75,6 +88,7 @@ struct ArtifactsView: View {
7588 // on the tag rather than in the toolbar.
7689 Button {
7790 uploadTargetRef = refArtifacts.name
91 isImporting = true
7892 } label: {
7993 SwiftUI.Label("Upload", systemImage: "plus.circle")
8094 .font(.caption)
@@ -85,18 +99,18 @@ struct ArtifactsView: View {
8599 }
86100 }
87101 }
102 // isImporting drives presentation; uploadTargetRef carries the tag. They
103 // have to be separate: a binding derived from uploadTargetRef clears it on
104 // dismissal, and dismissal happens before the completion runs — so the
105 // completion read nil and returned without uploading anything.
88106 .fileImporter(
89 isPresented: .init(
90 get: { uploadTargetRef != nil },
91 set: { if !$0 { uploadTargetRef = nil } }
92 ),
107 isPresented: $isImporting,
93108 allowedContentTypes: [.data]
94109 ) { result in
95 guard let revspec = uploadTargetRef else { return }
110 let revspec = uploadTargetRef
96111 uploadTargetRef = nil
97 if case .success(let fileURL) = result {
98 Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) }
99 }
112 guard let revspec, case .success(let fileURL) = result else { return }
113 Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) }
100114 }
101115 .confirmationDialog(
102116 pendingDeletion.map { "Delete \($0.filename)?" } ?? "",
@@ -116,11 +130,24 @@ struct ArtifactsView: View {
116130 }
117131 .themedList()
118132 .listStyle(.insetGrouped)
133 .srhtErrorBanner(error: $vm.error)
119134 .task {
120135 // Tags drive the picker above and are not otherwise needed by this tab.
121136 if isOwnedByCurrentUser, viewModel.tags.isEmpty {
122137 await viewModel.loadReferences()
123138 }
139 #if DEBUG
140 // Temporary: diagnosing why the upload menu swallows taps.
141 artifactsLogger.debug(
142 """
143 canManage=\(canManage, privacy: .public) \
144 tags=\(viewModel.tags.count, privacy: .public) \
145 isMutating=\(viewModel.isMutatingArtifact, privacy: .public) \
146 menuDisabled=\(viewModel.isMutatingArtifact || viewModel.tags.isEmpty, privacy: .public) \
147 error=\(viewModel.error ?? "nil", privacy: .public)
148 """
149 )
150 #endif
124151 }
125152 .overlay {
126153 if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty {