1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
|
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 {
ForEach(refArtifacts.artifacts) { artifact in
ArtifactRow(artifact: artifact) {
openURL(artifact.url)
}
// See MailingListListView: a full-swipe destructive
// action animates the row out before the confirmation.
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
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…")
} else if let error = viewModel.error, viewModel.referenceArtifacts.isEmpty {
SRHTErrorStateView(
title: "Couldn't Load Artifacts",
message: error,
retryAction: { await viewModel.loadArtifacts() }
)
} else if viewModel.referenceArtifacts.isEmpty {
ContentUnavailableView(
"No Artifacts",
systemImage: "archivebox",
description: Text("This repository has no release artifacts.")
)
}
}
.task {
if viewModel.referenceArtifacts.isEmpty {
await viewModel.loadArtifacts()
}
}
.refreshable {
await viewModel.loadArtifacts()
}
}
}
private struct ArtifactRow: View {
let artifact: ArtifactInfo
let onDownload: () -> Void
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(artifact.filename)
.font(.subheadline)
Text(artifact.size.formattedByteCount)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
onDownload()
} label: {
Image(systemName: "arrow.down.circle")
.imageScale(.large)
}
}
}
}
|