search: Allow running a search with different options

Conrad Irwin created

Refactor search options to use bitflags so that we can represent
the entire set of settings in one place.

Change summary

Cargo.lock                          |   1 
crates/search/Cargo.toml            |   1 
crates/search/src/buffer_search.rs  | 181 +++++++++++++++++++++++-------
crates/search/src/project_search.rs |  70 ++++-------
crates/search/src/search.rs         |  43 ++++--
5 files changed, 196 insertions(+), 100 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -6428,6 +6428,7 @@ name = "search"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "bitflags",
  "client",
  "collections",
  "editor",

crates/search/Cargo.toml 🔗

@@ -9,6 +9,7 @@ path = "src/search.rs"
 doctest = false
 
 [dependencies]
+bitflags = "1"
 collections = { path = "../collections" }
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }

crates/search/src/buffer_search.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{
-    SearchOption, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
+    SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
     ToggleWholeWord,
 };
 use collections::HashMap;
@@ -42,12 +42,12 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(BufferSearchBar::select_next_match_on_pane);
     cx.add_action(BufferSearchBar::select_prev_match_on_pane);
     cx.add_action(BufferSearchBar::handle_editor_cancel);
-    add_toggle_option_action::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx);
-    add_toggle_option_action::<ToggleWholeWord>(SearchOption::WholeWord, cx);
-    add_toggle_option_action::<ToggleRegex>(SearchOption::Regex, cx);
+    add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
+    add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
+    add_toggle_option_action::<ToggleRegex>(SearchOptions::REGEX, cx);
 }
 
-fn add_toggle_option_action<A: Action>(option: SearchOption, cx: &mut AppContext) {
+fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
     cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
         if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
             if search_bar.update(cx, |search_bar, cx| search_bar.show(false, false, cx)) {
@@ -69,9 +69,8 @@ pub struct BufferSearchBar {
     seachable_items_with_matches:
         HashMap<Box<dyn WeakSearchableItemHandle>, Vec<Box<dyn Any + Send>>>,
     pending_search: Option<Task<()>>,
-    case_sensitive: bool,
-    whole_word: bool,
-    regex: bool,
+    search_options: SearchOptions,
+    default_options: SearchOptions,
     query_contains_error: bool,
     dismissed: bool,
 }
@@ -153,19 +152,19 @@ impl View for BufferSearchBar {
                             .with_children(self.render_search_option(
                                 supported_options.case,
                                 "Case",
-                                SearchOption::CaseSensitive,
+                                SearchOptions::CASE_SENSITIVE,
                                 cx,
                             ))
                             .with_children(self.render_search_option(
                                 supported_options.word,
                                 "Word",
-                                SearchOption::WholeWord,
+                                SearchOptions::WHOLE_WORD,
                                 cx,
                             ))
                             .with_children(self.render_search_option(
                                 supported_options.regex,
                                 "Regex",
-                                SearchOption::Regex,
+                                SearchOptions::REGEX,
                                 cx,
                             ))
                             .contained()
@@ -250,9 +249,8 @@ impl BufferSearchBar {
             active_searchable_item_subscription: None,
             active_match_index: None,
             seachable_items_with_matches: Default::default(),
-            case_sensitive: false,
-            whole_word: false,
-            regex: false,
+            default_options: SearchOptions::NONE,
+            search_options: SearchOptions::NONE,
             pending_search: None,
             query_contains_error: false,
             dismissed: true,
@@ -280,6 +278,17 @@ impl BufferSearchBar {
     }
 
     pub fn show(&mut self, focus: bool, suggest_query: bool, cx: &mut ViewContext<Self>) -> bool {
+        self.show_with_options(focus, suggest_query, self.default_options, cx)
+    }
+
+    pub fn show_with_options(
+        &mut self,
+        focus: bool,
+        suggest_query: bool,
+        search_option: SearchOptions,
+        cx: &mut ViewContext<Self>,
+    ) -> bool {
+        self.search_options = search_option;
         let searchable_item = if let Some(searchable_item) = &self.active_searchable_item {
             SearchableItemHandle::boxed_clone(searchable_item.as_ref())
         } else {
@@ -320,7 +329,7 @@ impl BufferSearchBar {
         &self,
         option_supported: bool,
         icon: &'static str,
-        option: SearchOption,
+        option: SearchOptions,
         cx: &mut ViewContext<Self>,
     ) -> Option<AnyElement<Self>> {
         if !option_supported {
@@ -328,9 +337,9 @@ impl BufferSearchBar {
         }
 
         let tooltip_style = theme::current(cx).tooltip.clone();
-        let is_active = self.is_search_option_enabled(option);
+        let is_active = self.search_options.contains(option);
         Some(
-            MouseEventHandler::<Self, _>::new(option as usize, cx, |state, cx| {
+            MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
                 let theme = theme::current(cx);
                 let style = theme
                     .search
@@ -346,7 +355,7 @@ impl BufferSearchBar {
             })
             .with_cursor_style(CursorStyle::PointingHand)
             .with_tooltip::<Self>(
-                option as usize,
+                option.bits as usize,
                 format!("Toggle {}", option.label()),
                 Some(option.to_toggle_action()),
                 tooltip_style,
@@ -461,21 +470,10 @@ impl BufferSearchBar {
         }
     }
 
-    fn is_search_option_enabled(&self, search_option: SearchOption) -> bool {
-        match search_option {
-            SearchOption::WholeWord => self.whole_word,
-            SearchOption::CaseSensitive => self.case_sensitive,
-            SearchOption::Regex => self.regex,
-        }
-    }
+    fn toggle_search_option(&mut self, search_option: SearchOptions, cx: &mut ViewContext<Self>) {
+        self.search_options.toggle(search_option);
+        self.default_options = self.search_options;
 
-    fn toggle_search_option(&mut self, search_option: SearchOption, cx: &mut ViewContext<Self>) {
-        let value = match search_option {
-            SearchOption::WholeWord => &mut self.whole_word,
-            SearchOption::CaseSensitive => &mut self.case_sensitive,
-            SearchOption::Regex => &mut self.regex,
-        };
-        *value = !*value;
         self.update_matches(false, cx);
         cx.notify();
     }
@@ -571,11 +569,11 @@ impl BufferSearchBar {
                 self.active_match_index.take();
                 active_searchable_item.clear_matches(cx);
             } else {
-                let query = if self.regex {
+                let query = if self.search_options.contains(SearchOptions::REGEX) {
                     match SearchQuery::regex(
                         query,
-                        self.whole_word,
-                        self.case_sensitive,
+                        self.search_options.contains(SearchOptions::WHOLE_WORD),
+                        self.search_options.contains(SearchOptions::CASE_SENSITIVE),
                         Vec::new(),
                         Vec::new(),
                     ) {
@@ -589,8 +587,8 @@ impl BufferSearchBar {
                 } else {
                     SearchQuery::text(
                         query,
-                        self.whole_word,
-                        self.case_sensitive,
+                        self.search_options.contains(SearchOptions::WHOLE_WORD),
+                        self.search_options.contains(SearchOptions::CASE_SENSITIVE),
                         Vec::new(),
                         Vec::new(),
                     )
@@ -656,8 +654,7 @@ mod tests {
     use language::Buffer;
     use unindent::Unindent as _;
 
-    #[gpui::test]
-    async fn test_search_simple(cx: &mut TestAppContext) {
+    fn init_test(cx: &mut TestAppContext) -> (ViewHandle<Editor>, ViewHandle<BufferSearchBar>) {
         crate::project_search::tests::init_test(cx);
 
         let buffer = cx.add_model(|cx| {
@@ -684,6 +681,13 @@ mod tests {
             search_bar
         });
 
+        (editor, search_bar)
+    }
+
+    #[gpui::test]
+    async fn test_search_simple(cx: &mut TestAppContext) {
+        let (editor, search_bar) = init_test(cx);
+
         // Search for a string that appears with different casing.
         // By default, search is case-insensitive.
         search_bar.update(cx, |search_bar, cx| {
@@ -708,7 +712,7 @@ mod tests {
 
         // Switch to a case sensitive search.
         search_bar.update(cx, |search_bar, cx| {
-            search_bar.toggle_search_option(SearchOption::CaseSensitive, cx);
+            search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
         });
         editor.next_notification(cx).await;
         editor.update(cx, |editor, cx| {
@@ -765,7 +769,7 @@ mod tests {
 
         // Switch to a whole word search.
         search_bar.update(cx, |search_bar, cx| {
-            search_bar.toggle_search_option(SearchOption::WholeWord, cx);
+            search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
         });
         editor.next_notification(cx).await;
         editor.update(cx, |editor, cx| {
@@ -966,4 +970,99 @@ mod tests {
             assert_eq!(search_bar.active_match_index, Some(2));
         });
     }
+
+    #[gpui::test]
+    async fn test_search_with_options(cx: &mut TestAppContext) {
+        let (editor, search_bar) = init_test(cx);
+
+        // show with options should make current search case sensitive
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.show_with_options(false, false, SearchOptions::CASE_SENSITIVE, cx);
+            search_bar.set_query("us", cx);
+        });
+        editor.next_notification(cx).await;
+        editor.update(cx, |editor, cx| {
+            assert_eq!(
+                editor.all_background_highlights(cx),
+                &[(
+                    DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45),
+                    Color::red(),
+                )]
+            );
+        });
+
+        // show should return to the default options (case insensitive)
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.show(true, true, cx);
+        });
+        editor.next_notification(cx).await;
+        editor.update(cx, |editor, cx| {
+            assert_eq!(
+                editor.all_background_highlights(cx),
+                &[
+                    (
+                        DisplayPoint::new(2, 17)..DisplayPoint::new(2, 19),
+                        Color::red(),
+                    ),
+                    (
+                        DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45),
+                        Color::red(),
+                    )
+                ]
+            );
+        });
+
+        // toggling a search option (even in show_with_options mode) should update the defaults
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.set_query("regex", cx);
+            search_bar.show_with_options(false, false, SearchOptions::CASE_SENSITIVE, cx);
+            search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx)
+        });
+        editor.next_notification(cx).await;
+        editor.update(cx, |editor, cx| {
+            assert_eq!(
+                editor.all_background_highlights(cx),
+                &[(
+                    DisplayPoint::new(0, 35)..DisplayPoint::new(0, 40),
+                    Color::red(),
+                ),]
+            );
+        });
+
+        // defaults should still include whole word
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.show(true, true, cx);
+        });
+        editor.next_notification(cx).await;
+        editor.update(cx, |editor, cx| {
+            assert_eq!(
+                editor.all_background_highlights(cx),
+                &[(
+                    DisplayPoint::new(0, 35)..DisplayPoint::new(0, 40),
+                    Color::red(),
+                ),]
+            );
+        });
+
+        // removing whole word changes the search again
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx)
+        });
+        editor.next_notification(cx).await;
+        editor.update(cx, |editor, cx| {
+            assert_eq!(
+                editor.all_background_highlights(cx),
+                &[
+                    (
+                        DisplayPoint::new(0, 35)..DisplayPoint::new(0, 40),
+                        Color::red(),
+                    ),
+                    (
+                        DisplayPoint::new(0, 44)..DisplayPoint::new(0, 49),
+                        Color::red()
+                    )
+                ]
+            );
+        });
+    }
 }

crates/search/src/project_search.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{
-    SearchOption, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
+    SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
     ToggleWholeWord,
 };
 use anyhow::Result;
@@ -51,12 +51,12 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(ProjectSearchBar::select_prev_match);
     cx.capture_action(ProjectSearchBar::tab);
     cx.capture_action(ProjectSearchBar::tab_previous);
-    add_toggle_option_action::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx);
-    add_toggle_option_action::<ToggleWholeWord>(SearchOption::WholeWord, cx);
-    add_toggle_option_action::<ToggleRegex>(SearchOption::Regex, cx);
+    add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
+    add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
+    add_toggle_option_action::<ToggleRegex>(SearchOptions::REGEX, cx);
 }
 
-fn add_toggle_option_action<A: Action>(option: SearchOption, cx: &mut AppContext) {
+fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
     cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
         if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<ProjectSearchBar>() {
             if search_bar.update(cx, |search_bar, cx| {
@@ -89,9 +89,7 @@ pub struct ProjectSearchView {
     model: ModelHandle<ProjectSearch>,
     query_editor: ViewHandle<Editor>,
     results_editor: ViewHandle<Editor>,
-    case_sensitive: bool,
-    whole_word: bool,
-    regex: bool,
+    search_options: SearchOptions,
     panels_with_errors: HashSet<InputPanel>,
     active_match_index: Option<usize>,
     search_id: usize,
@@ -408,9 +406,7 @@ impl ProjectSearchView {
         let project;
         let excerpts;
         let mut query_text = String::new();
-        let mut regex = false;
-        let mut case_sensitive = false;
-        let mut whole_word = false;
+        let mut options = SearchOptions::NONE;
 
         {
             let model = model.read(cx);
@@ -418,9 +414,7 @@ impl ProjectSearchView {
             excerpts = model.excerpts.clone();
             if let Some(active_query) = model.active_query.as_ref() {
                 query_text = active_query.as_str().to_string();
-                regex = active_query.is_regex();
-                case_sensitive = active_query.case_sensitive();
-                whole_word = active_query.whole_word();
+                options = SearchOptions::from_query(active_query);
             }
         }
         cx.observe(&model, |this, _, cx| this.model_changed(cx))
@@ -496,9 +490,7 @@ impl ProjectSearchView {
             model,
             query_editor,
             results_editor,
-            case_sensitive,
-            whole_word,
-            regex,
+            search_options: options,
             panels_with_errors: HashSet::new(),
             active_match_index: None,
             query_editor_was_focused: false,
@@ -594,11 +586,11 @@ impl ProjectSearchView {
                     return None;
                 }
             };
-        if self.regex {
+        if self.search_options.contains(SearchOptions::REGEX) {
             match SearchQuery::regex(
                 text,
-                self.whole_word,
-                self.case_sensitive,
+                self.search_options.contains(SearchOptions::WHOLE_WORD),
+                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
                 included_files,
                 excluded_files,
             ) {
@@ -615,8 +607,8 @@ impl ProjectSearchView {
         } else {
             Some(SearchQuery::text(
                 text,
-                self.whole_word,
-                self.case_sensitive,
+                self.search_options.contains(SearchOptions::WHOLE_WORD),
+                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
                 included_files,
                 excluded_files,
             ))
@@ -765,9 +757,7 @@ impl ProjectSearchBar {
                         search_view.query_editor.update(cx, |editor, cx| {
                             editor.set_text(old_query.as_str(), cx);
                         });
-                        search_view.regex = old_query.is_regex();
-                        search_view.whole_word = old_query.whole_word();
-                        search_view.case_sensitive = old_query.case_sensitive();
+                        search_view.search_options = SearchOptions::from_query(&old_query);
                     }
                 }
                 new_query
@@ -855,15 +845,10 @@ impl ProjectSearchBar {
         });
     }
 
-    fn toggle_search_option(&mut self, option: SearchOption, cx: &mut ViewContext<Self>) -> bool {
+    fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
         if let Some(search_view) = self.active_project_search.as_ref() {
             search_view.update(cx, |search_view, cx| {
-                let value = match option {
-                    SearchOption::WholeWord => &mut search_view.whole_word,
-                    SearchOption::CaseSensitive => &mut search_view.case_sensitive,
-                    SearchOption::Regex => &mut search_view.regex,
-                };
-                *value = !*value;
+                search_view.search_options.toggle(option);
                 search_view.search(cx);
             });
             cx.notify();
@@ -920,12 +905,12 @@ impl ProjectSearchBar {
     fn render_option_button(
         &self,
         icon: &'static str,
-        option: SearchOption,
+        option: SearchOptions,
         cx: &mut ViewContext<Self>,
     ) -> AnyElement<Self> {
         let tooltip_style = theme::current(cx).tooltip.clone();
         let is_active = self.is_option_enabled(option, cx);
-        MouseEventHandler::<Self, _>::new(option as usize, cx, |state, cx| {
+        MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
             let theme = theme::current(cx);
             let style = theme
                 .search
@@ -941,7 +926,7 @@ impl ProjectSearchBar {
         })
         .with_cursor_style(CursorStyle::PointingHand)
         .with_tooltip::<Self>(
-            option as usize,
+            option.bits as usize,
             format!("Toggle {}", option.label()),
             Some(option.to_toggle_action()),
             tooltip_style,
@@ -950,14 +935,9 @@ impl ProjectSearchBar {
         .into_any()
     }
 
-    fn is_option_enabled(&self, option: SearchOption, cx: &AppContext) -> bool {
+    fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
         if let Some(search) = self.active_project_search.as_ref() {
-            let search = search.read(cx);
-            match option {
-                SearchOption::WholeWord => search.whole_word,
-                SearchOption::CaseSensitive => search.case_sensitive,
-                SearchOption::Regex => search.regex,
-            }
+            search.read(cx).search_options.contains(option)
         } else {
             false
         }
@@ -1048,17 +1028,17 @@ impl View for ProjectSearchBar {
                             Flex::row()
                                 .with_child(self.render_option_button(
                                     "Case",
-                                    SearchOption::CaseSensitive,
+                                    SearchOptions::CASE_SENSITIVE,
                                     cx,
                                 ))
                                 .with_child(self.render_option_button(
                                     "Word",
-                                    SearchOption::WholeWord,
+                                    SearchOptions::WHOLE_WORD,
                                     cx,
                                 ))
                                 .with_child(self.render_option_button(
                                     "Regex",
-                                    SearchOption::Regex,
+                                    SearchOptions::REGEX,
                                     cx,
                                 ))
                                 .contained()

crates/search/src/search.rs 🔗

@@ -1,5 +1,7 @@
+use bitflags::bitflags;
 pub use buffer_search::BufferSearchBar;
 use gpui::{actions, Action, AppContext};
+use project::search::SearchQuery;
 pub use project_search::{ProjectSearchBar, ProjectSearchView};
 
 pub mod buffer_search;
@@ -21,27 +23,40 @@ actions!(
     ]
 );
 
-#[derive(Clone, Copy, PartialEq)]
-pub enum SearchOption {
-    WholeWord,
-    CaseSensitive,
-    Regex,
+bitflags! {
+    #[derive(Default)]
+    pub struct SearchOptions: u8 {
+        const NONE = 0b000;
+        const WHOLE_WORD = 0b001;
+        const CASE_SENSITIVE = 0b010;
+        const REGEX = 0b100;
+    }
 }
 
-impl SearchOption {
+impl SearchOptions {
     pub fn label(&self) -> &'static str {
-        match self {
-            SearchOption::WholeWord => "Match Whole Word",
-            SearchOption::CaseSensitive => "Match Case",
-            SearchOption::Regex => "Use Regular Expression",
+        match *self {
+            SearchOptions::WHOLE_WORD => "Match Whole Word",
+            SearchOptions::CASE_SENSITIVE => "Match Case",
+            SearchOptions::REGEX => "Use Regular Expression",
+            _ => panic!("{:?} is not a named SearchOption", self),
         }
     }
 
     pub fn to_toggle_action(&self) -> Box<dyn Action> {
-        match self {
-            SearchOption::WholeWord => Box::new(ToggleWholeWord),
-            SearchOption::CaseSensitive => Box::new(ToggleCaseSensitive),
-            SearchOption::Regex => Box::new(ToggleRegex),
+        match *self {
+            SearchOptions::WHOLE_WORD => Box::new(ToggleWholeWord),
+            SearchOptions::CASE_SENSITIVE => Box::new(ToggleCaseSensitive),
+            SearchOptions::REGEX => Box::new(ToggleRegex),
+            _ => panic!("{:?} is not a named SearchOption", self),
         }
     }
+
+    pub fn from_query(query: &SearchQuery) -> SearchOptions {
+        let mut options = SearchOptions::NONE;
+        options.set(SearchOptions::WHOLE_WORD, query.whole_word());
+        options.set(SearchOptions::CASE_SENSITIVE, query.case_sensitive());
+        options.set(SearchOptions::REGEX, query.is_regex());
+        options
+    }
 }