Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions macOS/Synapse Notes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
F3E1AA11556677889900AABB /* FindReplaceSafetyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A0112233445566778899AA /* FindReplaceSafetyTests.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 */; };
Expand Down Expand Up @@ -444,6 +445,7 @@
CFE935FC16158D93B5B6C05C /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = "<group>"; };
D00CA4844F0E36301A2C61A6 /* FolderAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAppearance.swift; sourceTree = "<group>"; };
D27CF1236F7775109B48756D /* FileSearchResultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchResultTests.swift; sourceTree = "<group>"; };
F8A0112233445566778899AA /* FindReplaceSafetyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindReplaceSafetyTests.swift; sourceTree = "<group>"; };
D2EC80568C4675330252028C /* AppStateGitDateFilteringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateGitDateFilteringTests.swift; sourceTree = "<group>"; };
D3B6B8AE43A5A53AC28EC7C0 /* EditorFontStylingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorFontStylingTests.swift; sourceTree = "<group>"; };
D653C01C03E027EF0D3CBE1B /* TaskListCheckboxInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListCheckboxInteractionTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -567,6 +569,7 @@
DE37BFA970CBBD53D660C05B /* EmojiFlickerTests.swift */,
1BE44B10BDADC754E1B880CB /* FileBrowserErrorTests.swift */,
D27CF1236F7775109B48756D /* FileSearchResultTests.swift */,
F8A0112233445566778899AA /* FindReplaceSafetyTests.swift */,
BD8522CDE0F66E2CBBE14548 /* FileTreeDragDropTests.swift */,
A0F4AC3667A2DBE976050A49 /* FileTreeHiddenItemsTests.swift */,
E60A5A7B35E4BA0A0789846F /* FileTreeSortingTests.swift */,
Expand Down Expand Up @@ -988,6 +991,7 @@
7C18AFF2F761AD1F8DE0FCAA /* FSEventsVaultWatcherTests.swift in Sources */,
F92DE068F0D6C76962CF0898 /* FileBrowserErrorTests.swift in Sources */,
D6144DA4D24E7FD74DADF363 /* FileSearchResultTests.swift in Sources */,
F3E1AA11556677889900AABB /* FindReplaceSafetyTests.swift in Sources */,
1544C9F45B0FCC6B21E87F7D /* FileTreeDragDropTests.swift in Sources */,
EB34163E2847E7EDCEE97C0E /* FileTreeHiddenItemsTests.swift in Sources */,
67C6CA3A7398776DFF7CEBF4 /* FileTreeSortingTests.swift in Sources */,
Expand Down
10 changes: 9 additions & 1 deletion macOS/SynapseNotes/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2721,8 +2721,16 @@ class LinkAwareTextView: NSTextView {
guard !query.isEmpty,
lastSearchHighlightRanges.indices.contains(focusIndex) else { return }
let range = lastSearchHighlightRanges[focusIndex]
guard let storage = textStorage else { return }
let storageLength = storage.length
guard range.location >= 0, NSMaxRange(range) <= storageLength else { return }
let hit = (storage.string as NSString).substring(with: range)
// Highlight ranges are not cleared on every edit; `reapplySearchHighlights` only skips
// stale ranges visually. Without this check, an in-bounds but stale range could replace
// the wrong characters (or trip AppKit) after the document shifted under the find bar.
guard hit.compare(query, options: .caseInsensitive) == .orderedSame 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
Expand Down
50 changes: 50 additions & 0 deletions macOS/SynapseNotesTests/FindReplaceSafetyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import XCTest
import AppKit
@testable import Synapse

/// Regression tests for find/replace when cached highlight ranges lag behind the document.
final class FindReplaceSafetyTests: XCTestCase {

func test_replaceCurrent_skipsWhenCachedRangeNoLongerContainsQuery() {
let textView = LinkAwareTextView()
textView.frame = NSRect(x: 0, y: 0, width: 800, height: 600)
textView.isEditable = true
textView.participatesInGlobalSearch = true
textView.setPlainText("hello test world")

textView.installSearchObservers()

NotificationCenter.default.post(
name: .scrollToSearchMatch,
object: nil,
userInfo: [SearchMatchKey.query: "test", SearchMatchKey.matchIndex: 0]
)

XCTAssertEqual(textView.string, "hello test world")

// Edit without refreshing search highlights: "test" moves to the front, but the
// cached match range still points at the old UTF-16 offset (now wrong text).
guard let storage = textView.textStorage else {
return XCTFail("Expected text storage")
}
storage.replaceCharacters(in: NSRange(location: 0, length: 6), with: "")
XCTAssertEqual(textView.string, "test world")

NotificationCenter.default.post(
name: .replaceCurrentMatch,
object: nil,
userInfo: [
SearchMatchKey.query: "test",
SearchMatchKey.matchIndex: 0,
SearchMatchKey.replacement: "X",
SearchMatchKey.advanceAfter: false,
]
)

XCTAssertEqual(
textView.string,
"test world",
"Replace must not run when the cached range no longer matches the search string"
)
}
}
Loading