Implement regex_select action for Helix (#38736)

Jonathan Hart created

Closes #31561

Release Notes:

- Implemented the select_regex Helix keymap

Prior: The keymap `s` defaulted to `vim::Substitute`

After:
<img width="1387" height="376" alt="image"
src="https://github.com/user-attachments/assets/4d3181d9-9d3f-40d2-890f-022655c77577"
/>

Thank you to @ConradIrwin for pairing to work on this

Change summary

assets/keymaps/vim.json            |  1 
crates/editor/src/items.rs         | 23 ++++---
crates/search/src/buffer_search.rs | 63 ++++++++++++++------
crates/vim/src/helix.rs            | 93 +++++++++++++++++++++++++++++++
crates/vim/src/normal/search.rs    |  7 ++
crates/vim/src/state.rs            |  1 
crates/workspace/src/searchable.rs | 26 +++++++-
7 files changed, 180 insertions(+), 34 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -426,6 +426,7 @@
       ";": "vim::HelixCollapseSelection",
       ":": "command_palette::Toggle",
       "m": "vim::PushHelixMatch",
+      "s": "vim::HelixSelectRegex",
       "]": ["vim::PushHelixNext", { "around": true }],
       "[": ["vim::PushHelixPrevious", { "around": true }],
       "left": "vim::WrappingLeft",

crates/editor/src/items.rs 🔗

@@ -44,7 +44,9 @@ use workspace::{
     CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId,
     invalid_buffer_view::InvalidBufferView,
     item::{FollowableItem, Item, ItemEvent, ProjectItem, SaveOptions},
-    searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
+    searchable::{
+        Direction, FilteredSearchRange, SearchEvent, SearchableItem, SearchableItemHandle,
+    },
 };
 use workspace::{
     OpenOptions,
@@ -1510,7 +1512,7 @@ impl SearchableItem for Editor {
 
     fn toggle_filtered_search_ranges(
         &mut self,
-        enabled: bool,
+        enabled: Option<FilteredSearchRange>,
         _: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -1520,15 +1522,16 @@ impl SearchableItem for Editor {
                 .map(|(_, ranges)| ranges)
         }
 
-        if !enabled {
-            return;
-        }
+        if let Some(range) = enabled {
+            let ranges = self.selections.disjoint_anchor_ranges().collect::<Vec<_>>();
 
-        let ranges = self.selections.disjoint_anchor_ranges().collect::<Vec<_>>();
-        if ranges.iter().any(|s| s.start != s.end) {
-            self.set_search_within_ranges(&ranges, cx);
-        } else if let Some(previous_search_ranges) = self.previous_search_ranges.take() {
-            self.set_search_within_ranges(&previous_search_ranges, cx)
+            if ranges.iter().any(|s| s.start != s.end) {
+                self.set_search_within_ranges(&ranges, cx);
+            } else if let Some(previous_search_ranges) = self.previous_search_ranges.take()
+                && range != FilteredSearchRange::Selection
+            {
+                self.set_search_within_ranges(&previous_search_ranges, cx);
+            }
         }
     }
 

crates/search/src/buffer_search.rs 🔗

@@ -38,7 +38,9 @@ use util::ResultExt;
 use workspace::{
     ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
     item::ItemHandle,
-    searchable::{Direction, SearchEvent, SearchableItemHandle, WeakSearchableItemHandle},
+    searchable::{
+        Direction, FilteredSearchRange, SearchEvent, SearchableItemHandle, WeakSearchableItemHandle,
+    },
 };
 
 pub use registrar::DivRegistrar;
@@ -117,7 +119,7 @@ pub struct BufferSearchBar {
     search_history: SearchHistory,
     search_history_cursor: SearchHistoryCursor,
     replace_enabled: bool,
-    selection_search_enabled: bool,
+    selection_search_enabled: Option<FilteredSearchRange>,
     scroll_handle: ScrollHandle,
     editor_scroll_handle: ScrollHandle,
     editor_needed_width: Pixels,
@@ -255,13 +257,13 @@ impl Render for BufferSearchBar {
                     )
                     .style(ButtonStyle::Subtle)
                     .shape(IconButtonShape::Square)
-                    .when(self.selection_search_enabled, |button| {
+                    .when(self.selection_search_enabled.is_some(), |button| {
                         button.style(ButtonStyle::Filled)
                     })
                     .on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
                         this.toggle_selection(&ToggleSelection, window, cx);
                     }))
-                    .toggle_state(self.selection_search_enabled)
+                    .toggle_state(self.selection_search_enabled.is_some())
                     .tooltip({
                         let focus_handle = focus_handle.clone();
                         move |window, cx| {
@@ -673,7 +675,7 @@ impl BufferSearchBar {
             search_history_cursor: Default::default(),
             active_search: None,
             replace_enabled: false,
-            selection_search_enabled: false,
+            selection_search_enabled: None,
             scroll_handle: ScrollHandle::new(),
             editor_scroll_handle: ScrollHandle::new(),
             editor_needed_width: px(0.),
@@ -696,10 +698,10 @@ impl BufferSearchBar {
             }
         }
         if let Some(active_editor) = self.active_searchable_item.as_mut() {
-            self.selection_search_enabled = false;
+            self.selection_search_enabled = None;
             self.replace_enabled = false;
             active_editor.search_bar_visibility_changed(false, window, cx);
-            active_editor.toggle_filtered_search_ranges(false, window, cx);
+            active_editor.toggle_filtered_search_ranges(None, window, cx);
             let handle = active_editor.item_focus_handle(cx);
             self.focus(&handle, window);
         }
@@ -711,18 +713,23 @@ impl BufferSearchBar {
     }
 
     pub fn deploy(&mut self, deploy: &Deploy, window: &mut Window, cx: &mut Context<Self>) -> bool {
+        let filtered_search_range = if deploy.selection_search_enabled {
+            Some(FilteredSearchRange::Default)
+        } else {
+            None
+        };
         if self.show(window, cx) {
             if let Some(active_item) = self.active_searchable_item.as_mut() {
-                active_item.toggle_filtered_search_ranges(
-                    deploy.selection_search_enabled,
-                    window,
-                    cx,
-                );
+                active_item.toggle_filtered_search_ranges(filtered_search_range, window, cx);
             }
             self.search_suggested(window, cx);
             self.smartcase(window, cx);
             self.replace_enabled = deploy.replace_enabled;
-            self.selection_search_enabled = deploy.selection_search_enabled;
+            self.selection_search_enabled = if deploy.selection_search_enabled {
+                Some(FilteredSearchRange::Default)
+            } else {
+                None
+            };
             if deploy.focus {
                 let mut handle = self.query_editor.focus_handle(cx);
                 let mut select_query = true;
@@ -923,6 +930,19 @@ impl BufferSearchBar {
         }
     }
 
+    pub fn set_search_within_selection(
+        &mut self,
+        search_within_selection: Option<FilteredSearchRange>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<oneshot::Receiver<()>> {
+        let active_item = self.active_searchable_item.as_mut()?;
+        self.selection_search_enabled = search_within_selection;
+        active_item.toggle_filtered_search_ranges(self.selection_search_enabled, window, cx);
+        cx.notify();
+        Some(self.update_matches(false, false, window, cx))
+    }
+
     pub fn set_search_options(&mut self, search_options: SearchOptions, cx: &mut Context<Self>) {
         self.search_options = search_options;
         self.adjust_query_regex_language(cx);
@@ -957,7 +977,7 @@ impl BufferSearchBar {
         self.select_match(Direction::Prev, 1, window, cx);
     }
 
-    fn select_all_matches(
+    pub fn select_all_matches(
         &mut self,
         _: &SelectAllMatches,
         window: &mut Window,
@@ -1125,12 +1145,15 @@ impl BufferSearchBar {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if let Some(active_item) = self.active_searchable_item.as_mut() {
-            self.selection_search_enabled = !self.selection_search_enabled;
-            active_item.toggle_filtered_search_ranges(self.selection_search_enabled, window, cx);
-            drop(self.update_matches(false, false, window, cx));
-            cx.notify();
-        }
+        self.set_search_within_selection(
+            if let Some(_) = self.selection_search_enabled {
+                None
+            } else {
+                Some(FilteredSearchRange::Default)
+            },
+            window,
+            cx,
+        );
     }
 
     fn toggle_regex(&mut self, _: &ToggleRegex, window: &mut Window, cx: &mut Context<Self>) {

crates/vim/src/helix.rs 🔗

@@ -5,14 +5,20 @@ mod select;
 
 use editor::display_map::DisplaySnapshot;
 use editor::{
-    DisplayPoint, Editor, HideMouseCursorOrigin, SelectionEffects, ToOffset, ToPoint, movement,
+    DisplayPoint, Editor, EditorSettings, HideMouseCursorOrigin, SelectionEffects, ToOffset,
+    ToPoint, movement,
 };
 use gpui::actions;
 use gpui::{Context, Window};
 use language::{CharClassifier, CharKind, Point};
+use search::{BufferSearchBar, SearchOptions};
+use settings::Settings;
 use text::{Bias, SelectionGoal};
+use workspace::searchable;
+use workspace::searchable::FilteredSearchRange;
 
 use crate::motion;
+use crate::state::SearchState;
 use crate::{
     Vim,
     motion::{Motion, right},
@@ -32,6 +38,8 @@ actions!(
         HelixGotoLastModification,
         /// Select entire line or multiple lines, extending downwards.
         HelixSelectLine,
+        /// Select all matches of a given pattern within the current selection.
+        HelixSelectRegex,
     ]
 );
 
@@ -42,6 +50,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
     Vim::action(editor, cx, Vim::helix_yank);
     Vim::action(editor, cx, Vim::helix_goto_last_modification);
     Vim::action(editor, cx, Vim::helix_paste);
+    Vim::action(editor, cx, Vim::helix_select_regex);
 }
 
 impl Vim {
@@ -368,6 +377,64 @@ impl Vim {
         self.switch_mode(Mode::Insert, false, window, cx);
     }
 
+    fn helix_select_regex(
+        &mut self,
+        _: &HelixSelectRegex,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        Vim::take_forced_motion(cx);
+        let Some(pane) = self.pane(window, cx) else {
+            return;
+        };
+        let prior_selections = self.editor_selections(window, cx);
+        pane.update(cx, |pane, cx| {
+            if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
+                search_bar.update(cx, |search_bar, cx| {
+                    if !search_bar.show(window, cx) {
+                        return;
+                    }
+
+                    search_bar.select_query(window, cx);
+                    cx.focus_self(window);
+
+                    search_bar.set_replacement(None, cx);
+                    let mut options = SearchOptions::NONE;
+                    options |= SearchOptions::REGEX;
+                    if EditorSettings::get_global(cx).search.case_sensitive {
+                        options |= SearchOptions::CASE_SENSITIVE;
+                    }
+                    search_bar.set_search_options(options, cx);
+                    if let Some(search) = search_bar.set_search_within_selection(
+                        Some(FilteredSearchRange::Selection),
+                        window,
+                        cx,
+                    ) {
+                        cx.spawn_in(window, async move |search_bar, cx| {
+                            if search.await.is_ok() {
+                                search_bar.update_in(cx, |search_bar, window, cx| {
+                                    search_bar.activate_current_match(window, cx)
+                                })
+                            } else {
+                                Ok(())
+                            }
+                        })
+                        .detach_and_log_err(cx);
+                    }
+                    self.search = SearchState {
+                        direction: searchable::Direction::Next,
+                        count: 1,
+                        prior_selections,
+                        prior_operator: self.operator_stack.last().cloned(),
+                        prior_mode: self.mode,
+                        helix_select: true,
+                    }
+                });
+            }
+        });
+        self.start_recording(cx);
+    }
+
     fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context<Self>) {
         self.start_recording(cx);
         self.switch_mode(Mode::Insert, false, window, cx);
@@ -1121,4 +1188,28 @@ mod test {
         cx.simulate_keystrokes("v w");
         cx.assert_state("«one ˇ»two", Mode::HelixSelect);
     }
+
+    #[gpui::test]
+    async fn test_helix_select_regex(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        cx.set_state("ˇone two one", Mode::HelixNormal);
+        cx.simulate_keystrokes("x");
+        cx.assert_state("«one two oneˇ»", Mode::HelixNormal);
+        cx.simulate_keystrokes("s o n e");
+        cx.run_until_parked();
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("«oneˇ» two «oneˇ»", Mode::HelixNormal);
+
+        cx.simulate_keystrokes("x");
+        cx.simulate_keystrokes("s");
+        cx.run_until_parked();
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("«oneˇ» two «oneˇ»", Mode::HelixNormal);
+
+        cx.set_state("ˇone two one", Mode::HelixNormal);
+        cx.simulate_keystrokes("s o n e enter");
+        cx.assert_state("ˇone two one", Mode::HelixNormal);
+    }
 }

crates/vim/src/normal/search.rs 🔗

@@ -195,6 +195,7 @@ impl Vim {
                         prior_selections,
                         prior_operator: self.operator_stack.last().cloned(),
                         prior_mode,
+                        helix_select: false,
                     }
                 });
             }
@@ -218,6 +219,12 @@ impl Vim {
         let new_selections = self.editor_selections(window, cx);
         let result = pane.update(cx, |pane, cx| {
             let search_bar = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()?;
+            if self.search.helix_select {
+                search_bar.update(cx, |search_bar, cx| {
+                    search_bar.select_all_matches(&Default::default(), window, cx)
+                });
+                return None;
+            }
             search_bar.update(cx, |search_bar, cx| {
                 let mut count = self.search.count;
                 let direction = self.search.direction;

crates/vim/src/state.rs 🔗

@@ -988,6 +988,7 @@ pub struct SearchState {
     pub prior_selections: Vec<Range<Anchor>>,
     pub prior_operator: Option<Operator>,
     pub prior_mode: Mode,
+    pub helix_select: bool,
 }
 
 impl Operator {

crates/workspace/src/searchable.rs 🔗

@@ -45,6 +45,16 @@ pub struct SearchOptions {
     pub find_in_results: bool,
 }
 
+// Whether to always select the current selection (even if empty)
+// or to use the default (restoring the previous search ranges if some,
+// otherwise using the whole file).
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub enum FilteredSearchRange {
+    Selection,
+    #[default]
+    Default,
+}
+
 pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
     type Match: Any + Sync + Send + Clone;
 
@@ -73,7 +83,7 @@ pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
 
     fn toggle_filtered_search_ranges(
         &mut self,
-        _enabled: bool,
+        _enabled: Option<FilteredSearchRange>,
         _window: &mut Window,
         _cx: &mut Context<Self>,
     ) {
@@ -216,7 +226,12 @@ pub trait SearchableItemHandle: ItemHandle {
     ) -> Option<usize>;
     fn search_bar_visibility_changed(&self, visible: bool, window: &mut Window, cx: &mut App);
 
-    fn toggle_filtered_search_ranges(&mut self, enabled: bool, window: &mut Window, cx: &mut App);
+    fn toggle_filtered_search_ranges(
+        &mut self,
+        enabled: Option<FilteredSearchRange>,
+        window: &mut Window,
+        cx: &mut App,
+    );
 }
 
 impl<T: SearchableItem> SearchableItemHandle for Entity<T> {
@@ -362,7 +377,12 @@ impl<T: SearchableItem> SearchableItemHandle for Entity<T> {
         });
     }
 
-    fn toggle_filtered_search_ranges(&mut self, enabled: bool, window: &mut Window, cx: &mut App) {
+    fn toggle_filtered_search_ranges(
+        &mut self,
+        enabled: Option<FilteredSearchRange>,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
         self.update(cx, |this, cx| {
             this.toggle_filtered_search_ranges(enabled, window, cx)
         });