From 8ddc2b1b436c3e8c434927c0f95bca261acbe7b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 14 May 2026 11:07:20 +0000 Subject: [PATCH] fix(macOS): guard Replace against stale find highlight ranges Replace All previously fixed stale ranges before full-document replace; Replace Current could still call replaceCharacters with out-of-bounds NSRanges after a manual edit left cached highlight ranges invalid. Recompute highlights instead of crashing. Add regression test. Co-authored-by: Danny Peck --- macOS/Synapse Notes.xcodeproj/project.pbxproj | 4 ++ macOS/SynapseNotes/EditorView.swift | 9 +++- .../FindReplaceStaleHighlightTests.swift | 46 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 macOS/SynapseNotesTests/FindReplaceStaleHighlightTests.swift diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index f8dc002..b3ae7fa 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -225,6 +225,7 @@ F228B8C0174B187AF8E91BB3 /* MiniBrowserURLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08CB0284C3C5D4EE5C720C7 /* MiniBrowserURLNormalizer.swift */; }; F2C9C4B772AEAA1E6CB07B04 /* FolderAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00CA4844F0E36301A2C61A6 /* FolderAppearance.swift */; }; F3D098320828F15946A3146B /* SearchNotificationConstantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626454D7EB8684347CE78962 /* SearchNotificationConstantsTests.swift */; }; + 7F8E9D0C1B2A394857463524 /* FindReplaceStaleHighlightTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5D4C3B2A19081726354657 /* FindReplaceStaleHighlightTests.swift */; }; F4163BF689BE7BC5A40BCB43 /* AppStatePendingSearchQueryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F638E6858D9C03A15E702690 /* AppStatePendingSearchQueryTests.swift */; }; F422146CA313257EAB4ABEAF /* AppStateWikiLinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6820DD3112C970C66C5BD3 /* AppStateWikiLinkTests.swift */; }; F50070470A90436FCBDC6B9B /* AppStateEditModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125FEE59A72FC5BB02643BBC /* AppStateEditModeTests.swift */; }; @@ -328,6 +329,7 @@ 62195C34AC61C3601DA4E5F9 /* CriticalSidebarAndEditorRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalSidebarAndEditorRoutingTests.swift; sourceTree = ""; }; 621DD480ABAE023F532B8613 /* FolderPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderPickerView.swift; sourceTree = ""; }; 626454D7EB8684347CE78962 /* SearchNotificationConstantsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchNotificationConstantsTests.swift; sourceTree = ""; }; + 6E5D4C3B2A19081726354657 /* FindReplaceStaleHighlightTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindReplaceStaleHighlightTests.swift; sourceTree = ""; }; 6429DBD855DC174CAA00D16F /* SaveButtonVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveButtonVisibilityTests.swift; sourceTree = ""; }; 658EA3321387C8DF857E0932 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 66D30F1B11D472332E215555 /* MarkdownEditorSemanticStylesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEditorSemanticStylesTests.swift; sourceTree = ""; }; @@ -624,6 +626,7 @@ 26CB9DF00BA64A1F3AEB042E /* PullAndRefreshWIPAutoSaveTests.swift */, 01F60B6E95850384FC16643E /* RespectGitignoreSettingTests.swift */, 6429DBD855DC174CAA00D16F /* SaveButtonVisibilityTests.swift */, + 6E5D4C3B2A19081726354657 /* FindReplaceStaleHighlightTests.swift */, 4A54A8F1A063C6DB71E6A58C /* SearchIndexTests.swift */, 626454D7EB8684347CE78962 /* SearchNotificationConstantsTests.swift */, 703D69E0596A8F0C009BA65F /* SettingsManagerApplyPaneAssignmentsTests.swift */, @@ -1044,6 +1047,7 @@ 6CDFC82E14665A2F8E4224EC /* PullAndRefreshWIPAutoSaveTests.swift in Sources */, 1C1DDE1D7852ACB813AFDC4B /* RespectGitignoreSettingTests.swift in Sources */, 8C86B3AA977FBBA2C09822B0 /* SaveButtonVisibilityTests.swift in Sources */, + 7F8E9D0C1B2A394857463524 /* FindReplaceStaleHighlightTests.swift in Sources */, D336C7BB900A385C187FCEA5 /* SearchIndexTests.swift in Sources */, F3D098320828F15946A3146B /* SearchNotificationConstantsTests.swift in Sources */, C42C30EBF243BBE671B3D4FB /* SettingsManagerApplyPaneAssignmentsTests.swift in Sources */, diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 7617eec..70ac6e6 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -2721,8 +2721,15 @@ class LinkAwareTextView: NSTextView { guard !query.isEmpty, lastSearchHighlightRanges.indices.contains(focusIndex) else { return } let range = lastSearchHighlightRanges[focusIndex] + // `reapplySearchHighlights()` skips stale ranges but does not prune + // `lastSearchHighlightRanges`. A manual edit can leave an out-of-bounds + // range here; `replaceCharacters` would raise NSRangeException. + guard let storage = textStorage, NSMaxRange(range) <= storage.length else { + applySearchHighlights(query: query, focusIndex: focusIndex) + return + } guard shouldChangeText(in: range, replacementString: replacement) else { return } - textStorage?.replaceCharacters(in: range, with: replacement) + storage.replaceCharacters(in: range, with: replacement) didChangeText() // Recompute matches against new text. Anchor on the position of the replacement diff --git a/macOS/SynapseNotesTests/FindReplaceStaleHighlightTests.swift b/macOS/SynapseNotesTests/FindReplaceStaleHighlightTests.swift new file mode 100644 index 0000000..9128d3e --- /dev/null +++ b/macOS/SynapseNotesTests/FindReplaceStaleHighlightTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import Synapse + +/// Regression: Replace with a stale cached match range must not crash. +/// +/// `reapplySearchHighlights()` skips out-of-bounds ranges when repainting but +/// leaves `lastSearchHighlightRanges` unchanged. A Replace action must not call +/// `replaceCharacters` with those stale NSRanges. +final class FindReplaceStaleHighlightTests: XCTestCase { + + func test_replaceCurrentMatch_afterEditInvalidatesCachedRange_doesNotCrash() { + let textView = LinkAwareTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 300)) + textView.isEditable = true + textView.participatesInGlobalSearch = true + textView.installSearchObservers() + textView.string = "aaatest" + + NotificationCenter.default.post( + name: .scrollToSearchMatch, + object: nil, + userInfo: [SearchMatchKey.query: "test", SearchMatchKey.matchIndex: 0] + ) + + guard let storage = textView.textStorage else { + XCTFail("expected text storage") + return + } + + storage.replaceCharacters(in: NSRange(location: 0, length: 3), with: "") + XCTAssertEqual(textView.string, "test") + textView.applyMarkdownStyling() + + NotificationCenter.default.post( + name: .replaceCurrentMatch, + object: nil, + userInfo: [ + SearchMatchKey.query: "test", + SearchMatchKey.matchIndex: 0, + SearchMatchKey.replacement: "x", + SearchMatchKey.advanceAfter: false, + ] + ) + + XCTAssertEqual(textView.string, "test") + } +}