diff --git a/assets/settings/default.json b/assets/settings/default.json index 7fb583f95b0d6d39146ffe9e406201e958598905..d8c800081246dcf937f7380399d726dd3d349679 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -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 diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8c165a6d7ce0a5410000cb21d9616e4c508a6fb3..17eb051e35ad6e2ef0c2358cd0664cdba93af013 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -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, folding_newlines: Task<()>, + select_next_is_case_sensitive: Option, pub lookup_key: Option>, } @@ -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::(); 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::(); let is_empty = query.is_empty(); let select_state = SelectNextState { - query: AhoCorasick::new(&[query.chars().rev().collect::()])?, + query: self.build_query(&[query.chars().rev().collect::()], 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::()])?, + query: self + .build_query(&[selected_text.chars().rev().collect::()], 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(&self, patterns: I, cx: &Context) -> Result + where + I: IntoIterator, + 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, diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index de4198493a9ba2722aef58276ee385a117749fa0..e1984311d4eb0ba9d989f77a707b22698b00c750 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -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, } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index ce97cf9a1cc68ed4ff06d57ac02e0dbb9fdd8788..598d1383726a9610bb5a2c851cd1d56a709546ec 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -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); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index f82a4e7a30f47798e2db00a17b082a88fb6c7239..12590e4b3f95648dd653d408252ced460e2e834e 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1796,6 +1796,14 @@ impl SearchableItem for Editor { fn search_bar_visibility_changed(&mut self, _: bool, _: &mut Window, _: &mut Context) { self.expect_bounds_change = self.last_bounds; } + + fn set_search_is_case_sensitive( + &mut self, + case_sensitive: Option, + _cx: &mut Context, + ) { + self.select_next_is_case_sensitive = case_sensitive; + } } pub fn active_match_index( diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index d4b8e0b3eb9edb3612ba04dcd33deb61ed883755..764d0a81f7ac8c7fd03fe63c478aea14b3e2e31b 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -127,12 +127,6 @@ pub struct BufferSearchBar { regex_language: Option>, } -impl BufferSearchBar { - pub fn query_editor_focused(&self) -> bool { - self.query_editor_focused - } -} - impl EventEmitter for BufferSearchBar {} impl EventEmitter 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.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.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) { + 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| { diff --git a/crates/settings/src/settings_content/editor.rs b/crates/settings/src/settings_content/editor.rs index 2dc3c6c0fdc78bf470e78e0577cc886d1471e8b2..4ef5f3e427b8ca8a2658c7bb35012ecc9618e377 100644 --- a/crates/settings/src/settings_content/editor.rs +++ b/crates/settings/src/settings_content/editor.rs @@ -759,9 +759,13 @@ pub enum SnippetSortOrder { pub struct SearchSettingsContent { /// Whether to show the project search button in the status bar. pub button: Option, + /// Whether to only match on whole words. pub whole_word: Option, + /// Whether to match case sensitively. pub case_sensitive: Option, + /// Whether to include gitignored files in search results. pub include_ignored: Option, + /// Whether to interpret the search query as a regular expression. pub regex: Option, /// Whether to center the cursor on each search match when navigating. pub center_on_match: Option, diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 18da3f16f2e7a1e57dd42287059c0041d9309a78..9907df3be3eb8594f6cc8f63f05e2e93befd416c 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -166,6 +166,7 @@ pub trait SearchableItem: Item + EventEmitter { window: &mut Window, cx: &mut Context, ) -> Option; + fn set_search_is_case_sensitive(&mut self, _: Option, _: &mut Context) {} } 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, cx: &mut App); } impl SearchableItemHandle for Entity { @@ -390,6 +393,11 @@ impl SearchableItemHandle for Entity { this.toggle_filtered_search_ranges(enabled, window, cx) }); } + fn set_search_is_case_sensitive(&self, enabled: Option, cx: &mut App) { + self.update(cx, |this, cx| { + this.set_search_is_case_sensitive(enabled, cx) + }); + } } impl From> for AnyView { diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index ac72abd7c2635f1873ea2ee23770ba58babbaf6d..145620c3962984407db73bf7ac4c0a3bbfa75324 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -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