diff options
Diffstat (limited to 'Hutch/Views/Repositories/ArtifactsView.swift')
| -rw-r--r-- | Hutch/Views/Repositories/ArtifactsView.swift | 154 |
1 files changed, 145 insertions, 9 deletions
diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift index b8caf4c..69da0d6 100644 --- a/Hutch/Views/Repositories/ArtifactsView.swift +++ b/Hutch/Views/Repositories/ArtifactsView.swift @@ -1,24 +1,146 @@ import SwiftUI +import UniformTypeIdentifiers struct ArtifactsView: View { let viewModel: RepositoryDetailViewModel - @Environment(\.openURL) private var openURL + /// Passed in rather than recomputed: RepositoryDetailView already owns this + /// check and gates its other management surfaces on it. + var canManage: Bool = false + + @State private var uploadTargetRef: String? + @State private var isImporting = false + @State private var pendingDeletion: ArtifactInfo? + @State private var downloadedFile: DownloadedArtifact? + + private var isOwnedByCurrentUser: Bool { canManage } + + /// A menu rather than a confirmation dialog: this view already presents one + /// for delete, and two .confirmationDialog modifiers on the same view leave + /// one of them silently dead. A menu also puts the tags one tap away. + @ViewBuilder + private var uploadMenu: some View { + Menu { + if viewModel.tags.isEmpty { + Text("This repository has no tags") + } else { + 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") + } + // 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 + // reach the navigation bar. It also has to be reachable when there are + // no artifacts at all, which is the state a new tag is in. + if isOwnedByCurrentUser { + uploadMenu + .themedRow() + } + ForEach(viewModel.referenceArtifacts) { refArtifacts in - Section(refArtifacts.name) { + Section { ForEach(refArtifacts.artifacts) { artifact in ArtifactRow(artifact: artifact) { - openURL(artifact.url) + Task { + // Artifact.url is on the API origin and 401s + // without a bearer token, so it cannot be handed + // to a browser. Fetch it and share the file. + if let fileURL = await viewModel.downloadArtifact(artifact) { + downloadedFile = DownloadedArtifact(url: fileURL) + } + } + } + // See MailingListListView: a full-swipe destructive + // action animates the row out before the confirmation. + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if isOwnedByCurrentUser { + Button { + pendingDeletion = artifact + } label: { + SwiftUI.Label("Delete", systemImage: "trash") + } + .tint(.red) + } } } .themedRow() + } header: { + HStack { + Text(refArtifacts.name) + if isOwnedByCurrentUser { + Spacer() + // Upload targets a specific tag, so the control belongs + // on the tag rather than in the toolbar. + Button { + uploadTargetRef = refArtifacts.name + isImporting = true + } label: { + SwiftUI.Label("Upload", systemImage: "plus.circle") + .font(.caption) + } + .disabled(viewModel.isMutatingArtifact) + } + } } } } + // 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: $isImporting, + allowedContentTypes: [.data] + ) { result in + let revspec = uploadTargetRef + uploadTargetRef = nil + guard let revspec, case .success(let fileURL) = result else { return } + Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) } + } + .confirmationDialog( + pendingDeletion.map { "Delete \($0.filename)?" } ?? "", + isPresented: .init( + get: { pendingDeletion != nil }, + set: { if !$0 { pendingDeletion = nil } } + ), + titleVisibility: .visible, + presenting: pendingDeletion + ) { artifact in + Button("Delete Artifact", role: .destructive) { + Task { await viewModel.deleteArtifact(id: artifact.id) } + } + Button("Cancel", role: .cancel) { pendingDeletion = nil } + } message: { _ in + Text("This permanently removes the artifact from the tag. This cannot be undone.") + } .themedList() .listStyle(.insetGrouped) + .srhtErrorBanner(error: $vm.error) + .sheet(item: $downloadedFile) { download in + FileContentShareSheet(activityItems: [download.url]) + } + .task { + // Tags drive the picker above and are not otherwise needed by this tab. + if isOwnedByCurrentUser, viewModel.tags.isEmpty { + await viewModel.loadReferences() + } + } .overlay { if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty { SRHTLoadingStateView(message: "Loading artifacts…") @@ -29,11 +151,18 @@ struct ArtifactsView: View { retryAction: { await viewModel.loadArtifacts() } ) } else if viewModel.referenceArtifacts.isEmpty { - ContentUnavailableView( - "No Artifacts", - systemImage: "archivebox", - description: Text("This repository has no release artifacts.") - ) + // The overlay covers the whole list, so the upload row above is + // hidden underneath it — and a repository with no artifacts is + // exactly the one that needs uploading. Offer it here too. + ContentUnavailableView { + SwiftUI.Label("No Artifacts", systemImage: "archivebox") + } description: { + Text("This repository has no release artifacts.") + } actions: { + if isOwnedByCurrentUser { + uploadMenu + } + } } } .task { @@ -47,6 +176,13 @@ struct ArtifactsView: View { } } +/// Wraps the downloaded file for `.sheet(item:)`. URL is not Identifiable, and +/// conforming a stdlib type retroactively is worse than a four-line struct. +private struct DownloadedArtifact: Identifiable { + let id = UUID() + let url: URL +} + private struct ArtifactRow: View { let artifact: ArtifactInfo let onDownload: () -> Void |
