Commit c2f1ecce1f

c2f1ecce1f88790d04ea032e440cf24283bd96b8

parent: 04cdcb0374

Unsigned

cmc <hello@cleberg.net> · 2026-07-16 04:50 UTC

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.

Layout: unified · split

Hutch/Views/Repositories/ArtifactsView.swift +95 −1
@@ -1,24 +1,118 @@
11import SwiftUI
2import UniformTypeIdentifiers
23
34struct ArtifactsView: View {
45 let viewModel: RepositoryDetailViewModel
6 /// Passed in rather than recomputed: RepositoryDetailView already owns this
7 /// check and gates its other management surfaces on it.
8 var canManage: Bool = false
59 @Environment(\.openURL) private var openURL
610
11 @State private var uploadTargetRef: String?
12 @State private var pendingDeletion: ArtifactInfo?
13 @State private var showTagPicker = false
14
15 private var isOwnedByCurrentUser: Bool { canManage }
16
717 var body: some View {
818 List {
919 ForEach(viewModel.referenceArtifacts) { refArtifacts in
10 Section(refArtifacts.name) {
20 Section {
1121 ForEach(refArtifacts.artifacts) { artifact in
1222 ArtifactRow(artifact: artifact) {
1323 openURL(artifact.url)
1424 }
25 .swipeActions(edge: .trailing) {
26 if isOwnedByCurrentUser {
27 Button(role: .destructive) {
28 pendingDeletion = artifact
29 } label: {
30 SwiftUI.Label("Delete", systemImage: "trash")
31 }
32 }
33 }
1534 }
1635 .themedRow()
36 } header: {
37 HStack {
38 Text(refArtifacts.name)
39 if isOwnedByCurrentUser {
40 Spacer()
41 // Upload targets a specific tag, so the control belongs
42 // on the tag rather than in the toolbar.
43 Button {
44 uploadTargetRef = refArtifacts.name
45 } label: {
46 SwiftUI.Label("Upload", systemImage: "plus.circle")
47 .font(.caption)
48 }
49 .disabled(viewModel.isMutatingArtifact)
50 }
51 }
1752 }
1853 }
1954 }
55 .fileImporter(
56 isPresented: .init(
57 get: { uploadTargetRef != nil },
58 set: { if !$0 { uploadTargetRef = nil } }
59 ),
60 allowedContentTypes: [.data]
61 ) { result in
62 guard let revspec = uploadTargetRef else { return }
63 uploadTargetRef = nil
64 if case .success(let fileURL) = result {
65 Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) }
66 }
67 }
68 .confirmationDialog(
69 pendingDeletion.map { "Delete \($0.filename)?" } ?? "",
70 isPresented: .init(
71 get: { pendingDeletion != nil },
72 set: { if !$0 { pendingDeletion = nil } }
73 ),
74 titleVisibility: .visible,
75 presenting: pendingDeletion
76 ) { artifact in
77 Button("Delete Artifact", role: .destructive) {
78 Task { await viewModel.deleteArtifact(id: artifact.id) }
79 }
80 Button("Cancel", role: .cancel) { pendingDeletion = nil }
81 } message: { _ in
82 Text("This permanently removes the artifact from the tag. This cannot be undone.")
83 }
84 // The sections above only list tags that already have an artifact, so
85 // without this there would be no way to attach the first one to a tag.
86 .toolbar {
87 if isOwnedByCurrentUser {
88 ToolbarItem(placement: .topBarTrailing) {
89 Button {
90 showTagPicker = true
91 } label: {
92 SwiftUI.Label("Upload Artifact", systemImage: "square.and.arrow.up")
93 }
94 .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty)
95 }
96 }
97 }
98 .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) {
99 ForEach(viewModel.tags.prefix(12), id: \.name) { tag in
100 Button(RepositorySummary.displayBranchName(for: tag.name)) {
101 uploadTargetRef = tag.name
102 }
103 }
104 Button("Cancel", role: .cancel) {}
105 } message: {
106 Text("Artifacts attach to a tag. Filenames must be unique within the repository.")
107 }
20108 .themedList()
21109 .listStyle(.insetGrouped)
110 .task {
111 // Tags drive the picker above and are not otherwise needed by this tab.
112 if isOwnedByCurrentUser, viewModel.tags.isEmpty {
113 await viewModel.loadReferences()
114 }
115 }
22116 .overlay {
23117 if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty {
24118 SRHTLoadingStateView(message: "Loading artifacts…")
Hutch/Views/Repositories/RepositoryDetailView.swift +1 −1
@@ -121,7 +121,7 @@ struct RepositoryDetailView: View {
121121 case .refs:
122122 ReferencesListView(viewModel: viewModel)
123123 case .artifacts:
124 ArtifactsView(viewModel: viewModel)
124 ArtifactsView(viewModel: viewModel, canManage: canManageRepository)
125125 }
126126 }
127127 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Hutch/Views/Repositories/RepositoryDetailViewModel.swift +120
@@ -75,6 +75,20 @@ private struct PathObject: Decodable, Sendable {
7575 let text: String?
7676}
7777
78private struct UploadArtifactResponse: Decodable, Sendable {
79 let uploadArtifact: ArtifactInfo
80}
81
82private struct DeleteArtifactResponse: Decodable, Sendable {
83 /// Nullable in the schema: sr.ht returns null when there was no artifact to
84 /// remove, which is still a success from the caller's point of view.
85 let deleteArtifact: ArtifactIDPayload?
86}
87
88private struct ArtifactIDPayload: Decodable, Sendable {
89 let id: Int
90}
91
7892private struct ArtifactsResponse: Decodable, Sendable {
7993 let repository: ArtifactsRepository?
8094}
@@ -144,6 +158,7 @@ final class RepositoryDetailViewModel {
144158
145159 private(set) var referenceArtifacts: [ReferenceWithArtifacts] = []
146160 private(set) var isLoadingArtifacts = false
161 private(set) var isMutatingArtifact = false
147162
148163 // MARK: - Error
149164
@@ -457,6 +472,26 @@ final class RepositoryDetailViewModel {
457472
458473 // MARK: - Artifacts
459474
475 /// `file` is a top-level Upload variable here, unlike meta's avatar upload
476 /// where it is nested inside an input object.
477 private static let uploadArtifactMutation = """
478 mutation uploadArtifact($repoId: Int!, $revspec: String!, $file: Upload!) {
479 uploadArtifact(repoId: $repoId, revspec: $revspec, file: $file) {
480 id
481 filename
482 checksum
483 size
484 url
485 }
486 }
487 """
488
489 private static let deleteArtifactMutation = """
490 mutation deleteArtifact($id: Int!) {
491 deleteArtifact(id: $id) { id }
492 }
493 """
494
460495 private static let artifactsQuery = """
461496 query artifacts($rid: ID!) {
462497 repository(rid: $rid) {
@@ -480,6 +515,91 @@ final class RepositoryDetailViewModel {
480515 }
481516 """
482517
518 /// Attaches a file to the tag named by `revspec`.
519 ///
520 /// sr.ht requires the filename to be unique among the repository's artifacts,
521 /// and rejects a duplicate rather than replacing it, so the error is surfaced
522 /// as-is rather than being retried.
523 @discardableResult
524 func uploadArtifact(revspec: String, fileURL: URL) async -> Bool {
525 guard !isMutatingArtifact else { return false }
526 isMutatingArtifact = true
527 error = nil
528 defer { isMutatingArtifact = false }
529
530 let needsScopedAccess = fileURL.startAccessingSecurityScopedResource()
531 defer {
532 if needsScopedAccess {
533 fileURL.stopAccessingSecurityScopedResource()
534 }
535 }
536
537 let fileData: Data
538 do {
539 fileData = try Data(contentsOf: fileURL)
540 } catch {
541 self.error = "Couldn't read \(fileURL.lastPathComponent)."
542 return false
543 }
544
545 do {
546 _ = try await client.executeMultipart(
547 service: service,
548 query: Self.uploadArtifactMutation,
549 variables: [
550 "repoId": repository.id,
551 "revspec": revspec,
552 "file": nil as String? as Any
553 ],
554 file: MultipartUploadFile(
555 variablePath: "file",
556 fileData: fileData,
557 fileName: fileURL.lastPathComponent,
558 mimeType: Self.mimeType(for: fileURL)
559 ),
560 responseType: UploadArtifactResponse.self
561 )
562 await reloadArtifacts()
563 return true
564 } catch {
565 self.error = "Couldn't upload \(fileURL.lastPathComponent). \(error.userFacingMessage)"
566 return false
567 }
568 }
569
570 @discardableResult
571 func deleteArtifact(id: Int) async -> Bool {
572 guard !isMutatingArtifact else { return false }
573 isMutatingArtifact = true
574 error = nil
575 defer { isMutatingArtifact = false }
576
577 do {
578 _ = try await client.execute(
579 service: service,
580 query: Self.deleteArtifactMutation,
581 variables: ["id": id],
582 responseType: DeleteArtifactResponse.self
583 )
584 await reloadArtifacts()
585 return true
586 } catch {
587 self.error = "Couldn't delete the artifact. \(error.userFacingMessage)"
588 return false
589 }
590 }
591
592 private func reloadArtifacts() async {
593 isLoadingArtifacts = false
594 await loadArtifacts()
595 }
596
597 /// Artifacts are release tarballs and signatures rather than media, so a
598 /// generic binary type is honest more often than guessing from the extension.
599 private nonisolated static func mimeType(for url: URL) -> String {
600 "application/octet-stream"
601 }
602
483603 func loadArtifacts() async {
484604 guard !isLoadingArtifacts else { return }
485605 isLoadingArtifacts = true