diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 92ae4a81ee..d970df1abd 100644 --- a/assets/keymaps/default.json +++ b/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" } }, { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 8097f5ecfd..6f5ae99df9 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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| { 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, + ) { + 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, path: impl AsRef, diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index abebb9a48f..9054d9e121 100644 --- a/crates/search/src/project_search.rs +++ b/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, + ) { + 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, + 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::()) + }); + 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::()) + }); + 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::()) + }) 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(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index c64b5189e1..5c1a75e97a 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -512,7 +512,7 @@ pub struct Workspace { follower_states_by_leader: FollowerStatesByLeader, last_leaders_by_pane: HashMap, PeerId>, window_edited: bool, - active_call: Option<(ModelHandle, Vec)>, + active_call: Option<(ModelHandle, Vec)>, leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>, database_id: WorkspaceId, app_state: Arc, @@ -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 { let project = self.project().read(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 6bbba0bd02..8a2691da15 100644 --- a/crates/zed/src/zed.rs +++ b/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);