krz/hutch

an ios client for sourcehut

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

cf87c44f674e020c32fea70903f02c317b57da3b

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-23T20:17:48Z

Add scoped auto-refresh to build detail
 Hutch/Models/Builds.swift                     |  14 ++-
 Hutch/Views/Builds/BuildDetailView.swift      |  60 ++++++++++---
 Hutch/Views/Builds/BuildDetailViewModel.swift | 125 +++++++++++++++++++++++++-
 3 files changed, 179 insertions(+), 20 deletions(-)

diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift
index b0c0e8d..8de4235 100644
--- a/Hutch/Models/Builds.swift
+++ b/Hutch/Models/Builds.swift
@@ -19,6 +19,14 @@ enum JobStatus: String, Codable, Sendable {
         default: false
         }
     }
+
+    /// Whether the job has reached a terminal state and no longer changes.
+    var isTerminal: Bool {
+        switch self {
+        case .success, .failed, .cancelled, .timeout: true
+        case .pending, .queued, .running: false
+        }
+    }
 }
 
 /// Status of a single build task within a job.
@@ -33,7 +41,7 @@ enum TaskStatus: String, Codable, Sendable {
 // MARK: - Build Task
 
 /// A single task within a build job.
-struct BuildTask: Codable, Sendable, Identifiable {
+struct BuildTask: Codable, Sendable, Identifiable, Equatable {
     private(set) var ordinal: Int?
     let name: String
     let status: TaskStatus
@@ -103,7 +111,7 @@ struct JobTaskSummary: Codable, Sendable, Hashable {
 // MARK: - Job Detail (for detail view)
 
 /// Full job model with all fields for the detail view.
-struct JobDetail: Codable, Sendable {
+struct JobDetail: Codable, Sendable, Equatable {
     let id: Int
     let created: Date
     let updated: Date
@@ -119,7 +127,7 @@ struct JobDetail: Codable, Sendable {
 }
 
 /// The log associated with a build job.
-struct BuildLog: Codable, Sendable {
+struct BuildLog: Codable, Sendable, Equatable {
     let fullURL: String
 }
 
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
index cd2a972..104b961 100644
--- a/Hutch/Views/Builds/BuildDetailView.swift
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -6,10 +6,14 @@ struct BuildDetailView: View {
     @Environment(AppState.self) private var appState
     @State private var viewModel: BuildDetailViewModel?
     @State private var rebuiltJobId: Int?
-    @State private var selectedTask: BuildTask?
+    @State private var selectedTaskName: String?
     @State private var showEditResubmitSheet = false
     @State private var showCancelConfirmation = false
 
+    private var isPresentingLogSheet: Bool {
+        selectedTaskName != nil
+    }
+
     var body: some View {
         Group {
             if let viewModel {
@@ -42,18 +46,6 @@ struct BuildDetailView: View {
                 BuildDetailView(jobId: rebuiltJobId)
             }
         }
-        .navigationDestination(isPresented: Binding(
-            get: { selectedTask != nil },
-            set: { isPresented in
-                if !isPresented {
-                    selectedTask = nil
-                }
-            }
-        )) {
-            if let selectedTask, let viewModel {
-                BuildTaskLogView(task: selectedTask, viewModel: viewModel)
-            }
-        }
         .sheet(isPresented: $showEditResubmitSheet) {
             if let viewModel, let job = viewModel.job {
                 EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in
@@ -62,6 +54,38 @@ struct BuildDetailView: View {
                 }
             }
         }
+        .sheet(isPresented: Binding(
+            get: { selectedTaskName != nil },
+            set: { isPresented in
+                if !isPresented {
+                    selectedTaskName = nil
+                }
+            }
+        )) {
+            if let selectedTaskName, let viewModel {
+                NavigationStack {
+                    BuildTaskLogView(taskName: selectedTaskName, viewModel: viewModel)
+                        .toolbar {
+                            ToolbarItem(placement: .cancellationAction) {
+                                Button("Done") {
+                                    self.selectedTaskName = nil
+                                }
+                            }
+                        }
+                }
+            } else {
+                NavigationStack {
+                    SRHTLoadingStateView(message: "Loading…")
+                        .toolbar {
+                            ToolbarItem(placement: .cancellationAction) {
+                                Button("Done") {
+                                    self.selectedTaskName = nil
+                                }
+                            }
+                        }
+                }
+            }
+        }
         .alert("Cancel Build?", isPresented: $showCancelConfirmation) {
             Button("Keep Running", role: .cancel) {}
             Button("Cancel Build", role: .destructive) {
@@ -75,8 +99,16 @@ struct BuildDetailView: View {
                 let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
                 viewModel = vm
                 await vm.loadJob()
+                vm.startAutoRefresh()
             }
         }
+        .onAppear {
+            viewModel?.startAutoRefresh()
+        }
+        .onDisappear {
+            guard !isPresentingLogSheet else { return }
+            viewModel?.stopAutoRefresh()
+        }
     }
 
     @ViewBuilder
@@ -129,7 +161,7 @@ struct BuildDetailView: View {
                     ForEach(job.tasks) { task in
                         Section {
                             Button {
-                                selectedTask = task
+                                selectedTaskName = task.name
                             } label: {
                                 HStack {
                                     Text(task.status.rawValue.capitalized)
diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift
index 2b91a90..ec4d265 100644
--- a/Hutch/Views/Builds/BuildDetailViewModel.swift
+++ b/Hutch/Views/Builds/BuildDetailViewModel.swift
@@ -27,14 +27,20 @@ private struct SubmittedJob: Decodable, Sendable {
 @Observable
 @MainActor
 final class BuildDetailViewModel {
+    private static let autoRefreshInterval: Duration = .seconds(5)
 
     let jobId: Int
     private let client: SRHTClient
 
+    private var autoRefreshTask: Task<Void, Never>?
     private(set) var job: JobDetail?
     private(set) var isLoading = false
+    private(set) var buildLogText: String?
+    private(set) var isLoadingBuildLog = false
     private(set) var taskLogs: [String: String] = [:]
     private(set) var loadingTaskLogs: Set<String> = []
+    private(set) var failedTaskLogs: Set<String> = []
+    private var taskLogRetryCounts: [String: Int] = [:]
     private(set) var isCancelling = false
     private(set) var isRebuilding = false
     private(set) var isSubmittingEditedBuild = false
@@ -108,7 +114,13 @@ final class BuildDetailViewModel {
             loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
                 task.withOrdinal(index)
             }
-            job = loadedJob
+            if job != loadedJob {
+                job = loadedJob
+            }
+
+            if loadedJob.status.isTerminal {
+                stopAutoRefresh()
+            }
         } catch {
             self.error = error.userFacingMessage
         }
@@ -118,21 +130,82 @@ final class BuildDetailViewModel {
 
     func loadTaskLog(task: BuildTask) async {
         let cacheKey = task.logCacheKey
+        let jobIsTerminal = job?.status.isTerminal ?? false
+
+        // Task-specific logs are only fetched after the job reaches a terminal
+        // state. While the build is active, the UI shows the shared live build log.
         guard let log = task.log,
               let logURL = URL(string: log.fullURL),
-              !loadingTaskLogs.contains(cacheKey),
-              taskLogs[cacheKey] == nil else { return }
+              !loadingTaskLogs.contains(cacheKey) else { return }
+        guard jobIsTerminal else { return }
+        if jobIsTerminal, taskLogs[cacheKey] != nil { return }
+        failedTaskLogs.remove(cacheKey)
         loadingTaskLogs.insert(cacheKey)
 
         do {
             taskLogs[cacheKey] = try await client.fetchText(url: logURL)
+            failedTaskLogs.remove(cacheKey)
         } catch {
+            failedTaskLogs.insert(cacheKey)
             self.error = error.userFacingMessage
         }
 
         loadingTaskLogs.remove(cacheKey)
     }
 
+    func loadBuildLog() async {
+        guard let log = job?.log,
+              let logURL = URL(string: log.fullURL),
+              !isLoadingBuildLog else { return }
+
+        let jobIsTerminal = job?.status.isTerminal ?? false
+        if jobIsTerminal, buildLogText != nil { return }
+
+        isLoadingBuildLog = true
+
+        do {
+            buildLogText = try await client.fetchText(url: logURL)
+        } catch {
+            self.error = error.userFacingMessage
+        }
+
+        isLoadingBuildLog = false
+    }
+
+    func retryTaskLog(task: BuildTask) async {
+        let cacheKey = task.logCacheKey
+        failedTaskLogs.remove(cacheKey)
+        taskLogRetryCounts[cacheKey, default: 0] += 1
+        await loadTaskLog(task: task)
+    }
+
+    func displayedLogText(for task: BuildTask?) -> String? {
+        guard let task else { return nil }
+        guard let job else { return nil }
+
+        if !job.status.isTerminal {
+            return buildLogText
+        }
+
+        return taskLogs[task.logCacheKey] ?? buildLogText
+    }
+
+    func isShowingBuildLogFallback(for task: BuildTask?) -> Bool {
+        guard let task, let job else { return false }
+        if !job.status.isTerminal {
+            return buildLogText != nil
+        }
+
+        return taskLogs[task.logCacheKey] == nil && buildLogText != nil
+    }
+
+    func taskLogTrigger(for task: BuildTask?) -> String? {
+        guard let task, let logURL = task.log?.fullURL else { return nil }
+        let retryCount = taskLogRetryCounts[task.logCacheKey, default: 0]
+        let isTerminal = job?.status.isTerminal ?? false
+        return "\(logURL)#\(retryCount)#\(isTerminal)"
+    }
+
     func cancelJob() async {
         guard let job, job.status.isCancellable, !isCancelling else { return }
         isCancelling = true
@@ -237,4 +310,50 @@ final class BuildDetailViewModel {
             return nil
         }
     }
+
+    func startAutoRefresh() {
+        guard autoRefreshTask == nil else { return }
+        guard shouldAutoRefresh else { return }
+
+        print("Build auto-refresh started")
+
+        autoRefreshTask = Task { [weak self] in
+            while !Task.isCancelled {
+                do {
+                    try await Task.sleep(for: Self.autoRefreshInterval)
+                } catch {
+                    break
+                }
+
+                guard let self else { return }
+                await self.performAutoRefreshTick()
+            }
+        }
+    }
+
+    func stopAutoRefresh() {
+        guard let autoRefreshTask else { return }
+
+        autoRefreshTask.cancel()
+        self.autoRefreshTask = nil
+        print("Build auto-refresh stopped")
+    }
+
+    private var shouldAutoRefresh: Bool {
+        guard let job else { return true }
+        return !job.status.isTerminal
+    }
+
+    private func performAutoRefreshTick() async {
+        guard !Task.isCancelled, shouldAutoRefresh, !isLoading else {
+            if !shouldAutoRefresh {
+                stopAutoRefresh()
+            }
+            return
+        }
+
+        print("Build refresh tick")
+        await loadJob()
+        await loadBuildLog()
+    }
 }