diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 21:12:26 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 21:12:26 -0500 |
| commit | d89f2d52b623aad98df687865b263adeee7f5de9 (patch) | |
| tree | 0fa61b80a7c5553f43b65fd5326879b8e94b9462 /Hutch/Views/Builds | |
| parent | 9cd99c3e1c7e2620c34bfd9e78931e3b2b63aa8a (diff) | |
| download | hutch-d89f2d52b623aad98df687865b263adeee7f5de9.tar.gz hutch-d89f2d52b623aad98df687865b263adeee7f5de9.tar.bz2 hutch-d89f2d52b623aad98df687865b263adeee7f5de9.zip | |
feat: add build log search and jump-to-error navigation
Implements: https://todo.sr.ht/~ccleberg/hutch/29
Implements: https://todo.sr.ht/~ccleberg/hutch/30
Diffstat (limited to 'Hutch/Views/Builds')
| -rw-r--r-- | Hutch/Views/Builds/BuildTaskLogView.swift | 417 |
1 files changed, 391 insertions, 26 deletions
diff --git a/Hutch/Views/Builds/BuildTaskLogView.swift b/Hutch/Views/Builds/BuildTaskLogView.swift index d1d60a4..69353bf 100644 --- a/Hutch/Views/Builds/BuildTaskLogView.swift +++ b/Hutch/Views/Builds/BuildTaskLogView.swift @@ -14,31 +14,31 @@ struct BuildTaskLogView: View { Group { if let task { if let logText = viewModel.displayedLogText(for: task) { - BuildLogTextView(text: logText) - .safeAreaInset(edge: .bottom) { - if viewModel.isShowingBuildLogFallback(for: task) { - Text("Showing the live build log until a task-specific log is available.") - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - .padding(.vertical, 8) - .background(.thinMaterial) - } - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - ShareLink(item: logText) + BuildTaskLogContentView(text: logText) + .safeAreaInset(edge: .bottom) { + if viewModel.isShowingBuildLogFallback(for: task) { + Text("Showing the live build log until a task-specific log is available.") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.vertical, 8) + .background(.thinMaterial) + } } - ToolbarItem(placement: .topBarTrailing) { - Button { - UIPasteboard.general.string = logText - } label: { - Image(systemName: "doc.on.doc") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + ShareLink(item: logText) + } + ToolbarItem(placement: .topBarTrailing) { + Button { + UIPasteboard.general.string = logText + } label: { + Image(systemName: "doc.on.doc") + } + .accessibilityLabel("Copy log to clipboard") } - .accessibilityLabel("Copy log to clipboard") } - } } else if viewModel.loadingTaskLogs.contains(task.logCacheKey) { SRHTLoadingStateView(message: "Loading log…") } else if task.log == nil { @@ -89,8 +89,209 @@ struct BuildTaskLogView: View { } } +private struct BuildTaskLogContentView: View { + let text: String + + @State private var searchQuery = "" + @State private var matches: [LogTextRange] = [] + @State private var anchors: [LogAnchor] = [] + @State private var selectedMatchIndex: Int? + @State private var scrollTarget: LogScrollTarget? + + private var selectedMatch: LogTextRange? { + guard let selectedMatchIndex, matches.indices.contains(selectedMatchIndex) else { return nil } + return matches[selectedMatchIndex] + } + + var body: some View { + VStack(spacing: 0) { + searchControls + + if !anchors.isEmpty { + anchorBar + } + + BuildLogTextView( + text: text, + highlights: matches, + selectedHighlight: selectedMatch, + scrollTarget: scrollTarget + ) + } + .task(id: text) { + await refreshAnchors() + await refreshSearch() + } + .task(id: searchQuery) { + await refreshSearch() + } + } + + private var searchControls: some View { + VStack(spacing: 10) { + HStack(spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + + TextField("Search log", text: $searchQuery) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + if !searchQuery.isEmpty { + Button { + searchQuery = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.thinMaterial) + } + + HStack(spacing: 4) { + Button { + moveSelection(step: -1) + } label: { + Image(systemName: "chevron.up") + } + .disabled(matches.isEmpty) + + Button { + moveSelection(step: 1) + } label: { + Image(systemName: "chevron.down") + } + .disabled(matches.isEmpty) + } + .buttonStyle(.borderless) + .labelStyle(.iconOnly) + } + + HStack { + if searchQuery.isEmpty { + Text("\(anchors.count) error anchors") + } else if matches.isEmpty { + Text("No matches") + } else if let selectedMatchIndex { + Text("\(selectedMatchIndex + 1) of \(matches.count) matches") + } + + Spacer() + } + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 10) + .background(.bar) + } + + private var anchorBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(anchors) { anchor in + Button { + requestScroll(to: anchor.range) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("Line \(anchor.lineNumber)") + .font(.caption.weight(.semibold)) + Text(anchor.label) + .font(.caption2) + .lineLimit(1) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: 220, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.red.opacity(0.12)) + } + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.bottom, 10) + } + .background(.bar) + } + + private func refreshAnchors() async { + let text = text + let newAnchors = await Task.detached(priority: .userInitiated) { + detectLogAnchors(in: text) + }.value + + guard !Task.isCancelled else { return } + anchors = newAnchors + } + + private func refreshSearch() async { + let query = searchQuery + let text = text + + if !query.isEmpty { + try? await Task.sleep(for: .milliseconds(150)) + } + guard !Task.isCancelled else { return } + + let newMatches = await Task.detached(priority: .userInitiated) { + logMatchRanges(in: text, query: query) + }.value + + guard !Task.isCancelled else { return } + + let previousSelectedRange = selectedMatch + matches = newMatches + + if newMatches.isEmpty { + selectedMatchIndex = nil + return + } + + if let previousSelectedRange, + let newIndex = newMatches.firstIndex(of: previousSelectedRange) { + selectedMatchIndex = newIndex + return + } + + selectedMatchIndex = 0 + requestScroll(to: newMatches[0]) + } + + private func moveSelection(step: Int) { + guard !matches.isEmpty else { return } + let currentIndex = selectedMatchIndex ?? 0 + let nextIndex = (currentIndex + step + matches.count) % matches.count + selectedMatchIndex = nextIndex + requestScroll(to: matches[nextIndex]) + } + + private func requestScroll(to range: LogTextRange) { + let nextID = (scrollTarget?.id ?? 0) + 1 + scrollTarget = LogScrollTarget(id: nextID, range: range) + } +} + private struct BuildLogTextView: UIViewRepresentable { let text: String + let highlights: [LogTextRange] + let selectedHighlight: LogTextRange? + let scrollTarget: LogScrollTarget? + + func makeCoordinator() -> Coordinator { + Coordinator() + } func makeUIView(context _: Context) -> UITextView { let textView = UITextView() @@ -105,13 +306,177 @@ private struct BuildLogTextView: UIViewRepresentable { textView.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) textView.textContainer.lineFragmentPadding = 0 textView.textContainer.widthTracksTextView = false - textView.font = UIFont.monospacedSystemFont(ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, weight: .regular) + textView.font = UIFont.monospacedSystemFont( + ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, + weight: .regular + ) textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return textView } - func updateUIView(_ uiView: UITextView, context _: Context) { - guard uiView.text != text else { return } - uiView.text = text + func updateUIView(_ uiView: UITextView, context: Context) { + let coordinator = context.coordinator + + if coordinator.lastText != text { + uiView.attributedText = NSAttributedString(string: text, attributes: baseAttributes(for: uiView)) + coordinator.lastText = text + coordinator.lastHighlights = [] + coordinator.lastSelectedHighlight = nil + } + + if coordinator.lastHighlights != highlights || coordinator.lastSelectedHighlight != selectedHighlight { + applyHighlights(to: uiView) + coordinator.lastHighlights = highlights + coordinator.lastSelectedHighlight = selectedHighlight + } + + if coordinator.lastScrollTarget != scrollTarget, let scrollTarget { + scroll(to: scrollTarget.range.nsRange, in: uiView) + coordinator.lastScrollTarget = scrollTarget + } + } + + private func applyHighlights(to textView: UITextView) { + let textStorage = textView.textStorage + let fullRange = NSRange(location: 0, length: textStorage.length) + + textStorage.beginEditing() + textStorage.removeAttribute(.backgroundColor, range: fullRange) + textStorage.removeAttribute(.foregroundColor, range: fullRange) + textStorage.addAttributes(baseAttributes(for: textView), range: fullRange) + + for range in highlights { + textStorage.addAttribute( + .backgroundColor, + value: UIColor.systemYellow.withAlphaComponent(0.28), + range: range.nsRange + ) + } + + if let selectedHighlight { + textStorage.addAttributes([ + .backgroundColor: UIColor.systemOrange.withAlphaComponent(0.5), + .foregroundColor: UIColor.label + ], range: selectedHighlight.nsRange) + } + textStorage.endEditing() + } + + private func scroll(to range: NSRange, in textView: UITextView) { + guard range.location != NSNotFound else { return } + textView.selectedRange = range + textView.scrollRangeToVisible(range) + } + + private func baseAttributes(for textView: UITextView) -> [NSAttributedString.Key: Any] { + [ + .font: textView.font as Any, + .foregroundColor: UIColor.label + ] + } + + final class Coordinator { + var lastText = "" + var lastHighlights: [LogTextRange] = [] + var lastSelectedHighlight: LogTextRange? + var lastScrollTarget: LogScrollTarget? + } +} + +struct LogTextRange: Hashable, Equatable, Sendable { + let location: Int + let length: Int + + var nsRange: NSRange { + NSRange(location: location, length: length) } } + +struct LogAnchor: Identifiable, Equatable, Sendable { + let lineNumber: Int + let label: String + let range: LogTextRange + + var id: String { + "\(lineNumber):\(range.location)" + } +} + +private struct LogScrollTarget: Equatable { + let id: Int + let range: LogTextRange +} + +func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { return [] } + + let nsText = text as NSString + var searchRange = NSRange(location: 0, length: nsText.length) + var matches: [LogTextRange] = [] + + while searchRange.length > 0, matches.count < limit { + let foundRange = nsText.range( + of: trimmedQuery, + options: [.caseInsensitive, .diacriticInsensitive], + range: searchRange + ) + + guard foundRange.location != NSNotFound else { break } + matches.append(LogTextRange(location: foundRange.location, length: foundRange.length)) + + let nextLocation = foundRange.location + max(foundRange.length, 1) + guard nextLocation <= nsText.length else { break } + searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) + } + + return matches +} + +func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] { + let nsText = text as NSString + let strongMarkers = [ + "fatal error", + "fatal:", + "error:", + "exception:", + "uncaught exception", + "traceback", + "panic:", + "undefined reference", + "segmentation fault", + "assertion failed", + "failed:" + ] + + var anchors: [LogAnchor] = [] + var lineNumber = 1 + var cursor = 0 + var lastAnchorLine: Int? + + while cursor < nsText.length, anchors.count < limit { + let lineRange = nsText.lineRange(for: NSRange(location: cursor, length: 0)) + let rawLine = nsText.substring(with: lineRange) + let trimmedLine = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + let foldedLine = trimmedLine.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + + let isMatch = !trimmedLine.isEmpty && strongMarkers.contains { foldedLine.contains($0) } + let isDistinctFromPrevious = lastAnchorLine.map { lineNumber - $0 > 1 } ?? true + + if isMatch, isDistinctFromPrevious { + anchors.append( + LogAnchor( + lineNumber: lineNumber, + label: String(trimmedLine.prefix(100)), + range: LogTextRange(location: lineRange.location, length: lineRange.length) + ) + ) + lastAnchorLine = lineNumber + } + + cursor = lineRange.upperBound + lineNumber += 1 + } + + return anchors +} |
