Commit 9f42c10f10

9f42c10f107cf5097be5a51161656ee64068fd3e

parent: ace3f95b39

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 02:17 UTC

feat: add build artifact listing

Implements: https://todo.sr.ht/~ccleberg/hutch/31

Layout: unified · split

Hutch/Models/Builds.swift +19
@@ -122,10 +122,29 @@ struct JobDetail: Codable, Sendable, Equatable {
122122 let image: String?
123123 let manifest: String?
124124 var tasks: [BuildTask]
125 let artifacts: [BuildArtifact]
125126 let log: BuildLog?
126127 let owner: Entity
127128}
128129
130/// A downloadable artifact emitted by a build job.
131struct BuildArtifact: Codable, Sendable, Identifiable, Equatable {
132 let id: Int
133 let created: Date
134 let path: String
135 let size: Int
136 let url: URL?
137
138 var filename: String {
139 let pathComponents = path.split(separator: "/")
140 return pathComponents.last.map(String.init) ?? path
141 }
142
143 var isDownloadable: Bool {
144 url != nil
145 }
146}
147
129148/// The log associated with a build job.
130149struct BuildLog: Codable, Sendable, Equatable {
131150 let fullURL: String
Hutch/Views/Builds/BuildDetailView.swift +63
@@ -4,6 +4,7 @@ struct BuildDetailView: View {
44 let jobId: Int
55
66 @Environment(AppState.self) private var appState
7 @Environment(\.openURL) private var openURL
78 @State private var viewModel: BuildDetailViewModel?
89 @State private var rebuiltJobId: Int?
910 @State private var selectedTaskName: String?
@@ -181,6 +182,23 @@ struct BuildDetailView: View {
181182 }
182183 }
183184
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
184202 // Per-task logs
185203 if !job.tasks.isEmpty {
186204 ForEach(job.tasks) { task in
@@ -285,6 +303,51 @@ struct BuildDetailView: View {
285303 }
286304}
287305
306private 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
288351private struct EditResubmitBuildSheet: View {
289352 let viewModel: BuildDetailViewModel
290353 let job: JobDetail
Hutch/Views/Builds/BuildDetailViewModel.swift +2
@@ -86,6 +86,7 @@ final class BuildDetailViewModel {
8686 image
8787 manifest
8888 tasks { name status log { fullURL } }
89 artifacts { id created path size url }
8990 log { fullURL }
9091 owner { canonicalName }
9192 }
@@ -237,6 +238,7 @@ final class BuildDetailViewModel {
237238 status: .cancelled, note: job.note, tags: job.tags,
238239 visibility: job.visibility, image: job.image,
239240 manifest: job.manifest, tasks: job.tasks,
241 artifacts: job.artifacts,
240242 log: job.log, owner: job.owner
241243 )
242244 stopAutoRefresh()