diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index f5abc1ad79..5a46f7c2ff 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -4920,6 +4920,7 @@ async fn test_project_search( false, Default::default(), Default::default(), + None, ) .unwrap(), cx, diff --git a/crates/collab/src/tests/random_project_collaboration_tests.rs b/crates/collab/src/tests/random_project_collaboration_tests.rs index 490718a9ce..884a491eb3 100644 --- a/crates/collab/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab/src/tests/random_project_collaboration_tests.rs @@ -883,6 +883,7 @@ impl RandomizedTest for ProjectCollaborationTest { false, Default::default(), Default::default(), + None, ) .unwrap(), cx, diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs index 0be9071489..88a1e72f1b 100644 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ b/crates/collab_ui/src/chat_panel/message_editor.rs @@ -31,6 +31,7 @@ static MENTIONS_SEARCH: LazyLock = LazyLock::new(|| { false, Default::default(), Default::default(), + None, ) .unwrap() }); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 0b24e2d95f..a446c03bdd 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -7238,8 +7238,11 @@ impl Project { ) -> Receiver { let (result_tx, result_rx) = smol::channel::unbounded(); - let matching_buffers_rx = - self.search_for_candidate_buffers(&query, MAX_SEARCH_RESULT_FILES + 1, cx); + let matching_buffers_rx = if query.is_opened_only() { + self.sort_candidate_buffers(&query, cx) + } else { + self.search_for_candidate_buffers(&query, MAX_SEARCH_RESULT_FILES + 1, cx) + }; cx.spawn(|_, cx| async move { let mut range_count = 0; @@ -7317,6 +7320,48 @@ impl Project { } } + fn sort_candidate_buffers( + &mut self, + search_query: &SearchQuery, + cx: &mut ModelContext, + ) -> Receiver> { + let worktree_store = self.worktree_store.read(cx); + let mut buffers = search_query + .buffers() + .into_iter() + .flatten() + .filter(|buffer| { + let b = buffer.read(cx); + if let Some(file) = b.file() { + if !search_query.file_matches(file.path()) { + return false; + } + if let Some(entry) = b + .entry_id(cx) + .and_then(|entry_id| worktree_store.entry_for_id(entry_id, cx)) + { + if entry.is_ignored && !search_query.include_ignored() { + return false; + } + } + } + return true; + }) + .collect::>(); + let (tx, rx) = smol::channel::unbounded(); + buffers.sort_by(|a, b| match (a.read(cx).file(), b.read(cx).file()) { + (None, None) => a.read(cx).remote_id().cmp(&b.read(cx).remote_id()), + (None, Some(_)) => std::cmp::Ordering::Less, + (Some(_), None) => std::cmp::Ordering::Greater, + (Some(a), Some(b)) => compare_paths((a.path(), true), (b.path(), true)), + }); + for buffer in buffers { + tx.send_blocking(buffer.clone()).unwrap() + } + + rx + } + fn search_for_candidate_buffers_remote( &mut self, query: &SearchQuery, diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 5280234beb..da875034e9 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -3941,7 +3941,8 @@ async fn test_search(cx: &mut gpui::TestAppContext) { true, false, Default::default(), - Default::default() + Default::default(), + None ) .unwrap(), cx @@ -3974,7 +3975,8 @@ async fn test_search(cx: &mut gpui::TestAppContext) { true, false, Default::default(), - Default::default() + Default::default(), + None, ) .unwrap(), cx @@ -4017,7 +4019,8 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { true, false, PathMatcher::new(&["*.odd".to_owned()]).unwrap(), - Default::default() + Default::default(), + None ) .unwrap(), cx @@ -4037,7 +4040,8 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { true, false, PathMatcher::new(&["*.rs".to_owned()]).unwrap(), - Default::default() + Default::default(), + None ) .unwrap(), cx @@ -4063,6 +4067,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { PathMatcher::new(&["*.ts".to_owned(), "*.odd".to_owned()]).unwrap(), Default::default(), + None, ).unwrap(), cx ) @@ -4087,6 +4092,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { PathMatcher::new(&["*.rs".to_owned(), "*.ts".to_owned(), "*.odd".to_owned()]).unwrap(), Default::default(), + None, ).unwrap(), cx ) @@ -4131,6 +4137,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { false, Default::default(), PathMatcher::new(&["*.odd".to_owned()]).unwrap(), + None, ) .unwrap(), cx @@ -4155,7 +4162,8 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { true, false, Default::default(), - PathMatcher::new(&["*.rs".to_owned()]).unwrap() + PathMatcher::new(&["*.rs".to_owned()]).unwrap(), + None, ) .unwrap(), cx @@ -4180,6 +4188,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { Default::default(), PathMatcher::new(&["*.ts".to_owned(), "*.odd".to_owned()]).unwrap(), + None, ).unwrap(), cx @@ -4204,6 +4213,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { Default::default(), PathMatcher::new(&["*.rs".to_owned(), "*.ts".to_owned(), "*.odd".to_owned()]).unwrap(), + None, ).unwrap(), cx @@ -4243,6 +4253,7 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex false, PathMatcher::new(&["*.odd".to_owned()]).unwrap(), PathMatcher::new(&["*.odd".to_owned()]).unwrap(), + None, ) .unwrap(), cx @@ -4263,6 +4274,7 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex false, PathMatcher::new(&["*.ts".to_owned()]).unwrap(), PathMatcher::new(&["*.ts".to_owned()]).unwrap(), + None, ).unwrap(), cx ) @@ -4282,6 +4294,7 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex false, PathMatcher::new(&["*.ts".to_owned(), "*.odd".to_owned()]).unwrap(), PathMatcher::new(&["*.ts".to_owned(), "*.odd".to_owned()]).unwrap(), + None, ) .unwrap(), cx @@ -4302,6 +4315,7 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex false, PathMatcher::new(&["*.ts".to_owned(), "*.odd".to_owned()]).unwrap(), PathMatcher::new(&["*.rs".to_owned(), "*.odd".to_owned()]).unwrap(), + None, ) .unwrap(), cx @@ -4354,7 +4368,8 @@ async fn test_search_multiple_worktrees_with_inclusions(cx: &mut gpui::TestAppCo true, false, PathMatcher::new(&["worktree-a/*.rs".to_owned()]).unwrap(), - Default::default() + Default::default(), + None, ) .unwrap(), cx @@ -4373,7 +4388,8 @@ async fn test_search_multiple_worktrees_with_inclusions(cx: &mut gpui::TestAppCo true, false, PathMatcher::new(&["worktree-b/*.rs".to_owned()]).unwrap(), - Default::default() + Default::default(), + None, ) .unwrap(), cx @@ -4393,7 +4409,8 @@ async fn test_search_multiple_worktrees_with_inclusions(cx: &mut gpui::TestAppCo true, false, PathMatcher::new(&["*.ts".to_owned()]).unwrap(), - Default::default() + Default::default(), + None, ) .unwrap(), cx @@ -4447,7 +4464,8 @@ async fn test_search_in_gitignored_dirs(cx: &mut gpui::TestAppContext) { false, false, Default::default(), - Default::default() + Default::default(), + None, ) .unwrap(), cx @@ -4468,7 +4486,8 @@ async fn test_search_in_gitignored_dirs(cx: &mut gpui::TestAppContext) { false, true, Default::default(), - Default::default() + Default::default(), + None, ) .unwrap(), cx @@ -4508,6 +4527,7 @@ async fn test_search_in_gitignored_dirs(cx: &mut gpui::TestAppContext) { true, files_to_include, files_to_exclude, + None, ) .unwrap(), cx @@ -4554,6 +4574,7 @@ async fn test_search_ordering(cx: &mut gpui::TestAppContext) { true, Default::default(), Default::default(), + None, ) .unwrap(), cx, diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index d545d8d574..1def39cbaf 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -30,6 +30,7 @@ pub struct SearchInputs { query: Arc, files_to_include: PathMatcher, files_to_exclude: PathMatcher, + buffers: Option>>, } impl SearchInputs { @@ -42,6 +43,9 @@ impl SearchInputs { pub fn files_to_exclude(&self) -> &PathMatcher { &self.files_to_exclude } + pub fn buffers(&self) -> &Option>> { + &self.buffers + } } #[derive(Clone, Debug)] pub enum SearchQuery { @@ -73,6 +77,7 @@ impl SearchQuery { include_ignored: bool, files_to_include: PathMatcher, files_to_exclude: PathMatcher, + buffers: Option>>, ) -> Result { let query = query.to_string(); let search = AhoCorasickBuilder::new() @@ -82,6 +87,7 @@ impl SearchQuery { query: query.into(), files_to_exclude, files_to_include, + buffers, }; Ok(Self::Text { search: Arc::new(search), @@ -100,6 +106,7 @@ impl SearchQuery { include_ignored: bool, files_to_include: PathMatcher, files_to_exclude: PathMatcher, + buffers: Option>>, ) -> Result { let mut query = query.to_string(); let initial_query = Arc::from(query.as_str()); @@ -120,6 +127,7 @@ impl SearchQuery { query: initial_query, files_to_exclude, files_to_include, + buffers, }; Ok(Self::Regex { regex, @@ -141,6 +149,7 @@ impl SearchQuery { message.include_ignored, deserialize_path_matches(&message.files_to_include)?, deserialize_path_matches(&message.files_to_exclude)?, + None, ) } else { Self::text( @@ -150,6 +159,7 @@ impl SearchQuery { message.include_ignored, deserialize_path_matches(&message.files_to_include)?, deserialize_path_matches(&message.files_to_exclude)?, + None, ) } } @@ -163,6 +173,7 @@ impl SearchQuery { message.include_ignored, deserialize_path_matches(&message.files_to_include)?, deserialize_path_matches(&message.files_to_exclude)?, + None, // search opened only don't need search remote ) } else { Self::text( @@ -172,6 +183,7 @@ impl SearchQuery { message.include_ignored, deserialize_path_matches(&message.files_to_include)?, deserialize_path_matches(&message.files_to_exclude)?, + None, // search opened only don't need search remote ) } } @@ -420,6 +432,14 @@ impl SearchQuery { self.as_inner().files_to_exclude() } + pub fn buffers(&self) -> Option<&Vec>> { + self.as_inner().buffers.as_ref() + } + + pub fn is_opened_only(&self) -> bool { + self.as_inner().buffers.is_some() + } + pub fn filters_path(&self) -> bool { !(self.files_to_exclude().sources().is_empty() && self.files_to_include().sources().is_empty()) diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index de035c1527..18d61527b5 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -142,6 +142,7 @@ async fn test_remote_project_search(cx: &mut TestAppContext, server_cx: &mut Tes false, Default::default(), Default::default(), + None, ) .unwrap(), cx, diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index e9a71e1f37..f6af87733e 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -936,6 +936,7 @@ impl BufferSearchBar { false, Default::default(), Default::default(), + None, ) { Ok(query) => query.with_replacement(self.replacement(cx)), Err(_) => { @@ -953,6 +954,7 @@ impl BufferSearchBar { false, Default::default(), Default::default(), + None, ) { Ok(query) => query.with_replacement(self.replacement(cx)), Err(_) => { diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 37b477e66d..c5277995fc 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -16,8 +16,9 @@ use gpui::{ actions, div, Action, AnyElement, AnyView, AppContext, Context as _, EntityId, EventEmitter, FocusHandle, FocusableView, Global, Hsla, InteractiveElement, IntoElement, KeyContext, Model, ModelContext, ParentElement, Point, Render, SharedString, Styled, Subscription, Task, - TextStyle, UpdateGlobal, View, ViewContext, VisualContext, WeakModel, WindowContext, + TextStyle, UpdateGlobal, View, ViewContext, VisualContext, WeakModel, WeakView, WindowContext, }; +use language::Buffer; use menu::Confirm; use project::{search::SearchQuery, search_history::SearchHistoryCursor, Project, ProjectPath}; use settings::Settings; @@ -134,6 +135,7 @@ enum InputPanel { } pub struct ProjectSearchView { + workspace: WeakView, focus_handle: FocusHandle, model: Model, query_editor: View, @@ -147,6 +149,7 @@ pub struct ProjectSearchView { excluded_files_editor: View, filters_enabled: bool, replace_enabled: bool, + included_opened_only: bool, _subscriptions: Vec, } @@ -478,7 +481,7 @@ impl Item for ProjectSearchView { Self: Sized, { let model = self.model.update(cx, |model, cx| model.clone(cx)); - Some(cx.new_view(|cx| Self::new(model, cx, None))) + Some(cx.new_view(|cx| Self::new(self.workspace.clone(), model, cx, None))) } fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { @@ -556,6 +559,10 @@ impl ProjectSearchView { }); } + fn toggle_opened_only(&mut self, _cx: &mut ViewContext) { + self.included_opened_only = !self.included_opened_only; + } + fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext) { if self.model.read(cx).match_ranges.is_empty() { return; @@ -606,6 +613,7 @@ impl ProjectSearchView { } fn new( + workspace: WeakView, model: Model, cx: &mut ViewContext, settings: Option, @@ -711,6 +719,7 @@ impl ProjectSearchView { // Check if Worktrees have all been previously indexed let mut this = ProjectSearchView { + workspace, focus_handle, replacement_editor, search_id: model.read(cx).search_id, @@ -724,6 +733,7 @@ impl ProjectSearchView { excluded_files_editor, filters_enabled, replace_enabled: false, + included_opened_only: false, _subscriptions: subscriptions, }; this.model_changed(cx); @@ -739,8 +749,10 @@ impl ProjectSearchView { return; }; + let weak_workspace = cx.view().downgrade(); + let model = cx.new_model(|cx| ProjectSearch::new(workspace.project().clone(), cx)); - let search = cx.new_view(|cx| ProjectSearchView::new(model, cx, None)); + let search = cx.new_view(|cx| ProjectSearchView::new(weak_workspace, model, cx, None)); workspace.add_item_to_active_pane(Box::new(search.clone()), None, true, cx); search.update(cx, |search, cx| { search @@ -790,8 +802,11 @@ impl ProjectSearchView { model.search(new_query, cx); model }); + let weak_workspace = cx.view().downgrade(); workspace.add_item_to_active_pane( - Box::new(cx.new_view(|cx| ProjectSearchView::new(model, cx, None))), + Box::new( + cx.new_view(|cx| ProjectSearchView::new(weak_workspace, model, cx, None)), + ), None, true, cx, @@ -840,8 +855,11 @@ impl ProjectSearchView { None }; + let weak_workspace = cx.view().downgrade(); + let model = cx.new_model(|cx| ProjectSearch::new(workspace.project().clone(), cx)); - let view = cx.new_view(|cx| ProjectSearchView::new(model, cx, settings)); + let view = + cx.new_view(|cx| ProjectSearchView::new(weak_workspace, model, cx, settings)); workspace.add_item_to_active_pane(Box::new(view.clone()), None, true, cx); view @@ -869,6 +887,11 @@ impl ProjectSearchView { fn build_search_query(&mut self, cx: &mut ViewContext) -> Option { // Do not bail early in this function, as we want to fill out `self.panels_with_errors`. let text = self.query_editor.read(cx).text(cx); + let open_buffers = if self.included_opened_only { + Some(self.open_buffers(cx)) + } else { + None + }; let included_files = match Self::parse_path_matches(&self.included_files_editor.read(cx).text(cx)) { Ok(included_files) => { @@ -913,6 +936,7 @@ impl ProjectSearchView { self.search_options.contains(SearchOptions::INCLUDE_IGNORED), included_files, excluded_files, + open_buffers, ) { Ok(query) => { let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query); @@ -939,6 +963,7 @@ impl ProjectSearchView { self.search_options.contains(SearchOptions::INCLUDE_IGNORED), included_files, excluded_files, + open_buffers, ) { Ok(query) => { let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query); @@ -967,6 +992,20 @@ impl ProjectSearchView { query } + fn open_buffers(&self, cx: &mut ViewContext) -> Vec> { + let mut buffers = Vec::new(); + self.workspace + .update(cx, |workspace, cx| { + for editor in workspace.items_of_type::(cx) { + if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() { + buffers.push(buffer); + } + } + }) + .ok(); + buffers + } + fn parse_path_matches(text: &str) -> anyhow::Result { let queries = text .split(',') @@ -1272,6 +1311,30 @@ impl ProjectSearchBar { } } + fn toggle_opened_only(&mut self, cx: &mut ViewContext) -> bool { + if let Some(search_view) = self.active_project_search.as_ref() { + search_view.update(cx, |search_view, cx| { + search_view.toggle_opened_only(cx); + if search_view.model.read(cx).active_query.is_some() { + search_view.search(cx); + } + }); + + cx.notify(); + true + } else { + false + } + } + + fn is_opened_only_enabled(&self, cx: &AppContext) -> bool { + if let Some(search_view) = self.active_project_search.as_ref() { + search_view.read(cx).included_opened_only + } else { + false + } + } + fn move_focus_to_results(&self, cx: &mut ViewContext) { if let Some(search_view) = self.active_project_search.as_ref() { search_view.update(cx, |search_view, cx| { @@ -1607,6 +1670,14 @@ impl Render for ProjectSearchBar { .rounded_lg() .child(self.render_text_input(&search.excluded_files_editor, cx)), ) + .child( + IconButton::new("project-search-opened-only", IconName::FileDoc) + .selected(self.is_opened_only_enabled(cx)) + .tooltip(|cx| Tooltip::text("Only search open files", cx)) + .on_click(cx.listener(|this, _, cx| { + this.toggle_opened_only(cx); + })), + ) .child( SearchOptions::INCLUDE_IGNORED.as_button( search @@ -1779,8 +1850,12 @@ pub mod tests { ) .await; let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; - let search = cx.new_model(|cx| ProjectSearch::new(project, cx)); - let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None)); + let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let workspace = window.root(cx).unwrap(); + let search = cx.new_model(|cx| ProjectSearch::new(project.clone(), cx)); + let search_view = cx.add_window(|cx| { + ProjectSearchView::new(workspace.downgrade(), search.clone(), cx, None) + }); perform_search(search_view, "TWO", cx); search_view.update(cx, |search_view, cx| { @@ -3253,8 +3328,12 @@ pub mod tests { ) .await; let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let workspace = window.root(cx).unwrap(); let search = cx.new_model(|cx| ProjectSearch::new(project, cx)); - let search_view = cx.add_window(|cx| ProjectSearchView::new(search.clone(), cx, None)); + let search_view = cx.add_window(|cx| { + ProjectSearchView::new(workspace.downgrade(), search.clone(), cx, None) + }); // First search perform_search(search_view, "A", cx); diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 5c5ecc8338..8fc235523e 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1245,6 +1245,7 @@ impl SearchableItem for TerminalView { query.include_ignored(), query.files_to_include().clone(), query.files_to_exclude().clone(), + None, ) .unwrap()), ),