summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories/ArtifactsView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-16 01:29:06 -0500
committerGitHub <[email protected]>2026-07-16 01:29:06 -0500
commit8e585a709d8979d493fef3ed4015db93687f48e8 (patch)
treefaebe598a4c6f7a5f3db92d465ed693aab594252 /Hutch/Views/Repositories/ArtifactsView.swift
parentf100206cc6d6784563d8eb905c9feb15c58bcc1e (diff)
parent21be03e6ca54c1a15607faab905b119eda9a548f (diff)
downloadhutch-8e585a709d8979d493fef3ed4015db93687f48e8.tar.gz
hutch-8e585a709d8979d493fef3ed4015db93687f48e8.tar.bz2
hutch-8e585a709d8979d493fef3ed4015db93687f48e8.zip
Merge pull request #6 from zerolabsco/phase-3-api-features
Phase 3: API features
Diffstat (limited to 'Hutch/Views/Repositories/ArtifactsView.swift')
-rw-r--r--Hutch/Views/Repositories/ArtifactsView.swift154
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