diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 7617eec..e1f9684 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -2719,10 +2719,20 @@ class LinkAwareTextView: NSTextView { private func replaceCurrentMatch(query: String, focusIndex: Int, replacement: String, advanceAfter: Bool) { guard !query.isEmpty, - lastSearchHighlightRanges.indices.contains(focusIndex) else { return } - let range = lastSearchHighlightRanges[focusIndex] + lastSearchHighlightRanges.indices.contains(focusIndex), + let storage = textStorage else { return } + var range = lastSearchHighlightRanges[focusIndex] + // Body edits do not repost the find query; debounced restyle only skips stale highlight + // ranges in `reapplySearchHighlights` without shrinking `lastSearchHighlightRanges`. + // Replacing with an out-of-bounds NSRange throws NSRangeException — resync first. + if NSMaxRange(range) > storage.length { + applySearchHighlights(query: query, focusIndex: focusIndex) + guard lastSearchHighlightRanges.indices.contains(focusIndex) else { return } + range = lastSearchHighlightRanges[focusIndex] + guard NSMaxRange(range) <= storage.length else { 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/EditorViewPendingStateConsumptionTests.swift b/macOS/SynapseNotesTests/EditorViewPendingStateConsumptionTests.swift index d42aa78..c99e71e 100644 --- a/macOS/SynapseNotesTests/EditorViewPendingStateConsumptionTests.swift +++ b/macOS/SynapseNotesTests/EditorViewPendingStateConsumptionTests.swift @@ -91,4 +91,42 @@ final class EditorViewPendingStateConsumptionTests: XCTestCase { let maxOffset = max(0, textView.bounds.height - scrollView.contentView.bounds.height) XCTAssertEqual(scrollView.contentView.bounds.origin.y, maxOffset, accuracy: 0.5) } + + /// Editing the note while the find bar stays open does not repost `scrollToSearchMatch`. + /// Debounced markdown restyle re-applies highlights but skips out-of-bounds cached ranges + /// without removing them, so Replace must not call `replaceCharacters` with a stale NSRange. + func test_replaceCurrentMatch_doesNotCrashWhenCachedHighlightRangeIsStaleAfterBodyEdit() { + let textView = RawEditor.configuredTextView(isEditable: true, settings: nil) + textView.participatesInGlobalSearch = true + textView.installSearchObservers() + + textView.setPlainText("aaaaaaaaaahello") + NotificationCenter.default.post( + name: .scrollToSearchMatch, + object: nil, + userInfo: [SearchMatchKey.query: "hello", SearchMatchKey.matchIndex: 0] + ) + + guard let storage = textView.textStorage else { + XCTFail("expected text storage") + return + } + storage.beginEditing() + storage.replaceCharacters(in: NSRange(location: 0, length: storage.length), with: "no") + storage.endEditing() + textView.didChangeText() + + NotificationCenter.default.post( + name: .replaceCurrentMatch, + object: nil, + userInfo: [ + SearchMatchKey.query: "hello", + SearchMatchKey.matchIndex: 0, + SearchMatchKey.replacement: "x", + SearchMatchKey.advanceAfter: false, + ] + ) + + XCTAssertEqual(textView.string, "no") + } }