Detailed changes
@@ -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"
}
},
{
@@ -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>,
@@ -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();
@@ -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);
@@ -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);