Commit c2f1ecce1f
Unsigned
Layout: unified · split
Hutch/Views/Repositories/ArtifactsView.swift +95 −1
| @@ -1,24 +1,118 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | import UniformTypeIdentifiers | ||
| 2 | 3 | ||
| 3 | struct ArtifactsView: View { | 4 | struct 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 | ||
| 78 | private struct UploadArtifactResponse: Decodable, Sendable { | ||
| 79 | let uploadArtifact: ArtifactInfo | ||
| 80 | } | ||
| 81 | |||
| 82 | private 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 | |||
| 88 | private struct ArtifactIDPayload: Decodable, Sendable { | ||
| 89 | let id: Int | ||
| 90 | } | ||
| 91 | |||
| 78 | private struct ArtifactsResponse: Decodable, Sendable { | 92 | private 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 |