From 43867668f44549dd8da9954c62b1229c2fb6bec7 Mon Sep 17 00:00:00 2001 From: Jozsef Lazar Date: Tue, 7 Apr 2026 18:11:39 +0200 Subject: [PATCH] Add query and search options to pane::DeploySearch action (#47331) Extend the DeploySearch action to accept additional parameters for configuring the project search from keymaps: - query: prefilled search query string - regex: enable regex search mode - case_sensitive: match case exactly - whole_word: match whole words only - include_ignored: search in gitignored files With this change, the following keymap becomes possible: ```json ["pane::DeploySearch", { "query": "TODO|FIXME|NOTE|BUG|HACK|XXX|WARN", "regex": true }], ``` Release Notes: - Added options to `pane::DeploySearch` for keymap-driven search initiation --- crates/search/src/project_search.rs | 313 +++++++++++++++++++++++++++- crates/workspace/src/pane.rs | 30 +-- crates/zed/src/zed/app_menus.rs | 2 +- 3 files changed, 317 insertions(+), 28 deletions(-) diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 1bccf1ae52fb2c52a8d01e53aabb1b3ff5c7c16f..7c9d3f176ed3f17ec5e21faa7c1b483252657614 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -769,6 +769,17 @@ impl ProjectSearchView { } } + fn set_search_option_enabled( + &mut self, + option: SearchOptions, + enabled: bool, + cx: &mut Context, + ) { + if self.search_options.contains(option) != enabled { + self.toggle_search_option(option, cx); + } + } + fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut Context) { self.search_options.toggle(option); ActiveSettings::update_global(cx, |settings, cx| { @@ -1153,7 +1164,7 @@ impl ProjectSearchView { window: &mut Window, cx: &mut Context, ) { - Self::existing_or_new_search(workspace, None, &DeploySearch::find(), window, cx) + Self::existing_or_new_search(workspace, None, &DeploySearch::default(), window, cx) } fn existing_or_new_search( @@ -1203,8 +1214,29 @@ impl ProjectSearchView { search.update(cx, |search, cx| { search.replace_enabled |= action.replace_enabled; + if let Some(regex) = action.regex { + search.set_search_option_enabled(SearchOptions::REGEX, regex, cx); + } + if let Some(case_sensitive) = action.case_sensitive { + search.set_search_option_enabled(SearchOptions::CASE_SENSITIVE, case_sensitive, cx); + } + if let Some(whole_word) = action.whole_word { + search.set_search_option_enabled(SearchOptions::WHOLE_WORD, whole_word, cx); + } + if let Some(include_ignored) = action.include_ignored { + search.set_search_option_enabled( + SearchOptions::INCLUDE_IGNORED, + include_ignored, + cx, + ); + } + let query = action + .query + .as_deref() + .filter(|q| !q.is_empty()) + .or(query.as_deref()); if let Some(query) = query { - search.set_query(&query, window, cx); + search.set_query(query, window, cx); } if let Some(included_files) = action.included_files.as_deref() { search @@ -3101,7 +3133,7 @@ pub mod tests { ProjectSearchView::deploy_search( workspace, - &workspace::DeploySearch::find(), + &workspace::DeploySearch::default(), window, cx, ) @@ -3252,7 +3284,7 @@ pub mod tests { workspace.update_in(cx, |workspace, window, cx| { ProjectSearchView::deploy_search( workspace, - &workspace::DeploySearch::find(), + &workspace::DeploySearch::default(), window, cx, ) @@ -3325,7 +3357,7 @@ pub mod tests { ProjectSearchView::deploy_search( workspace, - &workspace::DeploySearch::find(), + &workspace::DeploySearch::default(), window, cx, ) @@ -4560,7 +4592,7 @@ pub mod tests { }); // Deploy a new search - cx.dispatch_action(DeploySearch::find()); + cx.dispatch_action(DeploySearch::default()); // Both panes should now have a project search in them workspace.update_in(cx, |workspace, window, cx| { @@ -4585,7 +4617,7 @@ pub mod tests { .unwrap(); // Deploy a new search - cx.dispatch_action(DeploySearch::find()); + cx.dispatch_action(DeploySearch::default()); // The project search view should now be focused in the second pane // And the number of items should be unchanged. @@ -4823,7 +4855,7 @@ pub mod tests { assert!(workspace.has_active_modal(window, cx)); }); - cx.dispatch_action(DeploySearch::find()); + cx.dispatch_action(DeploySearch::default()); workspace.update_in(cx, |workspace, window, cx| { assert!(!workspace.has_active_modal(window, cx)); @@ -5136,6 +5168,271 @@ pub mod tests { .unwrap(); } + #[gpui::test] + async fn test_deploy_search_applies_and_resets_options(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new()); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.panes()[0].update(cx, |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) + }); + + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch { + regex: Some(true), + case_sensitive: Some(true), + whole_word: Some(true), + include_ignored: Some(true), + query: Some("Test_Query".into()), + ..Default::default() + }, + window, + cx, + ) + }); + + let search_view = cx + .read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + }) + .expect("Search view should be active after deploy"); + + search_view.update_in(cx, |search_view, _window, cx| { + assert!( + search_view.search_options.contains(SearchOptions::REGEX), + "Regex option should be enabled" + ); + assert!( + search_view + .search_options + .contains(SearchOptions::CASE_SENSITIVE), + "Case sensitive option should be enabled" + ); + assert!( + search_view + .search_options + .contains(SearchOptions::WHOLE_WORD), + "Whole word option should be enabled" + ); + assert!( + search_view + .search_options + .contains(SearchOptions::INCLUDE_IGNORED), + "Include ignored option should be enabled" + ); + let query_text = search_view.query_editor.read(cx).text(cx); + assert_eq!( + query_text, "Test_Query", + "Query should be set from the action" + ); + }); + + // Redeploy with only regex - unspecified options should be preserved. + cx.dispatch_action(menu::Cancel); + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch { + regex: Some(true), + ..Default::default() + }, + window, + cx, + ) + }); + + search_view.update_in(cx, |search_view, _window, _cx| { + assert!( + search_view.search_options.contains(SearchOptions::REGEX), + "Regex should still be enabled" + ); + assert!( + search_view + .search_options + .contains(SearchOptions::CASE_SENSITIVE), + "Case sensitive should be preserved from previous deploy" + ); + assert!( + search_view + .search_options + .contains(SearchOptions::WHOLE_WORD), + "Whole word should be preserved from previous deploy" + ); + assert!( + search_view + .search_options + .contains(SearchOptions::INCLUDE_IGNORED), + "Include ignored should be preserved from previous deploy" + ); + }); + + // Redeploy explicitly turning off options. + cx.dispatch_action(menu::Cancel); + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch { + regex: Some(true), + case_sensitive: Some(false), + whole_word: Some(false), + include_ignored: Some(false), + ..Default::default() + }, + window, + cx, + ) + }); + + search_view.update_in(cx, |search_view, _window, _cx| { + assert_eq!( + search_view.search_options, + SearchOptions::REGEX, + "Explicit Some(false) should turn off options" + ); + }); + + // Redeploy with an empty query - should not overwrite the existing query. + cx.dispatch_action(menu::Cancel); + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch { + query: Some("".into()), + ..Default::default() + }, + window, + cx, + ) + }); + + search_view.update_in(cx, |search_view, _window, cx| { + let query_text = search_view.query_editor.read(cx).text(cx); + assert_eq!( + query_text, "Test_Query", + "Empty query string should not overwrite the existing query" + ); + }); + } + + #[gpui::test] + async fn test_smartcase_overrides_explicit_case_sensitive(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_default_settings(cx, |settings| { + settings.editor.use_smartcase_search = Some(true); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new()); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.panes()[0].update(cx, |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) + }); + + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch { + case_sensitive: Some(true), + query: Some("lowercase_query".into()), + ..Default::default() + }, + window, + cx, + ) + }); + + let search_view = cx + .read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + }) + .expect("Search view should be active after deploy"); + + // Smartcase should override the explicit case_sensitive flag + // because the query is all lowercase. + search_view.update_in(cx, |search_view, _window, cx| { + assert!( + !search_view + .search_options + .contains(SearchOptions::CASE_SENSITIVE), + "Smartcase should disable case sensitivity for a lowercase query, \ + even when case_sensitive was explicitly set in the action" + ); + let query_text = search_view.query_editor.read(cx).text(cx); + assert_eq!(query_text, "lowercase_query"); + }); + + // Now deploy with an uppercase query - smartcase should enable case sensitivity. + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch { + query: Some("Uppercase_Query".into()), + ..Default::default() + }, + window, + cx, + ) + }); + + search_view.update_in(cx, |search_view, _window, cx| { + assert!( + search_view + .search_options + .contains(SearchOptions::CASE_SENSITIVE), + "Smartcase should enable case sensitivity for a query containing uppercase" + ); + let query_text = search_view.query_editor.read(cx).text(cx); + assert_eq!(query_text, "Uppercase_Query"); + }); + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings = SettingsStore::test(cx); diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index a09ba73add7e94fbe6910eb400b1364bd21cd313..cbcd60b734644cb61473bef85e27f2403e3c7d3c 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -198,6 +198,16 @@ pub struct DeploySearch { pub included_files: Option, #[serde(default)] pub excluded_files: Option, + #[serde(default)] + pub query: Option, + #[serde(default)] + pub regex: Option, + #[serde(default)] + pub case_sensitive: Option, + #[serde(default)] + pub whole_word: Option, + #[serde(default)] + pub include_ignored: Option, } #[derive(Clone, Copy, PartialEq, Debug, Deserialize, JsonSchema, Default)] @@ -309,16 +319,6 @@ actions!( ] ); -impl DeploySearch { - pub fn find() -> Self { - Self { - replace_enabled: false, - included_files: None, - excluded_files: None, - } - } -} - const MAX_NAVIGATION_HISTORY_LEN: usize = 1024; pub enum Event { @@ -4188,15 +4188,7 @@ fn default_render_tab_bar_buttons( menu.action("New File", NewFile.boxed_clone()) .action("Open File", ToggleFileFinder::default().boxed_clone()) .separator() - .action( - "Search Project", - DeploySearch { - replace_enabled: false, - included_files: None, - excluded_files: None, - } - .boxed_clone(), - ) + .action("Search Project", DeploySearch::default().boxed_clone()) .action("Search Symbols", ToggleProjectSymbols.boxed_clone()) .separator() .action("New Terminal", NewTerminal::default().boxed_clone()) diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index 3edbcad2d81d63b56e777218a3db5e57a42de7bc..f3913a6556626e2919024ca02bcba0f1f41819eb 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -165,7 +165,7 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::os_action("Paste", editor::actions::Paste, OsAction::Paste), MenuItem::separator(), MenuItem::action("Find", search::buffer_search::Deploy::find()), - MenuItem::action("Find in Project", workspace::DeploySearch::find()), + MenuItem::action("Find in Project", workspace::DeploySearch::default()), MenuItem::separator(), MenuItem::action( "Toggle Line Comment",