| @@ -4,6 +4,7 @@ struct BuildDetailView: View { |
| 4 | let jobId: Int |
4 | let jobId: Int |
| 5 | |
5 | |
| 6 | @Environment(AppState.self) private var appState |
6 | @Environment(AppState.self) private var appState |
| |
7 | @Environment(\.openURL) private var openURL |
| 7 | @State private var viewModel: BuildDetailViewModel? |
8 | @State private var viewModel: BuildDetailViewModel? |
| 8 | @State private var rebuiltJobId: Int? |
9 | @State private var rebuiltJobId: Int? |
| 9 | @State private var selectedTaskName: String? |
10 | @State private var selectedTaskName: String? |
| @@ -181,6 +182,23 @@ struct BuildDetailView: View { |
| 181 | } |
182 | } |
| 182 | } |
183 | } |
| 183 | |
184 | |
| |
185 | if !job.artifacts.isEmpty { |
| |
186 | Section { |
| |
187 | ForEach(job.artifacts) { artifact in |
| |
188 | BuildArtifactRow(artifact: artifact) { |
| |
189 | guard let url = artifact.url else { return } |
| |
190 | openURL(url) |
| |
191 | } |
| |
192 | } |
| |
193 | } header: { |
| |
194 | Text("Artifacts") |
| |
195 | } footer: { |
| |
196 | if job.artifacts.contains(where: { !$0.isDownloadable }) { |
| |
197 | Text("Artifacts without a download URL are no longer available for download.") |
| |
198 | } |
| |
199 | } |
| |
200 | } |
| |
201 | |
| 184 | // Per-task logs |
202 | // Per-task logs |
| 185 | if !job.tasks.isEmpty { |
203 | if !job.tasks.isEmpty { |
| 186 | ForEach(job.tasks) { task in |
204 | ForEach(job.tasks) { task in |
| @@ -285,6 +303,51 @@ struct BuildDetailView: View { |
| 285 | } |
303 | } |
| 286 | } |
304 | } |
| 287 | |
305 | |
| |
306 | private struct BuildArtifactRow: View { |
| |
307 | let artifact: BuildArtifact |
| |
308 | let onDownload: () -> Void |
| |
309 | |
| |
310 | var body: some View { |
| |
311 | HStack(alignment: .top, spacing: 12) { |
| |
312 | VStack(alignment: .leading, spacing: 4) { |
| |
313 | Text(artifact.filename) |
| |
314 | .font(.subheadline.weight(.medium)) |
| |
315 | |
| |
316 | if artifact.path != artifact.filename { |
| |
317 | Text(artifact.path) |
| |
318 | .font(.caption) |
| |
319 | .foregroundStyle(.secondary) |
| |
320 | .textSelection(.enabled) |
| |
321 | } |
| |
322 | |
| |
323 | Text(metadataText) |
| |
324 | .font(.caption) |
| |
325 | .foregroundStyle(.secondary) |
| |
326 | } |
| |
327 | |
| |
328 | Spacer(minLength: 12) |
| |
329 | |
| |
330 | Button { |
| |
331 | onDownload() |
| |
332 | } label: { |
| |
333 | Image(systemName: artifact.isDownloadable ? "arrow.down.circle" : "archivebox") |
| |
334 | .imageScale(.large) |
| |
335 | } |
| |
336 | .disabled(!artifact.isDownloadable) |
| |
337 | .accessibilityLabel(artifact.isDownloadable ? "Download \(artifact.filename)" : "\(artifact.filename) is unavailable") |
| |
338 | } |
| |
339 | } |
| |
340 | |
| |
341 | private var metadataText: String { |
| |
342 | var parts = [artifact.size.formattedByteCount] |
| |
343 | parts.append("Created \(artifact.created.relativeDescription)") |
| |
344 | if !artifact.isDownloadable { |
| |
345 | parts.append("Unavailable") |
| |
346 | } |
| |
347 | return parts.joined(separator: " • ") |
| |
348 | } |
| |
349 | } |
| |
350 | |
| 288 | private struct EditResubmitBuildSheet: View { |
351 | private struct EditResubmitBuildSheet: View { |
| 289 | let viewModel: BuildDetailViewModel |
352 | let viewModel: BuildDetailViewModel |
| 290 | let job: JobDetail |
353 | let job: JobDetail |