editor: Fix selection occurrence highlights during regex buffer search (#52611)

saberoueslati created

## Context

When using regex buffer search (e.g. `^something`), Zed correctly
navigates only to actual matches. However, navigating to a match selects
the matched text, which triggers the **selection occurrence highlight**
feature. That feature performs a *plain literal* search for the selected
word and highlights all occurrences, including ones that don't satisfy
the regex. The user would see a 4th mid-line occurrence highlighted,
even though `^something` never matched it, this behavior is quite
confusing.

The fix tracks whether the current selection was set by search
navigation via a new `from_search: bool` on `SelectionEffects`. When
`last_selection_from_search` is set and `BufferSearchHighlights` are
active, selection occurrence highlights are suppressed. Making a manual
text selection during an active search clears the flag, restoring normal
occurrence-highlight behavior.

The behavior now matches how VSCode handles this case.

The video below demonstrates the behavior after the fix and shows that
it matches the VSCode behavior now

[Screencast from 2026-03-28
01-33-46.webm](https://github.com/user-attachments/assets/07a005b8-53b1-4abf-93d2-96406f0b6a11)

Closes #52589

## How to Review

Three files changed — read in this order:

1. **`crates/editor/src/editor.rs`** Adds `from_search: bool` to
`SelectionEffects` (with a builder method) and
`last_selection_from_search: bool` to `Editor`.
`selections_did_change()` records the flag from effects.
`prepare_highlight_query_from_selection()` returns `None` early when
both `last_selection_from_search` and active `BufferSearchHighlights`
are set.

2. **`crates/editor/src/items.rs`** `activate_match()` now calls
`.from_search(true)` on its `SelectionEffects` so search-driven
selections are marked at the source.

3. **`crates/search/src/buffer_search.rs`** Regression test
`test_regex_search_does_not_highlight_non_matching_occurrences`:
verifies that after search navigation, `SelectedTextHighlight` is
suppressed and exactly 3 `BufferSearchHighlights` exist; and that after
a manual selection, `SelectedTextHighlight` is restored.

## Self-Review Checklist

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Fixed regex buffer search highlighting non-matching word occurrences
via the selection occurrence highlight feature

Change summary

crates/editor/src/editor.rs        | 23 +++++++++
crates/editor/src/items.rs         | 11 +++-
crates/search/src/buffer_search.rs | 79 +++++++++++++++++++++++++++++++
3 files changed, 108 insertions(+), 5 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -1217,6 +1217,7 @@ pub struct Editor {
     quick_selection_highlight_task: Option<(Range<Anchor>, Task<()>)>,
     debounced_selection_highlight_task: Option<(Range<Anchor>, Task<()>)>,
     debounced_selection_highlight_complete: bool,
+    last_selection_from_search: bool,
     document_highlights_task: Option<Task<()>>,
     linked_editing_range_task: Option<Task<Option<()>>>,
     linked_edit_ranges: linked_editing_ranges::LinkedEditingRanges,
@@ -1506,6 +1507,7 @@ pub struct SelectionEffects {
     nav_history: Option<bool>,
     completions: bool,
     scroll: Option<Autoscroll>,
+    from_search: bool,
 }
 
 impl Default for SelectionEffects {
@@ -1514,6 +1516,7 @@ impl Default for SelectionEffects {
             nav_history: None,
             completions: true,
             scroll: Some(Autoscroll::fit()),
+            from_search: false,
         }
     }
 }
@@ -1545,6 +1548,13 @@ impl SelectionEffects {
             ..self
         }
     }
+
+    pub fn from_search(self, from_search: bool) -> Self {
+        Self {
+            from_search,
+            ..self
+        }
+    }
 }
 
 struct DeferredSelectionEffectsState {
@@ -2461,6 +2471,7 @@ impl Editor {
             quick_selection_highlight_task: None,
             debounced_selection_highlight_task: None,
             debounced_selection_highlight_complete: false,
+            last_selection_from_search: false,
             document_highlights_task: None,
             linked_editing_range_task: None,
             pending_rename: None,
@@ -3647,6 +3658,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        self.last_selection_from_search = effects.from_search;
         window.invalidate_character_coordinates();
 
         // Copy selections to primary selection buffer
@@ -7709,6 +7721,17 @@ impl Editor {
         if !self.use_selection_highlight || !EditorSettings::get_global(cx).selection_highlight {
             return None;
         }
+        // When the current selection was set by search navigation, suppress selection
+        // occurrence highlights to avoid confusing non-matching occurrences with actual
+        // search results (e.g. `^something` matches 3 line-start occurrences, but a
+        // literal highlight would also mark a mid-line "something" that never matched
+        // the regex). A manual selection made by the user clears this flag, restoring
+        // the normal occurrence-highlight behavior.
+        if self.last_selection_from_search
+            && self.has_background_highlights(HighlightKey::BufferSearchHighlights)
+        {
+            return None;
+        }
         if self.selections.count() != 1 || self.selections.line_mode() {
             return None;
         }

crates/editor/src/items.rs 🔗

@@ -1689,9 +1689,14 @@ impl SearchableItem for Editor {
         } else {
             Autoscroll::fit()
         };
-        self.change_selections(SelectionEffects::scroll(autoscroll), window, cx, |s| {
-            s.select_ranges([range]);
-        })
+        self.change_selections(
+            SelectionEffects::scroll(autoscroll).from_search(true),
+            window,
+            cx,
+            |s| {
+                s.select_ranges([range]);
+            },
+        )
     }
 
     fn select_matches(

crates/search/src/buffer_search.rs 🔗

@@ -1900,11 +1900,12 @@ impl BufferSearchBar {
 
 #[cfg(test)]
 mod tests {
-    use std::ops::Range;
+    use std::{ops::Range, time::Duration};
 
     use super::*;
     use editor::{
-        DisplayPoint, Editor, MultiBuffer, PathKey, SearchSettings, SelectionEffects,
+        DisplayPoint, Editor, HighlightKey, MultiBuffer, PathKey,
+        SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT, SearchSettings, SelectionEffects,
         display_map::DisplayRow, test::editor_test_context::EditorTestContext,
     };
     use gpui::{Hsla, TestAppContext, UpdateGlobal, VisualTestContext};
@@ -3788,6 +3789,80 @@ mod tests {
         editor_cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\nFoo\nfoo");
     }
 
+    #[gpui::test]
+    async fn test_regex_search_does_not_highlight_non_matching_occurrences(
+        cx: &mut TestAppContext,
+    ) {
+        init_globals(cx);
+        let buffer = cx.new(|cx| {
+            Buffer::local(
+                "something is at the top\nsomething is behind something\nsomething is at the bottom\n",
+                cx,
+            )
+        });
+        let cx = cx.add_empty_window();
+        let editor =
+            cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx));
+        let search_bar = cx.new_window_entity(|window, cx| {
+            let mut search_bar = BufferSearchBar::new(None, window, cx);
+            search_bar.set_active_pane_item(Some(&editor), window, cx);
+            search_bar.show(window, cx);
+            search_bar
+        });
+
+        search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.toggle_search_option(SearchOptions::REGEX, window, cx);
+        });
+
+        search_bar
+            .update_in(cx, |search_bar, window, cx| {
+                search_bar.search("^something", None, true, window, cx)
+            })
+            .await
+            .unwrap();
+
+        search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.select_next_match(&SelectNextMatch, window, cx);
+        });
+
+        // Advance past the debounce so the selection occurrence highlight would
+        // have fired if it were not suppressed by the active buffer search.
+        cx.executor()
+            .advance_clock(SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(1));
+        cx.run_until_parked();
+
+        editor.update(cx, |editor, cx| {
+            assert!(
+                !editor.has_background_highlights(HighlightKey::SelectedTextHighlight),
+                "selection occurrence highlights must be suppressed during buffer search"
+            );
+            assert_eq!(
+                editor.search_background_highlights(cx).len(),
+                3,
+                "expected exactly 3 search highlights (one per line start)"
+            );
+        });
+
+        // Manually select "something" — this should restore occurrence highlights
+        // because it clears the search-navigation flag.
+        editor.update_in(cx, |editor, window, cx| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                s.select_ranges([Point::new(0, 0)..Point::new(0, 9)])
+            });
+        });
+
+        cx.executor()
+            .advance_clock(SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(1));
+        cx.run_until_parked();
+
+        editor.update(cx, |editor, _cx| {
+            assert!(
+                editor.has_background_highlights(HighlightKey::SelectedTextHighlight),
+                "selection occurrence highlights must be restored after a manual selection"
+            );
+        });
+    }
+
     fn update_search_settings(search_settings: SearchSettings, cx: &mut TestAppContext) {
         cx.update(|cx| {
             SettingsStore::update_global(cx, |store, cx| {