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 @@
1import SwiftUI 1import SwiftUI
2import UniformTypeIdentifiers
2 3
3struct ArtifactsView: View { 4struct ArtifactsView: View {
4 let viewModel: RepositoryDetailViewModel 5 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
5 @Environment(\.openURL) private var openURL 9 @Environment(\.openURL) private var openURL
6 10
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
7 var body: some View { 17 var body: some View {
8 List { 18 List {
9 ForEach(viewModel.referenceArtifacts) { refArtifacts in 19 ForEach(viewModel.referenceArtifacts) { refArtifacts in
10 Section(refArtifacts.name) { 20 Section {
11 ForEach(refArtifacts.artifacts) { artifact in 21 ForEach(refArtifacts.artifacts) { artifact in
12 ArtifactRow(artifact: artifact) { 22 ArtifactRow(artifact: artifact) {
13 openURL(artifact.url) 23 openURL(artifact.url)
14 } 24 }
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 }
15 } 34 }
16 .themedRow() 35 .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 }
17 } 52 }
18 } 53 }
19 } 54 }
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 }
20 .themedList() 108 .themedList()
21 .listStyle(.insetGrouped) 109 .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 }
22 .overlay { 116 .overlay {
23 if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty { 117 if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty {
24 SRHTLoadingStateView(message: "Loading artifacts…") 118 SRHTLoadingStateView(message: "Loading artifacts…")
Hutch/Views/Repositories/RepositoryDetailView.swift +1 −1
@@ -121,7 +121,7 @@ struct RepositoryDetailView: View {
121 case .refs: 121 case .refs:
122 ReferencesListView(viewModel: viewModel) 122 ReferencesListView(viewModel: viewModel)
123 case .artifacts: 123 case .artifacts:
124 ArtifactsView(viewModel: viewModel) 124 ArtifactsView(viewModel: viewModel, canManage: canManageRepository)
125 } 125 }
126 } 126 }
127 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) 127 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Hutch/Views/Repositories/RepositoryDetailViewModel.swift +120
@@ -75,6 +75,20 @@ private struct PathObject: Decodable, Sendable {
75 let text: String? 75 let text: String?
76} 76}
77 77
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
78private struct ArtifactsResponse: Decodable, Sendable { 92private struct ArtifactsResponse: Decodable, Sendable {
79 let repository: ArtifactsRepository? 93 let repository: ArtifactsRepository?
80} 94}
@@ -144,6 +158,7 @@ final class RepositoryDetailViewModel {
144 158
145 private(set) var referenceArtifacts: [ReferenceWithArtifacts] = [] 159 private(set) var referenceArtifacts: [ReferenceWithArtifacts] = []
146 private(set) var isLoadingArtifacts = false 160 private(set) var isLoadingArtifacts = false
161 private(set) var isMutatingArtifact = false
147 162
148 // MARK: - Error 163 // MARK: - Error
149 164
@@ -457,6 +472,26 @@ final class RepositoryDetailViewModel {
457 472
458 // MARK: - Artifacts 473 // MARK: - Artifacts
459 474
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
460 private static let artifactsQuery = """ 495 private static let artifactsQuery = """
461 query artifacts($rid: ID!) { 496 query artifacts($rid: ID!) {
462 repository(rid: $rid) { 497 repository(rid: $rid) {
@@ -480,6 +515,91 @@ final class RepositoryDetailViewModel {
480 } 515 }
481 """ 516 """
482 517
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
483 func loadArtifacts() async { 603 func loadArtifacts() async {
484 guard !isLoadingArtifacts else { return } 604 guard !isLoadingArtifacts else { return }
485 isLoadingArtifacts = true 605 isLoadingArtifacts = true