From aa533c12ca3ceddda5e2cedf5567fdd058ba993c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 10 May 2026 11:07:10 +0000 Subject: [PATCH] fix(macOS): show true find match count when highlights are capped Find in note stops storing highlight ranges at 2000 for performance, but Replace All still replaced every occurrence. The status line only showed the capped count, so large notes could misstate scope and risk massive unintended replacements. Track full-document total alongside navigable (highlighted) count, update the find bar when totals diverge, and add unit tests for the match scan. Co-authored-by: Danny Peck --- macOS/Synapse Notes.xcodeproj/project.pbxproj | 4 ++ macOS/SynapseNotes/AppState.swift | 6 ++ macOS/SynapseNotes/EditorView.swift | 57 ++++++++++++------- macOS/SynapseNotes/SearchView.swift | 14 ++++- .../StringSearchMatchRangesTests.swift | 24 ++++++++ 5 files changed, 81 insertions(+), 24 deletions(-) create mode 100644 macOS/SynapseNotesTests/StringSearchMatchRangesTests.swift diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index f8dc002..09ed85d 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -237,6 +237,7 @@ FBDE6F50B234E8BEEC81D70B /* GitServiceConflictsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 995602FCC2094AD94768D147 /* GitServiceConflictsTests.swift */; }; FD832B5AFBA2E311DEB0EF87 /* CommandPaletteScoringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CFDA7432187B97B46732652 /* CommandPaletteScoringTests.swift */; }; FEDF6C1421F4515A506A0F6B /* WikiLinkClickTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABD9B6E3A3ED294575CBF65 /* WikiLinkClickTests.swift */; }; + C91E22F83BC54D67E8F00201 /* StringSearchMatchRangesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82F33E94CD65E78F9F11313 /* StringSearchMatchRangesTests.swift */; }; FFB6DAEB709AE113A8039AC1 /* CommandPaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA805991148562D94A36617 /* CommandPaletteView.swift */; }; /* End PBXBuildFile section */ @@ -288,6 +289,7 @@ 2AF93A428A55CD52A1488410 /* AppThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppThemeTests.swift; sourceTree = ""; }; 3642C0B4C5DF51584C3FB7C7 /* AppStateContentChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateContentChangeTests.swift; sourceTree = ""; }; 3ABD9B6E3A3ED294575CBF65 /* WikiLinkClickTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WikiLinkClickTests.swift; sourceTree = ""; }; + D82F33E94CD65E78F9F11313 /* StringSearchMatchRangesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringSearchMatchRangesTests.swift; sourceTree = ""; }; 3B5764CC787B08BA6E1D64B4 /* MarkdownPreviewRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewRendererTests.swift; sourceTree = ""; }; 3DA805991148562D94A36617 /* CommandPaletteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteView.swift; sourceTree = ""; }; 3DB8C6CC66C5265161E6B171 /* CalendarDayActivityCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayActivityCalculatorTests.swift; sourceTree = ""; }; @@ -668,6 +670,7 @@ 2080178498715BA2E9CE7F9C /* VaultIndexTests.swift */, 46245289C81F5BC71F3DAA8D /* VaultRootResolverTests.swift */, 3ABD9B6E3A3ED294575CBF65 /* WikiLinkClickTests.swift */, + D82F33E94CD65E78F9F11313 /* StringSearchMatchRangesTests.swift */, ); path = SynapseNotesTests; sourceTree = ""; @@ -1088,6 +1091,7 @@ 5D395266B546904502376A52 /* VaultIndexTests.swift in Sources */, 2C5DA5CE689E982A146800B0 /* VaultRootResolverTests.swift in Sources */, FEDF6C1421F4515A506A0F6B /* WikiLinkClickTests.swift in Sources */, + C91E22F83BC54D67E8F00201 /* StringSearchMatchRangesTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/macOS/SynapseNotes/AppState.swift b/macOS/SynapseNotes/AppState.swift index 7c48237..d0c027d 100644 --- a/macOS/SynapseNotes/AppState.swift +++ b/macOS/SynapseNotes/AppState.swift @@ -233,7 +233,10 @@ class AppState: ObservableObject { // Current-file find state (shared so CMD-G works globally) @Published var searchQuery: String = "" @Published var searchMatchIndex: Int = 0 + /// Number of matches that have highlight ranges (capped); used for ⌘G navigation wrapping. @Published var searchMatchCount: Int = 0 + /// Full-document match count for the current query (may exceed `searchMatchCount` when over highlight cap). + @Published var searchMatchTotal: Int = 0 @Published var isReplaceVisible: Bool = false @Published var replaceText: String = "" @@ -2279,6 +2282,9 @@ class AppState: ObservableObject { isSearchPresented = false isReplaceVisible = false replaceText = "" + searchMatchTotal = 0 + searchMatchIndex = 0 + searchMatchCount = 0 } func presentRootNoteSheet(in directory: URL? = nil) { diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 7617eec..d45a630 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -873,7 +873,12 @@ struct RawEditor: NSViewRepresentable { // Access the binding directly from RawEditor self.selectedEmbedID = embedID } - textView.onMatchCountUpdate = participatesInGlobalEditorCommands ? { count in appState.searchMatchCount = count } : nil + textView.onMatchCountUpdate = participatesInGlobalEditorCommands + ? { highlighted, total in + appState.searchMatchCount = highlighted + appState.searchMatchTotal = total + } + : nil textView.participatesInGlobalSearch = participatesInGlobalEditorCommands textView.onActivatePane = isEditable ? nil : { appState.focusPane(paneIndex) } textView.refreshInlineImagePreviews() @@ -2228,7 +2233,8 @@ class LinkAwareTextView: NSTextView { var onOpenExternalURL: ((URL) -> Void)? // External URL opening (defaults to NSWorkspace) var onSelectEmbed: ((String) -> Void)? // embed ID when clicking on markdown var currentFileURL: URL? - var onMatchCountUpdate: ((Int) -> Void)? + /// `(highlightedMatchCount, totalMatchCount)` — highlights cap at 2000 ranges; total is full-document count. + var onMatchCountUpdate: ((Int, Int) -> Void)? /// Only the editor participating in global commands (current focused note) should react to /// find/replace notifications that mutate text. Mirrors `onMatchCountUpdate` gating. var participatesInGlobalSearch: Bool = false @@ -2661,15 +2667,7 @@ class LinkAwareTextView: NSTextView { return } let content = storage.string - let needle = query.lowercased() - var matches: [NSRange] = [] - var searchStart = content.startIndex - while searchStart < content.endIndex, - let range = content.range(of: needle, options: .caseInsensitive, range: searchStart.. 2000 { break } - } + let (matches, totalMatches) = content.synapseSearchMatchRanges(caseInsensitive: query, maxStored: 2000) let dimHighlight = NSColor.yellow.withAlphaComponent(0.30) let focusHighlight = NSColor.yellow @@ -2694,8 +2692,8 @@ class LinkAwareTextView: NSTextView { lastSearchHighlightRanges = matches lastSearchFocusIndex = focusIndex - // Report match count back to SwiftUI - onMatchCountUpdate?(matches.count) + // Report navigable (highlighted) count and true total so Replace All scope is never understated. + onMatchCountUpdate?(matches.count, totalMatches) // Scroll focused match into view (don't select — selection rendering overwrites highlight attributes) if matches.indices.contains(focusIndex) { @@ -2714,6 +2712,7 @@ class LinkAwareTextView: NSTextView { storage.endEditing() lastSearchHighlightRanges = [] lastSearchFocusIndex = -1 + onMatchCountUpdate?(0, 0) applyMarkdownStyling() } @@ -2773,14 +2772,7 @@ class LinkAwareTextView: NSTextView { private func nextMatchIndex(forQuery query: String, after location: Int) -> Int { guard let storage = textStorage else { return 0 } let content = storage.string - var matches: [NSRange] = [] - var searchStart = content.startIndex - while searchStart < content.endIndex, - let r = content.range(of: query, options: .caseInsensitive, range: searchStart.. 2000 { break } - } + let (matches, _) = content.synapseSearchMatchRanges(caseInsensitive: query, maxStored: 2000) if matches.isEmpty { return 0 } if let idx = matches.firstIndex(where: { $0.location >= location }) { return idx @@ -5542,3 +5534,26 @@ struct MarkdownPreviewView: NSViewRepresentable { """ } } + +// MARK: - Find in note: match scan (capped highlight list + true total) + +extension String { + /// Case-insensitive search: returns at most `maxStored` UTF-16 ranges for highlighting and + /// the full number of matches (used so Replace All cannot mislead when total exceeds the cap). + func synapseSearchMatchRanges(caseInsensitive query: String, maxStored: Int) -> (ranges: [NSRange], total: Int) { + guard !query.isEmpty else { return ([], 0) } + let needle = query.lowercased() + var matches: [NSRange] = [] + var totalMatches = 0 + var searchStart = startIndex + while searchStart < endIndex, + let range = range(of: needle, options: .caseInsensitive, range: searchStart.. appState.searchMatchCount { + return "\(appState.searchMatchIndex + 1) / \(appState.searchMatchCount) shown (\(appState.searchMatchTotal) total)" + } + return "\(appState.searchMatchIndex + 1) / \(appState.searchMatchCount)" + } + // MARK: Subviews private var findRow: some View { @@ -130,12 +139,11 @@ struct FileSearchResult: Identifiable { .onSubmit { advance(by: 1) } if !appState.searchQuery.isEmpty { - Text(appState.searchMatchCount == 0 - ? "No matches" - : "\(appState.searchMatchIndex + 1) / \(appState.searchMatchCount)") + Text(matchCountLabel) .font(.system(size: 11, weight: .medium, design: .rounded)) .foregroundStyle(SynapseTheme.textMuted) .animation(.none, value: appState.searchMatchIndex) + .animation(.none, value: appState.searchMatchTotal) .fixedSize() HStack(spacing: 2) { diff --git a/macOS/SynapseNotesTests/StringSearchMatchRangesTests.swift b/macOS/SynapseNotesTests/StringSearchMatchRangesTests.swift new file mode 100644 index 0000000..f37acb9 --- /dev/null +++ b/macOS/SynapseNotesTests/StringSearchMatchRangesTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import Synapse + +final class StringSearchMatchRangesTests: XCTestCase { + + func test_totalExceedsCap_countsAllStoresOnlyCap() { + let s = String(repeating: "a", count: 2500) + let (ranges, total) = s.synapseSearchMatchRanges(caseInsensitive: "a", maxStored: 2000) + XCTAssertEqual(total, 2500) + XCTAssertEqual(ranges.count, 2000) + } + + func test_emptyQuery_returnsEmpty() { + let (ranges, total) = "hello".synapseSearchMatchRanges(caseInsensitive: "", maxStored: 2000) + XCTAssertEqual(total, 0) + XCTAssertTrue(ranges.isEmpty) + } + + func test_caseInsensitive_matchesLiteral() { + let (ranges, total) = "AaA".synapseSearchMatchRanges(caseInsensitive: "a", maxStored: 10) + XCTAssertEqual(total, 3) + XCTAssertEqual(ranges.count, 3) + } +}