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 {
122 let image: String? 122 let image: String?
123 let manifest: String? 123 let manifest: String?
124 var tasks: [BuildTask] 124 var tasks: [BuildTask]
125 let artifacts: [BuildArtifact]
125 let log: BuildLog? 126 let log: BuildLog?
126 let owner: Entity 127 let owner: Entity
127} 128}
128 129
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
129/// The log associated with a build job. 148/// The log associated with a build job.
130struct BuildLog: Codable, Sendable, Equatable { 149struct BuildLog: Codable, Sendable, Equatable {
131 let fullURL: String 150 let fullURL: String
Hutch/Views/Builds/BuildDetailView.swift +63
@@ -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
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
288private struct EditResubmitBuildSheet: View { 351private struct EditResubmitBuildSheet: View {
289 let viewModel: BuildDetailViewModel 352 let viewModel: BuildDetailViewModel
290 let job: JobDetail 353 let job: JobDetail
Hutch/Views/Builds/BuildDetailViewModel.swift +2
@@ -86,6 +86,7 @@ final class BuildDetailViewModel {
86 image 86 image
87 manifest 87 manifest
88 tasks { name status log { fullURL } } 88 tasks { name status log { fullURL } }
89 artifacts { id created path size url }
89 log { fullURL } 90 log { fullURL }
90 owner { canonicalName } 91 owner { canonicalName }
91 } 92 }
@@ -237,6 +238,7 @@ final class BuildDetailViewModel {
237 status: .cancelled, note: job.note, tags: job.tags, 238 status: .cancelled, note: job.note, tags: job.tags,
238 visibility: job.visibility, image: job.image, 239 visibility: job.visibility, image: job.image,
239 manifest: job.manifest, tasks: job.tasks, 240 manifest: job.manifest, tasks: job.tasks,
241 artifacts: job.artifacts,
240 log: job.log, owner: job.owner 242 log: job.log, owner: job.owner
241 ) 243 )
242 stopAutoRefresh() 244 stopAutoRefresh()