summaryrefslogtreecommitdiff
path: root/Hutch/Views/Builds
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Builds')
-rw-r--r--Hutch/Views/Builds/BuildDetailView.swift383
-rw-r--r--Hutch/Views/Builds/BuildDetailViewModel.swift235
-rw-r--r--Hutch/Views/Builds/BuildListView.swift217
-rw-r--r--Hutch/Views/Builds/BuildListViewModel.swift220
-rw-r--r--Hutch/Views/Builds/BuildRowView.swift106
5 files changed, 1161 insertions, 0 deletions
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
new file mode 100644
index 0000000..977cd87
--- /dev/null
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -0,0 +1,383 @@
+import SwiftUI
+
+struct BuildDetailView: View {
+ let jobId: Int
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: BuildDetailViewModel?
+ @State private var rebuiltJobId: Int?
+ @State private var showEditResubmitSheet = false
+ @State private var showCancelConfirmation = false
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ detailContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading build…")
+ }
+ }
+ .navigationTitle("Job #\(jobId)")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ SRHTShareButton(
+ url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) },
+ target: .build
+ ) {
+ Image(systemName: "square.and.arrow.up")
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { rebuiltJobId != nil },
+ set: { isPresented in
+ if !isPresented {
+ rebuiltJobId = nil
+ }
+ }
+ )) {
+ if let rebuiltJobId {
+ BuildDetailView(jobId: rebuiltJobId)
+ }
+ }
+ .sheet(isPresented: $showEditResubmitSheet) {
+ if let viewModel, let job = viewModel.job {
+ EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in
+ showEditResubmitSheet = false
+ rebuiltJobId = jobId
+ }
+ }
+ }
+ .alert("Cancel Build?", isPresented: $showCancelConfirmation) {
+ Button("Keep Running", role: .cancel) {}
+ Button("Cancel Build", role: .destructive) {
+ Task { await viewModel?.cancelJob() }
+ }
+ } message: {
+ Text("The build will stop as soon as possible.")
+ }
+ .task {
+ if viewModel == nil {
+ let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
+ viewModel = vm
+ await vm.loadJob()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func detailContent(_ viewModel: BuildDetailViewModel) -> some View {
+ if viewModel.isLoading, viewModel.job == nil {
+ SRHTLoadingStateView(message: "Loading build…")
+ } else if let error = viewModel.error, viewModel.job == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Build",
+ message: error,
+ retryAction: { await viewModel.loadJob() }
+ )
+ } else if let job = viewModel.job {
+ List {
+ // Status & metadata
+ Section("Details") {
+ HStack {
+ Text("Status")
+ Spacer()
+ HStack(spacing: 6) {
+ JobStatusIcon(status: job.status)
+ Text(job.status.rawValue)
+ .font(.subheadline.weight(.medium))
+ }
+ }
+
+ if let note = job.note, !note.isEmpty {
+ LabeledContent("Note", value: note)
+ }
+
+ if let image = job.image {
+ LabeledContent("Image", value: image)
+ }
+
+ if !job.tags.isEmpty {
+ LabeledContent("Tags", value: job.tags.joined(separator: ", "))
+ }
+
+ if let visibility = job.visibility {
+ LabeledContent("Visibility", value: visibility.rawValue.capitalized)
+ }
+
+ LabeledContent("Owner", value: job.owner.canonicalName)
+ LabeledContent("Created", value: job.created.relativeDescription)
+ LabeledContent("Updated", value: job.updated.relativeDescription)
+ }
+
+ // Per-task logs
+ if !job.tasks.isEmpty {
+ ForEach(job.tasks) { task in
+ Section {
+ TaskLogSection(task: task, viewModel: viewModel)
+ } header: {
+ HStack(spacing: 6) {
+ TaskStatusIcon(status: task.status)
+ Text(task.name)
+ }
+ }
+ }
+ }
+
+ // Cancel button
+ if job.status.isCancellable {
+ Section {
+ Button(role: .destructive) {
+ showCancelConfirmation = true
+ } label: {
+ HStack {
+ Text("Cancel Build")
+ if viewModel.isCancelling {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(viewModel.isCancelling)
+ }
+ }
+
+ if let manifest = job.manifest,
+ !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ Section {
+ Button {
+ Task {
+ rebuiltJobId = await viewModel.rebuildJob()
+ }
+ } label: {
+ HStack {
+ Text(job.status == .failed || job.status == .cancelled || job.status == .timeout ? "Retry Build" : "Rebuild")
+ if viewModel.isRebuilding {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(viewModel.isRebuilding)
+
+ Button {
+ showEditResubmitSheet = true
+ } label: {
+ Text("Edit & Resubmit")
+ }
+ .disabled(viewModel.isSubmittingEditedBuild)
+ } footer: {
+ Text("Creates a new build using this job’s saved manifest, tags, note, and visibility.")
+ }
+ }
+ }
+ .refreshable {
+ await viewModel.loadJob()
+ }
+ .srhtErrorBanner(error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ ))
+ }
+ }
+}
+
+private struct EditResubmitBuildSheet: View {
+ let viewModel: BuildDetailViewModel
+ let job: JobDetail
+ let onSubmitted: (Int) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @Bindable var viewModelBindable: BuildDetailViewModel
+ @State private var manifest: String
+ @State private var tagsText: String
+ @State private var note: String
+ @State private var secrets = false
+ @State private var execute = true
+ @State private var visibility: Visibility
+
+ init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) {
+ self.viewModel = viewModel
+ self._viewModelBindable = Bindable(viewModel)
+ self.job = job
+ self.onSubmitted = onSubmitted
+ _manifest = State(initialValue: job.manifest ?? "")
+ _tagsText = State(initialValue: job.tags.joined(separator: ", "))
+ _note = State(initialValue: job.note ?? "")
+ _visibility = State(initialValue: job.visibility ?? .public)
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Build Manifest") {
+ TextField("Build manifest", text: $manifest, axis: .vertical)
+ .font(.system(.body, design: .monospaced))
+ .lineLimit(12...24)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
+
+ Section("Build Options") {
+ TextField("Note (optional)", text: $note)
+ TextField("Tags (comma-separated, optional)", text: $tagsText)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+ Toggle("Start build now", isOn: $execute)
+ Toggle("Allow build secrets", isOn: $secrets)
+ }
+
+ Section {
+ Text("This submits a new build. “Start build now” and “Allow build secrets” use local defaults because the current job does not include those original values.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ if let error = viewModel.error {
+ Section {
+ Label {
+ Text(error)
+ } icon: {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ }
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("Edit & Resubmit")
+ .navigationBarTitleDisplayMode(.inline)
+ .onDisappear {
+ viewModelBindable.error = nil
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ viewModelBindable.error = nil
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ let tags = tagsText
+ .split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ if let jobId = await viewModel.submitBuild(
+ manifest: manifest,
+ tags: tags,
+ note: note,
+ secrets: secrets,
+ execute: execute,
+ visibility: visibility
+ ) {
+ onSubmitted(jobId)
+ }
+ }
+ } label: {
+ if viewModel.isSubmittingEditedBuild {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Submit Build")
+ }
+ }
+ .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Task Log Section
+
+private struct TaskLogSection: View {
+ let task: BuildTask
+ let viewModel: BuildDetailViewModel
+
+ @State private var isExpanded: Bool
+
+ init(task: BuildTask, viewModel: BuildDetailViewModel) {
+ self.task = task
+ self.viewModel = viewModel
+ self._isExpanded = State(initialValue: task.status == .failed)
+ }
+
+ var body: some View {
+ DisclosureGroup(isExpanded: $isExpanded) {
+ if viewModel.loadingTaskLogs.contains(task.name) {
+ HStack {
+ Spacer()
+ ProgressView("Loading log…")
+ Spacer()
+ }
+ } else if let logText = viewModel.taskLogs[task.name] {
+ ScrollView(.horizontal, showsIndicators: false) {
+ Text(logText)
+ .font(.caption2.monospaced())
+ .foregroundStyle(.primary)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ } else if task.log == nil {
+ Text("No log available.")
+ .foregroundStyle(.secondary)
+ }
+ } label: {
+ HStack {
+ Text(task.status.rawValue)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .task {
+ if isExpanded {
+ await viewModel.loadTaskLog(task: task)
+ }
+ }
+ .onChange(of: isExpanded) { _, expanded in
+ if expanded {
+ Task { await viewModel.loadTaskLog(task: task) }
+ }
+ }
+ }
+}
+
+// MARK: - Task Status Icon
+
+private struct TaskStatusIcon: View {
+ let status: TaskStatus
+
+ var body: some View {
+ Image(systemName: iconName)
+ .foregroundStyle(color)
+ .frame(width: 20)
+ }
+
+ private var iconName: String {
+ switch status {
+ case .success: "checkmark.circle.fill"
+ case .failed: "xmark.circle.fill"
+ case .running: "arrow.trianglehead.2.clockwise.rotate.90"
+ case .pending: "circle.dashed"
+ case .skipped: "forward.circle.fill"
+ }
+ }
+
+ private var color: Color {
+ switch status {
+ case .success: .green
+ case .failed: .red
+ case .running: .yellow
+ case .pending: .gray
+ case .skipped: .secondary
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift
new file mode 100644
index 0000000..c14e47b
--- /dev/null
+++ b/Hutch/Views/Builds/BuildDetailViewModel.swift
@@ -0,0 +1,235 @@
+import Foundation
+
+// MARK: - Response types
+
+private struct JobDetailResponse: Decodable, Sendable {
+ let job: JobDetail
+}
+
+private struct CancelResponse: Decodable, Sendable {
+ let cancel: CancelResult
+}
+
+private struct CancelResult: Decodable, Sendable {
+ let id: Int
+}
+
+private struct SubmitJobResponse: Decodable, Sendable {
+ let submit: SubmittedJob
+}
+
+private struct SubmittedJob: Decodable, Sendable {
+ let id: Int
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class BuildDetailViewModel {
+
+ let jobId: Int
+ private let client: SRHTClient
+
+ private(set) var job: JobDetail?
+ private(set) var isLoading = false
+ private(set) var taskLogs: [String: String] = [:]
+ private(set) var loadingTaskLogs: Set<String> = []
+ private(set) var isCancelling = false
+ private(set) var isRebuilding = false
+ private(set) var isSubmittingEditedBuild = false
+ var error: String?
+
+ init(jobId: Int, client: SRHTClient) {
+ self.jobId = jobId
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let detailQuery = """
+ query job($id: Int!) {
+ job(id: $id) {
+ id
+ created
+ updated
+ status
+ note
+ tags
+ visibility
+ image
+ manifest
+ tasks { name status log { fullURL } }
+ log { fullURL }
+ owner { canonicalName }
+ }
+ }
+ """
+
+ private static let cancelMutation = """
+ mutation cancel($id: Int!) {
+ cancel(jobId: $id) {
+ id
+ }
+ }
+ """
+
+ private static let submitMutation = """
+ mutation submit($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) {
+ submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) {
+ id
+ }
+ }
+ """
+
+ private static let editableSubmitMutation = """
+ mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
+ submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
+ id
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ func loadJob() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.detailQuery,
+ variables: ["id": jobId],
+ responseType: JobDetailResponse.self
+ )
+ job = result.job
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ func loadTaskLog(task: BuildTask) async {
+ guard let log = task.log,
+ let logURL = URL(string: log.fullURL),
+ !loadingTaskLogs.contains(task.name),
+ taskLogs[task.name] == nil else { return }
+ loadingTaskLogs.insert(task.name)
+
+ do {
+ taskLogs[task.name] = try await client.fetchText(url: logURL)
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ loadingTaskLogs.remove(task.name)
+ }
+
+ func cancelJob() async {
+ guard let job, job.status.isCancellable, !isCancelling else { return }
+ isCancelling = true
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .builds,
+ query: Self.cancelMutation,
+ variables: ["id": jobId],
+ responseType: CancelResponse.self
+ )
+ // Reload job to get updated status.
+ await loadJob()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isCancelling = false
+ }
+
+ func rebuildJob() async -> Int? {
+ guard let job, let manifest = job.manifest, !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !isRebuilding else {
+ return nil
+ }
+
+ isRebuilding = true
+ error = nil
+ defer { isRebuilding = false }
+
+ var variables: [String: any Sendable] = [
+ "manifest": manifest.trimmingCharacters(in: .whitespacesAndNewlines)
+ ]
+ if !job.tags.isEmpty {
+ variables["tags"] = job.tags
+ }
+ if let note = job.note, !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ variables["note"] = note.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ if let visibility = job.visibility {
+ variables["visibility"] = visibility.rawValue
+ }
+
+ do {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.submitMutation,
+ variables: variables,
+ responseType: SubmitJobResponse.self
+ )
+ return result.submit.id
+ } catch {
+ self.error = error.localizedDescription
+ return nil
+ }
+ }
+
+ func submitBuild(
+ manifest: String,
+ tags: [String],
+ note: String,
+ secrets: Bool,
+ execute: Bool,
+ visibility: Visibility
+ ) async -> Int? {
+ guard !isSubmittingEditedBuild else { return nil }
+
+ let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedManifest.isEmpty else {
+ error = "Paste a build manifest."
+ return nil
+ }
+
+ isSubmittingEditedBuild = true
+ error = nil
+ defer { isSubmittingEditedBuild = false }
+
+ var variables: [String: any Sendable] = [
+ "manifest": trimmedManifest,
+ "secrets": secrets,
+ "execute": execute,
+ "visibility": visibility.rawValue
+ ]
+ if !tags.isEmpty {
+ variables["tags"] = tags
+ }
+ let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedNote.isEmpty {
+ variables["note"] = trimmedNote
+ }
+
+ do {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.editableSubmitMutation,
+ variables: variables,
+ responseType: SubmitJobResponse.self
+ )
+ return result.submit.id
+ } catch {
+ self.error = "Couldn’t submit the build. \(error.localizedDescription)"
+ return nil
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
new file mode 100644
index 0000000..875a836
--- /dev/null
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -0,0 +1,217 @@
+import SwiftUI
+
+struct BuildListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: BuildListViewModel?
+ @State private var showSubmitSheet = false
+ @State private var submittedJobId: Int?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading builds…")
+ }
+ }
+ .navigationTitle("Builds")
+ .toolbar {
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showSubmitSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showSubmitSheet) {
+ if let viewModel {
+ SubmitBuildSheet(viewModel: viewModel) { jobId in
+ showSubmitSheet = false
+ submittedJobId = jobId
+ }
+ }
+ }
+ .navigationDestination(for: JobSummary.self) { job in
+ BuildDetailView(jobId: job.id)
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { submittedJobId != nil },
+ set: { isPresented in
+ if !isPresented {
+ submittedJobId = nil
+ }
+ }
+ )) {
+ if let submittedJobId {
+ BuildDetailView(jobId: submittedJobId)
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = BuildListViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadJobs()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: BuildListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.jobs) { job in
+ NavigationLink(value: job) {
+ BuildRowView(job: job)
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: job)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.jobs.isEmpty {
+ SRHTLoadingStateView(message: "Loading builds…")
+ } else if let error = viewModel.error, viewModel.jobs.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Builds",
+ message: error,
+ retryAction: { await viewModel.loadJobs() }
+ )
+ } else if viewModel.jobs.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "No Builds",
+ systemImage: "hammer",
+ description: Text("Your build jobs will appear here.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.jobs.isEmpty) {
+ await viewModel.loadJobs()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadJobs()
+ }
+ }
+}
+
+private struct SubmitBuildSheet: View {
+ let viewModel: BuildListViewModel
+ let onSubmitted: (Int) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @Bindable var viewModelBindable: BuildListViewModel
+ @State private var manifest = ""
+ @State private var tagsText = ""
+ @State private var note = ""
+ @State private var secrets = false
+ @State private var execute = true
+ @State private var visibility: Visibility = .public
+
+ init(viewModel: BuildListViewModel, onSubmitted: @escaping (Int) -> Void) {
+ self.viewModel = viewModel
+ self._viewModelBindable = Bindable(viewModel)
+ self.onSubmitted = onSubmitted
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Build Manifest") {
+ TextField("Paste a build manifest", text: $manifest, axis: .vertical)
+ .font(.system(.body, design: .monospaced))
+ .lineLimit(12...24)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
+
+ Section("Build Options") {
+ TextField("Note (optional)", text: $note)
+ TextField("Tags (comma-separated, optional)", text: $tagsText)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+ Toggle("Start build now", isOn: $execute)
+ Toggle("Allow build secrets", isOn: $secrets)
+ }
+
+ Section {
+ Text("You need a valid builds.sr.ht manifest and a token with BUILDS:RW.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ if let error = viewModel.error {
+ Section {
+ Label {
+ Text(error)
+ } icon: {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ }
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("Submit Build")
+ .navigationBarTitleDisplayMode(.inline)
+ .onDisappear {
+ viewModelBindable.error = nil
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ viewModelBindable.error = nil
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ let tags = tagsText
+ .split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ if let jobId = await viewModel.submitBuild(
+ manifest: manifest,
+ tags: tags,
+ note: note,
+ secrets: secrets,
+ execute: execute,
+ visibility: visibility
+ ) {
+ onSubmitted(jobId)
+ }
+ }
+ } label: {
+ if viewModel.isSubmitting {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Submit Build")
+ }
+ }
+ .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift
new file mode 100644
index 0000000..8d0f961
--- /dev/null
+++ b/Hutch/Views/Builds/BuildListViewModel.swift
@@ -0,0 +1,220 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct JobsResponse: Decodable, Sendable {
+ let jobs: JobsPage
+}
+
+private struct JobsPage: Decodable, Sendable {
+ let results: [JobSummary]
+ let cursor: String?
+}
+
+private struct SubmitJobResponse: Decodable, Sendable {
+ let submit: SubmittedJob
+}
+
+private struct SubmittedJob: Decodable, Sendable {
+ let id: Int
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class BuildListViewModel {
+
+ private(set) var jobs: [JobSummary] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isRefreshing = false
+ private(set) var isSubmitting = false
+ var error: String?
+
+ private var cursor: String?
+ private var hasMore = true
+ private let client: SRHTClient
+
+ private static let cacheKey = "builds.jobs"
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ // MARK: - Query
+
+ private static let query = """
+ query jobs($cursor: Cursor) {
+ jobs(cursor: $cursor) {
+ results {
+ id
+ created
+ updated
+ status
+ note
+ tags
+ visibility
+ image
+ tasks { name status }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let submitMutation = """
+ mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
+ submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
+ id
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ /// Fetch the first page of jobs. Shows cached data instantly if available,
+ /// then refreshes from the network in the background.
+ func loadJobs() async {
+ // Show cached data immediately on first load
+ if jobs.isEmpty {
+ loadFromCache()
+ }
+
+ if jobs.isEmpty {
+ isLoading = true
+ } else {
+ isRefreshing = true
+ }
+ error = nil
+ cursor = nil
+ hasMore = true
+
+ do {
+ let page = try await fetchPage(cursor: nil, useCache: true)
+ jobs = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ if jobs.isEmpty {
+ self.error = error.localizedDescription
+ }
+ }
+
+ isLoading = false
+ isRefreshing = false
+ }
+
+ func loadMoreIfNeeded(currentItem: JobSummary) async {
+ guard let last = jobs.last,
+ last.id == currentItem.id,
+ hasMore,
+ !isLoadingMore else {
+ return
+ }
+
+ isLoadingMore = true
+
+ do {
+ let page = try await fetchPage(cursor: cursor, useCache: false)
+ jobs.append(contentsOf: page.results)
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingMore = false
+ }
+
+ func submitBuild(
+ manifest: String,
+ tags: [String],
+ note: String,
+ secrets: Bool,
+ execute: Bool,
+ visibility: Visibility
+ ) async -> Int? {
+ guard !isSubmitting else { return nil }
+
+ let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedManifest.isEmpty else {
+ error = "Paste a build manifest."
+ return nil
+ }
+
+ isSubmitting = true
+ error = nil
+ defer { isSubmitting = false }
+
+ var variables: [String: any Sendable] = [
+ "manifest": trimmedManifest,
+ "secrets": secrets,
+ "execute": execute,
+ "visibility": visibility.rawValue
+ ]
+ if !tags.isEmpty {
+ variables["tags"] = tags
+ }
+ let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedNote.isEmpty {
+ variables["note"] = trimmedNote
+ }
+
+ do {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.submitMutation,
+ variables: variables,
+ responseType: SubmitJobResponse.self
+ )
+ await loadJobs()
+ return result.submit.id
+ } catch {
+ self.error = "Couldn’t submit the build. \(error.localizedDescription)"
+ return nil
+ }
+ }
+
+ // MARK: - Private
+
+ private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ if useCache && cursor == nil {
+ let result = try await client.executeAndCache(
+ service: .builds,
+ query: Self.query,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: JobsResponse.self,
+ cacheKey: Self.cacheKey
+ )
+ return result.jobs
+ } else {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.query,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: JobsResponse.self
+ )
+ return result.jobs
+ }
+ }
+
+ private func loadFromCache() {
+ guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .srhtFlexible
+ if let response = try? decoder.decode(
+ GraphQLResponse<JobsResponse>.self,
+ from: data
+ ), let page = response.data?.jobs {
+ jobs = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildRowView.swift b/Hutch/Views/Builds/BuildRowView.swift
new file mode 100644
index 0000000..e2afd27
--- /dev/null
+++ b/Hutch/Views/Builds/BuildRowView.swift
@@ -0,0 +1,106 @@
+import SwiftUI
+
+struct BuildRowView: View {
+ let job: JobSummary
+
+ var body: some View {
+ HStack(spacing: 12) {
+ JobStatusIcon(status: job.status)
+ .frame(width: 28)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(job.displayLabel)
+ .font(.subheadline)
+ .lineLimit(1)
+
+ HStack(spacing: 8) {
+ if let image = job.image {
+ Text(image)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Text(job.created.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+
+ if !job.tasks.isEmpty {
+ TaskProgressView(tasks: job.tasks)
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+// MARK: - Job Status Icon
+
+struct JobStatusIcon: View {
+ let status: JobStatus
+
+ var body: some View {
+ Image(systemName: iconName)
+ .foregroundStyle(color)
+ .symbolEffect(.pulse, isActive: status == .running)
+ }
+
+ private var iconName: String {
+ switch status {
+ case .success: "checkmark.circle.fill"
+ case .failed, .timeout: "xmark.circle.fill"
+ case .running: "arrow.trianglehead.2.clockwise.rotate.90"
+ case .queued: "clock.fill"
+ case .pending: "circle.dashed"
+ case .cancelled: "minus.circle.fill"
+ }
+ }
+
+ private var color: Color {
+ switch status {
+ case .success: .green
+ case .failed, .timeout: .red
+ case .running: .yellow
+ case .queued: .orange
+ case .pending, .cancelled: .gray
+ }
+ }
+}
+
+// MARK: - Task Progress
+
+struct TaskProgressView: View {
+ let tasks: [JobTaskSummary]
+
+ var body: some View {
+ HStack(spacing: 6) {
+ ProgressView(value: progress, total: 1.0)
+ .tint(progressColor)
+ .frame(maxWidth: 80)
+
+ Text("\(completedCount)/\(tasks.count) tasks")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var completedCount: Int {
+ tasks.filter { $0.status == .success }.count
+ }
+
+ private var progress: Double {
+ tasks.isEmpty ? 0 : Double(completedCount) / Double(tasks.count)
+ }
+
+ private var progressColor: Color {
+ if tasks.contains(where: { $0.status == .failed }) {
+ return .red
+ }
+ if completedCount == tasks.count {
+ return .green
+ }
+ return .blue
+ }
+}