diff options
| author | Christian Cleberg <[email protected]> | 2026-07-15 23:50:18 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-15 23:50:18 -0500 |
| commit | c4930f31ffc5d7c5de5eeafd3da184c7691f8ab7 (patch) | |
| tree | 1a5761b045d02a5c293096782524b6d7a8226552 /Hutch/Views/Repositories | |
| parent | f100206cc6d6784563d8eb905c9feb15c58bcc1e (diff) | |
| download | hutch-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')
| -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 |
