krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.6: Hutch/Views/Builds/BuildTaskLogView.swift · raw
1import SwiftUI
2
3struct BuildTaskLogView: View {
4 @Environment(AppState.self) private var appState
5
6 let taskName: String
7 let viewModel: BuildDetailViewModel
8
9 /// Always reflects the latest version of the task from the live job data.
10 private var task: BuildTask? {
11 viewModel.job?.tasks.first(where: { $0.name == taskName })
12 }
13
14 var body: some View {
15 Group {
16 if let task {
17 if let logText = viewModel.displayedLogText(for: task) {
18 BuildTaskLogContentView(text: logText)
19 .safeAreaInset(edge: .bottom) {
20 if viewModel.isShowingBuildLogFallback(for: task) {
21 Text("Showing the live build log until a task-specific log is available.")
22 .font(.footnote)
23 .foregroundStyle(.secondary)
24 .frame(maxWidth: .infinity, alignment: .leading)
25 .padding(.horizontal)
26 .padding(.vertical, 8)
27 .background(.thinMaterial)
28 }
29 }
30 .toolbar {
31 ToolbarItem(placement: .topBarTrailing) {
32 ShareLink(item: logText)
33 }
34 ToolbarItem(placement: .topBarTrailing) {
35 Button {
36 appState.copyToPasteboard(logText, label: "build log")
37 } label: {
38 Image(systemName: "doc.on.doc")
39 }
40 .accessibilityLabel("Copy log to clipboard")
41 }
42 }
43 } else if viewModel.loadingTaskLogs.contains(task.logCacheKey) {
44 SRHTLoadingStateView(message: "Loading log…")
45 } else if task.log == nil {
46 if task.status == .running || task.status == .pending {
47 if viewModel.isLoadingBuildLog {
48 SRHTLoadingStateView(message: "Loading build log…")
49 } else {
50 SRHTLoadingStateView(message: "Waiting for log…")
51 }
52 } else {
53 ContentUnavailableView(
54 "No Log",
55 systemImage: "doc.text",
56 description: Text("This task has no log output.")
57 )
58 }
59 } else if viewModel.failedTaskLogs.contains(task.logCacheKey) {
60 ContentUnavailableView {
61 Label("Couldn't Load Log", systemImage: "exclamationmark.triangle")
62 } description: {
63 Text("The log is temporarily unavailable. Retry to fetch the latest output.")
64 } actions: {
65 Button("Retry") {
66 Task {
67 await viewModel.retryTaskLog(task: task)
68 }
69 }
70 }
71 } else {
72 SRHTLoadingStateView(message: "Loading log…")
73 }
74 } else {
75 SRHTLoadingStateView(message: "Loading log…")
76 }
77 }
78 .navigationTitle(taskName)
79 .navigationBarTitleDisplayMode(.inline)
80 // Re-runs whenever the log URL changes or a retry is requested.
81 .task(id: viewModel.taskLogTrigger(for: task)) {
82 guard let task else { return }
83 await viewModel.loadTaskLog(task: task)
84 }
85 .task(id: viewModel.job?.log?.fullURL) {
86 guard let task else { return }
87 guard task.status == .running || task.status == .pending else { return }
88 await viewModel.loadBuildLog()
89 }
90 }
91}
92
93private struct BuildTaskLogContentView: View {
94 let text: String
95
96 @State private var searchQuery = ""
97 @State private var matches: [LogTextRange] = []
98 @State private var anchors: [LogAnchor] = []
99 @State private var selectedMatchIndex: Int?
100 @State private var scrollTarget: LogScrollTarget?
101
102 private var selectedMatch: LogTextRange? {
103 guard let selectedMatchIndex, matches.indices.contains(selectedMatchIndex) else { return nil }
104 return matches[selectedMatchIndex]
105 }
106
107 var body: some View {
108 VStack(spacing: 0) {
109 searchControls
110
111 if !anchors.isEmpty {
112 anchorBar
113 }
114
115 BuildLogTextView(
116 text: text,
117 highlights: matches,
118 selectedHighlight: selectedMatch,
119 scrollTarget: scrollTarget
120 )
121 }
122 .task(id: text) {
123 await refreshAnchors()
124 await refreshSearch()
125 }
126 .task(id: searchQuery) {
127 await refreshSearch()
128 }
129 }
130
131 private var searchControls: some View {
132 VStack(spacing: 10) {
133 HStack(spacing: 10) {
134 HStack(spacing: 8) {
135 Image(systemName: "magnifyingglass")
136 .foregroundStyle(.secondary)
137
138 TextField("Search log", text: $searchQuery)
139 .textInputAutocapitalization(.never)
140 .autocorrectionDisabled()
141
142 if !searchQuery.isEmpty {
143 Button {
144 searchQuery = ""
145 } label: {
146 Image(systemName: "xmark.circle.fill")
147 .foregroundStyle(.secondary)
148 }
149 .buttonStyle(.plain)
150 .accessibilityLabel("Clear search")
151 }
152 }
153 .padding(.horizontal, 10)
154 .padding(.vertical, 8)
155 .background {
156 RoundedRectangle(cornerRadius: 12, style: .continuous)
157 .fill(.thinMaterial)
158 }
159
160 HStack(spacing: 4) {
161 Button {
162 moveSelection(step: -1)
163 } label: {
164 Image(systemName: "chevron.up")
165 }
166 .disabled(matches.isEmpty)
167
168 Button {
169 moveSelection(step: 1)
170 } label: {
171 Image(systemName: "chevron.down")
172 }
173 .disabled(matches.isEmpty)
174 }
175 .buttonStyle(.borderless)
176 .labelStyle(.iconOnly)
177 }
178
179 HStack {
180 if searchQuery.isEmpty {
181 Text("\(anchors.count) error anchors")
182 } else if matches.isEmpty {
183 Text("No matches")
184 } else if let selectedMatchIndex {
185 Text("\(selectedMatchIndex + 1) of \(matches.count) matches")
186 }
187
188 Spacer()
189 }
190 .font(.footnote)
191 .foregroundStyle(.secondary)
192 }
193 .padding(.horizontal)
194 .padding(.top, 12)
195 .padding(.bottom, 10)
196 .background(.bar)
197 }
198
199 private var anchorBar: some View {
200 ScrollView(.horizontal, showsIndicators: false) {
201 HStack(spacing: 8) {
202 ForEach(anchors) { anchor in
203 Button {
204 requestScroll(to: anchor.range)
205 } label: {
206 VStack(alignment: .leading, spacing: 2) {
207 Text("Line \(anchor.lineNumber)")
208 .font(.caption.weight(.semibold))
209 Text(anchor.label)
210 .font(.caption2)
211 .lineLimit(1)
212 }
213 .padding(.horizontal, 10)
214 .padding(.vertical, 8)
215 .frame(maxWidth: 220, alignment: .leading)
216 .background {
217 RoundedRectangle(cornerRadius: 10, style: .continuous)
218 .fill(Color.red.opacity(0.12))
219 }
220 }
221 .buttonStyle(.plain)
222 }
223 }
224 .padding(.horizontal)
225 .padding(.bottom, 10)
226 }
227 .background(.bar)
228 }
229
230 private func refreshAnchors() async {
231 let text = text
232 let newAnchors = await Task.detached(priority: .userInitiated) {
233 detectLogAnchors(in: text)
234 }.value
235
236 guard !Task.isCancelled else { return }
237 anchors = newAnchors
238 }
239
240 private func refreshSearch() async {
241 let query = searchQuery
242 let text = text
243
244 if !query.isEmpty {
245 try? await Task.sleep(for: .milliseconds(150))
246 }
247 guard !Task.isCancelled else { return }
248
249 let newMatches = await Task.detached(priority: .userInitiated) {
250 logMatchRanges(in: text, query: query)
251 }.value
252
253 guard !Task.isCancelled else { return }
254
255 let previousSelectedRange = selectedMatch
256 matches = newMatches
257
258 if newMatches.isEmpty {
259 selectedMatchIndex = nil
260 return
261 }
262
263 if let previousSelectedRange,
264 let newIndex = newMatches.firstIndex(of: previousSelectedRange) {
265 selectedMatchIndex = newIndex
266 return
267 }
268
269 selectedMatchIndex = 0
270 requestScroll(to: newMatches[0])
271 }
272
273 private func moveSelection(step: Int) {
274 guard !matches.isEmpty else { return }
275 let currentIndex = selectedMatchIndex ?? 0
276 let nextIndex = (currentIndex + step + matches.count) % matches.count
277 selectedMatchIndex = nextIndex
278 requestScroll(to: matches[nextIndex])
279 }
280
281 private func requestScroll(to range: LogTextRange) {
282 let nextID = (scrollTarget?.id ?? 0) + 1
283 scrollTarget = LogScrollTarget(id: nextID, range: range)
284 }
285}
286
287private struct BuildLogTextView: UIViewRepresentable {
288 let text: String
289 let highlights: [LogTextRange]
290 let selectedHighlight: LogTextRange?
291 let scrollTarget: LogScrollTarget?
292
293 func makeCoordinator() -> Coordinator {
294 Coordinator()
295 }
296
297 func makeUIView(context _: Context) -> UITextView {
298 let textView = UITextView()
299 textView.isEditable = false
300 textView.isSelectable = true
301 textView.isScrollEnabled = true
302 textView.alwaysBounceVertical = true
303 textView.alwaysBounceHorizontal = true
304 textView.showsHorizontalScrollIndicator = true
305 textView.showsVerticalScrollIndicator = true
306 textView.backgroundColor = .clear
307 textView.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
308 textView.textContainer.lineFragmentPadding = 0
309 textView.textContainer.widthTracksTextView = false
310 textView.font = UIFont.monospacedSystemFont(
311 ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize,
312 weight: .regular
313 )
314 textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
315 return textView
316 }
317
318 func updateUIView(_ uiView: UITextView, context: Context) {
319 let coordinator = context.coordinator
320
321 if coordinator.lastText != text {
322 uiView.attributedText = NSAttributedString(string: text, attributes: baseAttributes(for: uiView))
323 coordinator.lastText = text
324 coordinator.lastHighlights = []
325 coordinator.lastSelectedHighlight = nil
326 }
327
328 if coordinator.lastHighlights != highlights || coordinator.lastSelectedHighlight != selectedHighlight {
329 applyHighlights(to: uiView)
330 coordinator.lastHighlights = highlights
331 coordinator.lastSelectedHighlight = selectedHighlight
332 }
333
334 if coordinator.lastScrollTarget != scrollTarget, let scrollTarget {
335 scroll(to: scrollTarget.range.nsRange, in: uiView)
336 coordinator.lastScrollTarget = scrollTarget
337 }
338 }
339
340 private func applyHighlights(to textView: UITextView) {
341 let textStorage = textView.textStorage
342 let fullRange = NSRange(location: 0, length: textStorage.length)
343
344 textStorage.beginEditing()
345 textStorage.removeAttribute(.backgroundColor, range: fullRange)
346 textStorage.removeAttribute(.foregroundColor, range: fullRange)
347 textStorage.addAttributes(baseAttributes(for: textView), range: fullRange)
348
349 for range in highlights {
350 textStorage.addAttribute(
351 .backgroundColor,
352 value: UIColor.systemYellow.withAlphaComponent(0.28),
353 range: range.nsRange
354 )
355 }
356
357 if let selectedHighlight {
358 textStorage.addAttributes([
359 .backgroundColor: UIColor.systemOrange.withAlphaComponent(0.5),
360 .foregroundColor: UIColor.label
361 ], range: selectedHighlight.nsRange)
362 }
363 textStorage.endEditing()
364 }
365
366 private func scroll(to range: NSRange, in textView: UITextView) {
367 guard range.location != NSNotFound else { return }
368 textView.selectedRange = range
369 textView.scrollRangeToVisible(range)
370 }
371
372 private func baseAttributes(for textView: UITextView) -> [NSAttributedString.Key: Any] {
373 [
374 .font: textView.font as Any,
375 .foregroundColor: UIColor.label
376 ]
377 }
378
379 final class Coordinator {
380 var lastText = ""
381 var lastHighlights: [LogTextRange] = []
382 var lastSelectedHighlight: LogTextRange?
383 var lastScrollTarget: LogScrollTarget?
384 }
385}
386
387struct LogTextRange: Hashable, Equatable, Sendable {
388 let location: Int
389 let length: Int
390
391 var nsRange: NSRange {
392 NSRange(location: location, length: length)
393 }
394}
395
396struct LogAnchor: Identifiable, Equatable, Sendable {
397 let lineNumber: Int
398 let label: String
399 let range: LogTextRange
400
401 var id: String {
402 "\(lineNumber):\(range.location)"
403 }
404}
405
406private struct LogScrollTarget: Equatable {
407 let id: Int
408 let range: LogTextRange
409}
410
411nonisolated func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] {
412 let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
413 guard !trimmedQuery.isEmpty else { return [] }
414
415 let nsText = text as NSString
416 var searchRange = NSRange(location: 0, length: nsText.length)
417 var matches: [LogTextRange] = []
418
419 while searchRange.length > 0, matches.count < limit {
420 let foundRange = nsText.range(
421 of: trimmedQuery,
422 options: [.caseInsensitive, .diacriticInsensitive],
423 range: searchRange
424 )
425
426 guard foundRange.location != NSNotFound else { break }
427 matches.append(LogTextRange(location: foundRange.location, length: foundRange.length))
428
429 let nextLocation = foundRange.location + max(foundRange.length, 1)
430 guard nextLocation <= nsText.length else { break }
431 searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation)
432 }
433
434 return matches
435}
436
437nonisolated func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] {
438 let nsText = text as NSString
439 let strongMarkers = [
440 "fatal error",
441 "fatal:",
442 "error:",
443 "exception:",
444 "uncaught exception",
445 "traceback",
446 "panic:",
447 "undefined reference",
448 "segmentation fault",
449 "assertion failed",
450 "failed:"
451 ]
452
453 var anchors: [LogAnchor] = []
454 var lineNumber = 1
455 var cursor = 0
456 var lastAnchorLine: Int?
457
458 while cursor < nsText.length, anchors.count < limit {
459 let lineRange = nsText.lineRange(for: NSRange(location: cursor, length: 0))
460 let rawLine = nsText.substring(with: lineRange)
461 let trimmedLine = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
462 let foldedLine = trimmedLine.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
463
464 let isMatch = !trimmedLine.isEmpty && strongMarkers.contains { foldedLine.contains($0) }
465 let isDistinctFromPrevious = lastAnchorLine.map { lineNumber - $0 > 1 } ?? true
466
467 if isMatch, isDistinctFromPrevious {
468 anchors.append(
469 LogAnchor(
470 lineNumber: lineNumber,
471 label: String(trimmedLine.prefix(100)),
472 range: LogTextRange(location: lineRange.location, length: lineRange.length)
473 )
474 )
475 lastAnchorLine = lineNumber
476 }
477
478 cursor = lineRange.upperBound
479 lineNumber += 1
480 }
481
482 return anchors
483}