From d2335c5ef848ef44ebc4426aa659241db01fa055 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 6 May 2026 11:04:50 +0000 Subject: [PATCH] fix(macOS): validate cached find ranges before Replace Highlight ranges can lag the document while the find bar stays open because markdown restyle skips stale ranges in reapplySearchHighlights without removing them from lastSearchHighlightRanges. Replace then called replaceCharacters with an invalid range or wrong substring. Co-authored-by: Danny Peck --- macOS/Synapse Notes.xcodeproj/project.pbxproj | 4 +++ macOS/SynapseNotes/EditorView.swift | 26 +++++++++++++- .../SearchReplaceStaleHighlightTests.swift | 36 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 macOS/SynapseNotesTests/SearchReplaceStaleHighlightTests.swift diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index f8dc002..c8a5eac 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 */; }; + 4E8F901A2B3C4D5E6F708192 /* SearchReplaceStaleHighlightTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D7E8F901A2B4C5D6E7F8091 /* SearchReplaceStaleHighlightTests.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 = ""; }; + 3D7E8F901A2B4C5D6E7F8091 /* SearchReplaceStaleHighlightTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchReplaceStaleHighlightTests.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 */, + 3D7E8F901A2B4C5D6E7F8091 /* SearchReplaceStaleHighlightTests.swift */, ); path = SynapseNotesTests; sourceTree = ""; @@ -1088,6 +1091,7 @@ 5D395266B546904502376A52 /* VaultIndexTests.swift in Sources */, 2C5DA5CE689E982A146800B0 /* VaultRootResolverTests.swift in Sources */, FEDF6C1421F4515A506A0F6B /* WikiLinkClickTests.swift in Sources */, + 4E8F901A2B3C4D5E6F708192 /* SearchReplaceStaleHighlightTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 7617eec..7e89502 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -2717,10 +2717,34 @@ class LinkAwareTextView: NSTextView { applyMarkdownStyling() } + /// Used by replace-current and tests. Cached highlight ranges can lag the live document when the + /// user edits while the find bar stays open (`reapplySearchHighlights` skips invalid ranges but + /// does not remove them from `lastSearchHighlightRanges`). + internal static func cachedHighlightRangeStillMatchesQuery( + range: NSRange, + fullString: String, + query: String + ) -> Bool { + let ns = fullString as NSString + let storageLength = ns.length + guard NSMaxRange(range) <= storageLength else { return false } + let fragment = ns.substring(with: range) + return fragment.compare(query, options: .caseInsensitive) == .orderedSame + } + private func replaceCurrentMatch(query: String, focusIndex: Int, replacement: String, advanceAfter: Bool) { guard !query.isEmpty, - lastSearchHighlightRanges.indices.contains(focusIndex) else { return } + lastSearchHighlightRanges.indices.contains(focusIndex), + let storage = textStorage else { return } let range = lastSearchHighlightRanges[focusIndex] + guard Self.cachedHighlightRangeStillMatchesQuery( + range: range, + fullString: storage.string, + query: query + ) else { + applySearchHighlights(query: query, focusIndex: focusIndex) + return + } guard shouldChangeText(in: range, replacementString: replacement) else { return } textStorage?.replaceCharacters(in: range, with: replacement) didChangeText() diff --git a/macOS/SynapseNotesTests/SearchReplaceStaleHighlightTests.swift b/macOS/SynapseNotesTests/SearchReplaceStaleHighlightTests.swift new file mode 100644 index 0000000..643d1da --- /dev/null +++ b/macOS/SynapseNotesTests/SearchReplaceStaleHighlightTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import Synapse + +/// Guards for inline find/replace: cached highlight ranges must not drive replace after the buffer changed. +final class SearchReplaceStaleHighlightTests: XCTestCase { + + func test_cachedHighlightRangeStillMatchesQuery_validMatch() { + XCTAssertTrue( + LinkAwareTextView.cachedHighlightRangeStillMatchesQuery( + range: NSRange(location: 4, length: 3), + fullString: "foo bar baz", + query: "BAR" + ) + ) + } + + func test_cachedHighlightRangeStillMatchesQuery_outOfBounds_returnsFalse() { + XCTAssertFalse( + LinkAwareTextView.cachedHighlightRangeStillMatchesQuery( + range: NSRange(location: 8, length: 10), + fullString: "short", + query: "x" + ) + ) + } + + func test_cachedHighlightRangeStillMatchesQuery_wrongSpan_returnsFalse() { + XCTAssertFalse( + LinkAwareTextView.cachedHighlightRangeStillMatchesQuery( + range: NSRange(location: 0, length: 3), + fullString: "foo bar", + query: "bar" + ) + ) + } +}