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 @@
1import SwiftUI 1import SwiftUI
2import UniformTypeIdentifiers 2import 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
3 9
4struct ArtifactsView: View { 10struct ArtifactsView: View {
5 let viewModel: RepositoryDetailViewModel 11 let viewModel: RepositoryDetailViewModel
@@ -9,6 +15,7 @@ struct ArtifactsView: View {
9 @Environment(\.openURL) private var openURL 15 @Environment(\.openURL) private var openURL
10 16
11 @State private var uploadTargetRef: String? 17 @State private var uploadTargetRef: String?
18 @State private var isImporting = false
12 @State private var pendingDeletion: ArtifactInfo? 19 @State private var pendingDeletion: ArtifactInfo?
13 20
14 private var isOwnedByCurrentUser: Bool { canManage } 21 private var isOwnedByCurrentUser: Bool { canManage }
@@ -25,17 +32,23 @@ struct ArtifactsView: View {
25 ForEach(viewModel.tags.prefix(12), id: \.name) { tag in 32 ForEach(viewModel.tags.prefix(12), id: \.name) { tag in
26 Button(RepositorySummary.displayBranchName(for: tag.name)) { 33 Button(RepositorySummary.displayBranchName(for: tag.name)) {
27 uploadTargetRef = tag.name 34 uploadTargetRef = tag.name
35 isImporting = true
28 } 36 }
29 } 37 }
30 } 38 }
31 } label: { 39 } label: {
32 SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up") 40 SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up")
33 } 41 }
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)
35 } 46 }
36 47
37 var body: some View { 48 var body: some View {
38 List { 49 @Bindable var vm = viewModel
50
51 return List {
39 // In the list rather than the toolbar: this view is a segment inside 52 // In the list rather than the toolbar: this view is a segment inside
40 // RepositoryDetailView's tab switch, not its own navigation 53 // RepositoryDetailView's tab switch, not its own navigation
41 // destination, and a toolbar declared from there does not reliably 54 // destination, and a toolbar declared from there does not reliably
@@ -75,6 +88,7 @@ struct ArtifactsView: View {
75 // on the tag rather than in the toolbar. 88 // on the tag rather than in the toolbar.
76 Button { 89 Button {
77 uploadTargetRef = refArtifacts.name 90 uploadTargetRef = refArtifacts.name
91 isImporting = true
78 } label: { 92 } label: {
79 SwiftUI.Label("Upload", systemImage: "plus.circle") 93 SwiftUI.Label("Upload", systemImage: "plus.circle")
80 .font(.caption) 94 .font(.caption)
@@ -85,18 +99,18 @@ struct ArtifactsView: View {
85 } 99 }
86 } 100 }
87 } 101 }
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.
88 .fileImporter( 106 .fileImporter(
89 isPresented: .init( 107 isPresented: $isImporting,
90 get: { uploadTargetRef != nil },
91 set: { if !$0 { uploadTargetRef = nil } }
92 ),
93 allowedContentTypes: [.data] 108 allowedContentTypes: [.data]
94 ) { result in 109 ) { result in
95 guard let revspec = uploadTargetRef else { return } 110 let revspec = uploadTargetRef
96 uploadTargetRef = nil 111 uploadTargetRef = nil
97 if case .success(let fileURL) = result { 112 guard let revspec, case .success(let fileURL) = result else { return }
98 Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) } 113 Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) }
99 }
100 } 114 }
101 .confirmationDialog( 115 .confirmationDialog(
102 pendingDeletion.map { "Delete \($0.filename)?" } ?? "", 116 pendingDeletion.map { "Delete \($0.filename)?" } ?? "",
@@ -116,11 +130,24 @@ struct ArtifactsView: View {
116 } 130 }
117 .themedList() 131 .themedList()
118 .listStyle(.insetGrouped) 132 .listStyle(.insetGrouped)
133 .srhtErrorBanner(error: $vm.error)
119 .task { 134 .task {
120 // Tags drive the picker above and are not otherwise needed by this tab. 135 // Tags drive the picker above and are not otherwise needed by this tab.
121 if isOwnedByCurrentUser, viewModel.tags.isEmpty { 136 if isOwnedByCurrentUser, viewModel.tags.isEmpty {
122 await viewModel.loadReferences() 137 await viewModel.loadReferences()
123 } 138 }
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
124 } 151 }
125 .overlay { 152 .overlay {
126 if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty { 153 if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty {