@@ -313,7 +313,6 @@ struct EditorView: NSViewRepresentable {
313313 context. coordinator. isHighlightingInProgress = true
314314 context. coordinator. highlighter? . highlightAll ( textView. textStorage!, caller: " appearance " )
315315 context. coordinator. isHighlightingInProgress = false
316- context. coordinator. restoreFindHighlightsIfNeeded ( )
317316
318317 // Refresh ruler when appearance/font changes
319318 context. coordinator. gutterView? . appearanceDidChange ( )
@@ -334,7 +333,16 @@ struct EditorView: NSViewRepresentable {
334333 context. coordinator. isHighlightingInProgress = true
335334 context. coordinator. highlighter? . highlightAll ( textView. textStorage!, caller: " externalText " )
336335 context. coordinator. isHighlightingInProgress = false
337- context. coordinator. restoreFindHighlightsIfNeeded ( )
336+ // External text replacement (file load/revert) — old match ranges are stale.
337+ context. coordinator. clearFindHighlights ( )
338+ if let findState = context. coordinator. findState, findState. isVisible, findState. activeMode == . edit {
339+ DispatchQueue . main. async { [ weak findState] in
340+ guard let findState, findState. activeMode == . edit else { return }
341+ findState. matchCount = 0
342+ findState. currentIndex = 0
343+ findState. resultsAreStale = true
344+ }
345+ }
338346 context. coordinator. gutterView? . textDidChange ( )
339347 context. coordinator. gutterView? . selectionDidChange ( selectedRange: textView. selectedRange ( ) )
340348 context. coordinator. isUpdating = false
@@ -521,12 +529,15 @@ struct EditorView: NSViewRepresentable {
521529
522530 state. $query
523531 . removeDuplicates ( )
524- . sink { [ weak self] _ in
532+ . sink { [ weak self] newQuery in
525533 guard let self,
526534 let findState = self . findState,
527535 findState. isVisible,
528536 findState. activeMode == . edit else { return }
529- self . performFind ( )
537+ // `@Published` fires in willSet — `findState.query` still
538+ // reads the OLD value here. Pass `newQuery` explicitly so
539+ // performFind doesn't run a keystroke behind.
540+ self . performFind ( query: newQuery)
530541 }
531542 . store ( in: & findCancellables)
532543
@@ -602,7 +613,6 @@ struct EditorView: NSViewRepresentable {
602613 sv. reflectScrolledClipView ( sv. contentView)
603614 }
604615 self . invalidateVisibleRegion ( of: textView)
605- self . restoreFindHighlightsIfNeeded ( )
606616 }
607617 pendingFullHighlightWork = work
608618 DispatchQueue . main. asyncAfter ( deadline: . now( ) + 0.3 , execute: work)
@@ -619,8 +629,18 @@ struct EditorView: NSViewRepresentable {
619629 // the visible rect so large documents don't repaint end-to-end.
620630 invalidateVisibleRegion ( of: textView)
621631
622- // Re-apply find highlights after syntax highlighting
623- restoreFindHighlightsIfNeeded ( )
632+ // Clear on edit; user retypes the query to refresh (#264).
633+ if let findState, findState. isVisible, !matchRanges. isEmpty {
634+ clearFindHighlights ( )
635+ if findState. activeMode == . edit {
636+ DispatchQueue . main. async { [ weak findState] in
637+ guard let findState, findState. activeMode == . edit else { return }
638+ findState. matchCount = 0
639+ findState. currentIndex = 0
640+ findState. resultsAreStale = true
641+ }
642+ }
643+ }
624644
625645 // Update line number ruler
626646 gutterView? . textDidChange ( )
@@ -763,17 +783,9 @@ struct EditorView: NSViewRepresentable {
763783
764784 // MARK: - Find
765785
766- func restoreFindHighlightsIfNeeded( ) {
767- guard findState? . isVisible == true , !( findState? . query. isEmpty ?? true ) else { return }
768- // Re-apply cached highlight colors without re-running the full find
769- // (which would scroll to the first match on every keystroke).
770- guard !matchRanges. isEmpty else { return }
771- applyFindHighlights ( )
772- }
773-
774- func performFind( ) {
786+ func performFind( query overrideQuery: String ? = nil ) {
775787 guard let textView, let findState else { return }
776- let query = findState. query
788+ let query = overrideQuery ?? findState. query
777789 clearFindHighlights ( )
778790
779791 guard !query. isEmpty else {
@@ -785,6 +797,7 @@ struct EditorView: NSViewRepresentable {
785797 findState. activeMode == . edit else { return }
786798 findState. matchCount = 0
787799 findState. currentIndex = 0
800+ findState. resultsAreStale = false
788801 }
789802 return
790803 }
@@ -801,6 +814,7 @@ struct EditorView: NSViewRepresentable {
801814 findState. activeMode == . edit else { return }
802815 findState. matchCount = ranges. count
803816 findState. currentIndex = ranges. isEmpty ? 0 : 1
817+ findState. resultsAreStale = false
804818 }
805819
806820 if !ranges. isEmpty {
@@ -809,6 +823,12 @@ struct EditorView: NSViewRepresentable {
809823 }
810824
811825 func navigateToNextMatch( ) {
826+ // After clear-on-edit, matchRanges is empty but the user's query is
827+ // still in the find bar — treat ⌘G / Find Next as a re-run trigger.
828+ if matchRanges. isEmpty, let findState, !findState. query. isEmpty {
829+ performFind ( )
830+ return
831+ }
812832 guard !matchRanges. isEmpty else { return }
813833 currentMatchIdx = ( currentMatchIdx + 1 ) % matchRanges. count
814834 applyFindHighlights ( )
@@ -822,6 +842,10 @@ struct EditorView: NSViewRepresentable {
822842 }
823843
824844 func navigateToPreviousMatch( ) {
845+ if matchRanges. isEmpty, let findState, !findState. query. isEmpty {
846+ performFind ( )
847+ return
848+ }
825849 guard !matchRanges. isEmpty else { return }
826850 currentMatchIdx = ( currentMatchIdx - 1 + matchRanges. count) % matchRanges. count
827851 applyFindHighlights ( )
@@ -834,31 +858,27 @@ struct EditorView: NSViewRepresentable {
834858 }
835859 }
836860
861+ // Find highlights live on the layout manager via temporary attributes,
862+ // not on text storage. This is Apple's recommended pattern for
863+ // transient UI highlighting and means find painting never overwrites
864+ // `==highlight==` markdown backgrounds (which DO live on storage).
837865 private func applyFindHighlights( ) {
838- guard let textView else { return }
866+ guard let textView, let layoutManager = textView . layoutManager else { return }
839867 let storage = textView. textStorage!
840-
841- // Clear existing find highlights first
842868 let fullRange = NSRange ( location: 0 , length: storage. length)
843- storage. beginEditing ( )
844- storage. removeAttribute ( . backgroundColor, range: fullRange)
845-
846- // Apply highlight to all matches
869+ layoutManager. removeTemporaryAttribute ( . backgroundColor, forCharacterRange: fullRange)
847870 for (i, range) in matchRanges. enumerated ( ) {
848871 guard range. upperBound <= storage. length else { continue }
849872 let color = ( i == currentMatchIdx) ? Theme . findCurrentHighlightColor : Theme . findHighlightColor
850- storage . addAttribute ( . backgroundColor, value: color, range : range)
873+ layoutManager . addTemporaryAttribute ( . backgroundColor, value: color, forCharacterRange : range)
851874 }
852- storage. endEditing ( )
853875 }
854876
855877 func clearFindHighlights( ) {
856- guard let textView else { return }
878+ guard let textView, let layoutManager = textView . layoutManager else { return }
857879 let storage = textView. textStorage!
858880 let fullRange = NSRange ( location: 0 , length: storage. length)
859- storage. beginEditing ( )
860- storage. removeAttribute ( . backgroundColor, range: fullRange)
861- storage. endEditing ( )
881+ layoutManager. removeTemporaryAttribute ( . backgroundColor, forCharacterRange: fullRange)
862882 matchRanges = [ ]
863883 currentMatchIdx = 0
864884 }
0 commit comments