krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: 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 .accessibilityLabel("Previous match")
168
169 Button {
170 moveSelection(step: 1)
171 } label: {
172 Image(systemName: "chevron.down")
173 }
174 .disabled(matches.isEmpty)
175 .accessibilityLabel("Next match")
176 }
177 .buttonStyle(.borderless)
178 .labelStyle(.iconOnly)
179 }
180
181 HStack {
182 if searchQuery.isEmpty {
183 Text("\(anchors.count) error anchors")
184 } else if matches.isEmpty {
185 Text("No matches")
186 } else if let selectedMatchIndex {
187 Text("\(selectedMatchIndex + 1) of \(matches.count) matches")
188 }
189
190 Spacer()
191 }
192 .font(.footnote)
193 .foregroundStyle(.secondary)
194 }
195 .padding(.horizontal)
196 .padding(.top, 12)
197 .padding(.bottom, 10)
198 .background(.bar)
199 }
200
201 private var anchorBar: some View {
202 ScrollView(.horizontal, showsIndicators: false) {
203 HStack(spacing: 8) {
204 ForEach(anchors) { anchor in
205 Button {
206 requestScroll(to: anchor.range)
207 } label: {
208 VStack(alignment: .leading, spacing: 2) {
209 Text("Line \(anchor.lineNumber)")
210 .font(.caption.weight(.semibold))
211 Text(anchor.label)
212 .font(.caption2)
213 .lineLimit(1)
214 }
215 .padding(.horizontal, 10)
216 .padding(.vertical, 8)
217 .frame(maxWidth: 220, alignment: .leading)
218 .background {
219 RoundedRectangle(cornerRadius: 10, style: .continuous)
220 .fill(Color.red.opacity(0.12))
221 }
222 }
223 .buttonStyle(.plain)
224 }
225 }
226 .padding(.horizontal)
227 .padding(.bottom, 10)
228 }
229 .background(.bar)
230 }
231
232 private func refreshAnchors() async {
233 let text = text
234 let newAnchors = await Task.detached(priority: .userInitiated) {
235 detectLogAnchors(in: text)
236 }.value
237
238 guard !Task.isCancelled else { return }
239 anchors = newAnchors
240 }
241
242 private func refreshSearch() async {
243 let query = searchQuery
244 let text = text
245
246 if !query.isEmpty {
247 try? await Task.sleep(for: .milliseconds(150))
248 }
249 guard !Task.isCancelled else { return }
250
251 let newMatches = await Task.detached(priority: .userInitiated) {
252 logMatchRanges(in: text, query: query)
253 }.value
254
255 guard !Task.isCancelled else { return }
256
257 let previousSelectedRange = selectedMatch
258 matches = newMatches
259
260 if newMatches.isEmpty {
261 selectedMatchIndex = nil
262 return
263 }
264
265 if let previousSelectedRange,
266 let newIndex = newMatches.firstIndex(of: previousSelectedRange) {
267 selectedMatchIndex = newIndex
268 return
269 }
270
271 selectedMatchIndex = 0
272 requestScroll(to: newMatches[0])
273 }
274
275 private func moveSelection(step: Int) {
276 guard !matches.isEmpty else { return }
277 let currentIndex = selectedMatchIndex ?? 0
278 let nextIndex = (currentIndex + step + matches.count) % matches.count
279 selectedMatchIndex = nextIndex
280 requestScroll(to: matches[nextIndex])
281 }
282
283 private func requestScroll(to range: LogTextRange) {
284 let nextID = (scrollTarget?.id ?? 0) + 1
285 scrollTarget = LogScrollTarget(id: nextID, range: range)
286 }
287}
288
289private struct BuildLogTextView: UIViewRepresentable {
290 let text: String
291 let highlights: [LogTextRange]
292 let selectedHighlight: LogTextRange?
293 let scrollTarget: LogScrollTarget?
294
295 func makeCoordinator() -> Coordinator {
296 Coordinator()
297 }
298
299 func makeUIView(context _: Context) -> UITextView {
300 let textView = UITextView()
301 textView.isEditable = false
302 textView.isSelectable = true
303 textView.isScrollEnabled = true
304 textView.alwaysBounceVertical = true
305 textView.alwaysBounceHorizontal = true
306 textView.showsHorizontalScrollIndicator = true
307 textView.showsVerticalScrollIndicator = true
308 textView.backgroundColor = .clear
309 textView.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
310 textView.textContainer.lineFragmentPadding = 0
311 textView.textContainer.widthTracksTextView = false
312 textView.font = UIFont.monospacedSystemFont(
313 ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize,
314 weight: .regular
315 )
316 textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
317 return textView
318 }
319
320 func updateUIView(_ uiView: UITextView, context: Context) {
321 let coordinator = context.coordinator
322
323 if coordinator.lastText != text {
324 uiView.attributedText = NSAttributedString(string: text, attributes: baseAttributes(for: uiView))
325 coordinator.lastText = text
326 coordinator.lastHighlights = []
327 coordinator.lastSelectedHighlight = nil
328 }
329
330 if coordinator.lastHighlights != highlights || coordinator.lastSelectedHighlight != selectedHighlight {
331 applyHighlights(to: uiView)
332 coordinator.lastHighlights = highlights
333 coordinator.lastSelectedHighlight = selectedHighlight
334 }
335
336 if coordinator.lastScrollTarget != scrollTarget, let scrollTarget {
337 scroll(to: scrollTarget.range.nsRange, in: uiView)
338 coordinator.lastScrollTarget = scrollTarget
339 }
340 }
341
342 private func applyHighlights(to textView: UITextView) {
343 let textStorage = textView.textStorage
344 let fullRange = NSRange(location: 0, length: textStorage.length)
345
346 textStorage.beginEditing()
347 textStorage.removeAttribute(.backgroundColor, range: fullRange)
348 textStorage.removeAttribute(.foregroundColor, range: fullRange)
349 textStorage.addAttributes(baseAttributes(for: textView), range: fullRange)
350
351 for range in highlights {
352 textStorage.addAttribute(
353 .backgroundColor,
354 value: UIColor.systemYellow.withAlphaComponent(0.28),
355 range: range.nsRange
356 )
357 }
358
359 if let selectedHighlight {
360 textStorage.addAttributes([
361 .backgroundColor: UIColor.systemOrange.withAlphaComponent(0.5),
362 .foregroundColor: UIColor.label
363 ], range: selectedHighlight.nsRange)
364 }
365 textStorage.endEditing()
366 }
367
368 private func scroll(to range: NSRange, in textView: UITextView) {
369 guard range.location != NSNotFound else { return }
370 textView.selectedRange = range
371 textView.scrollRangeToVisible(range)
372 }
373
374 private func baseAttributes(for textView: UITextView) -> [NSAttributedString.Key: Any] {
375 [
376 .font: textView.font as Any,
377 .foregroundColor: UIColor.label
378 ]
379 }
380
381 final class Coordinator {
382 var lastText = ""
383 var lastHighlights: [LogTextRange] = []
384 var lastSelectedHighlight: LogTextRange?
385 var lastScrollTarget: LogScrollTarget?
386 }
387}
388
389struct LogTextRange: Hashable, Equatable, Sendable {
390 let location: Int
391 let length: Int
392
393 var nsRange: NSRange {
394 NSRange(location: location, length: length)
395 }
396}
397
398struct LogAnchor: Identifiable, Equatable, Sendable {
399 let lineNumber: Int
400 let label: String
401 let range: LogTextRange
402
403 var id: String {
404 "\(lineNumber):\(range.location)"
405 }
406}
407
408private struct LogScrollTarget: Equatable {
409 let id: Int
410 let range: LogTextRange
411}
412
413nonisolated func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] {
414 let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
415 guard !trimmedQuery.isEmpty else { return [] }
416
417 let nsText = text as NSString
418 var searchRange = NSRange(location: 0, length: nsText.length)
419 var matches: [LogTextRange] = []
420
421 while searchRange.length > 0, matches.count < limit {
422 let foundRange = nsText.range(
423 of: trimmedQuery,
424 options: [.caseInsensitive, .diacriticInsensitive],
425 range: searchRange
426 )
427
428 guard foundRange.location != NSNotFound else { break }
429 matches.append(LogTextRange(location: foundRange.location, length: foundRange.length))
430
431 let nextLocation = foundRange.location + max(foundRange.length, 1)
432 guard nextLocation <= nsText.length else { break }
433 searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation)
434 }
435
436 return matches
437}
438
439nonisolated func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] {
440 let nsText = text as NSString
441 let strongMarkers = [
442 "fatal error",
443 "fatal:",
444 "error:",
445 "exception:",
446 "uncaught exception",
447 "traceback",
448 "panic:",
449 "undefined reference",
450 "segmentation fault",
451 "assertion failed",
452 "failed:"
453 ]
454
455 var anchors: [LogAnchor] = []
456 var lineNumber = 1
457 var cursor = 0
458 var lastAnchorLine: Int?
459
460 while cursor < nsText.length, anchors.count < limit {
461 let lineRange = nsText.lineRange(for: NSRange(location: cursor, length: 0))
462 let rawLine = nsText.substring(with: lineRange)
463 let trimmedLine = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
464 let foldedLine = trimmedLine.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
465
466 let isMatch = !trimmedLine.isEmpty && strongMarkers.contains { foldedLine.contains($0) }
467 let isDistinctFromPrevious = lastAnchorLine.map { lineNumber - $0 > 1 } ?? true
468
469 if isMatch, isDistinctFromPrevious {
470 anchors.append(
471 LogAnchor(
472 lineNumber: lineNumber,
473 label: String(trimmedLine.prefix(100)),
474 range: LogTextRange(location: lineRange.location, length: lineRange.length)
475 )
476 )
477 lastAnchorLine = lineNumber
478 }
479
480 cursor = lineRange.upperBound
481 lineNumber += 1
482 }
483
484 return anchors
485}