krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.8.0: Hutch/Views/Repositories/ArtifactsView.swift · raw
1import SwiftUI
2import UniformTypeIdentifiers
3
4struct ArtifactsView: View {
5 let viewModel: RepositoryDetailViewModel
6 /// Passed in rather than recomputed: RepositoryDetailView already owns this
7 /// check and gates its other management surfaces on it.
8 var canManage: Bool = false
9
10 @State private var uploadTargetRef: String?
11 @State private var isImporting = false
12 @State private var pendingDeletion: ArtifactInfo?
13 @State private var downloadedFile: DownloadedArtifact?
14
15 private var isOwnedByCurrentUser: Bool { canManage }
16
17 /// A menu rather than a confirmation dialog: this view already presents one
18 /// for delete, and two .confirmationDialog modifiers on the same view leave
19 /// one of them silently dead. A menu also puts the tags one tap away.
20 @ViewBuilder
21 private var uploadMenu: some View {
22 Menu {
23 if viewModel.tags.isEmpty {
24 Text("This repository has no tags")
25 } else {
26 ForEach(viewModel.tags.prefix(12), id: \.name) { tag in
27 Button(RepositorySummary.displayBranchName(for: tag.name)) {
28 uploadTargetRef = tag.name
29 isImporting = true
30 }
31 }
32 }
33 } label: {
34 SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up")
35 }
36 // Deliberately not disabled when there are no tags. The explanation for
37 // that state lives inside the menu, and disabling the control makes the
38 // explanation unreachable — the tap just dies with no reason given.
39 .disabled(viewModel.isMutatingArtifact)
40 }
41
42 var body: some View {
43 @Bindable var vm = viewModel
44
45 return List {
46 // In the list rather than the toolbar: this view is a segment inside
47 // RepositoryDetailView's tab switch, not its own navigation
48 // destination, and a toolbar declared from there does not reliably
49 // reach the navigation bar. It also has to be reachable when there are
50 // no artifacts at all, which is the state a new tag is in.
51 if isOwnedByCurrentUser {
52 uploadMenu
53 .themedRow()
54 }
55
56 ForEach(viewModel.referenceArtifacts) { refArtifacts in
57 Section {
58 ForEach(refArtifacts.artifacts) { artifact in
59 ArtifactRow(artifact: artifact) {
60 Task {
61 // Artifact.url is on the API origin and 401s
62 // without a bearer token, so it cannot be handed
63 // to a browser. Fetch it and share the file.
64 if let fileURL = await viewModel.downloadArtifact(artifact) {
65 downloadedFile = DownloadedArtifact(url: fileURL)
66 }
67 }
68 }
69 // See MailingListListView: a full-swipe destructive
70 // action animates the row out before the confirmation.
71 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
72 if isOwnedByCurrentUser {
73 Button {
74 pendingDeletion = artifact
75 } label: {
76 SwiftUI.Label("Delete", systemImage: "trash")
77 }
78 .tint(.red)
79 }
80 }
81 }
82 .themedRow()
83 } header: {
84 HStack {
85 Text(refArtifacts.name)
86 if isOwnedByCurrentUser {
87 Spacer()
88 // Upload targets a specific tag, so the control belongs
89 // on the tag rather than in the toolbar.
90 Button {
91 uploadTargetRef = refArtifacts.name
92 isImporting = true
93 } label: {
94 SwiftUI.Label("Upload", systemImage: "plus.circle")
95 .font(.caption)
96 }
97 .disabled(viewModel.isMutatingArtifact)
98 }
99 }
100 }
101 }
102 }
103 // isImporting drives presentation; uploadTargetRef carries the tag. They
104 // have to be separate: a binding derived from uploadTargetRef clears it on
105 // dismissal, and dismissal happens before the completion runs — so the
106 // completion read nil and returned without uploading anything.
107 .fileImporter(
108 isPresented: $isImporting,
109 allowedContentTypes: [.data]
110 ) { result in
111 let revspec = uploadTargetRef
112 uploadTargetRef = nil
113 guard let revspec, case .success(let fileURL) = result else { return }
114 Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) }
115 }
116 .confirmationDialog(
117 pendingDeletion.map { "Delete \($0.filename)?" } ?? "",
118 isPresented: .init(
119 get: { pendingDeletion != nil },
120 set: { if !$0 { pendingDeletion = nil } }
121 ),
122 titleVisibility: .visible,
123 presenting: pendingDeletion
124 ) { artifact in
125 Button("Delete Artifact", role: .destructive) {
126 Task { await viewModel.deleteArtifact(id: artifact.id) }
127 }
128 Button("Cancel", role: .cancel) { pendingDeletion = nil }
129 } message: { _ in
130 Text("This permanently removes the artifact from the tag. This cannot be undone.")
131 }
132 .themedList()
133 .listStyle(.insetGrouped)
134 .srhtErrorBanner(error: $vm.error)
135 .sheet(item: $downloadedFile) { download in
136 FileContentShareSheet(activityItems: [download.url])
137 }
138 .task {
139 // Tags drive the picker above and are not otherwise needed by this tab.
140 if isOwnedByCurrentUser, viewModel.tags.isEmpty {
141 await viewModel.loadReferences()
142 }
143 }
144 .overlay {
145 if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty {
146 SRHTLoadingStateView(message: "Loading artifacts…")
147 } else if let error = viewModel.error, viewModel.referenceArtifacts.isEmpty {
148 SRHTErrorStateView(
149 title: "Couldn't Load Artifacts",
150 message: error,
151 retryAction: { await viewModel.loadArtifacts() }
152 )
153 } else if viewModel.referenceArtifacts.isEmpty {
154 // The overlay covers the whole list, so the upload row above is
155 // hidden underneath it — and a repository with no artifacts is
156 // exactly the one that needs uploading. Offer it here too.
157 ContentUnavailableView {
158 SwiftUI.Label("No Artifacts", systemImage: "archivebox")
159 } description: {
160 Text("This repository has no release artifacts.")
161 } actions: {
162 if isOwnedByCurrentUser {
163 uploadMenu
164 }
165 }
166 }
167 }
168 .task {
169 if viewModel.referenceArtifacts.isEmpty {
170 await viewModel.loadArtifacts()
171 }
172 }
173 .refreshable {
174 await viewModel.loadArtifacts()
175 }
176 }
177}
178
179/// Wraps the downloaded file for `.sheet(item:)`. URL is not Identifiable, and
180/// conforming a stdlib type retroactively is worse than a four-line struct.
181private struct DownloadedArtifact: Identifiable {
182 let id = UUID()
183 let url: URL
184}
185
186private struct ArtifactRow: View {
187 let artifact: ArtifactInfo
188 let onDownload: () -> Void
189
190 var body: some View {
191 HStack {
192 VStack(alignment: .leading, spacing: 4) {
193 Text(artifact.filename)
194 .font(.subheadline)
195
196 Text(artifact.size.formattedByteCount)
197 .font(.caption)
198 .foregroundStyle(.secondary)
199 }
200
201 Spacer()
202
203 Button {
204 onDownload()
205 } label: {
206 Image(systemName: "arrow.down.circle")
207 .imageScale(.large)
208 }
209 }
210 }
211}