Detailed changes
@@ -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
@@ -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,
@@ -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,
}
@@ -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);
@@ -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(
@@ -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>,
@@ -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 {
@@ -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