Add search in directory action in the project panel (#2774)

Kirill Bulatov created

Part of https://github.com/zed-industries/zed/issues/1153
Closes https://github.com/zed-industries/community/issues/1326

<img width="432" alt="image"
src="https://github.com/zed-industries/zed/assets/2690773/a50ee073-9d2e-4e5c-ae5e-23312693c540">

Adds an `project_panel::NewSearchInDirectory` action ("alt-shift-f"
default) in the project editor context to open a new project search in
the selected directory.

Release Notes:

- Adds an action to open project search in the project panel's directory

Change summary

assets/keymaps/default.json               |   3 
crates/project_panel/src/project_panel.rs | 110 +++++++++++++++++
crates/search/src/project_search.rs       | 152 ++++++++++++++++++++++++
crates/workspace/src/workspace.rs         |   6 
crates/zed/src/zed.rs                     |  21 +++
5 files changed, 287 insertions(+), 5 deletions(-)

Detailed changes

assets/keymaps/default.json 🔗

@@ -529,7 +529,8 @@
       "alt-cmd-shift-c": "project_panel::CopyRelativePath",
       "f2": "project_panel::Rename",
       "backspace": "project_panel::Delete",
-      "alt-cmd-r": "project_panel::RevealInFinder"
+      "alt-cmd-r": "project_panel::RevealInFinder",
+      "alt-shift-f": "project_panel::NewSearchInDirectory"
     }
   },
   {

crates/project_panel/src/project_panel.rs 🔗

@@ -125,7 +125,8 @@ actions!(
         Paste,
         Delete,
         Rename,
-        ToggleFocus
+        ToggleFocus,
+        NewSearchInDirectory,
     ]
 );
 
@@ -151,6 +152,7 @@ pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
     cx.add_action(ProjectPanel::copy_path);
     cx.add_action(ProjectPanel::copy_relative_path);
     cx.add_action(ProjectPanel::reveal_in_finder);
+    cx.add_action(ProjectPanel::new_search_in_directory);
     cx.add_action(
         |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
             this.paste(action, cx);
@@ -169,6 +171,9 @@ pub enum Event {
     },
     DockPositionChanged,
     Focus,
+    NewSearchInDirectory {
+        dir_entry: Entry,
+    },
 }
 
 #[derive(Serialize, Deserialize)]
@@ -417,6 +422,12 @@ impl ProjectPanel {
                 CopyRelativePath,
             ));
             menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
+            if entry.is_dir() {
+                menu_entries.push(ContextMenuItem::action(
+                    "Search inside",
+                    NewSearchInDirectory,
+                ));
+            }
             if let Some(clipboard_entry) = self.clipboard_entry {
                 if clipboard_entry.worktree_id() == worktree.id() {
                     menu_entries.push(ContextMenuItem::action("Paste", Paste));
@@ -928,6 +939,20 @@ impl ProjectPanel {
         }
     }
 
+    pub fn new_search_in_directory(
+        &mut self,
+        _: &NewSearchInDirectory,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if let Some((_, entry)) = self.selected_entry(cx) {
+            if entry.is_dir() {
+                cx.emit(Event::NewSearchInDirectory {
+                    dir_entry: entry.clone(),
+                });
+            }
+        }
+    }
+
     fn move_entry(
         &mut self,
         entry_to_move: ProjectEntryId,
@@ -1677,7 +1702,11 @@ mod tests {
     use project::FakeFs;
     use serde_json::json;
     use settings::SettingsStore;
-    use std::{collections::HashSet, path::Path};
+    use std::{
+        collections::HashSet,
+        path::Path,
+        sync::atomic::{self, AtomicUsize},
+    };
     use workspace::{pane, AppState};
 
     #[gpui::test]
@@ -2516,6 +2545,83 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
+        init_test_with_editor(cx);
+
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/src",
+            json!({
+                "test": {
+                    "first.rs": "// First Rust file",
+                    "second.rs": "// Second Rust file",
+                    "third.rs": "// Third Rust file",
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
+        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
+
+        let new_search_events_count = Arc::new(AtomicUsize::new(0));
+        let _subscription = panel.update(cx, |_, cx| {
+            let subcription_count = Arc::clone(&new_search_events_count);
+            cx.subscribe(&cx.handle(), move |_, _, event, _| {
+                if matches!(event, Event::NewSearchInDirectory { .. }) {
+                    subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
+                }
+            })
+        });
+
+        toggle_expand_dir(&panel, "src/test", cx);
+        select_path(&panel, "src/test/first.rs", cx);
+        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v src",
+                "    v test",
+                "          first.rs  <== selected",
+                "          second.rs",
+                "          third.rs"
+            ]
+        );
+        panel.update(cx, |panel, cx| {
+            panel.new_search_in_directory(&NewSearchInDirectory, cx)
+        });
+        assert_eq!(
+            new_search_events_count.load(atomic::Ordering::SeqCst),
+            0,
+            "Should not trigger new search in directory when called on a file"
+        );
+
+        select_path(&panel, "src/test", cx);
+        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v src",
+                "    v test  <== selected",
+                "          first.rs",
+                "          second.rs",
+                "          third.rs"
+            ]
+        );
+        panel.update(cx, |panel, cx| {
+            panel.new_search_in_directory(&NewSearchInDirectory, cx)
+        });
+        assert_eq!(
+            new_search_events_count.load(atomic::Ordering::SeqCst),
+            1,
+            "Should trigger new search in directory when called on a directory"
+        );
+    }
+
     fn toggle_expand_dir(
         panel: &ViewHandle<ProjectPanel>,
         path: impl AsRef<Path>,

crates/search/src/project_search.rs 🔗

@@ -18,7 +18,7 @@ use gpui::{
     Task, View, ViewContext, ViewHandle, WeakModelHandle, WeakViewHandle,
 };
 use menu::Confirm;
-use project::{search::SearchQuery, Project};
+use project::{search::SearchQuery, Entry, Project};
 use smallvec::SmallVec;
 use std::{
     any::{Any, TypeId},
@@ -501,6 +501,28 @@ impl ProjectSearchView {
         this
     }
 
+    pub fn new_search_in_directory(
+        workspace: &mut Workspace,
+        dir_entry: &Entry,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        if !dir_entry.is_dir() {
+            return;
+        }
+        let filter_path = dir_entry.path.join("**");
+        let Some(filter_str) = filter_path.to_str() else { return; };
+
+        let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
+        let search = cx.add_view(|cx| ProjectSearchView::new(model, cx));
+        workspace.add_item(Box::new(search.clone()), cx);
+        search.update(cx, |search, cx| {
+            search
+                .included_files_editor
+                .update(cx, |editor, cx| editor.set_text(filter_str, cx));
+            search.focus_query_editor(cx)
+        });
+    }
+
     // Re-activate the most recently activated search or the most recent if it has been closed.
     // If no search exists in the workspace, create a new one.
     fn deploy(
@@ -1414,6 +1436,134 @@ pub mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_new_project_search_in_directory(
+        deterministic: Arc<Deterministic>,
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/dir",
+            json!({
+                "a": {
+                    "one.rs": "const ONE: usize = 1;",
+                    "two.rs": "const TWO: usize = one::ONE + one::ONE;",
+                },
+                "b": {
+                    "three.rs": "const THREE: usize = one::ONE + two::TWO;",
+                    "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
+                },
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
+        let worktree_id = project.read_with(cx, |project, cx| {
+            project.worktrees(cx).next().unwrap().read(cx).id()
+        });
+        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
+
+        let active_item = cx.read(|cx| {
+            workspace
+                .read(cx)
+                .active_pane()
+                .read(cx)
+                .active_item()
+                .and_then(|item| item.downcast::<ProjectSearchView>())
+        });
+        assert!(
+            active_item.is_none(),
+            "Expected no search panel to be active, but got: {active_item:?}"
+        );
+
+        let one_file_entry = cx.update(|cx| {
+            workspace
+                .read(cx)
+                .project()
+                .read(cx)
+                .entry_for_path(&(worktree_id, "a/one.rs").into(), cx)
+                .expect("no entry for /a/one.rs file")
+        });
+        assert!(one_file_entry.is_file());
+        workspace.update(cx, |workspace, cx| {
+            ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx)
+        });
+        let active_search_entry = cx.read(|cx| {
+            workspace
+                .read(cx)
+                .active_pane()
+                .read(cx)
+                .active_item()
+                .and_then(|item| item.downcast::<ProjectSearchView>())
+        });
+        assert!(
+            active_search_entry.is_none(),
+            "Expected no search panel to be active for file entry"
+        );
+
+        let a_dir_entry = cx.update(|cx| {
+            workspace
+                .read(cx)
+                .project()
+                .read(cx)
+                .entry_for_path(&(worktree_id, "a").into(), cx)
+                .expect("no entry for /a/ directory")
+        });
+        assert!(a_dir_entry.is_dir());
+        workspace.update(cx, |workspace, cx| {
+            ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx)
+        });
+
+        let Some(search_view) = cx.read(|cx| {
+            workspace
+                .read(cx)
+                .active_pane()
+                .read(cx)
+                .active_item()
+                .and_then(|item| item.downcast::<ProjectSearchView>())
+        }) else {
+            panic!("Search view expected to appear after new search in directory event trigger")
+        };
+        deterministic.run_until_parked();
+        search_view.update(cx, |search_view, cx| {
+            assert!(
+                search_view.query_editor.is_focused(cx),
+                "On new search in directory, focus should be moved into query editor"
+            );
+            search_view.excluded_files_editor.update(cx, |editor, cx| {
+                assert!(
+                    editor.display_text(cx).is_empty(),
+                    "New search in directory should not have any excluded files"
+                );
+            });
+            search_view.included_files_editor.update(cx, |editor, cx| {
+                assert_eq!(
+                    editor.display_text(cx),
+                    a_dir_entry.path.join("**").display().to_string(),
+                    "New search in directory should have included dir entry path"
+                );
+            });
+        });
+
+        search_view.update(cx, |search_view, cx| {
+            search_view
+                .query_editor
+                .update(cx, |query_editor, cx| query_editor.set_text("const", cx));
+            search_view.search(cx);
+        });
+        deterministic.run_until_parked();
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(
+                search_view
+                    .results_editor
+                    .update(cx, |editor, cx| editor.display_text(cx)),
+                "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
+                "New search in directory should have a filter that matches a certain directory"
+            );
+        });
+    }
+
     pub fn init_test(cx: &mut TestAppContext) {
         cx.foreground().forbid_parking();
         let fonts = cx.font_cache();

crates/workspace/src/workspace.rs 🔗

@@ -512,7 +512,7 @@ pub struct Workspace {
     follower_states_by_leader: FollowerStatesByLeader,
     last_leaders_by_pane: HashMap<WeakViewHandle<Pane>, PeerId>,
     window_edited: bool,
-    active_call: Option<(ModelHandle<ActiveCall>, Vec<gpui::Subscription>)>,
+    active_call: Option<(ModelHandle<ActiveCall>, Vec<Subscription>)>,
     leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
     database_id: WorkspaceId,
     app_state: Arc<AppState>,
@@ -3009,6 +3009,10 @@ impl Workspace {
         self.database_id
     }
 
+    pub fn push_subscription(&mut self, subscription: Subscription) {
+        self.subscriptions.push(subscription)
+    }
+
     fn location(&self, cx: &AppContext) -> Option<WorkspaceLocation> {
         let project = self.project().read(cx);
 

crates/zed/src/zed.rs 🔗

@@ -338,6 +338,27 @@ pub fn initialize_workspace(
         let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone());
         let (project_panel, terminal_panel, assistant_panel) =
             futures::try_join!(project_panel, terminal_panel, assistant_panel)?;
+
+        cx.update(|cx| {
+            if let Some(workspace) = workspace_handle.upgrade(cx) {
+                cx.update_window(project_panel.window_id(), |cx| {
+                    workspace.update(cx, |workspace, cx| {
+                        let project_panel_subscription =
+                            cx.subscribe(&project_panel, move |workspace, _, event, cx| {
+                                if let project_panel::Event::NewSearchInDirectory { dir_entry } =
+                                    event
+                                {
+                                    search::ProjectSearchView::new_search_in_directory(
+                                        workspace, dir_entry, cx,
+                                    )
+                                }
+                            });
+                        workspace.push_subscription(project_panel_subscription);
+                    });
+                });
+            }
+        });
+
         workspace_handle.update(&mut cx, |workspace, cx| {
             let project_panel_position = project_panel.position(cx);
             workspace.add_panel(project_panel, cx);