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}