From 1ac669d108fbcf9b14bbfa45ed0a2d16e830930e Mon Sep 17 00:00:00 2001 From: saberoueslati Date: Tue, 14 Apr 2026 07:25:28 +0100 Subject: [PATCH] editor: Fix selection occurrence highlights during regex buffer search (#52611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- 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(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bc343ca0d4c8fbba8ddd6622a20c217385c2b919..bca55b5d175572200a33517888fd5eecfad15c5b 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1217,6 +1217,7 @@ pub struct Editor { quick_selection_highlight_task: Option<(Range, Task<()>)>, debounced_selection_highlight_task: Option<(Range, Task<()>)>, debounced_selection_highlight_complete: bool, + last_selection_from_search: bool, document_highlights_task: Option>, linked_editing_range_task: Option>>, linked_edit_ranges: linked_editing_ranges::LinkedEditingRanges, @@ -1506,6 +1507,7 @@ pub struct SelectionEffects { nav_history: Option, completions: bool, scroll: Option, + 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.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; } diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index d2c157014330cc26f0024ace87ee0e3688f85eaa..55fbf1c8ff1bc470736af58165115c287fb6c9c8 100644 --- a/crates/editor/src/items.rs +++ b/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( diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 3a5fbe3fcae6241495deb43930b83bb78ba81968..ca5acb53c084ca7208278ef7beeff8acf819b627 100644 --- a/crates/search/src/buffer_search.rs +++ b/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| {