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) + } +}