Improve Find/Replace shortcuts (#10297)

Andrew Lygin created

This PR changes ways the Find/Replace functionality in the
Buffer/Project Search is accessible via shortcuts. It makes those panels
work the same way as in VS Code and Sublime Text.

The details are described in the issue: [Make Find/Replace easier to
use](https://github.com/zed-industries/zed/issues/9142)

There's a difficulty with the Linux keybindings:

VS Code uses on MacOS (this PR replicates it):

| Action | Buffer Search | Project Search |
| --- | --- | --- |
| Find | `cmd-f` | `cmd-shift-f` |
| Replace | `cmd-alt-f` | `cmd-shift-h` |

VS Code uses on Linux (this PR replicates all but one):

| Action | Buffer Search | Project Search |
| --- | --- | --- |
| Find | `ctrl-f` | `ctrl-shift-f` |
| Replace | `ctrl-h` ❗ | `ctrl-shift-h` |

The problem is that `ctrl-h` is already taken by the `editor::Backspace`
action in Zed on Linux.

There's two options here:

1. Change keybinding for `editor::Backspace` on Linux to something else,
and use `ctrl-h` for the "replace in buffer" action.
2. Use some other keybinding on Linux in Zed. This PR introduces
`ctrl-r` for this purpose, though I'm not sure it's the best choice.

What do you think?

fixes #9142

Release Notes:

- Improved access to "Find/Replace in Buffer" and "Find/Replace in
Files" via shortcuts (#9142).

Optionally, include screenshots / media showcasing your addition that
can be included in the release notes.

- N/A

Change summary

assets/keymaps/default-linux.json               | 19 +++++++--
assets/keymaps/default-macos.json               | 16 ++++++-
crates/quick_action_bar/src/quick_action_bar.rs |  4 +-
crates/search/src/buffer_search.rs              | 37 ++++++++++++++++--
crates/search/src/project_search.rs             | 34 ++++++++++++-----
crates/search/src/search.rs                     |  1 
crates/workspace/src/pane.rs                    | 18 ++++++++-
crates/zed/src/zed/app_menus.rs                 |  2 
8 files changed, 103 insertions(+), 28 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -28,7 +28,7 @@
       "ctrl-0": "zed::ResetBufferFontSize",
       "ctrl-,": "zed::OpenSettings",
       "ctrl-q": "zed::Quit",
-      "ctrl-h": "zed::Hide",
+      "alt-f9": "zed::Hide",
       "f11": "zed::ToggleFullScreen"
     }
   },
@@ -38,7 +38,6 @@
       "escape": "editor::Cancel",
       "backspace": "editor::Backspace",
       "shift-backspace": "editor::Backspace",
-      "ctrl-h": "editor::Backspace",
       "delete": "editor::Delete",
       "ctrl-d": "editor::Delete",
       "tab": "editor::Tab",
@@ -150,10 +149,11 @@
       "ctrl-shift-enter": "editor::NewlineBelow",
       "ctrl-enter": "editor::NewlineAbove",
       "alt-z": "editor::ToggleSoftWrap",
-      "ctrl-f": [
+      "ctrl-f": "buffer_search::Deploy",
+      "ctrl-h": [
         "buffer_search::Deploy",
         {
-          "focus": true
+          "replace_enabled": true
         }
       ],
       // "cmd-e": [
@@ -212,7 +212,9 @@
       "enter": "search::SelectNextMatch",
       "shift-enter": "search::SelectPrevMatch",
       "alt-enter": "search::SelectAllMatches",
-      "alt-tab": "search::CycleMode"
+      "alt-tab": "search::CycleMode",
+      "ctrl-f": "search::FocusSearch",
+      "ctrl-h": "search::ToggleReplace"
     }
   },
   {
@@ -234,6 +236,7 @@
     "bindings": {
       "escape": "project_search::ToggleFocus",
       "alt-tab": "search::CycleMode",
+      "ctrl-shift-f": "search::FocusSearch",
       "ctrl-shift-h": "search::ToggleReplace",
       "alt-ctrl-g": "search::ActivateRegexMode",
       "alt-ctrl-x": "search::ActivateTextMode"
@@ -419,6 +422,12 @@
       "ctrl-j": "workspace::ToggleBottomDock",
       "ctrl-alt-y": "workspace::CloseAllDocks",
       "ctrl-shift-f": "pane::DeploySearch",
+      "ctrl-shift-h": [
+        "pane::DeploySearch",
+        {
+          "replace_enabled": true
+        }
+      ],
       "ctrl-k ctrl-s": "zed::OpenKeymap",
       "ctrl-k ctrl-t": "theme_selector::Toggle",
       "ctrl-shift-t": "project_symbols::Toggle",

assets/keymaps/default-macos.json 🔗

@@ -170,10 +170,11 @@
       "cmd-shift-enter": "editor::NewlineAbove",
       "cmd-enter": "editor::NewlineBelow",
       "alt-z": "editor::ToggleSoftWrap",
-      "cmd-f": [
+      "cmd-f": "buffer_search::Deploy",
+      "cmd-alt-f": [
         "buffer_search::Deploy",
         {
-          "focus": true
+          "replace_enabled": true
         }
       ],
       "cmd-e": [
@@ -232,7 +233,9 @@
       "enter": "search::SelectNextMatch",
       "shift-enter": "search::SelectPrevMatch",
       "alt-enter": "search::SelectAllMatches",
-      "alt-tab": "search::CycleMode"
+      "alt-tab": "search::CycleMode",
+      "cmd-f": "search::FocusSearch",
+      "cmd-alt-f": "search::ToggleReplace"
     }
   },
   {
@@ -254,6 +257,7 @@
     "bindings": {
       "escape": "project_search::ToggleFocus",
       "alt-tab": "search::CycleMode",
+      "cmd-shift-f": "search::FocusSearch",
       "cmd-shift-h": "search::ToggleReplace",
       "alt-cmd-g": "search::ActivateRegexMode",
       "alt-cmd-x": "search::ActivateTextMode"
@@ -436,6 +440,12 @@
       "cmd-j": "workspace::ToggleBottomDock",
       "alt-cmd-y": "workspace::CloseAllDocks",
       "cmd-shift-f": "pane::DeploySearch",
+      "cmd-shift-h": [
+        "pane::DeploySearch",
+        {
+          "replace_enabled": true
+        }
+      ],
       "cmd-k cmd-s": "zed::OpenKeymap",
       "cmd-k cmd-t": "theme_selector::Toggle",
       "cmd-t": "project_symbols::Toggle",

crates/quick_action_bar/src/quick_action_bar.rs 🔗

@@ -91,13 +91,13 @@ impl Render for QuickActionBar {
             "toggle buffer search",
             IconName::MagnifyingGlass,
             !self.buffer_search_bar.read(cx).is_dismissed(),
-            Box::new(buffer_search::Deploy { focus: false }),
+            Box::new(buffer_search::Deploy::find()),
             "Buffer Search",
             {
                 let buffer_search_bar = self.buffer_search_bar.clone();
                 move |_, cx| {
                     buffer_search_bar.update(cx, |search_bar, cx| {
-                        search_bar.toggle(&buffer_search::Deploy { focus: true }, cx)
+                        search_bar.toggle(&buffer_search::Deploy::find(), cx)
                     });
                 }
             },

crates/search/src/buffer_search.rs 🔗

@@ -3,9 +3,9 @@ mod registrar;
 use crate::{
     mode::{next_mode, SearchMode},
     search_bar::render_nav_button,
-    ActivateRegexMode, ActivateTextMode, CycleMode, NextHistoryQuery, PreviousHistoryQuery,
-    ReplaceAll, ReplaceNext, SearchOptions, SelectAllMatches, SelectNextMatch, SelectPrevMatch,
-    ToggleCaseSensitive, ToggleReplace, ToggleWholeWord,
+    ActivateRegexMode, ActivateTextMode, CycleMode, FocusSearch, NextHistoryQuery,
+    PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions, SelectAllMatches,
+    SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleReplace, ToggleWholeWord,
 };
 use any_vec::AnyVec;
 use collections::HashMap;
@@ -44,15 +44,31 @@ const MIN_INPUT_WIDTH_REMS: f32 = 15.;
 const MAX_INPUT_WIDTH_REMS: f32 = 30.;
 const MAX_BUFFER_SEARCH_HISTORY_SIZE: usize = 50;
 
+const fn true_value() -> bool {
+    true
+}
+
 #[derive(PartialEq, Clone, Deserialize)]
 pub struct Deploy {
+    #[serde(default = "true_value")]
     pub focus: bool,
+    #[serde(default)]
+    pub replace_enabled: bool,
 }
 
 impl_actions!(buffer_search, [Deploy]);
 
 actions!(buffer_search, [Dismiss, FocusEditor]);
 
+impl Deploy {
+    pub fn find() -> Self {
+        Self {
+            focus: true,
+            replace_enabled: false,
+        }
+    }
+}
+
 pub enum Event {
     UpdateLocation,
 }
@@ -470,6 +486,9 @@ impl ToolbarItemView for BufferSearchBar {
 
 impl BufferSearchBar {
     pub fn register(registrar: &mut impl SearchActionsRegistrar) {
+        registrar.register_handler(ForDeployed(|this, _: &FocusSearch, cx| {
+            this.query_editor.focus_handle(cx).focus(cx);
+        }));
         registrar.register_handler(ForDeployed(|this, action: &ToggleCaseSensitive, cx| {
             if this.supported_options().case {
                 this.toggle_case_sensitive(action, cx);
@@ -583,9 +602,17 @@ impl BufferSearchBar {
     pub fn deploy(&mut self, deploy: &Deploy, cx: &mut ViewContext<Self>) -> bool {
         if self.show(cx) {
             self.search_suggested(cx);
+            self.replace_enabled = deploy.replace_enabled;
             if deploy.focus {
-                self.select_query(cx);
-                let handle = self.query_editor.focus_handle(cx);
+                let mut handle = self.query_editor.focus_handle(cx).clone();
+                let mut select_query = true;
+                if deploy.replace_enabled && handle.is_focused(cx) {
+                    handle = self.replacement_editor.focus_handle(cx).clone();
+                    select_query = false;
+                };
+                if select_query {
+                    self.select_query(cx);
+                }
                 cx.focus(&handle);
             }
             return true;

crates/search/src/project_search.rs 🔗

@@ -1,7 +1,8 @@
 use crate::{
-    mode::SearchMode, ActivateRegexMode, ActivateTextMode, CycleMode, NextHistoryQuery,
-    PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions, SelectNextMatch, SelectPrevMatch,
-    ToggleCaseSensitive, ToggleIncludeIgnored, ToggleReplace, ToggleWholeWord,
+    mode::SearchMode, ActivateRegexMode, ActivateTextMode, CycleMode, FocusSearch,
+    NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions,
+    SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleIncludeIgnored, ToggleReplace,
+    ToggleWholeWord,
 };
 use anyhow::Context as _;
 use collections::{HashMap, HashSet};
@@ -60,6 +61,9 @@ const SEARCH_CONTEXT: u32 = 2;
 pub fn init(cx: &mut AppContext) {
     cx.set_global(ActiveSettings::default());
     cx.observe_new_views(|workspace: &mut Workspace, _cx| {
+        register_workspace_action(workspace, move |search_bar, _: &FocusSearch, cx| {
+            search_bar.focus_search(cx);
+        });
         register_workspace_action(workspace, move |search_bar, _: &ToggleFilters, cx| {
             search_bar.toggle_filters(cx);
         });
@@ -797,7 +801,7 @@ impl ProjectSearchView {
     // If no search exists in the workspace, create a new one.
     fn deploy_search(
         workspace: &mut Workspace,
-        _: &workspace::DeploySearch,
+        action: &workspace::DeploySearch,
         cx: &mut ViewContext<Workspace>,
     ) {
         let existing = workspace
@@ -806,7 +810,7 @@ impl ProjectSearchView {
             .items()
             .find_map(|item| item.downcast::<ProjectSearchView>());
 
-        Self::existing_or_new_search(workspace, existing, cx)
+        Self::existing_or_new_search(workspace, existing, action, cx);
     }
 
     fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext<Workspace>) {
@@ -846,12 +850,13 @@ impl ProjectSearchView {
         _: &workspace::NewSearch,
         cx: &mut ViewContext<Workspace>,
     ) {
-        Self::existing_or_new_search(workspace, None, cx)
+        Self::existing_or_new_search(workspace, None, &DeploySearch::find(), cx)
     }
 
     fn existing_or_new_search(
         workspace: &mut Workspace,
         existing: Option<View<ProjectSearchView>>,
+        action: &workspace::DeploySearch,
         cx: &mut ViewContext<Workspace>,
     ) {
         let query = workspace.active_item(cx).and_then(|item| {
@@ -887,6 +892,7 @@ impl ProjectSearchView {
         };
 
         search.update(cx, |search, cx| {
+            search.replace_enabled = action.replace_enabled;
             if let Some(query) = query {
                 search.set_query(&query, cx);
             }
@@ -1172,6 +1178,14 @@ impl ProjectSearchBar {
         self.cycle_field(Direction::Prev, cx);
     }
 
+    fn focus_search(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(search_view) = self.active_project_search.as_ref() {
+            search_view.update(cx, |search_view, cx| {
+                search_view.query_editor.focus_handle(cx).focus(cx);
+            });
+        }
+    }
+
     fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
         let active_project_search = match &self.active_project_search {
             Some(active_project_search) => active_project_search,
@@ -2011,7 +2025,7 @@ pub mod tests {
                         .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
                 });
 
-                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch, cx)
+                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
             })
             .unwrap();
 
@@ -2160,7 +2174,7 @@ pub mod tests {
 
         workspace
             .update(cx, |workspace, cx| {
-                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch, cx)
+                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch::find(), cx)
             })
             .unwrap();
         window.update(cx, |_, cx| {
@@ -3259,7 +3273,7 @@ pub mod tests {
             .unwrap();
 
         // Deploy a new search
-        cx.dispatch_action(window.into(), DeploySearch);
+        cx.dispatch_action(window.into(), DeploySearch::find());
 
         // Both panes should now have a project search in them
         window
@@ -3284,7 +3298,7 @@ pub mod tests {
             .unwrap();
 
         // Deploy a new search
-        cx.dispatch_action(window.into(), DeploySearch);
+        cx.dispatch_action(window.into(), DeploySearch::find());
 
         // The project search view should now be focused in the second pane
         // And the number of items should be unchanged.

crates/search/src/search.rs 🔗

@@ -22,6 +22,7 @@ actions!(
     search,
     [
         CycleMode,
+        FocusSearch,
         ToggleWholeWord,
         ToggleCaseSensitive,
         ToggleIncludeIgnored,

crates/workspace/src/pane.rs 🔗

@@ -86,6 +86,12 @@ pub struct RevealInProjectPanel {
     pub entry_id: Option<u64>,
 }
 
+#[derive(PartialEq, Clone, Deserialize)]
+pub struct DeploySearch {
+    #[serde(default)]
+    pub replace_enabled: bool,
+}
+
 impl_actions!(
     pane,
     [
@@ -93,7 +99,8 @@ impl_actions!(
         CloseActiveItem,
         CloseInactiveItems,
         ActivateItem,
-        RevealInProjectPanel
+        RevealInProjectPanel,
+        DeploySearch,
     ]
 );
 
@@ -107,7 +114,6 @@ actions!(
         CloseItemsToTheLeft,
         CloseItemsToTheRight,
         GoBack,
-        DeploySearch,
         GoForward,
         ReopenClosedItem,
         SplitLeft,
@@ -117,6 +123,14 @@ actions!(
     ]
 );
 
+impl DeploySearch {
+    pub fn find() -> Self {
+        Self {
+            replace_enabled: false,
+        }
+    }
+}
+
 const MAX_NAVIGATION_HISTORY_LEN: usize = 1024;
 
 pub enum Event {

crates/zed/src/zed/app_menus.rs 🔗

@@ -67,7 +67,7 @@ pub fn app_menus() -> Vec<Menu<'static>> {
                 MenuItem::os_action("Copy", editor::actions::Copy, OsAction::Copy),
                 MenuItem::os_action("Paste", editor::actions::Paste, OsAction::Paste),
                 MenuItem::separator(),
-                MenuItem::action("Find", search::buffer_search::Deploy { focus: true }),
+                MenuItem::action("Find", search::buffer_search::Deploy::find()),
                 MenuItem::action("Find In Project", workspace::NewSearch),
                 MenuItem::separator(),
                 MenuItem::action(