summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories/ArtifactsView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-15 23:50:18 -0500
committerChristian Cleberg <[email protected]>2026-07-15 23:50:18 -0500
commitc4930f31ffc5d7c5de5eeafd3da184c7691f8ab7 (patch)
tree1a5761b045d02a5c293096782524b6d7a8226552 /Hutch/Views/Repositories/ArtifactsView.swift
parentf100206cc6d6784563d8eb905c9feb15c58bcc1e (diff)
downloadhutch-c4930f31ffc5d7c5de5eeafd3da184c7691f8ab7.tar.gz
hutch-c4930f31ffc5d7c5de5eeafd3da184c7691f8ab7.tar.bz2
hutch-c4930f31ffc5d7c5de5eeafd3da184c7691f8ab7.zip
feat: upload and delete repository artifacts
uploadArtifact and deleteArtifact existed in git.sr.ht's API but were never called, so the artifacts tab could only download. Upload is reachable two ways, and the second is the one that matters: the tab only lists tags that already carry an artifact, so a per-section button alone could never attach the first one to a tag — and the app cannot create that first artifact any other way. A toolbar action picks from all tags instead. The file variable is top-level here, unlike meta's avatar upload where it nests inside an input object. This is the second caller of executeMultipart, which until now only served avatars. Artifacts are tarballs and signatures, so the upload declares application/octet-stream rather than guessing a type from the extension. Security-scoped access is released after the read, since fileImporter hands back a URL the app does not otherwise own. Both actions are gated on repository ownership, reusing the check RepositoryDetailView already applies to its other management surfaces rather than recomputing it. Delete sits behind a confirmation naming the file.
Diffstat (limited to 'Hutch/Views/Repositories/ArtifactsView.swift')
-rw-r--r--Hutch/Views/Repositories/ArtifactsView.swift96
1 files changed, 95 insertions, 1 deletions
diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift
index b8caf4c..264a51e 100644
--- a/Hutch/Views/Repositories/ArtifactsView.swift
+++ b/Hutch/Views/Repositories/ArtifactsView.swift
@@ -1,24 +1,118 @@
import SwiftUI
+import UniformTypeIdentifiers
struct ArtifactsView: View {
let viewModel: RepositoryDetailViewModel
+ /// Passed in rather than recomputed: RepositoryDetailView already owns this
+ /// check and gates its other management surfaces on it.
+ var canManage: Bool = false
@Environment(\.openURL) private var openURL
+ @State private var uploadTargetRef: String?
+ @State private var pendingDeletion: ArtifactInfo?
+ @State private var showTagPicker = false
+
+ private var isOwnedByCurrentUser: Bool { canManage }
+
var body: some View {
List {
ForEach(viewModel.referenceArtifacts) { refArtifacts in
- Section(refArtifacts.name) {
+ Section {
ForEach(refArtifacts.artifacts) { artifact in
ArtifactRow(artifact: artifact) {
openURL(artifact.url)
}
+ .swipeActions(edge: .trailing) {
+ if isOwnedByCurrentUser {
+ Button(role: .destructive) {
+ pendingDeletion = artifact
+ } label: {
+ SwiftUI.Label("Delete", systemImage: "trash")
+ }
+ }
+ }
}
.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
+ } label: {
+ SwiftUI.Label("Upload", systemImage: "plus.circle")
+ .font(.caption)
+ }
+ .disabled(viewModel.isMutatingArtifact)
+ }
+ }
}
}
}
+ .fileImporter(
+ isPresented: .init(
+ get: { uploadTargetRef != nil },
+ set: { if !$0 { uploadTargetRef = nil } }
+ ),
+ allowedContentTypes: [.data]
+ ) { result in
+ guard let revspec = uploadTargetRef else { return }
+ uploadTargetRef = nil
+ if case .success(let fileURL) = result {
+ 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.")
+ }
+ // The sections above only list tags that already have an artifact, so
+ // without this there would be no way to attach the first one to a tag.
+ .toolbar {
+ if isOwnedByCurrentUser {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showTagPicker = true
+ } label: {
+ SwiftUI.Label("Upload Artifact", systemImage: "square.and.arrow.up")
+ }
+ .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty)
+ }
+ }
+ }
+ .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) {
+ ForEach(viewModel.tags.prefix(12), id: \.name) { tag in
+ Button(RepositorySummary.displayBranchName(for: tag.name)) {
+ uploadTargetRef = tag.name
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("Artifacts attach to a tag. Filenames must be unique within the repository.")
+ }
.themedList()
.listStyle(.insetGrouped)
+ .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…")