editor: Respect search case sensitivity when selecting occurrences (#42121)

Update how the editor's `select_*` methods work in order to respect the
`search.case_sensitive` setting, or to be overriden by the
`BufferSearchBar` search options.

- Update both the `SearchableItem` and `SearchableItemHandle` traits
  with a new `set_search_is_case_sensitive` method that allows callers
  to set the case sensitivity of the search
- Update the `BufferSearchBar` to leverage
  `SearchableItemHandle.set_search_is_case_sensitive` in order to sync
  its case sensitivity options with the searchable item
- Update the implementation of the `SearchableItem` trait for `Editor`
  so as to store the argument provided to the
  `set_search_is_case_sensitive` method
- Update the way search queries are built by `Editor` so as to rely on
  `SearchableItem.set_search_is_case_sensitive` argument, if not `None`,
  or default to the editor's `search.case_sensitive` settings

Closes #41070 

Release Notes:

- Improved the "Select Next Occurrence", "Select Previous Occurrence"
and "Select All Occurrences" actions in order to respect the case
sensitivity search settings

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
Dino
2025-11-11 10:26:40 +00:00
committed by GitHub
co-authored by Conrad Irwin
parent dcf56144b5
commit 97100ce52f
9 changed files with 285 additions and 18 deletions
+4
View File
@@ -616,9 +616,13 @@
"search": {
// Whether to show the project search button in the status bar.
"button": true,
// Whether to only match on whole words.
"whole_word": false,
// Whether to match case sensitively.
"case_sensitive": false,
// Whether to include gitignored files in search results.
"include_ignored": false,
// Whether to interpret the search query as a regular expression.
"regex": false,
// Whether to center the cursor on each search match when navigating.
"center_on_match": false
+27 -5
View File
@@ -74,7 +74,7 @@ use ::git::{
blame::{BlameEntry, ParsedCommitMessage},
status::FileStatus,
};
use aho_corasick::AhoCorasick;
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, BuildError};
use anyhow::{Context as _, Result, anyhow};
use blink_manager::BlinkManager;
use buffer_diff::DiffHunkStatus;
@@ -1190,6 +1190,7 @@ pub struct Editor {
refresh_colors_task: Task<()>,
inlay_hints: Option<LspInlayHintData>,
folding_newlines: Task<()>,
select_next_is_case_sensitive: Option<bool>,
pub lookup_key: Option<Box<dyn Any + Send + Sync>>,
}
@@ -2333,6 +2334,7 @@ impl Editor {
selection_drag_state: SelectionDragState::None,
folding_newlines: Task::ready(()),
lookup_key: None,
select_next_is_case_sensitive: None,
};
if is_minimap {
@@ -14645,7 +14647,7 @@ impl Editor {
.collect::<String>();
let is_empty = query.is_empty();
let select_state = SelectNextState {
query: AhoCorasick::new(&[query])?,
query: self.build_query(&[query], cx)?,
wordwise: true,
done: is_empty,
};
@@ -14655,7 +14657,7 @@ impl Editor {
}
} else if let Some(selected_text) = selected_text {
self.select_next_state = Some(SelectNextState {
query: AhoCorasick::new(&[selected_text])?,
query: self.build_query(&[selected_text], cx)?,
wordwise: false,
done: false,
});
@@ -14863,7 +14865,7 @@ impl Editor {
.collect::<String>();
let is_empty = query.is_empty();
let select_state = SelectNextState {
query: AhoCorasick::new(&[query.chars().rev().collect::<String>()])?,
query: self.build_query(&[query.chars().rev().collect::<String>()], cx)?,
wordwise: true,
done: is_empty,
};
@@ -14873,7 +14875,8 @@ impl Editor {
}
} else if let Some(selected_text) = selected_text {
self.select_prev_state = Some(SelectNextState {
query: AhoCorasick::new(&[selected_text.chars().rev().collect::<String>()])?,
query: self
.build_query(&[selected_text.chars().rev().collect::<String>()], cx)?,
wordwise: false,
done: false,
});
@@ -14883,6 +14886,25 @@ impl Editor {
Ok(())
}
/// Builds an `AhoCorasick` automaton from the provided patterns, while
/// setting the case sensitivity based on the global
/// `SelectNextCaseSensitive` setting, if set, otherwise based on the
/// editor's settings.
fn build_query<I, P>(&self, patterns: I, cx: &Context<Self>) -> Result<AhoCorasick, BuildError>
where
I: IntoIterator<Item = P>,
P: AsRef<[u8]>,
{
let case_sensitive = self.select_next_is_case_sensitive.map_or_else(
|| EditorSettings::get_global(cx).search.case_sensitive,
|value| value,
);
let mut builder = AhoCorasickBuilder::new();
builder.ascii_case_insensitive(!case_sensitive);
builder.build(patterns)
}
pub fn find_next_match(
&mut self,
_: &FindNextMatch,
+5
View File
@@ -162,10 +162,15 @@ pub struct DragAndDropSelection {
pub struct SearchSettings {
/// Whether to show the project search button in the status bar.
pub button: bool,
/// Whether to only match on whole words.
pub whole_word: bool,
/// Whether to match case sensitively.
pub case_sensitive: bool,
/// Whether to include gitignored files in search results.
pub include_ignored: bool,
/// Whether to interpret the search query as a regular expression.
pub regex: bool,
/// Whether to center the cursor on each search match when navigating.
pub center_on_match: bool,
}
+103 -5
View File
@@ -44,8 +44,8 @@ use project::{
};
use serde_json::{self, json};
use settings::{
AllLanguageSettingsContent, IndentGuideBackgroundColoring, IndentGuideColoring,
ProjectSettingsContent,
AllLanguageSettingsContent, EditorSettingsContent, IndentGuideBackgroundColoring,
IndentGuideColoring, ProjectSettingsContent, SearchSettingsContent,
};
use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant};
use std::{
@@ -8314,8 +8314,15 @@ async fn test_add_selection_above_below_multi_cursor_existing_state(cx: &mut Tes
#[gpui::test]
async fn test_select_next(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
// Enable case sensitive search.
update_test_editor_settings(&mut cx, |settings| {
let mut search_settings = SearchSettingsContent::default();
search_settings.case_sensitive = Some(true);
settings.search = Some(search_settings);
});
cx.set_state("abc\nˇabc abc\ndefabc\nabc");
cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx))
@@ -8346,14 +8353,41 @@ async fn test_select_next(cx: &mut TestAppContext) {
cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx))
.unwrap();
cx.assert_editor_state("abc\n«ˇabc» «ˇabc»\ndefabc\nabc");
// Test case sensitivity
cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
cx.update_editor(|e, window, cx| {
e.select_next(&SelectNext::default(), window, cx).unwrap();
});
cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»");
// Disable case sensitive search.
update_test_editor_settings(&mut cx, |settings| {
let mut search_settings = SearchSettingsContent::default();
search_settings.case_sensitive = Some(false);
settings.search = Some(search_settings);
});
cx.set_state("«ˇfoo»\nFOO\nFoo");
cx.update_editor(|e, window, cx| {
e.select_next(&SelectNext::default(), window, cx).unwrap();
e.select_next(&SelectNext::default(), window, cx).unwrap();
});
cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\n«ˇFoo»");
}
#[gpui::test]
async fn test_select_all_matches(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
// Enable case sensitive search.
update_test_editor_settings(&mut cx, |settings| {
let mut search_settings = SearchSettingsContent::default();
search_settings.case_sensitive = Some(true);
settings.search = Some(search_settings);
});
// Test caret-only selections
cx.set_state("abc\nˇabc abc\ndefabc\nabc");
cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx))
@@ -8398,6 +8432,26 @@ async fn test_select_all_matches(cx: &mut TestAppContext) {
e.set_clip_at_line_ends(false, cx);
});
cx.assert_editor_state("«abcˇ»");
// Test case sensitivity
cx.set_state("fˇoo\nFOO\nFoo");
cx.update_editor(|e, window, cx| {
e.select_all_matches(&SelectAllMatches, window, cx).unwrap();
});
cx.assert_editor_state("«fooˇ»\nFOO\nFoo");
// Disable case sensitive search.
update_test_editor_settings(&mut cx, |settings| {
let mut search_settings = SearchSettingsContent::default();
search_settings.case_sensitive = Some(false);
settings.search = Some(search_settings);
});
cx.set_state("fˇoo\nFOO\nFoo");
cx.update_editor(|e, window, cx| {
e.select_all_matches(&SelectAllMatches, window, cx).unwrap();
});
cx.assert_editor_state("«fooˇ»\n«FOOˇ»\n«Fooˇ»");
}
#[gpui::test]
@@ -8769,8 +8823,15 @@ let foo = «2ˇ»;"#,
#[gpui::test]
async fn test_select_previous_with_single_selection(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
// Enable case sensitive search.
update_test_editor_settings(&mut cx, |settings| {
let mut search_settings = SearchSettingsContent::default();
search_settings.case_sensitive = Some(true);
settings.search = Some(search_settings);
});
cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc");
cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx))
@@ -8795,6 +8856,32 @@ async fn test_select_previous_with_single_selection(cx: &mut TestAppContext) {
cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx))
.unwrap();
cx.assert_editor_state("«ˇabc»\n«ˇabc» «ˇabc»\ndef«ˇabc»\n«ˇabc»");
// Test case sensitivity
cx.set_state("foo\nFOO\nFoo\n«ˇfoo»");
cx.update_editor(|e, window, cx| {
e.select_previous(&SelectPrevious::default(), window, cx)
.unwrap();
e.select_previous(&SelectPrevious::default(), window, cx)
.unwrap();
});
cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»");
// Disable case sensitive search.
update_test_editor_settings(&mut cx, |settings| {
let mut search_settings = SearchSettingsContent::default();
search_settings.case_sensitive = Some(false);
settings.search = Some(search_settings);
});
cx.set_state("foo\nFOO\n«ˇFoo»");
cx.update_editor(|e, window, cx| {
e.select_previous(&SelectPrevious::default(), window, cx)
.unwrap();
e.select_previous(&SelectPrevious::default(), window, cx)
.unwrap();
});
cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\n«ˇFoo»");
}
#[gpui::test]
@@ -25717,6 +25804,17 @@ pub(crate) fn update_test_project_settings(
});
}
pub(crate) fn update_test_editor_settings(
cx: &mut TestAppContext,
f: impl Fn(&mut EditorSettingsContent),
) {
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| f(&mut settings.editor));
})
})
}
pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsContent)) {
cx.update(|cx| {
assets::Assets.load_test_fonts(cx);
+8
View File
@@ -1796,6 +1796,14 @@ impl SearchableItem for Editor {
fn search_bar_visibility_changed(&mut self, _: bool, _: &mut Window, _: &mut Context<Self>) {
self.expect_bounds_change = self.last_bounds;
}
fn set_search_is_case_sensitive(
&mut self,
case_sensitive: Option<bool>,
_cx: &mut Context<Self>,
) {
self.select_next_is_case_sensitive = case_sensitive;
}
}
pub fn active_match_index(
+85 -7
View File
@@ -127,12 +127,6 @@ pub struct BufferSearchBar {
regex_language: Option<Arc<Language>>,
}
impl BufferSearchBar {
pub fn query_editor_focused(&self) -> bool {
self.query_editor_focused
}
}
impl EventEmitter<Event> for BufferSearchBar {}
impl EventEmitter<workspace::ToolbarItemEvent> for BufferSearchBar {}
impl Render for BufferSearchBar {
@@ -521,6 +515,10 @@ impl ToolbarItemView for BufferSearchBar {
}
impl BufferSearchBar {
pub fn query_editor_focused(&self) -> bool {
self.query_editor_focused
}
pub fn register(registrar: &mut impl SearchActionsRegistrar) {
registrar.register_handler(ForDeployed(|this, _: &FocusSearch, window, cx| {
this.query_editor.focus_handle(cx).focus(window);
@@ -696,6 +694,8 @@ impl BufferSearchBar {
pub fn dismiss(&mut self, _: &Dismiss, window: &mut Window, cx: &mut Context<Self>) {
self.dismissed = true;
self.query_error = None;
self.sync_select_next_case_sensitivity(cx);
for searchable_item in self.searchable_items_with_matches.keys() {
if let Some(searchable_item) =
WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx)
@@ -711,6 +711,7 @@ impl BufferSearchBar {
let handle = active_editor.item_focus_handle(cx);
self.focus(&handle, window);
}
cx.emit(Event::UpdateLocation);
cx.emit(ToolbarItemEvent::ChangeLocation(
ToolbarItemLocation::Hidden,
@@ -730,6 +731,7 @@ impl BufferSearchBar {
}
self.search_suggested(window, cx);
self.smartcase(window, cx);
self.sync_select_next_case_sensitivity(cx);
self.replace_enabled = deploy.replace_enabled;
self.selection_search_enabled = if deploy.selection_search_enabled {
Some(FilteredSearchRange::Default)
@@ -919,6 +921,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);
cx.notify();
}
@@ -953,6 +956,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);
cx.notify();
}
@@ -1507,6 +1511,7 @@ impl BufferSearchBar {
.read(cx)
.as_singleton()
.expect("query editor should be backed by a singleton buffer");
if enable {
if let Some(regex_language) = self.regex_language.clone() {
query_buffer.update(cx, |query_buffer, cx| {
@@ -1519,6 +1524,24 @@ impl BufferSearchBar {
})
}
}
/// Updates the searchable item's case sensitivity option to match the
/// search bar's current case sensitivity setting. 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
/// only the editor's settings are respected.
fn sync_select_next_case_sensitivity(&self, cx: &mut Context<Self>) {
let case_sensitive = match self.dismissed {
true => None,
false => Some(self.search_options.contains(SearchOptions::CASE_SENSITIVE)),
};
if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
active_searchable_item.set_search_is_case_sensitive(case_sensitive, cx);
}
}
}
#[cfg(test)]
@@ -1528,7 +1551,7 @@ mod tests {
use super::*;
use editor::{
DisplayPoint, Editor, MultiBuffer, SearchSettings, SelectionEffects,
display_map::DisplayRow,
display_map::DisplayRow, test::editor_test_context::EditorTestContext,
};
use gpui::{Hsla, TestAppContext, UpdateGlobal, VisualTestContext};
use language::{Buffer, Point};
@@ -2963,6 +2986,61 @@ mod tests {
});
}
#[gpui::test]
async fn test_select_occurrence_case_sensitivity(cx: &mut TestAppContext) {
let (editor, search_bar, cx) = init_test(cx);
let mut editor_cx = EditorTestContext::for_editor_in(editor, cx).await;
// Start with case sensitive search settings.
let mut search_settings = SearchSettings::default();
search_settings.case_sensitive = true;
update_search_settings(search_settings, cx);
search_bar.update(cx, |search_bar, cx| {
let mut search_options = search_bar.search_options;
search_options.insert(SearchOptions::CASE_SENSITIVE);
search_bar.set_search_options(search_options, cx);
});
editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
editor_cx.update_editor(|e, window, cx| {
e.select_next(&Default::default(), window, cx).unwrap();
});
editor_cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»");
// Update the search bar's case sensitivite toggle, so we can later
// confirm that `select_next` will now be case-insensitive.
editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
search_bar.update_in(cx, |search_bar, window, cx| {
search_bar.toggle_case_sensitive(&Default::default(), window, cx);
});
editor_cx.update_editor(|e, window, cx| {
e.select_next(&Default::default(), window, cx).unwrap();
});
editor_cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\nFoo\nfoo");
// Confirm that, after dismissing the search bar, only the editor's
// search settings actually affect the behavior of `select_next`.
search_bar.update_in(cx, |search_bar, window, cx| {
search_bar.dismiss(&Default::default(), window, cx);
});
editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
editor_cx.update_editor(|e, window, cx| {
e.select_next(&Default::default(), window, cx).unwrap();
});
editor_cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»");
// Update the editor's search settings, disabling case sensitivity, to
// check that the value is respected.
let mut search_settings = SearchSettings::default();
search_settings.case_sensitive = false;
update_search_settings(search_settings, cx);
editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
editor_cx.update_editor(|e, window, cx| {
e.select_next(&Default::default(), window, cx).unwrap();
});
editor_cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\nFoo\nfoo");
}
fn update_search_settings(search_settings: SearchSettings, cx: &mut TestAppContext) {
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
@@ -759,9 +759,13 @@ pub enum SnippetSortOrder {
pub struct SearchSettingsContent {
/// Whether to show the project search button in the status bar.
pub button: Option<bool>,
/// Whether to only match on whole words.
pub whole_word: Option<bool>,
/// Whether to match case sensitively.
pub case_sensitive: Option<bool>,
/// Whether to include gitignored files in search results.
pub include_ignored: Option<bool>,
/// Whether to interpret the search query as a regular expression.
pub regex: Option<bool>,
/// Whether to center the cursor on each search match when navigating.
pub center_on_match: Option<bool>,
+8
View File
@@ -166,6 +166,7 @@ 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>) {}
}
pub trait SearchableItemHandle: ItemHandle {
@@ -234,6 +235,8 @@ pub trait SearchableItemHandle: ItemHandle {
window: &mut Window,
cx: &mut App,
);
fn set_search_is_case_sensitive(&self, is_case_sensitive: Option<bool>, cx: &mut App);
}
impl<T: SearchableItem> SearchableItemHandle for Entity<T> {
@@ -390,6 +393,11 @@ 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) {
self.update(cx, |this, cx| {
this.set_search_is_case_sensitive(enabled, cx)
});
}
}
impl From<Box<dyn SearchableItemHandle>> for AnyView {
+41 -1
View File
@@ -3184,13 +3184,53 @@ Non-negative `integer` values
```json [settings]
"search": {
"button": true,
"whole_word": false,
"case_sensitive": false,
"include_ignored": false,
"regex": false
"regex": false,
"center_on_match": false
},
```
### Button
- Description: Whether to show the project search button in the status bar.
- Setting: `button`
- Default: `true`
### Whole Word
- Description: Whether to only match on whole words.
- Setting: `whole_word`
- Default: `false`
### Case Sensitive
- Description: Whether to match case sensitively. This setting affects both
searches and editor actions like "Select Next Occurrence", "Select Previous
Occurrence", and "Select All Occurrences".
- Setting: `case_sensitive`
- Default: `false`
### Include Ignore
- Description: Whether to include gitignored files in search results.
- Setting: `include_ignored`
- Default: `false`
### Regex
- Description: Whether to interpret the search query as a regular expression.
- Setting: `regex`
- Default: `false`
### Center On Match
- Description: Whether to center the cursor on each search match when navigating.
- Setting: `center_on_match`
- Default: `false`
## Search Wrap
- Description: If `search_wrap` is disabled, search result do not wrap around the end of the file