krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

7548bfb595d853c682653c0dc21bb3bf17af80d5

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T02:17:24Z

feat: add build artifact listing

Implements: https://todo.sr.ht/~ccleberg/hutch/31
 Hutch/Models/Builds.swift                     | 19 ++++++++
 Hutch/Views/Builds/BuildDetailView.swift      | 63 +++++++++++++++++++++++++++
 Hutch/Views/Builds/BuildDetailViewModel.swift |  2 +
 3 files changed, 84 insertions(+)

diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift
index 8de4235..d666398 100644
--- a/Hutch/Models/Builds.swift
+++ b/Hutch/Models/Builds.swift
@@ -122,10 +122,29 @@ struct JobDetail: Codable, Sendable, Equatable {
     let image: String?
     let manifest: String?
     var tasks: [BuildTask]
+    let artifacts: [BuildArtifact]
     let log: BuildLog?
     let owner: Entity
 }
 
+/// A downloadable artifact emitted by a build job.
+struct BuildArtifact: Codable, Sendable, Identifiable, Equatable {
+    let id: Int
+    let created: Date
+    let path: String
+    let size: Int
+    let url: URL?
+
+    var filename: String {
+        let pathComponents = path.split(separator: "/")
+        return pathComponents.last.map(String.init) ?? path
+    }
+
+    var isDownloadable: Bool {
+        url != nil
+    }
+}
+
 /// The log associated with a build job.
 struct BuildLog: Codable, Sendable, Equatable {
     let fullURL: String
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
index 2f0bc6a..1ec9f64 100644
--- a/Hutch/Views/Builds/BuildDetailView.swift
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -4,6 +4,7 @@ struct BuildDetailView: View {
     let jobId: Int
 
     @Environment(AppState.self) private var appState
+    @Environment(\.openURL) private var openURL
     @State private var viewModel: BuildDetailViewModel?
     @State private var rebuiltJobId: Int?
     @State private var selectedTaskName: String?
@@ -181,6 +182,23 @@ struct BuildDetailView: View {
                     }
                 }
 
+                if !job.artifacts.isEmpty {
+                    Section {
+                        ForEach(job.artifacts) { artifact in
+                            BuildArtifactRow(artifact: artifact) {
+                                guard let url = artifact.url else { return }
+                                openURL(url)
+                            }
+                        }
+                    } header: {
+                        Text("Artifacts")
+                    } footer: {
+                        if job.artifacts.contains(where: { !$0.isDownloadable }) {
+                            Text("Artifacts without a download URL are no longer available for download.")
+                        }
+                    }
+                }
+
                 // Per-task logs
                 if !job.tasks.isEmpty {
                     ForEach(job.tasks) { task in
@@ -285,6 +303,51 @@ struct BuildDetailView: View {
     }
 }
 
+private struct BuildArtifactRow: View {
+    let artifact: BuildArtifact
+    let onDownload: () -> Void
+
+    var body: some View {
+        HStack(alignment: .top, spacing: 12) {
+            VStack(alignment: .leading, spacing: 4) {
+                Text(artifact.filename)
+                    .font(.subheadline.weight(.medium))
+
+                if artifact.path != artifact.filename {
+                    Text(artifact.path)
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                        .textSelection(.enabled)
+                }
+
+                Text(metadataText)
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+            }
+
+            Spacer(minLength: 12)
+
+            Button {
+                onDownload()
+            } label: {
+                Image(systemName: artifact.isDownloadable ? "arrow.down.circle" : "archivebox")
+                    .imageScale(.large)
+            }
+            .disabled(!artifact.isDownloadable)
+            .accessibilityLabel(artifact.isDownloadable ? "Download \(artifact.filename)" : "\(artifact.filename) is unavailable")
+        }
+    }
+
+    private var metadataText: String {
+        var parts = [artifact.size.formattedByteCount]
+        parts.append("Created \(artifact.created.relativeDescription)")
+        if !artifact.isDownloadable {
+            parts.append("Unavailable")
+        }
+        return parts.joined(separator: " • ")
+    }
+}
+
 private struct EditResubmitBuildSheet: View {
     let viewModel: BuildDetailViewModel
     let job: JobDetail
diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift
index 803ab33..9566e0d 100644
--- a/Hutch/Views/Builds/BuildDetailViewModel.swift
+++ b/Hutch/Views/Builds/BuildDetailViewModel.swift
@@ -86,6 +86,7 @@ final class BuildDetailViewModel {
             image
             manifest
             tasks { name status log { fullURL } }
+            artifacts { id created path size url }
             log { fullURL }
             owner { canonicalName }
         }
@@ -237,6 +238,7 @@ final class BuildDetailViewModel {
             status: .cancelled, note: job.note, tags: job.tags,
             visibility: job.visibility, image: job.image,
             manifest: job.manifest, tasks: job.tasks,
+            artifacts: job.artifacts,
             log: job.log, owner: job.owner
         )
         stopAutoRefresh()