Commit 7a5b45101f

7a5b45101f9b21bb93ba774a43e2aa735230d584

parent: fdb2fc5d0f

Unsigned

cmc <hello@cleberg.net> · 2026-03-23 20:17 UTC

Present build logs in a live-updating sheet

Layout: unified · split

Hutch/Views/Builds/BuildTaskLogView.swift +95 −33
@@ -2,54 +2,116 @@ import SwiftUI
22import UIKit
33
44struct BuildTaskLogView: View {
5 let task: BuildTask
5 let taskName: String
66 let viewModel: BuildDetailViewModel
77
8 /// Always reflects the latest version of the task from the live job data.
9 private var task: BuildTask? {
10 viewModel.job?.tasks.first(where: { $0.name == taskName })
11 }
12
813 var body: some View {
914 Group {
10 if viewModel.loadingTaskLogs.contains(task.logCacheKey) {
11 SRHTLoadingStateView(message: "Loading log…")
12 } else if let logText = viewModel.taskLogs[task.logCacheKey] {
13 GeometryReader { geometry in
14 ScrollView([.vertical, .horizontal]) {
15 Text(logText)
16 .font(.caption2.monospaced())
17 .textSelection(.enabled)
18 .padding()
19 .frame(
20 minWidth: geometry.size.width,
21 minHeight: geometry.size.height,
22 alignment: .topLeading
23 )
15 if let task {
16 if let logText = viewModel.displayedLogText(for: task) {
17 BuildLogTextView(text: logText)
18 .safeAreaInset(edge: .bottom) {
19 if viewModel.isShowingBuildLogFallback(for: task) {
20 Text("Showing the live build log until a task-specific log is available.")
21 .font(.footnote)
22 .foregroundStyle(.secondary)
23 .frame(maxWidth: .infinity, alignment: .leading)
24 .padding(.horizontal)
25 .padding(.vertical, 8)
26 .background(.thinMaterial)
27 }
2428 }
25 }
26 .toolbar {
27 ToolbarItem(placement: .topBarTrailing) {
28 ShareLink(item: logText)
29 .toolbar {
30 ToolbarItem(placement: .topBarTrailing) {
31 ShareLink(item: logText)
32 }
33 ToolbarItem(placement: .topBarTrailing) {
34 Button {
35 UIPasteboard.general.string = logText
36 } label: {
37 Image(systemName: "doc.on.doc")
38 }
39 .accessibilityLabel("Copy log to clipboard")
40 }
41 }
42 } else if viewModel.loadingTaskLogs.contains(task.logCacheKey) {
43 SRHTLoadingStateView(message: "Loading log…")
44 } else if task.log == nil {
45 if task.status == .running || task.status == .pending {
46 if viewModel.isLoadingBuildLog {
47 SRHTLoadingStateView(message: "Loading build log…")
48 } else {
49 SRHTLoadingStateView(message: "Waiting for log…")
50 }
51 } else {
52 ContentUnavailableView(
53 "No Log",
54 systemImage: "doc.text",
55 description: Text("This task has no log output.")
56 )
2957 }
30 ToolbarItem(placement: .topBarTrailing) {
31 Button {
32 UIPasteboard.general.string = logText
33 } label: {
34 Image(systemName: "doc.on.doc")
58 } else if viewModel.failedTaskLogs.contains(task.logCacheKey) {
59 ContentUnavailableView {
60 Label("Couldn't Load Log", systemImage: "exclamationmark.triangle")
61 } description: {
62 Text("The log is temporarily unavailable. Retry to fetch the latest output.")
63 } actions: {
64 Button("Retry") {
65 Task {
66 await viewModel.retryTaskLog(task: task)
67 }
3568 }
36 .accessibilityLabel("Copy log to clipboard")
3769 }
70 } else {
71 SRHTLoadingStateView(message: "Loading log…")
3872 }
39 } else if task.log == nil {
40 ContentUnavailableView(
41 "No Log",
42 systemImage: "doc.text",
43 description: Text("This task has no log output.")
44 )
4573 } else {
4674 SRHTLoadingStateView(message: "Loading log…")
4775 }
4876 }
49 .navigationTitle(task.name)
77 .navigationTitle(taskName)
5078 .navigationBarTitleDisplayMode(.inline)
51 .task {
79 // Re-runs whenever the log URL changes or a retry is requested.
80 .task(id: viewModel.taskLogTrigger(for: task)) {
81 guard let task else { return }
5282 await viewModel.loadTaskLog(task: task)
5383 }
84 .task(id: viewModel.job?.log?.fullURL) {
85 guard let task else { return }
86 guard task.status == .running || task.status == .pending else { return }
87 await viewModel.loadBuildLog()
88 }
89 }
90}
91
92private struct BuildLogTextView: UIViewRepresentable {
93 let text: String
94
95 func makeUIView(context: Context) -> UITextView {
96 let textView = UITextView()
97 textView.isEditable = false
98 textView.isSelectable = true
99 textView.isScrollEnabled = true
100 textView.alwaysBounceVertical = true
101 textView.alwaysBounceHorizontal = true
102 textView.showsHorizontalScrollIndicator = true
103 textView.showsVerticalScrollIndicator = true
104 textView.backgroundColor = .clear
105 textView.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
106 textView.textContainer.lineFragmentPadding = 0
107 textView.textContainer.widthTracksTextView = false
108 textView.font = UIFont.monospacedSystemFont(ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, weight: .regular)
109 textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
110 return textView
111 }
112
113 func updateUIView(_ uiView: UITextView, context: Context) {
114 guard uiView.text != text else { return }
115 uiView.text = text
54116 }
55117}