Fix Visual mode flip on project search (#44284)

MostlyK and dino created

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>

Change summary

crates/editor/src/editor.rs |  5 ++
crates/vim/Cargo.toml       |  1 
crates/vim/src/helix.rs     | 66 +++++++++++++++++++++++++++++++++++
crates/vim/src/test.rs      | 63 +++++++++++++++++++++++++++++++++-
crates/vim/src/vim.rs       | 72 ++++++++++++++++++++++++++++----------
5 files changed, 183 insertions(+), 24 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -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,

crates/vim/Cargo.toml 🔗

@@ -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"] }

crates/vim/src/helix.rs 🔗

@@ -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));
+        });
+    }
 }

crates/vim/src/test.rs 🔗

@@ -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));
+    });
+}

crates/vim/src/vim.rs 🔗

@@ -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)]