Fix Visual mode flip on project search (#44284)

Previously, when project search results first appeared, vim would
incorrectly switch to Visual mode. This happened because vim settings
(including `collapse_matches`) weren't synced to an editor until it
received focus. Since the results editor wasn't focused when the first
match was selected, the selection wasn't collapsed, causing vim to
interpret it as a visual selection.

Now vim settings are synced immediately when vim activates on an editor,
ensuring `collapse_matches` is set before any selections are made.

Closes #43878

Release Notes:

- Fixed vim mode incorrectly switching to Visual mode on first project
search

---------

Co-authored-by: dino <dinojoaocosta@gmail.com>
This commit is contained in:
MostlyKanddino authored and GitHub committed 2026-01-22 00:11:29 +00:00
1 parent b162fb4019
commit 8f5bdfc671
5 files changed
+183 -24

No files matched your search

+4 -1
View File
@@ -1196,7 +1196,10 @@ pub struct Editor {
/// selected (needed for vim visual mode)
cursor_offset_on_selection: bool,
current_line_highlight: Option<CurrentLineHighlight>,
pub collapse_matches: bool,
/// Whether to collapse search match ranges to just their start position.
/// When true, navigating to a match positions the cursor at the match
/// without selecting the matched text.
collapse_matches: bool,
autoindent_mode: Option<AutoindentMode>,
workspace: Option<(WeakEntity<Workspace>, Option<WorkspaceId>)>,
input_enabled: bool,
+1
View File
@@ -75,3 +75,4 @@ settings.workspace = true
perf.workspace = true
util = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }
search = { workspace = true, features = ["test-support"] }
+65 -1
View File
@@ -862,9 +862,16 @@ impl Vim {
#[cfg(test)]
mod test {
use gpui::{UpdateGlobal, VisualTestContext};
use indoc::indoc;
use project::FakeFs;
use search::{ProjectSearchView, project_search};
use serde_json::json;
use settings::SettingsStore;
use util::path;
use workspace::DeploySearch;
use crate::{state::Mode, test::VimTestContext};
use crate::{VimAddon, state::Mode, test::VimTestContext};
#[gpui::test]
async fn test_word_motions(cx: &mut gpui::TestAppContext) {
@@ -1694,4 +1701,61 @@ mod test {
Mode::HelixNormal,
);
}
#[gpui::test]
async fn test_project_search_opens_in_normal_mode(cx: &mut gpui::TestAppContext) {
VimTestContext::init(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/dir"),
json!({
"file_a.rs": "// File A.",
"file_b.rs": "// File B.",
}),
)
.await;
let project = project::Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let workspace =
cx.add_window(|window, cx| workspace::Workspace::test_new(project.clone(), window, cx));
cx.update(|cx| {
VimTestContext::init_keybindings(true, cx);
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |store| store.helix_mode = Some(true));
})
});
let cx = &mut VisualTestContext::from_window(*workspace, cx);
workspace
.update(cx, |workspace, window, cx| {
ProjectSearchView::deploy_search(workspace, &DeploySearch::default(), window, cx)
})
.unwrap();
let search_view = workspace
.update(cx, |workspace, _, cx| {
workspace
.active_pane()
.read(cx)
.items()
.find_map(|item| item.downcast::<ProjectSearchView>())
.expect("Project search view should be active")
})
.unwrap();
project_search::perform_project_search(&search_view, "File A", cx);
search_view.update(cx, |search_view, cx| {
let vim_mode = search_view
.results_editor()
.read(cx)
.addon::<VimAddon>()
.map(|addon| addon.entity.read(cx).mode);
assert_eq!(vim_mode, Some(Mode::HelixNormal));
});
}
}
+61 -2
View File
@@ -20,13 +20,18 @@ use language::{CursorShape, Language, LanguageConfig, Point};
pub use neovim_backed_test_context::*;
use settings::SettingsStore;
use ui::Pixels;
use util::test::marked_text_ranges;
use util::{path, test::marked_text_ranges};
pub use vim_test_context::*;
use gpui::VisualTestContext;
use indoc::indoc;
use project::FakeFs;
use search::BufferSearchBar;
use search::{ProjectSearchView, project_search};
use serde_json::json;
use workspace::DeploySearch;
use crate::{PushSneak, PushSneakBackward, insert::NormalBefore, motion, state::Mode};
use crate::{PushSneak, PushSneakBackward, VimAddon, insert::NormalBefore, motion, state::Mode};
use util_macros::perf;
@@ -2534,3 +2539,57 @@ async fn test_deactivate(cx: &mut gpui::TestAppContext) {
assert_eq!(editor.cursor_shape(), CursorShape::Underline);
});
}
#[gpui::test]
async fn test_project_search_opens_in_normal_mode(cx: &mut gpui::TestAppContext) {
VimTestContext::init(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/dir"),
json!({
"file_a.rs": "// File A.",
"file_b.rs": "// File B.",
}),
)
.await;
let project = project::Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let workspace =
cx.add_window(|window, cx| workspace::Workspace::test_new(project.clone(), window, cx));
cx.update(|cx| {
VimTestContext::init_keybindings(true, cx);
});
let cx = &mut VisualTestContext::from_window(*workspace, cx);
workspace
.update(cx, |workspace, window, cx| {
ProjectSearchView::deploy_search(workspace, &DeploySearch::default(), window, cx)
})
.unwrap();
let search_view = workspace
.update(cx, |workspace, _, cx| {
workspace
.active_pane()
.read(cx)
.items()
.find_map(|item| item.downcast::<ProjectSearchView>())
.expect("Project search view should be active")
})
.unwrap();
project_search::perform_project_search(&search_view, "File A", cx);
search_view.update(cx, |search_view, cx| {
let vim_mode = search_view
.results_editor()
.read(cx)
.addon::<VimAddon>()
.map(|addon| addon.entity.read(cx).mode);
assert_eq!(vim_mode, Some(Mode::Normal));
});
}
+52 -20
View File
@@ -633,12 +633,15 @@ impl Vim {
fn activate(editor: &mut Editor, window: &mut Window, cx: &mut Context<Editor>) {
let vim = Vim::new(window, cx);
if !editor.mode().is_full() {
vim.update(cx, |vim, _| {
let state = vim.update(cx, |vim, cx| {
if !editor.mode().is_full() {
vim.mode = Mode::Insert;
});
}
}
vim.state_for_editor_settings(cx)
});
Vim::sync_vim_settings_to_editor(&state, editor, window, cx);
editor.register_addon(VimAddon {
entity: vim.clone(),
@@ -1305,7 +1308,7 @@ impl Vim {
forced_motion
}
pub fn cursor_shape(&self, cx: &mut App) -> CursorShape {
pub fn cursor_shape(&self, cx: &App) -> CursorShape {
let cursor_shape = VimSettings::get_global(cx).cursor_shape;
match self.mode {
Mode::Normal => {
@@ -2022,23 +2025,52 @@ impl Vim {
}
fn sync_vim_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.update_editor(cx, |vim, editor, cx| {
editor.set_cursor_shape(vim.cursor_shape(cx), cx);
editor.set_clip_at_line_ends(vim.clip_at_line_ends(), cx);
let collapse_matches = !HelixModeSetting::get_global(cx).0;
editor.set_collapse_matches(collapse_matches);
editor.set_input_enabled(vim.editor_input_enabled());
editor.set_autoindent(vim.should_autoindent());
editor.set_cursor_offset_on_selection(vim.mode.is_visual());
editor
.selections
.set_line_mode(matches!(vim.mode, Mode::VisualLine));
let hide_edit_predictions = !matches!(vim.mode, Mode::Insert | Mode::Replace);
editor.set_edit_predictions_hidden_for_vim_mode(hide_edit_predictions, window, cx);
let state = self.state_for_editor_settings(cx);
self.update_editor(cx, |_, editor, cx| {
Vim::sync_vim_settings_to_editor(&state, editor, window, cx);
});
cx.notify()
}
fn state_for_editor_settings(&self, cx: &App) -> VimEditorSettingsState {
VimEditorSettingsState {
cursor_shape: self.cursor_shape(cx),
clip_at_line_ends: self.clip_at_line_ends(),
collapse_matches: !HelixModeSetting::get_global(cx).0,
input_enabled: self.editor_input_enabled(),
autoindent: self.should_autoindent(),
cursor_offset_on_selection: self.mode.is_visual(),
line_mode: matches!(self.mode, Mode::VisualLine),
hide_edit_predictions: !matches!(self.mode, Mode::Insert | Mode::Replace),
}
}
fn sync_vim_settings_to_editor(
state: &VimEditorSettingsState,
editor: &mut Editor,
window: &mut Window,
cx: &mut Context<Editor>,
) {
editor.set_cursor_shape(state.cursor_shape, cx);
editor.set_clip_at_line_ends(state.clip_at_line_ends, cx);
editor.set_collapse_matches(state.collapse_matches);
editor.set_input_enabled(state.input_enabled);
editor.set_autoindent(state.autoindent);
editor.set_cursor_offset_on_selection(state.cursor_offset_on_selection);
editor.selections.set_line_mode(state.line_mode);
editor.set_edit_predictions_hidden_for_vim_mode(state.hide_edit_predictions, window, cx);
}
}
struct VimEditorSettingsState {
cursor_shape: CursorShape,
clip_at_line_ends: bool,
collapse_matches: bool,
input_enabled: bool,
autoindent: bool,
cursor_offset_on_selection: bool,
line_mode: bool,
hide_edit_predictions: bool,
}
#[derive(RegisterSetting)]