krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.1.3: 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}