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
2import UIKit 2import UIKit
3 3
4struct BuildTaskLogView: View { 4struct BuildTaskLogView: View {
5 let task: BuildTask 5 let taskName: String
6 let viewModel: BuildDetailViewModel 6 let viewModel: BuildDetailViewModel
7 7
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
8 var body: some View { 13 var body: some View {
9 Group { 14 Group {
10 if viewModel.loadingTaskLogs.contains(task.logCacheKey) { 15 if let task {
11 SRHTLoadingStateView(message: "Loading log…") 16 if let logText = viewModel.displayedLogText(for: task) {
12 } else if let logText = viewModel.taskLogs[task.logCacheKey] { 17 BuildLogTextView(text: logText)
13 GeometryReader { geometry in 18 .safeAreaInset(edge: .bottom) {
14 ScrollView([.vertical, .horizontal]) { 19 if viewModel.isShowingBuildLogFallback(for: task) {
15 Text(logText) 20 Text("Showing the live build log until a task-specific log is available.")
16 .font(.caption2.monospaced()) 21 .font(.footnote)
17 .textSelection(.enabled) 22 .foregroundStyle(.secondary)
18 .padding() 23 .frame(maxWidth: .infinity, alignment: .leading)
19 .frame( 24 .padding(.horizontal)
20 minWidth: geometry.size.width, 25 .padding(.vertical, 8)
21 minHeight: geometry.size.height, 26 .background(.thinMaterial)
22 alignment: .topLeading 27 }
23 )
24 } 28 }
25 } 29 .toolbar {
26 .toolbar { 30 ToolbarItem(placement: .topBarTrailing) {
27 ToolbarItem(placement: .topBarTrailing) { 31 ShareLink(item: logText)
28 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 )
29 } 57 }
30 ToolbarItem(placement: .topBarTrailing) { 58 } else if viewModel.failedTaskLogs.contains(task.logCacheKey) {
31 Button { 59 ContentUnavailableView {
32 UIPasteboard.general.string = logText 60 Label("Couldn't Load Log", systemImage: "exclamationmark.triangle")
33 } label: { 61 } description: {
34 Image(systemName: "doc.on.doc") 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 }
35 } 68 }
36 .accessibilityLabel("Copy log to clipboard")
37 } 69 }
70 } else {
71 SRHTLoadingStateView(message: "Loading log…")
38 } 72 }
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 )
45 } else { 73 } else {
46 SRHTLoadingStateView(message: "Loading log…") 74 SRHTLoadingStateView(message: "Loading log…")
47 } 75 }
48 } 76 }
49 .navigationTitle(task.name) 77 .navigationTitle(taskName)
50 .navigationBarTitleDisplayMode(.inline) 78 .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 }
52 await viewModel.loadTaskLog(task: task) 82 await viewModel.loadTaskLog(task: task)
53 } 83 }
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
54 } 116 }
55} 117}