Allow buffer search in project search (#23819)

Conrad Irwin and Nico created

Closes #13437
Closes #19993

Release Notes:

- Allow searching within the results of a project search
- vim: Fix `/`/`?`, `n`/`N`, `gn`/`gN`,`*`/`#` in project search results

---------

Co-authored-by: Nico <nico.lehmann@gmail.com>

Change summary

assets/keymaps/default-linux.json         |   2 
assets/keymaps/default-macos.json         |   2 
crates/editor/src/editor.rs               |   6 
crates/editor/src/items.rs                |  27 ++
crates/language_tools/src/lsp_log.rs      |   3 
crates/search/src/buffer_search.rs        | 231 +++++++++++++++---------
crates/search/src/project_search.rs       |   4 
crates/terminal_view/src/terminal_view.rs |   3 
crates/workspace/src/searchable.rs        |  12 
crates/workspace/src/toolbar.rs           |   6 
10 files changed, 191 insertions(+), 105 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -136,7 +136,7 @@
       "ctrl-k z": "editor::ToggleSoftWrap",
       "find": "buffer_search::Deploy",
       "ctrl-f": "buffer_search::Deploy",
-      "ctrl-h": ["buffer_search::Deploy", { "replace_enabled": true }],
+      "ctrl-h": "buffer_search::DeployReplace",
       // "cmd-e": ["buffer_search::Deploy", { "focus": false }],
       "ctrl->": "assistant::QuoteSelection",
       "ctrl-<": "assistant::InsertIntoEditor",

assets/keymaps/default-macos.json 🔗

@@ -145,7 +145,7 @@
       "cmd-shift-enter": "editor::NewlineAbove",
       "cmd-k z": "editor::ToggleSoftWrap",
       "cmd-f": "buffer_search::Deploy",
-      "cmd-alt-f": ["buffer_search::Deploy", { "replace_enabled": true }],
+      "cmd-alt-f": "buffer_search::DeployReplace",
       "cmd-alt-l": ["buffer_search::Deploy", { "selection_search_enabled": true }],
       "cmd-e": ["buffer_search::Deploy", { "focus": false }],
       "cmd->": "assistant::QuoteSelection",

crates/editor/src/editor.rs 🔗

@@ -728,6 +728,7 @@ pub struct Editor {
     expect_bounds_change: Option<Bounds<Pixels>>,
     tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>,
     tasks_update_task: Option<Task<()>>,
+    in_project_search: bool,
     previous_search_ranges: Option<Arc<[Range<Anchor>]>>,
     breadcrumb_header: Option<String>,
     focused_block: Option<FocusedBlock>,
@@ -1426,6 +1427,7 @@ impl Editor {
             ],
             tasks_update_task: None,
             linked_edit_ranges: Default::default(),
+            in_project_search: false,
             previous_search_ranges: None,
             breadcrumb_header: None,
             focused_block: None,
@@ -1703,6 +1705,10 @@ impl Editor {
         self.collaboration_hub = Some(hub);
     }
 
+    pub fn set_in_project_search(&mut self, in_project_search: bool) {
+        self.in_project_search = in_project_search;
+    }
+
     pub fn set_custom_context_menu(
         &mut self,
         f: impl 'static

crates/editor/src/items.rs 🔗

@@ -38,8 +38,11 @@ use text::{BufferId, Selection};
 use theme::{Theme, ThemeSettings};
 use ui::{h_flex, prelude::*, IconDecorationKind, Label};
 use util::{paths::PathExt, ResultExt, TryFutureExt};
-use workspace::item::{BreadcrumbText, FollowEvent};
 use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams};
+use workspace::{
+    item::{BreadcrumbText, FollowEvent},
+    searchable::SearchOptions,
+};
 use workspace::{
     item::{FollowableItem, Item, ItemEvent, ProjectItem},
     searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
@@ -1324,6 +1327,28 @@ impl SearchableItem for Editor {
         }
     }
 
+    fn supported_options(&self) -> SearchOptions {
+        if self.in_project_search {
+            SearchOptions {
+                case: true,
+                word: true,
+                regex: true,
+                replacement: false,
+                selection: false,
+                find_in_results: true,
+            }
+        } else {
+            SearchOptions {
+                case: true,
+                word: true,
+                regex: true,
+                replacement: true,
+                selection: true,
+                find_in_results: false,
+            }
+        }
+    }
+
     fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context<Self>) -> String {
         let setting = EditorSettings::get_global(cx).seed_search_query_from_cursor;
         let snapshot = &self.snapshot(window, cx).buffer_snapshot;

crates/language_tools/src/lsp_log.rs 🔗

@@ -1148,11 +1148,12 @@ impl SearchableItem for LspLogView {
     ) {
         // Since LSP Log is read-only, it doesn't make sense to support replace operation.
     }
-    fn supported_options() -> workspace::searchable::SearchOptions {
+    fn supported_options(&self) -> workspace::searchable::SearchOptions {
         workspace::searchable::SearchOptions {
             case: true,
             word: true,
             regex: true,
+            find_in_results: false,
             // LSP log is read-only.
             replacement: false,
             selection: false,

crates/search/src/buffer_search.rs 🔗

@@ -55,7 +55,7 @@ pub struct Deploy {
 
 impl_actions!(buffer_search, [Deploy]);
 
-actions!(buffer_search, [Dismiss, FocusEditor]);
+actions!(buffer_search, [DeployReplace, Dismiss, FocusEditor]);
 
 impl Deploy {
     pub fn find() -> Self {
@@ -65,6 +65,14 @@ impl Deploy {
             selection_search_enabled: false,
         }
     }
+
+    pub fn replace() -> Self {
+        Self {
+            focus: true,
+            replace_enabled: true,
+            selection_search_enabled: false,
+        }
+    }
 }
 
 pub enum Event {
@@ -156,7 +164,7 @@ impl Render for BufferSearchBar {
         let hide_inline_icons = self.editor_needed_width
             > self.editor_scroll_handle.bounds().size.width - window.rem_size() * 6.;
 
-        let supported_options = self.supported_options();
+        let supported_options = self.supported_options(cx);
 
         if self.query_editor.update(cx, |query_editor, _cx| {
             query_editor.placeholder_text().is_none()
@@ -223,6 +231,9 @@ impl Render for BufferSearchBar {
 
         let search_line = h_flex()
             .gap_2()
+            .when(supported_options.find_in_results, |el| {
+                el.child(Label::new("Find in results").color(Color::Hint))
+            })
             .child(
                 input_base_styles()
                     .id("editor-scroll")
@@ -328,56 +339,70 @@ impl Render for BufferSearchBar {
                             }),
                         )
                     })
-                    .child(
-                        IconButton::new("select-all", ui::IconName::SelectAll)
-                            .on_click(|_, window, cx| {
-                                window.dispatch_action(SelectAllMatches.boxed_clone(), cx)
-                            })
-                            .shape(IconButtonShape::Square)
-                            .tooltip({
-                                let focus_handle = focus_handle.clone();
-                                move |window, cx| {
-                                    Tooltip::for_action_in(
-                                        "Select All Matches",
-                                        &SelectAllMatches,
-                                        &focus_handle,
-                                        window,
-                                        cx,
-                                    )
-                                }
-                            }),
-                    )
-                    .child(
-                        h_flex()
-                            .pl_2()
-                            .ml_1()
-                            .border_l_1()
-                            .border_color(cx.theme().colors().border_variant)
-                            .child(render_nav_button(
-                                ui::IconName::ChevronLeft,
-                                self.active_match_index.is_some(),
-                                "Select Previous Match",
-                                &SelectPrevMatch,
-                                focus_handle.clone(),
+                    .when(!supported_options.find_in_results, |el| {
+                        el.child(
+                            IconButton::new("select-all", ui::IconName::SelectAll)
+                                .on_click(|_, window, cx| {
+                                    window.dispatch_action(SelectAllMatches.boxed_clone(), cx)
+                                })
+                                .shape(IconButtonShape::Square)
+                                .tooltip({
+                                    let focus_handle = focus_handle.clone();
+                                    move |window, cx| {
+                                        Tooltip::for_action_in(
+                                            "Select All Matches",
+                                            &SelectAllMatches,
+                                            &focus_handle,
+                                            window,
+                                            cx,
+                                        )
+                                    }
+                                }),
+                        )
+                        .child(
+                            h_flex()
+                                .pl_2()
+                                .ml_1()
+                                .border_l_1()
+                                .border_color(cx.theme().colors().border_variant)
+                                .child(render_nav_button(
+                                    ui::IconName::ChevronLeft,
+                                    self.active_match_index.is_some(),
+                                    "Select Previous Match",
+                                    &SelectPrevMatch,
+                                    focus_handle.clone(),
+                                ))
+                                .child(render_nav_button(
+                                    ui::IconName::ChevronRight,
+                                    self.active_match_index.is_some(),
+                                    "Select Next Match",
+                                    &SelectNextMatch,
+                                    focus_handle.clone(),
+                                )),
+                        )
+                        .when(!narrow_mode, |this| {
+                            this.child(h_flex().ml_2().min_w(rems_from_px(40.)).child(
+                                Label::new(match_text).size(LabelSize::Small).color(
+                                    if self.active_match_index.is_some() {
+                                        Color::Default
+                                    } else {
+                                        Color::Disabled
+                                    },
+                                ),
                             ))
-                            .child(render_nav_button(
-                                ui::IconName::ChevronRight,
-                                self.active_match_index.is_some(),
-                                "Select Next Match",
-                                &SelectNextMatch,
-                                focus_handle.clone(),
-                            )),
-                    )
-                    .when(!narrow_mode, |this| {
-                        this.child(h_flex().ml_2().min_w(rems_from_px(40.)).child(
-                            Label::new(match_text).size(LabelSize::Small).color(
-                                if self.active_match_index.is_some() {
-                                    Color::Default
-                                } else {
-                                    Color::Disabled
-                                },
-                            ),
-                        ))
+                        })
+                    })
+                    .when(supported_options.find_in_results, |el| {
+                        el.child(
+                            IconButton::new(SharedString::from("Close"), IconName::Close)
+                                .shape(IconButtonShape::Square)
+                                .tooltip(move |window, cx| {
+                                    Tooltip::for_action("Close Search Bar", &Dismiss, window, cx)
+                                })
+                                .on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
+                                    this.dismiss(&Dismiss, window, cx)
+                                })),
+                        )
                     }),
             );
 
@@ -447,49 +472,43 @@ impl Render for BufferSearchBar {
             .on_action(cx.listener(Self::dismiss))
             .on_action(cx.listener(Self::select_next_match))
             .on_action(cx.listener(Self::select_prev_match))
-            .when(self.supported_options().replacement, |this| {
+            .when(self.supported_options(cx).replacement, |this| {
                 this.on_action(cx.listener(Self::toggle_replace))
                     .when(in_replace, |this| {
                         this.on_action(cx.listener(Self::replace_next))
                             .on_action(cx.listener(Self::replace_all))
                     })
             })
-            .when(self.supported_options().case, |this| {
+            .when(self.supported_options(cx).case, |this| {
                 this.on_action(cx.listener(Self::toggle_case_sensitive))
             })
-            .when(self.supported_options().word, |this| {
+            .when(self.supported_options(cx).word, |this| {
                 this.on_action(cx.listener(Self::toggle_whole_word))
             })
-            .when(self.supported_options().regex, |this| {
+            .when(self.supported_options(cx).regex, |this| {
                 this.on_action(cx.listener(Self::toggle_regex))
             })
-            .when(self.supported_options().selection, |this| {
+            .when(self.supported_options(cx).selection, |this| {
                 this.on_action(cx.listener(Self::toggle_selection))
             })
-            .child(
-                h_flex()
-                    .relative()
-                    .child(search_line.w_full())
-                    .when(!narrow_mode, |div| {
-                        div.child(
-                            h_flex().absolute().right_0().child(
-                                IconButton::new(SharedString::from("Close"), IconName::Close)
-                                    .shape(IconButtonShape::Square)
-                                    .tooltip(move |window, cx| {
-                                        Tooltip::for_action(
-                                            "Close Search Bar",
-                                            &Dismiss,
-                                            window,
-                                            cx,
-                                        )
-                                    })
-                                    .on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
-                                        this.dismiss(&Dismiss, window, cx)
-                                    })),
-                            ),
-                        )
-                    }),
-            )
+            .child(h_flex().relative().child(search_line.w_full()).when(
+                !narrow_mode && !supported_options.find_in_results,
+                |div| {
+                    div.child(
+                        h_flex().absolute().right_0().child(
+                            IconButton::new(SharedString::from("Close"), IconName::Close)
+                                .shape(IconButtonShape::Square)
+                                .tooltip(move |window, cx| {
+                                    Tooltip::for_action("Close Search Bar", &Dismiss, window, cx)
+                                })
+                                .on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
+                                    this.dismiss(&Dismiss, window, cx)
+                                })),
+                        ),
+                    )
+                    .w_full()
+                },
+            ))
             .children(replace_line)
     }
 }
@@ -531,10 +550,15 @@ 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);
             drop(self.update_matches(true, window, cx));
             if !self.dismissed {
-                return ToolbarItemLocation::Secondary;
+                if is_project_search {
+                    self.dismiss(&Default::default(), window, cx);
+                } else {
+                    return ToolbarItemLocation::Secondary;
+                }
             }
         }
         ToolbarItemLocation::Hidden
@@ -549,40 +573,56 @@ impl BufferSearchBar {
         }));
         registrar.register_handler(ForDeployed(
             |this, action: &ToggleCaseSensitive, window, cx| {
-                if this.supported_options().case {
+                if this.supported_options(cx).case {
                     this.toggle_case_sensitive(action, window, cx);
                 }
             },
         ));
         registrar.register_handler(ForDeployed(|this, action: &ToggleWholeWord, window, cx| {
-            if this.supported_options().word {
+            if this.supported_options(cx).word {
                 this.toggle_whole_word(action, window, cx);
             }
         }));
         registrar.register_handler(ForDeployed(|this, action: &ToggleRegex, window, cx| {
-            if this.supported_options().regex {
+            if this.supported_options(cx).regex {
                 this.toggle_regex(action, window, cx);
             }
         }));
         registrar.register_handler(ForDeployed(|this, action: &ToggleSelection, window, cx| {
-            if this.supported_options().selection {
+            if this.supported_options(cx).selection {
                 this.toggle_selection(action, window, cx);
+            } else {
+                cx.propagate();
             }
         }));
         registrar.register_handler(ForDeployed(|this, action: &ToggleReplace, window, cx| {
-            if this.supported_options().replacement {
+            if this.supported_options(cx).replacement {
                 this.toggle_replace(action, window, cx);
+            } else {
+                cx.propagate();
             }
         }));
         registrar.register_handler(WithResults(|this, action: &SelectNextMatch, window, cx| {
-            this.select_next_match(action, window, cx);
+            if this.supported_options(cx).find_in_results {
+                cx.propagate();
+            } else {
+                this.select_next_match(action, window, cx);
+            }
         }));
         registrar.register_handler(WithResults(|this, action: &SelectPrevMatch, window, cx| {
-            this.select_prev_match(action, window, cx);
+            if this.supported_options(cx).find_in_results {
+                cx.propagate();
+            } else {
+                this.select_prev_match(action, window, cx);
+            }
         }));
         registrar.register_handler(WithResults(
             |this, action: &SelectAllMatches, window, cx| {
-                this.select_all_matches(action, window, cx);
+                if this.supported_options(cx).find_in_results {
+                    cx.propagate();
+                } else {
+                    this.select_all_matches(action, window, cx);
+                }
             },
         ));
         registrar.register_handler(ForDeployed(
@@ -599,6 +639,13 @@ impl BufferSearchBar {
         registrar.register_handler(ForDeployed(|this, deploy, window, cx| {
             this.deploy(deploy, window, cx);
         }));
+        registrar.register_handler(ForDismissed(|this, _: &DeployReplace, window, cx| {
+            if this.supported_options(cx).find_in_results {
+                cx.propagate();
+            } else {
+                this.deploy(&Deploy::replace(), window, cx);
+            }
+        }));
         registrar.register_handler(ForDismissed(|this, deploy, window, cx| {
             this.deploy(deploy, window, cx);
         }))
@@ -735,10 +782,10 @@ impl BufferSearchBar {
         true
     }
 
-    fn supported_options(&self) -> workspace::searchable::SearchOptions {
+    fn supported_options(&self, cx: &mut Context<Self>) -> workspace::searchable::SearchOptions {
         self.active_searchable_item
-            .as_deref()
-            .map(SearchableItemHandle::supported_options)
+            .as_ref()
+            .map(|item| item.supported_options(cx))
             .unwrap_or_default()
     }
 

crates/search/src/project_search.rs 🔗

@@ -421,6 +421,9 @@ impl Item for ProjectSearchView {
             None
         }
     }
+    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+        Some(Box::new(self.results_editor.clone()))
+    }
 
     fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         self.results_editor
@@ -736,6 +739,7 @@ impl ProjectSearchView {
             let mut editor =
                 Editor::for_multibuffer(excerpts, Some(project.clone()), true, window, cx);
             editor.set_searchable(false);
+            editor.set_in_project_search(true);
             editor
         });
         subscriptions.push(cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab)));

crates/terminal_view/src/terminal_view.rs 🔗

@@ -1479,13 +1479,14 @@ impl SerializableItem for TerminalView {
 impl SearchableItem for TerminalView {
     type Match = RangeInclusive<Point>;
 
-    fn supported_options() -> SearchOptions {
+    fn supported_options(&self) -> SearchOptions {
         SearchOptions {
             case: false,
             word: false,
             regex: true,
             replacement: false,
             selection: false,
+            find_in_results: false,
         }
     }
 

crates/workspace/src/searchable.rs 🔗

@@ -42,18 +42,20 @@ pub struct SearchOptions {
     /// Specifies whether the  supports search & replace.
     pub replacement: bool,
     pub selection: bool,
+    pub find_in_results: bool,
 }
 
 pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
     type Match: Any + Sync + Send + Clone;
 
-    fn supported_options() -> SearchOptions {
+    fn supported_options(&self) -> SearchOptions {
         SearchOptions {
             case: true,
             word: true,
             regex: true,
             replacement: true,
             selection: true,
+            find_in_results: false,
         }
     }
 
@@ -66,7 +68,7 @@ pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
     }
 
     fn has_filtered_search_ranges(&mut self) -> bool {
-        Self::supported_options().selection
+        self.supported_options().selection
     }
 
     fn toggle_filtered_search_ranges(
@@ -157,7 +159,7 @@ pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
 pub trait SearchableItemHandle: ItemHandle {
     fn downgrade(&self) -> Box<dyn WeakSearchableItemHandle>;
     fn boxed_clone(&self) -> Box<dyn SearchableItemHandle>;
-    fn supported_options(&self) -> SearchOptions;
+    fn supported_options(&self, cx: &App) -> SearchOptions;
     fn subscribe_to_search_events(
         &self,
         window: &mut Window,
@@ -224,8 +226,8 @@ impl<T: SearchableItem> SearchableItemHandle for Entity<T> {
         Box::new(self.clone())
     }
 
-    fn supported_options(&self) -> SearchOptions {
-        T::supported_options()
+    fn supported_options(&self, cx: &App) -> SearchOptions {
+        self.read(cx).supported_options()
     }
 
     fn subscribe_to_search_events(

crates/workspace/src/toolbar.rs 🔗

@@ -82,7 +82,7 @@ impl Toolbar {
     }
 
     fn secondary_items(&self) -> impl Iterator<Item = &dyn ToolbarItemViewHandle> {
-        self.items.iter().filter_map(|(item, location)| {
+        self.items.iter().rev().filter_map(|(item, location)| {
             if *location == ToolbarItemLocation::Secondary {
                 Some(item.as_ref())
             } else {
@@ -98,7 +98,7 @@ impl Render for Toolbar {
             return div();
         }
 
-        let secondary_item = self.secondary_items().next().map(|item| item.to_any());
+        let secondary_items = self.secondary_items().map(|item| item.to_any());
 
         let has_left_items = self.left_items().count() > 0;
         let has_right_items = self.right_items().count() > 0;
@@ -145,7 +145,7 @@ impl Render for Toolbar {
                         }),
                 )
             })
-            .children(secondary_item)
+            .children(secondary_items)
     }
 }