krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.7.1: Hutch/Views/Builds/BuildTaskLogView.swift · raw
1import SwiftUI
2import UIKit
3
4struct BuildTaskLogView: View {
5 let taskName: String
6 let viewModel: BuildDetailViewModel
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
13 var body: some View {
14 Group {
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 }
28 }
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 )
57 }
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 }
68 }
69 }
70 } else {
71 SRHTLoadingStateView(message: "Loading log…")
72 }
73 } else {
74 SRHTLoadingStateView(message: "Loading log…")
75 }
76 }
77 .navigationTitle(taskName)
78 .navigationBarTitleDisplayMode(.inline)
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 }
82 await viewModel.loadTaskLog(task: task)
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
116 }
117}