summaryrefslogtreecommitdiff
path: root/Hutch/Views/Builds/BuildTaskLogView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-23 15:17:49 -0500
committerChristian Cleberg <[email protected]>2026-03-23 15:17:49 -0500
commit7fd0b33e4c6d39a40947e83d6a1c55a4d26f9476 (patch)
treed6b7d674da3217dfed4434f9352197c60cf8564a /Hutch/Views/Builds/BuildTaskLogView.swift
parentcf87c44f674e020c32fea70903f02c317b57da3b (diff)
downloadhutch-7fd0b33e4c6d39a40947e83d6a1c55a4d26f9476.tar.gz
hutch-7fd0b33e4c6d39a40947e83d6a1c55a4d26f9476.tar.bz2
hutch-7fd0b33e4c6d39a40947e83d6a1c55a4d26f9476.zip
Present build logs in a live-updating sheet
Diffstat (limited to 'Hutch/Views/Builds/BuildTaskLogView.swift')
-rw-r--r--Hutch/Views/Builds/BuildTaskLogView.swift128
1 files 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
}
}