krz/hutch

an ios client for sourcehut

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

d89f2d52b623aad98df687865b263adeee7f5de9

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T02:12:26Z

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
 Hutch.xcodeproj/project.pbxproj           |  16 +-
 Hutch/Views/Builds/BuildTaskLogView.swift | 417 ++++++++++++++++++++++++++++--
 Hutch/Views/Home/HomeView.swift           |   8 +
 HutchTests/BuildTaskLogSearchTests.swift  |  30 +++
 4 files changed, 437 insertions(+), 34 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index b457ff9..57441b8 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 40;
+				CURRENT_PROJECT_VERSION = 41;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.17.1;
+				MARKETING_VERSION = 2.18.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 40;
+				CURRENT_PROJECT_VERSION = 41;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.17.1;
+				MARKETING_VERSION = 2.18.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 40;
+				CURRENT_PROJECT_VERSION = 41;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.17.1;
+				MARKETING_VERSION = 2.18.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 40;
+				CURRENT_PROJECT_VERSION = 41;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.17.1;
+				MARKETING_VERSION = 2.18.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
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)
     }
 }
diff --git a/HutchTests/BuildTaskLogSearchTests.swift b/HutchTests/BuildTaskLogSearchTests.swift
new file mode 100644
index 0000000..4cb9984
--- /dev/null
+++ b/HutchTests/BuildTaskLogSearchTests.swift
@@ -0,0 +1,30 @@
+import Testing
+@testable import Hutch
+
+struct BuildTaskLogSearchTests {
+
+    @Test
+    func logMatchRangesFindsCaseInsensitiveOccurrences() {
+        let matches = logMatchRanges(in: "Error: one\nerror: two\nwarning", query: "ERROR")
+
+        #expect(matches.count == 2)
+        #expect(matches[0] == LogTextRange(location: 0, length: 5))
+        #expect(matches[1].location > matches[0].location)
+    }
+
+    @Test
+    func detectLogAnchorsFindsImportantFailureLines() {
+        let anchors = detectLogAnchors(in: """
+        compile step
+        warning: this is fine
+        error: missing header
+        traceback (most recent call last):
+        panic: build failed
+        """)
+
+        #expect(anchors.count == 2)
+        #expect(anchors[0].lineNumber == 3)
+        #expect(anchors[0].label.contains("missing header"))
+        #expect(anchors[1].lineNumber == 5)
+    }
+}