krz/hutch

an ios client for sourcehut

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

remove-splash-highlighter: 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}