summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 21:12:26 -0500
committerChristian Cleberg <[email protected]>2026-04-12 21:12:26 -0500
commitd89f2d52b623aad98df687865b263adeee7f5de9 (patch)
tree0fa61b80a7c5553f43b65fd5326879b8e94b9462 /Hutch/Views
parent9cd99c3e1c7e2620c34bfd9e78931e3b2b63aa8a (diff)
downloadhutch-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')
-rw-r--r--Hutch/Views/Builds/BuildTaskLogView.swift417
-rw-r--r--Hutch/Views/Home/HomeView.swift8
2 files changed, 399 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
+}
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index da58587..42a6737 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -795,7 +795,15 @@ private struct HomeAttentionLinkRow<Destination: View>: View {
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color(.secondarySystemFill), in: Capsule())
+
+ if action != nil {
+ Image(systemName: "chevron.right")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.tertiary)
+ }
}
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
.padding(.vertical, 2)
}
}