editor: Fix selection occurrence highlights during regex buffer search (#52611)
## 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
This commit is contained in:
1 parent
bf185f7402
commit
1ac669d108
3 files changed
+108
-5
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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| {
|
||||
|
||||
Reference in new issue
Block a user