editor: Inherit whole word match from buffer search (#64137)

# Objective

Make `editor: select next` / `editor: select previous` more powerful and
conform to VSCode behaviour
Partial fix for #15415

## Solution

When `editor: select next`/`editor: select previous`/`editor: select
all` is used from an existing selection, it'll now inherit whole-word
from the buffer search bar, while it's open.

If those commands are used starting from a single caret, continue to
always match whole words, with case sensitivity continuing to be
configurable in that case.

## Testing

Test both manually, as well as with unit tests:

- Updated `editor::editor_tests::test_select_next` with new assertion
for whole-word matching
- Added
`search::buffer_search::tests::test_set_active_pane_item_syncs_select_search_options`
- Added
`search::buffer_search::tests::test_search_option_change_during_selections`
- Added
`search::buffer_search::tests::test_show_syncs_select_search_options`

## Self-Review Checklist:

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

Release Notes:

- Improved `editor: select next` and `editor: select previous` to
respect the buffer search whole-word option.

---------

Co-authored-by: dino <dinojoaocosta@gmail.com>
This commit is contained in:
Alessandro Martone 2026-09-24 09:52:26 +00:00 • committed by GitHub
parent a405fb91d5
commit baee1ca6de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 315 additions and 51 deletions

View file

@ -274,7 +274,7 @@ use workspace::{
TabBarSettings, Toast, ViewId, Workspace, WorkspaceId, WorkspaceSettings,
item::{ItemBufferKind, ItemHandle, PreviewTabsSettings, SaveOptions},
notifications::{DetachAndPromptErr, NotificationId, NotifyResultExt, NotifyTaskExt},
searchable::SearchEvent,
searchable::{SearchEvent, SelectSearchOptions},
};
pub use zed_actions::editor::RevealInFileManager;
use zed_actions::editor::{MoveDown, MoveUp};
@ -1201,7 +1201,7 @@ pub struct Editor {
refresh_folding_ranges_task: Task<()>,
inlay_hints: Option<LspInlayHintData>,
folding_newlines: Task<()>,
select_next_is_case_sensitive: Option<bool>,
select_next_options: Option<SelectSearchOptions>,
pub lookup_key: Option<Box<dyn Any + Send + Sync>>,
on_local_selections_changed:
Option<Box<dyn Fn(Point, &mut Window, &mut Context<Self>) + 'static>>,
@ -2573,7 +2573,7 @@ impl Editor {
selection_drag_state: SelectionDragState::None,
folding_newlines: Task::ready(()),
lookup_key: None,
select_next_is_case_sensitive: None,
select_next_options: None,
on_local_selections_changed: None,
suppress_selection_callback: false,
applicable_language_settings: HashMap::default(),

View file

@ -14881,6 +14881,18 @@ async fn test_select_next(cx: &mut TestAppContext) {
e.select_next(&SelectNext::default(), window, cx).unwrap();
});
cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\n«ˇFoo»");
// Enable whole word
update_test_editor_settings(&mut cx, &|settings| {
let mut search_settings = SearchSettingsContent::default();
search_settings.whole_word = Some(true);
settings.search = Some(search_settings);
});
cx.set_state("abc\nabc «abcˇ»\ndefabc\nabc");
cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx))
.unwrap();
cx.assert_editor_state("abc\nabc «abcˇ»\ndefabc\n«abcˇ»");
}
#[gpui::test]

View file

@ -59,7 +59,7 @@ use workspace::{
item::{FollowableItem, Item, ItemBufferKind, ItemEvent, ProjectItem, SaveOptions},
searchable::{
Direction, FilteredSearchRange, SearchEvent, SearchToken, SearchableItem,
SearchableItemHandle,
SearchableItemHandle, SelectSearchOptions,
},
};
use workspace::{
@ -2121,12 +2121,18 @@ impl SearchableItem for Editor {
self.expect_bounds_change = self.last_bounds;
}
fn set_search_is_case_sensitive(
fn set_select_search_options(
&mut self,
case_sensitive: Option<bool>,
select_search_options: Option<SelectSearchOptions>,
_cx: &mut Context<Self>,
) {
self.select_next_is_case_sensitive = case_sensitive;
if self.select_next_options == select_search_options {
return;
}
self.select_next_options = select_search_options;
self.select_next_state = None;
self.select_prev_state = None;
}
}

View file

@ -1,6 +1,17 @@
use super::*;
use crate::columnar_selection::ColumnarSelectionRows;
/// How a [`crate::actions::SelectNext`] or [`crate::actions::SelectPrevious`]
/// action originated.
///
/// When started from a caret, whole-word matching is always enabled, compared
/// to when started from a selection, where the configured whole-word option is
/// used instead.
enum SelectNextOrigin {
Caret,
Selection,
}
impl Editor {
pub fn sync_selections(
&mut self,
@ -493,22 +504,23 @@ impl Editor {
.text_for_range(selection.start..selection.end)
.collect::<String>();
let is_empty = query.is_empty();
let select_state = SelectNextState {
query: self.build_query(&[query.chars().rev().collect::<String>()], cx)?,
wordwise: true,
done: is_empty,
};
let select_state = self.build_select_state(
query.chars().rev().collect(),
SelectNextOrigin::Caret,
is_empty,
cx,
)?;
self.select_prev_state = Some(select_state);
} else {
self.select_prev_state = None;
}
} else if let Some(selected_text) = selected_text {
self.select_prev_state = Some(SelectNextState {
query: self
.build_query(&[selected_text.chars().rev().collect::<String>()], cx)?,
wordwise: false,
done: false,
});
self.select_prev_state = Some(self.build_select_state(
selected_text.chars().rev().collect(),
SelectNextOrigin::Selection,
false,
cx,
)?);
self.select_previous(action, window, cx)?;
}
}
@ -2587,21 +2599,19 @@ impl Editor {
.text_for_range(selection.start..selection.end)
.collect::<String>();
let is_empty = query.is_empty();
let select_state = SelectNextState {
query: self.build_query(&[query], cx)?,
wordwise: true,
done: is_empty,
};
let select_state =
self.build_select_state(query, SelectNextOrigin::Caret, is_empty, cx)?;
self.select_next_state = Some(select_state);
} else {
self.select_next_state = None;
}
} else if let Some(selected_text) = selected_text {
self.select_next_state = Some(SelectNextState {
query: self.build_query(&[selected_text], cx)?,
wordwise: false,
done: false,
});
self.select_next_state = Some(self.build_select_state(
selected_text,
SelectNextOrigin::Selection,
false,
cx,
)?);
self.select_next_match_internal(
display_map,
replace_newest,
@ -2614,20 +2624,37 @@ impl Editor {
Ok(())
}
fn build_query<I, P>(&self, patterns: I, cx: &Context<Self>) -> Result<AhoCorasick, BuildError>
fn build_query<I, P>(patterns: I, case_sensitive: bool) -> Result<AhoCorasick, BuildError>
where
I: IntoIterator<Item = P>,
P: AsRef<[u8]>,
{
let case_sensitive = self
.select_next_is_case_sensitive
.unwrap_or_else(|| EditorSettings::get_global(cx).search.case_sensitive);
let mut builder = AhoCorasickBuilder::new();
builder.ascii_case_insensitive(!case_sensitive);
builder.build(patterns)
}
fn build_select_state(
&self,
query: String,
origin: SelectNextOrigin,
done: bool,
cx: &App,
) -> Result<SelectNextState, BuildError> {
let search_options = self.select_next_options.unwrap_or_else(|| {
let search_settings = EditorSettings::get_global(cx).search;
SelectSearchOptions {
case_sensitive: search_settings.case_sensitive,
whole_word: search_settings.whole_word,
}
});
Ok(SelectNextState {
query: Self::build_query(&[query], search_options.case_sensitive)?,
wordwise: matches!(origin, SelectNextOrigin::Caret) || search_options.whole_word,
done,
})
}
fn find_syntax_node_boundary(
&self,
selection_pos: MultiBufferOffset,

View file

@ -41,7 +41,7 @@ use workspace::{
item::{ItemBufferKind, ItemHandle},
searchable::{
Direction, FilteredSearchRange, SearchEvent, SearchToken, SearchableItemHandle,
WeakSearchableItemHandle,
SelectSearchOptions, WeakSearchableItemHandle,
},
};
@ -528,12 +528,14 @@ impl ToolbarItemView for BufferSearchBar {
) -> ToolbarItemLocation {
cx.notify();
self.active_searchable_item_subscriptions.take();
self.active_searchable_item.take();
self.splittable_editor = None;
self._splittable_editor_subscription = None;
self.pending_search.take();
if let Some(item) = self.active_searchable_item.take() {
item.set_select_search_options(None, cx);
}
if let Some(splittable_editor) = item
.and_then(|item| item.act_as_type(TypeId::of::<SplittableEditor>(), cx))
.and_then(|entity| entity.downcast::<SplittableEditor>().ok())
@ -608,6 +610,7 @@ impl ToolbarItemView for BufferSearchBar {
let is_project_search = searchable_item_handle.supported_options(cx).find_in_results;
self.active_searchable_item = Some(searchable_item_handle);
self.sync_select_search_options(cx);
drop(self.update_matches(true, false, window, cx));
if self.needs_expand_collapse_option(cx) && self.is_dismissed() {
return ToolbarItemLocation::PrimaryLeft;
@ -823,7 +826,7 @@ impl BufferSearchBar {
self.dismissed = true;
cx.emit(Event::Dismissed);
self.query_error = None;
self.sync_select_next_case_sensitivity(cx);
self.sync_select_search_options(cx);
for searchable_item in self.searchable_items_with_matches.keys() {
if let Some(searchable_item) =
@ -877,7 +880,6 @@ impl BufferSearchBar {
}
self.search_suggested(seed_query_override, window, cx);
self.smartcase(window, cx);
self.sync_select_next_case_sensitivity(cx);
self.replace_enabled |= deploy.replace_enabled;
self.selection_search_enabled =
self.selection_search_enabled
@ -940,6 +942,7 @@ impl BufferSearchBar {
self.search_options.remove(SearchOptions::BACKWARDS);
self.dismissed = false;
self.sync_select_search_options(cx);
self.adjust_query_regex_language(cx);
handle.search_bar_visibility_changed(true, window, cx);
cx.notify();
@ -1210,7 +1213,7 @@ impl BufferSearchBar {
self.default_options = self.search_options;
drop(self.update_matches(false, false, window, cx));
self.adjust_query_regex_language(cx);
self.sync_select_next_case_sensitivity(cx);
self.sync_select_search_options(cx);
cx.notify();
}
@ -1245,7 +1248,7 @@ impl BufferSearchBar {
pub fn set_search_options(&mut self, search_options: SearchOptions, cx: &mut Context<Self>) {
self.search_options = search_options;
self.adjust_query_regex_language(cx);
self.sync_select_next_case_sensitivity(cx);
self.sync_select_search_options(cx);
cx.notify();
}
@ -1849,21 +1852,24 @@ impl BufferSearchBar {
}
}
/// Updates the searchable item's case sensitivity option to match the
/// search bar's current case sensitivity setting. This ensures that
/// Updates the searchable item's search options to match the
/// search bar's current settings. This ensures that
/// editor's `select_next`/ `select_previous` operations respect the buffer
/// search bar's search options.
///
/// Clears the case sensitivity when the search bar is dismissed so that
/// Clears them when the search bar is dismissed so that
/// only the editor's settings are respected.
fn sync_select_next_case_sensitivity(&self, cx: &mut Context<Self>) {
let case_sensitive = match self.dismissed {
fn sync_select_search_options(&self, cx: &mut Context<Self>) {
let search_options = match self.dismissed {
true => None,
false => Some(self.search_options.contains(SearchOptions::CASE_SENSITIVE)),
false => Some(SelectSearchOptions {
case_sensitive: self.search_options.contains(SearchOptions::CASE_SENSITIVE),
whole_word: self.search_options.contains(SearchOptions::WHOLE_WORD),
}),
};
if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
active_searchable_item.set_search_is_case_sensitive(case_sensitive, cx);
active_searchable_item.set_select_search_options(search_options, cx);
}
}
}
@ -4297,6 +4303,212 @@ mod tests {
});
}
#[gpui::test]
async fn test_set_active_pane_item_syncs_select_search_options(cx: &mut TestAppContext) {
init_globals(cx);
// Set up 2 editors, with both buffers containing a lowercase and
// uppercase version of the same word, so we can confirm whether case
// sensitivity is affecting `editor: select next` .
let cx = cx.add_empty_window();
let buffer_a = cx.new(|cx| Buffer::local("zed\nZED\n", cx));
let buffer_b = cx.new(|cx| Buffer::local("zed\nZED\n", cx));
let editor_a = cx
.new_window_entity(|window, cx| Editor::for_buffer(buffer_a.clone(), None, window, cx));
let editor_b = cx
.new_window_entity(|window, cx| Editor::for_buffer(buffer_b.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_a), window, cx);
search_bar.show(window, cx);
search_bar
});
search_bar.update(cx, |search_bar, cx| {
search_bar.set_search_options(SearchOptions::CASE_SENSITIVE, cx)
});
// Editor A should use the search bar's case sensitivity option.
editor_a.update_in(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
selections.select_display_ranges([
DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3)
]);
});
editor
.select_next(&Default::default(), window, cx)
.expect("selecting next should succeed, even if selections are not changed");
assert_eq!(
editor
.selections
.display_ranges(&editor.display_snapshot(cx)),
vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3)]
);
});
// Switching the active item should move the search bar's options to
// Editor B.
search_bar.update_in(cx, |search_bar, window, cx| {
search_bar.set_active_pane_item(Some(&editor_b), window, cx)
});
editor_b.update_in(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
selections.select_display_ranges([
DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3)
]);
});
editor
.select_next(&Default::default(), window, cx)
.expect("selecting next should succeed, even if selections are not changed");
assert_eq!(
editor
.selections
.display_ranges(&editor.display_snapshot(cx)),
vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3)]
);
});
// Editor A should now fall back to the default case insensitive search
// setting.
editor_a.update_in(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
selections.select_display_ranges([
DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3)
]);
});
editor
.select_next(&Default::default(), window, cx)
.expect("selecting next should succeed, even if selections are not changed");
assert_eq!(
editor
.selections
.display_ranges(&editor.display_snapshot(cx)),
vec![
DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3),
DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 3)
]
);
});
}
#[gpui::test]
async fn test_search_option_change_during_selections(cx: &mut TestAppContext) {
init_globals(cx);
let cx = cx.add_empty_window();
let buffer = cx.new(|cx| Buffer::local("abc\ndefabc\nghiabc\nabc", cx));
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
});
editor.update_in(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
selections.select_display_ranges([
DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3)
]);
});
editor
.select_next(&Default::default(), window, cx)
.expect("should be able to select next");
assert_eq!(
editor
.selections
.display_ranges(&editor.display_snapshot(cx)),
vec![
DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3),
DisplayPoint::new(DisplayRow(1), 3)..DisplayPoint::new(DisplayRow(1), 6)
]
);
});
// Toggle the "Whole Word" search option, so we can later check that
// only the last line is now going to be selected.
search_bar.update_in(cx, |search_bar, window, cx| {
search_bar.toggle_whole_word(&Default::default(), window, cx)
});
editor.update_in(cx, |editor, window, cx| {
editor
.select_next(&Default::default(), window, cx)
.expect("should be able to select next");
assert_eq!(
editor
.selections
.display_ranges(&editor.display_snapshot(cx)),
vec![
DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3),
DisplayPoint::new(DisplayRow(1), 3)..DisplayPoint::new(DisplayRow(1), 6),
DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 3)
]
);
});
}
#[gpui::test]
async fn test_show_syncs_select_search_options(cx: &mut TestAppContext) {
init_globals(cx);
let cx = cx.add_empty_window();
let buffer = cx.new(|cx| Buffer::local("zed\nzedfoo\nzed", cx));
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.set_search_options(SearchOptions::WHOLE_WORD, cx);
// Reopening the search bar with the same options should sync them
// back to the editor after dismissing cleared them from the editor.
search_bar.dismiss(&Default::default(), window, cx);
search_bar.show(window, cx)
});
editor.update_in(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
selections.select_display_ranges([
DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3)
]);
});
editor
.select_next(&Default::default(), window, cx)
.expect("should be able to select next");
assert_eq!(
editor
.selections
.display_ranges(&editor.display_snapshot(cx)),
vec![
DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3),
DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 3)
]
);
});
}
fn update_search_settings(search_settings: SearchSettings, cx: &mut TestAppContext) {
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {

View file

@ -70,6 +70,12 @@ pub enum FilteredSearchRange {
Default,
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct SelectSearchOptions {
pub case_sensitive: bool,
pub whole_word: bool,
}
pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
type Match: Any + Sync + Send + Clone;
@ -205,7 +211,8 @@ pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<usize>;
fn set_search_is_case_sensitive(&mut self, _: Option<bool>, _: &mut Context<Self>) {}
fn set_select_search_options(&mut self, _: Option<SelectSearchOptions>, _: &mut Context<Self>) {
}
}
pub trait SearchableItemHandle: ItemHandle {
@ -303,7 +310,7 @@ pub trait SearchableItemHandle: ItemHandle {
cx: &mut App,
);
fn set_search_is_case_sensitive(&self, is_case_sensitive: Option<bool>, cx: &mut App);
fn set_select_search_options(&self, search_options: Option<SelectSearchOptions>, cx: &mut App);
}
impl<T: SearchableItem> SearchableItemHandle for Entity<T> {
@ -507,9 +514,9 @@ impl<T: SearchableItem> SearchableItemHandle for Entity<T> {
this.toggle_filtered_search_ranges(enabled, window, cx)
});
}
fn set_search_is_case_sensitive(&self, enabled: Option<bool>, cx: &mut App) {
fn set_select_search_options(&self, search_options: Option<SelectSearchOptions>, cx: &mut App) {
self.update(cx, |this, cx| {
this.set_search_is_case_sensitive(enabled, cx)
this.set_select_search_options(search_options, cx)
});
}
}