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:
<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
This commit is contained in:
Jonathan Hart
2025-09-23 15:44:40 -06:00
committed by GitHub
parent 28ed08340c
commit 0a261ad8d0
7 changed files with 180 additions and 34 deletions
+1
View File
@@ -426,6 +426,7 @@
";": "vim::HelixCollapseSelection", ";": "vim::HelixCollapseSelection",
":": "command_palette::Toggle", ":": "command_palette::Toggle",
"m": "vim::PushHelixMatch", "m": "vim::PushHelixMatch",
"s": "vim::HelixSelectRegex",
"]": ["vim::PushHelixNext", { "around": true }], "]": ["vim::PushHelixNext", { "around": true }],
"[": ["vim::PushHelixPrevious", { "around": true }], "[": ["vim::PushHelixPrevious", { "around": true }],
"left": "vim::WrappingLeft", "left": "vim::WrappingLeft",
+13 -10
View File
@@ -44,7 +44,9 @@ use workspace::{
CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId,
invalid_buffer_view::InvalidBufferView, invalid_buffer_view::InvalidBufferView,
item::{FollowableItem, Item, ItemEvent, ProjectItem, SaveOptions}, item::{FollowableItem, Item, ItemEvent, ProjectItem, SaveOptions},
searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle}, searchable::{
Direction, FilteredSearchRange, SearchEvent, SearchableItem, SearchableItemHandle,
},
}; };
use workspace::{ use workspace::{
OpenOptions, OpenOptions,
@@ -1510,7 +1512,7 @@ impl SearchableItem for Editor {
fn toggle_filtered_search_ranges( fn toggle_filtered_search_ranges(
&mut self, &mut self,
enabled: bool, enabled: Option<FilteredSearchRange>,
_: &mut Window, _: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
@@ -1520,15 +1522,16 @@ impl SearchableItem for Editor {
.map(|(_, ranges)| ranges) .map(|(_, ranges)| ranges)
} }
if !enabled { if let Some(range) = enabled {
return; 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) {
if ranges.iter().any(|s| s.start != s.end) { self.set_search_within_ranges(&ranges, cx);
self.set_search_within_ranges(&ranges, cx); } else if let Some(previous_search_ranges) = self.previous_search_ranges.take()
} else if let Some(previous_search_ranges) = self.previous_search_ranges.take() { && range != FilteredSearchRange::Selection
self.set_search_within_ranges(&previous_search_ranges, cx) {
self.set_search_within_ranges(&previous_search_ranges, cx);
}
} }
} }
+43 -20
View File
@@ -38,7 +38,9 @@ use util::ResultExt;
use workspace::{ use workspace::{
ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
item::ItemHandle, item::ItemHandle,
searchable::{Direction, SearchEvent, SearchableItemHandle, WeakSearchableItemHandle}, searchable::{
Direction, FilteredSearchRange, SearchEvent, SearchableItemHandle, WeakSearchableItemHandle,
},
}; };
pub use registrar::DivRegistrar; pub use registrar::DivRegistrar;
@@ -117,7 +119,7 @@ pub struct BufferSearchBar {
search_history: SearchHistory, search_history: SearchHistory,
search_history_cursor: SearchHistoryCursor, search_history_cursor: SearchHistoryCursor,
replace_enabled: bool, replace_enabled: bool,
selection_search_enabled: bool, selection_search_enabled: Option<FilteredSearchRange>,
scroll_handle: ScrollHandle, scroll_handle: ScrollHandle,
editor_scroll_handle: ScrollHandle, editor_scroll_handle: ScrollHandle,
editor_needed_width: Pixels, editor_needed_width: Pixels,
@@ -255,13 +257,13 @@ impl Render for BufferSearchBar {
) )
.style(ButtonStyle::Subtle) .style(ButtonStyle::Subtle)
.shape(IconButtonShape::Square) .shape(IconButtonShape::Square)
.when(self.selection_search_enabled, |button| { .when(self.selection_search_enabled.is_some(), |button| {
button.style(ButtonStyle::Filled) button.style(ButtonStyle::Filled)
}) })
.on_click(cx.listener(|this, _: &ClickEvent, window, cx| { .on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
this.toggle_selection(&ToggleSelection, window, cx); this.toggle_selection(&ToggleSelection, window, cx);
})) }))
.toggle_state(self.selection_search_enabled) .toggle_state(self.selection_search_enabled.is_some())
.tooltip({ .tooltip({
let focus_handle = focus_handle.clone(); let focus_handle = focus_handle.clone();
move |window, cx| { move |window, cx| {
@@ -673,7 +675,7 @@ impl BufferSearchBar {
search_history_cursor: Default::default(), search_history_cursor: Default::default(),
active_search: None, active_search: None,
replace_enabled: false, replace_enabled: false,
selection_search_enabled: false, selection_search_enabled: None,
scroll_handle: ScrollHandle::new(), scroll_handle: ScrollHandle::new(),
editor_scroll_handle: ScrollHandle::new(), editor_scroll_handle: ScrollHandle::new(),
editor_needed_width: px(0.), editor_needed_width: px(0.),
@@ -696,10 +698,10 @@ impl BufferSearchBar {
} }
} }
if let Some(active_editor) = self.active_searchable_item.as_mut() { 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; self.replace_enabled = false;
active_editor.search_bar_visibility_changed(false, window, cx); 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); let handle = active_editor.item_focus_handle(cx);
self.focus(&handle, window); 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 { 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 self.show(window, cx) {
if let Some(active_item) = self.active_searchable_item.as_mut() { if let Some(active_item) = self.active_searchable_item.as_mut() {
active_item.toggle_filtered_search_ranges( active_item.toggle_filtered_search_ranges(filtered_search_range, window, cx);
deploy.selection_search_enabled,
window,
cx,
);
} }
self.search_suggested(window, cx); self.search_suggested(window, cx);
self.smartcase(window, cx); self.smartcase(window, cx);
self.replace_enabled = deploy.replace_enabled; 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 { if deploy.focus {
let mut handle = self.query_editor.focus_handle(cx); let mut handle = self.query_editor.focus_handle(cx);
let mut select_query = true; 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>) { pub fn set_search_options(&mut self, search_options: SearchOptions, cx: &mut Context<Self>) {
self.search_options = search_options; self.search_options = search_options;
self.adjust_query_regex_language(cx); self.adjust_query_regex_language(cx);
@@ -957,7 +977,7 @@ impl BufferSearchBar {
self.select_match(Direction::Prev, 1, window, cx); self.select_match(Direction::Prev, 1, window, cx);
} }
fn select_all_matches( pub fn select_all_matches(
&mut self, &mut self,
_: &SelectAllMatches, _: &SelectAllMatches,
window: &mut Window, window: &mut Window,
@@ -1125,12 +1145,15 @@ impl BufferSearchBar {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
if let Some(active_item) = self.active_searchable_item.as_mut() { self.set_search_within_selection(
self.selection_search_enabled = !self.selection_search_enabled; if let Some(_) = self.selection_search_enabled {
active_item.toggle_filtered_search_ranges(self.selection_search_enabled, window, cx); None
drop(self.update_matches(false, false, window, cx)); } else {
cx.notify(); Some(FilteredSearchRange::Default)
} },
window,
cx,
);
} }
fn toggle_regex(&mut self, _: &ToggleRegex, window: &mut Window, cx: &mut Context<Self>) { fn toggle_regex(&mut self, _: &ToggleRegex, window: &mut Window, cx: &mut Context<Self>) {
+92 -1
View File
@@ -5,14 +5,20 @@ mod select;
use editor::display_map::DisplaySnapshot; use editor::display_map::DisplaySnapshot;
use editor::{ use editor::{
DisplayPoint, Editor, HideMouseCursorOrigin, SelectionEffects, ToOffset, ToPoint, movement, DisplayPoint, Editor, EditorSettings, HideMouseCursorOrigin, SelectionEffects, ToOffset,
ToPoint, movement,
}; };
use gpui::actions; use gpui::actions;
use gpui::{Context, Window}; use gpui::{Context, Window};
use language::{CharClassifier, CharKind, Point}; use language::{CharClassifier, CharKind, Point};
use search::{BufferSearchBar, SearchOptions};
use settings::Settings;
use text::{Bias, SelectionGoal}; use text::{Bias, SelectionGoal};
use workspace::searchable;
use workspace::searchable::FilteredSearchRange;
use crate::motion; use crate::motion;
use crate::state::SearchState;
use crate::{ use crate::{
Vim, Vim,
motion::{Motion, right}, motion::{Motion, right},
@@ -32,6 +38,8 @@ actions!(
HelixGotoLastModification, HelixGotoLastModification,
/// Select entire line or multiple lines, extending downwards. /// Select entire line or multiple lines, extending downwards.
HelixSelectLine, 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_yank);
Vim::action(editor, cx, Vim::helix_goto_last_modification); Vim::action(editor, cx, Vim::helix_goto_last_modification);
Vim::action(editor, cx, Vim::helix_paste); Vim::action(editor, cx, Vim::helix_paste);
Vim::action(editor, cx, Vim::helix_select_regex);
} }
impl Vim { impl Vim {
@@ -368,6 +377,64 @@ impl Vim {
self.switch_mode(Mode::Insert, false, window, cx); 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>) { fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context<Self>) {
self.start_recording(cx); self.start_recording(cx);
self.switch_mode(Mode::Insert, false, window, cx); self.switch_mode(Mode::Insert, false, window, cx);
@@ -1121,4 +1188,28 @@ mod test {
cx.simulate_keystrokes("v w"); cx.simulate_keystrokes("v w");
cx.assert_state("«one ˇ»two", Mode::HelixSelect); 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);
}
} }
+7
View File
@@ -195,6 +195,7 @@ impl Vim {
prior_selections, prior_selections,
prior_operator: self.operator_stack.last().cloned(), prior_operator: self.operator_stack.last().cloned(),
prior_mode, prior_mode,
helix_select: false,
} }
}); });
} }
@@ -218,6 +219,12 @@ impl Vim {
let new_selections = self.editor_selections(window, cx); let new_selections = self.editor_selections(window, cx);
let result = pane.update(cx, |pane, cx| { let result = pane.update(cx, |pane, cx| {
let search_bar = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()?; 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| { search_bar.update(cx, |search_bar, cx| {
let mut count = self.search.count; let mut count = self.search.count;
let direction = self.search.direction; let direction = self.search.direction;
+1
View File
@@ -988,6 +988,7 @@ pub struct SearchState {
pub prior_selections: Vec<Range<Anchor>>, pub prior_selections: Vec<Range<Anchor>>,
pub prior_operator: Option<Operator>, pub prior_operator: Option<Operator>,
pub prior_mode: Mode, pub prior_mode: Mode,
pub helix_select: bool,
} }
impl Operator { impl Operator {
+23 -3
View File
@@ -45,6 +45,16 @@ pub struct SearchOptions {
pub find_in_results: bool, 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> { pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
type Match: Any + Sync + Send + Clone; type Match: Any + Sync + Send + Clone;
@@ -73,7 +83,7 @@ pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
fn toggle_filtered_search_ranges( fn toggle_filtered_search_ranges(
&mut self, &mut self,
_enabled: bool, _enabled: Option<FilteredSearchRange>,
_window: &mut Window, _window: &mut Window,
_cx: &mut Context<Self>, _cx: &mut Context<Self>,
) { ) {
@@ -216,7 +226,12 @@ pub trait SearchableItemHandle: ItemHandle {
) -> Option<usize>; ) -> Option<usize>;
fn search_bar_visibility_changed(&self, visible: bool, window: &mut Window, cx: &mut App); 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> { 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| { self.update(cx, |this, cx| {
this.toggle_filtered_search_ranges(enabled, window, cx) this.toggle_filtered_search_ranges(enabled, window, cx)
}); });