From 0a261ad8d02c1903a6874ee7870e09e08e13b1e6 Mon Sep 17 00:00:00 2001
From: Jonathan Hart <43473765+Quplet@users.noreply.github.com>
Date: Tue, 23 Sep 2025 17:44:40 -0400
Subject: [PATCH] Implement regex_select action for Helix (#38736)
Closes #31561
Release Notes:
- Implemented the select_regex Helix keymap
Prior: The keymap `s` defaulted to `vim::Substitute`
After:
Thank you to @ConradIrwin for pairing to work on this
---
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(-)
diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json
index 8f5f99e96f708dcc08cc1a9c1fcfc799d6ba43e7..5d98fd2a1c833f3003edf623b997cb321656fdbe 100644
--- a/assets/keymaps/vim.json
+++ b/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",
diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs
index a1b311a3ac3b8ed330fee0f015c41d327efe342d..b5ae47bbdf0fc13a87b6bdac63f9f2a85594aca0 100644
--- a/crates/editor/src/items.rs
+++ b/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,
_: &mut Window,
cx: &mut Context,
) {
@@ -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::>();
- let ranges = self.selections.disjoint_anchor_ranges().collect::>();
- 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);
+ }
}
}
diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs
index 126215a0a75ee5057c560462f40958ba71d8cf74..81dd81050e2504013a173b8165532c6177126845 100644
--- a/crates/search/src/buffer_search.rs
+++ b/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,
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) -> 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,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Option> {
+ 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.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,
) {
- 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) {
diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs
index ec1618311f8b8e16b71a39fc1d29b5c60eb49c96..e2174fe1a7adee3f5b6cdac5167f2c28fb3296c1 100644
--- a/crates/vim/src/helix.rs
+++ b/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::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,
+ ) {
+ 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::() {
+ 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.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);
+ }
}
diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs
index 1023375cacc21152b27e8bf49177dc1d92f8ca91..0f7b4421ae7a19423be72be227667b2110dedf64 100644
--- a/crates/vim/src/normal/search.rs
+++ b/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::()?;
+ 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;
diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs
index f81299169058b430b6ea6557f3d66762a6705a82..ee1e0eedec799630662e9bc94721ceb588ad94a3 100644
--- a/crates/vim/src/state.rs
+++ b/crates/vim/src/state.rs
@@ -988,6 +988,7 @@ pub struct SearchState {
pub prior_selections: Vec>,
pub prior_operator: Option,
pub prior_mode: Mode,
+ pub helix_select: bool,
}
impl Operator {
diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs
index b21ba7a4b1a2ec7cc80521e91b4e5935333615f5..310fae908dbd6864c1636ebd393e4920d0f9ad02 100644
--- a/crates/workspace/src/searchable.rs
+++ b/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 {
type Match: Any + Sync + Send + Clone;
@@ -73,7 +83,7 @@ pub trait SearchableItem: Item + EventEmitter {
fn toggle_filtered_search_ranges(
&mut self,
- _enabled: bool,
+ _enabled: Option,
_window: &mut Window,
_cx: &mut Context,
) {
@@ -216,7 +226,12 @@ pub trait SearchableItemHandle: ItemHandle {
) -> Option;
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,
+ window: &mut Window,
+ cx: &mut App,
+ );
}
impl SearchableItemHandle for Entity {
@@ -362,7 +377,12 @@ impl SearchableItemHandle for Entity {
});
}
- fn toggle_filtered_search_ranges(&mut self, enabled: bool, window: &mut Window, cx: &mut App) {
+ fn toggle_filtered_search_ranges(
+ &mut self,
+ enabled: Option,
+ window: &mut Window,
+ cx: &mut App,
+ ) {
self.update(cx, |this, cx| {
this.toggle_filtered_search_ranges(enabled, window, cx)
});