krz/hutch

an ios client for sourcehut

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

7fd0b33e4c6d39a40947e83d6a1c55a4d26f9476

verified · cmc

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

Present build logs in a live-updating sheet
 Hutch/Views/Builds/BuildTaskLogView.swift | 128 ++++++++++++++++++++++--------
 1 file changed, 95 insertions(+), 33 deletions(-)

diff --git a/Hutch/Views/Builds/BuildTaskLogView.swift b/Hutch/Views/Builds/BuildTaskLogView.swift
index 9ed0bdd..85c9e21 100644
--- a/Hutch/Views/Builds/BuildTaskLogView.swift
+++ b/Hutch/Views/Builds/BuildTaskLogView.swift
@@ -2,54 +2,116 @@ import SwiftUI
 import UIKit
 
 struct BuildTaskLogView: View {
-    let task: BuildTask
+    let taskName: String
     let viewModel: BuildDetailViewModel
 
+    /// Always reflects the latest version of the task from the live job data.
+    private var task: BuildTask? {
+        viewModel.job?.tasks.first(where: { $0.name == taskName })
+    }
+
     var body: some View {
         Group {
-            if viewModel.loadingTaskLogs.contains(task.logCacheKey) {
-                SRHTLoadingStateView(message: "Loading log…")
-            } else if let logText = viewModel.taskLogs[task.logCacheKey] {
-                GeometryReader { geometry in
-                    ScrollView([.vertical, .horizontal]) {
-                        Text(logText)
-                            .font(.caption2.monospaced())
-                            .textSelection(.enabled)
-                            .padding()
-                            .frame(
-                                minWidth: geometry.size.width,
-                                minHeight: geometry.size.height,
-                                alignment: .topLeading
-                            )
+            if let task {
+                if let logText = viewModel.displayedLogText(for: task) {
+                    BuildLogTextView(text: logText)
+                    .safeAreaInset(edge: .bottom) {
+                        if viewModel.isShowingBuildLogFallback(for: task) {
+                            Text("Showing the live build log until a task-specific log is available.")
+                                .font(.footnote)
+                                .foregroundStyle(.secondary)
+                                .frame(maxWidth: .infinity, alignment: .leading)
+                                .padding(.horizontal)
+                                .padding(.vertical, 8)
+                                .background(.thinMaterial)
+                        }
                     }
-                }
-                .toolbar {
-                    ToolbarItem(placement: .topBarTrailing) {
-                        ShareLink(item: logText)
+                    .toolbar {
+                        ToolbarItem(placement: .topBarTrailing) {
+                            ShareLink(item: logText)
+                        }
+                        ToolbarItem(placement: .topBarTrailing) {
+                            Button {
+                                UIPasteboard.general.string = logText
+                            } label: {
+                                Image(systemName: "doc.on.doc")
+                            }
+                            .accessibilityLabel("Copy log to clipboard")
+                        }
+                    }
+                } else if viewModel.loadingTaskLogs.contains(task.logCacheKey) {
+                    SRHTLoadingStateView(message: "Loading log…")
+                } else if task.log == nil {
+                    if task.status == .running || task.status == .pending {
+                        if viewModel.isLoadingBuildLog {
+                            SRHTLoadingStateView(message: "Loading build log…")
+                        } else {
+                            SRHTLoadingStateView(message: "Waiting for log…")
+                        }
+                    } else {
+                        ContentUnavailableView(
+                            "No Log",
+                            systemImage: "doc.text",
+                            description: Text("This task has no log output.")
+                        )
                     }
-                    ToolbarItem(placement: .topBarTrailing) {
-                        Button {
-                            UIPasteboard.general.string = logText
-                        } label: {
-                            Image(systemName: "doc.on.doc")
+                } else if viewModel.failedTaskLogs.contains(task.logCacheKey) {
+                    ContentUnavailableView {
+                        Label("Couldn't Load Log", systemImage: "exclamationmark.triangle")
+                    } description: {
+                        Text("The log is temporarily unavailable. Retry to fetch the latest output.")
+                    } actions: {
+                        Button("Retry") {
+                            Task {
+                                await viewModel.retryTaskLog(task: task)
+                            }
                         }
-                        .accessibilityLabel("Copy log to clipboard")
                     }
+                } else {
+                    SRHTLoadingStateView(message: "Loading log…")
                 }
-            } else if task.log == nil {
-                ContentUnavailableView(
-                    "No Log",
-                    systemImage: "doc.text",
-                    description: Text("This task has no log output.")
-                )
             } else {
                 SRHTLoadingStateView(message: "Loading log…")
             }
         }
-        .navigationTitle(task.name)
+        .navigationTitle(taskName)
         .navigationBarTitleDisplayMode(.inline)
-        .task {
+        // Re-runs whenever the log URL changes or a retry is requested.
+        .task(id: viewModel.taskLogTrigger(for: task)) {
+            guard let task else { return }
             await viewModel.loadTaskLog(task: task)
         }
+        .task(id: viewModel.job?.log?.fullURL) {
+            guard let task else { return }
+            guard task.status == .running || task.status == .pending else { return }
+            await viewModel.loadBuildLog()
+        }
+    }
+}
+
+private struct BuildLogTextView: UIViewRepresentable {
+    let text: String
+
+    func makeUIView(context: Context) -> UITextView {
+        let textView = UITextView()
+        textView.isEditable = false
+        textView.isSelectable = true
+        textView.isScrollEnabled = true
+        textView.alwaysBounceVertical = true
+        textView.alwaysBounceHorizontal = true
+        textView.showsHorizontalScrollIndicator = true
+        textView.showsVerticalScrollIndicator = true
+        textView.backgroundColor = .clear
+        textView.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
+        textView.textContainer.lineFragmentPadding = 0
+        textView.textContainer.widthTracksTextView = false
+        textView.font = UIFont.monospacedSystemFont(ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, weight: .regular)
+        textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        return textView
+    }
+
+    func updateUIView(_ uiView: UITextView, context: Context) {
+        guard uiView.text != text else { return }
+        uiView.text = text
     }
 }