summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Repositories/ArtifactsView.swift96
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailView.swift2
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailViewModel.swift120
3 files changed, 216 insertions, 2 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…")
diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift
index 6e7343f..8f466ba 100644
--- a/Hutch/Views/Repositories/RepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailView.swift
@@ -121,7 +121,7 @@ struct RepositoryDetailView: View {
case .refs:
ReferencesListView(viewModel: viewModel)
case .artifacts:
- ArtifactsView(viewModel: viewModel)
+ ArtifactsView(viewModel: viewModel, canManage: canManageRepository)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
index 4839437..dce39c1 100644
--- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
@@ -75,6 +75,20 @@ private struct PathObject: Decodable, Sendable {
let text: String?
}
+private struct UploadArtifactResponse: Decodable, Sendable {
+ let uploadArtifact: ArtifactInfo
+}
+
+private struct DeleteArtifactResponse: Decodable, Sendable {
+ /// Nullable in the schema: sr.ht returns null when there was no artifact to
+ /// remove, which is still a success from the caller's point of view.
+ let deleteArtifact: ArtifactIDPayload?
+}
+
+private struct ArtifactIDPayload: Decodable, Sendable {
+ let id: Int
+}
+
private struct ArtifactsResponse: Decodable, Sendable {
let repository: ArtifactsRepository?
}
@@ -144,6 +158,7 @@ final class RepositoryDetailViewModel {
private(set) var referenceArtifacts: [ReferenceWithArtifacts] = []
private(set) var isLoadingArtifacts = false
+ private(set) var isMutatingArtifact = false
// MARK: - Error
@@ -457,6 +472,26 @@ final class RepositoryDetailViewModel {
// MARK: - Artifacts
+ /// `file` is a top-level Upload variable here, unlike meta's avatar upload
+ /// where it is nested inside an input object.
+ private static let uploadArtifactMutation = """
+ mutation uploadArtifact($repoId: Int!, $revspec: String!, $file: Upload!) {
+ uploadArtifact(repoId: $repoId, revspec: $revspec, file: $file) {
+ id
+ filename
+ checksum
+ size
+ url
+ }
+ }
+ """
+
+ private static let deleteArtifactMutation = """
+ mutation deleteArtifact($id: Int!) {
+ deleteArtifact(id: $id) { id }
+ }
+ """
+
private static let artifactsQuery = """
query artifacts($rid: ID!) {
repository(rid: $rid) {
@@ -480,6 +515,91 @@ final class RepositoryDetailViewModel {
}
"""
+ /// Attaches a file to the tag named by `revspec`.
+ ///
+ /// sr.ht requires the filename to be unique among the repository's artifacts,
+ /// and rejects a duplicate rather than replacing it, so the error is surfaced
+ /// as-is rather than being retried.
+ @discardableResult
+ func uploadArtifact(revspec: String, fileURL: URL) async -> Bool {
+ guard !isMutatingArtifact else { return false }
+ isMutatingArtifact = true
+ error = nil
+ defer { isMutatingArtifact = false }
+
+ let needsScopedAccess = fileURL.startAccessingSecurityScopedResource()
+ defer {
+ if needsScopedAccess {
+ fileURL.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ let fileData: Data
+ do {
+ fileData = try Data(contentsOf: fileURL)
+ } catch {
+ self.error = "Couldn't read \(fileURL.lastPathComponent)."
+ return false
+ }
+
+ do {
+ _ = try await client.executeMultipart(
+ service: service,
+ query: Self.uploadArtifactMutation,
+ variables: [
+ "repoId": repository.id,
+ "revspec": revspec,
+ "file": nil as String? as Any
+ ],
+ file: MultipartUploadFile(
+ variablePath: "file",
+ fileData: fileData,
+ fileName: fileURL.lastPathComponent,
+ mimeType: Self.mimeType(for: fileURL)
+ ),
+ responseType: UploadArtifactResponse.self
+ )
+ await reloadArtifacts()
+ return true
+ } catch {
+ self.error = "Couldn't upload \(fileURL.lastPathComponent). \(error.userFacingMessage)"
+ return false
+ }
+ }
+
+ @discardableResult
+ func deleteArtifact(id: Int) async -> Bool {
+ guard !isMutatingArtifact else { return false }
+ isMutatingArtifact = true
+ error = nil
+ defer { isMutatingArtifact = false }
+
+ do {
+ _ = try await client.execute(
+ service: service,
+ query: Self.deleteArtifactMutation,
+ variables: ["id": id],
+ responseType: DeleteArtifactResponse.self
+ )
+ await reloadArtifacts()
+ return true
+ } catch {
+ self.error = "Couldn't delete the artifact. \(error.userFacingMessage)"
+ return false
+ }
+ }
+
+ private func reloadArtifacts() async {
+ isLoadingArtifacts = false
+ await loadArtifacts()
+ }
+
+ /// Artifacts are release tarballs and signatures rather than media, so a
+ /// generic binary type is honest more often than guessing from the extension.
+ private nonisolated static func mimeType(for url: URL) -> String {
+ "application/octet-stream"
+ }
+
func loadArtifacts() async {
guard !isLoadingArtifacts else { return }
isLoadingArtifacts = true