diff options
Diffstat (limited to 'Hutch/Views')
| -rw-r--r-- | Hutch/Views/Repositories/ArtifactsView.swift | 96 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDetailView.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDetailViewModel.swift | 120 |
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 |
