vim gigv (#13028)

Conrad Irwin created

Release Notes:

- vim: Fix `gi` when the insert ended at the end of a line (#12162)
- vim: Add `gv` to restore previous visual selection (#12888)
- vim: Fix `gl` when the first match is at the end of a line

Change summary

assets/keymaps/vim.json                    |   3 
crates/assistant/src/prompt_library.rs     |   1 
crates/vim/src/insert.rs                   |   7 +
crates/vim/src/normal.rs                   |  16 +++
crates/vim/src/normal/mark.rs              |  54 ++++------
crates/vim/src/state.rs                    |   1 
crates/vim/src/test.rs                     |  30 ++++++
crates/vim/src/vim.rs                      |  17 --
crates/vim/src/visual.rs                   | 115 +++++++++++++++++++++++
crates/vim/test_data/test_caret_mark.json  |   9 +
crates/vim/test_data/test_gv.json          |  24 +++++
crates/vim/test_data/test_lt_gt_marks.json |  11 ++
12 files changed, 239 insertions(+), 49 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -246,9 +246,10 @@
           "displayLines": true
         }
       ],
+      "g v": "vim::RestoreVisualSelection",
       "g ]": "editor::GoToDiagnostic",
       "g [": "editor::GoToPrevDiagnostic",
-      "g i": ["workspace::SendKeystrokes", "` ^ i"],
+      "g i": "vim::InsertAtPrevious",
       "g ,": "vim::ChangeListNewer",
       "g ;": "vim::ChangeListOlder",
       "shift-h": "vim::WindowTop",

crates/assistant/src/prompt_library.rs 🔗

@@ -450,6 +450,7 @@ impl PromptLibrary {
                             editor.set_show_gutter(false, cx);
                             editor.set_show_wrap_guides(false, cx);
                             editor.set_show_indent_guides(false, cx);
+                            editor.set_use_modal_editing(false);
                             editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
                             editor.set_completion_provider(Box::new(
                                 SlashCommandCompletionProvider::new(commands, None, None),

crates/vim/src/insert.rs 🔗

@@ -1,4 +1,8 @@
-use crate::{normal::repeat, state::Mode, Vim};
+use crate::{
+    normal::{mark::create_mark, repeat},
+    state::Mode,
+    Vim,
+};
 use editor::{scroll::Autoscroll, Bias};
 use gpui::{actions, Action, ViewContext};
 use language::SelectionGoal;
@@ -15,6 +19,7 @@ fn normal_before(_: &mut Workspace, action: &NormalBefore, cx: &mut ViewContext<
         let count = vim.take_count(cx).unwrap_or(1);
         vim.stop_recording_immediately(action.boxed_clone());
         if count <= 1 || vim.workspace_state.replaying {
+            create_mark(vim, "^".into(), false, cx);
             vim.update_active_editor(cx, |_, editor, cx| {
                 editor.dismiss_menus_and_popups(false, cx);
                 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {

crates/vim/src/normal.rs 🔗

@@ -51,6 +51,7 @@ actions!(
         InsertEndOfLine,
         InsertLineAbove,
         InsertLineBelow,
+        InsertAtPrevious,
         DeleteLeft,
         DeleteRight,
         ChangeToEndOfLine,
@@ -73,6 +74,7 @@ pub(crate) fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace
     workspace.register_action(insert_end_of_line);
     workspace.register_action(insert_line_above);
     workspace.register_action(insert_line_below);
+    workspace.register_action(insert_at_previous);
     workspace.register_action(change_case);
     workspace.register_action(convert_to_upper_case);
     workspace.register_action(convert_to_lower_case);
@@ -341,6 +343,20 @@ fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewConte
     });
 }
 
+fn insert_at_previous(_: &mut Workspace, _: &InsertAtPrevious, cx: &mut ViewContext<Workspace>) {
+    Vim::update(cx, |vim, cx| {
+        vim.start_recording(cx);
+        vim.switch_mode(Mode::Insert, false, cx);
+        vim.update_active_editor(cx, |vim, editor, cx| {
+            if let Some(marks) = vim.state().marks.get("^") {
+                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                    s.select_anchor_ranges(marks.iter().map(|mark| *mark..*mark))
+                });
+            }
+        });
+    });
+}
+
 fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
         vim.start_recording(cx);

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

@@ -11,6 +11,7 @@ use language::SelectionGoal;
 
 use crate::{
     motion::{self, Motion},
+    state::Mode,
     Vim,
 };
 
@@ -29,41 +30,32 @@ pub fn create_mark(vim: &mut Vim, text: Arc<str>, tail: bool, cx: &mut WindowCon
     vim.clear_operator(cx);
 }
 
-pub fn create_mark_after(vim: &mut Vim, text: Arc<str>, cx: &mut WindowContext) {
-    let Some(anchors) = vim.update_active_editor(cx, |_, editor, cx| {
-        let (map, selections) = editor.selections.all_display(cx);
-        selections
-            .into_iter()
-            .map(|selection| {
-                let point = movement::saturating_right(&map, selection.tail());
-                map.buffer_snapshot
-                    .anchor_before(point.to_offset(&map, Bias::Left))
-            })
-            .collect::<Vec<_>>()
-    }) else {
-        return;
-    };
-
-    vim.update_state(|state| state.marks.insert(text.to_string(), anchors));
-    vim.clear_operator(cx);
-}
+pub fn create_visual_marks(vim: &mut Vim, mode: Mode, cx: &mut WindowContext) {
+    let mut starts = vec![];
+    let mut ends = vec![];
+    let mut reversed = vec![];
 
-pub fn create_mark_before(vim: &mut Vim, text: Arc<str>, cx: &mut WindowContext) {
-    let Some(anchors) = vim.update_active_editor(cx, |_, editor, cx| {
+    vim.update_active_editor(cx, |_, editor, cx| {
         let (map, selections) = editor.selections.all_display(cx);
-        selections
-            .into_iter()
-            .map(|selection| {
-                let point = movement::saturating_left(&map, selection.head());
+        for selection in selections {
+            let end = movement::saturating_left(&map, selection.end);
+            ends.push(
                 map.buffer_snapshot
-                    .anchor_before(point.to_offset(&map, Bias::Left))
-            })
-            .collect::<Vec<_>>()
-    }) else {
-        return;
-    };
+                    .anchor_before(end.to_offset(&map, Bias::Left)),
+            );
+            starts.push(
+                map.buffer_snapshot
+                    .anchor_after(selection.start.to_offset(&map, Bias::Right)),
+            );
+            reversed.push(selection.reversed)
+        }
+    });
 
-    vim.update_state(|state| state.marks.insert(text.to_string(), anchors));
+    vim.update_state(|state| {
+        state.marks.insert("<".to_string(), starts);
+        state.marks.insert(">".to_string(), ends);
+        state.stored_visual_mode.replace((mode, reversed));
+    });
     vim.clear_operator(cx);
 }
 

crates/vim/src/state.rs 🔗

@@ -84,6 +84,7 @@ pub struct EditorState {
     pub replacements: Vec<(Range<editor::Anchor>, String)>,
 
     pub marks: HashMap<String, Vec<Anchor>>,
+    pub stored_visual_mode: Option<(Mode, Vec<bool>)>,
     pub change_list: Vec<Vec<Anchor>>,
     pub change_list_position: Option<usize>,
 

crates/vim/src/test.rs 🔗

@@ -1127,6 +1127,26 @@ async fn test_lt_gt_marks(cx: &mut TestAppContext) {
         Line five
     "
     });
+
+    cx.simulate_shared_keystrokes("v i w o escape").await;
+    cx.simulate_shared_keystrokes("` >").await;
+    cx.shared_state().await.assert_eq(indoc! {"
+        Line one
+        Line two
+        Line three
+        Line fouˇr
+        Line five
+    "
+    });
+    cx.simulate_shared_keystrokes("` <").await;
+    cx.shared_state().await.assert_eq(indoc! {"
+        Line one
+        Line two
+        Line three
+        Line ˇfour
+        Line five
+    "
+    });
 }
 
 #[gpui::test]
@@ -1166,4 +1186,14 @@ async fn test_caret_mark(cx: &mut TestAppContext) {
         Line five
     "
     });
+
+    cx.simulate_shared_keystrokes("k a ! escape k g i ?").await;
+    cx.shared_state().await.assert_eq(indoc! {"
+        Line one
+        Line two
+        Line three!?ˇ
+        Straight thing four
+        Line five
+    "
+    });
 }

crates/vim/src/vim.rs 🔗

@@ -31,10 +31,7 @@ use gpui::{
 use language::{CursorShape, Point, SelectionGoal, TransactionId};
 pub use mode_indicator::ModeIndicator;
 use motion::Motion;
-use normal::{
-    mark::{create_mark, create_mark_after, create_mark_before},
-    normal_replace,
-};
+use normal::{mark::create_visual_marks, normal_replace};
 use replace::multi_replace;
 use schemars::JsonSchema;
 use serde::Deserialize;
@@ -431,8 +428,8 @@ impl Vim {
         // Sync editor settings like clip mode
         self.sync_vim_settings(cx);
 
-        if mode != Mode::Insert && last_mode == Mode::Insert {
-            create_mark_after(self, "^".into(), cx)
+        if !mode.is_visual() && last_mode.is_visual() {
+            create_visual_marks(self, last_mode, cx);
         }
 
         if leave_selections {
@@ -790,7 +787,6 @@ impl Vim {
         let is_multicursor = editor.read(cx).selections.count() > 1;
 
         let state = self.state();
-        let mut is_visual = state.mode.is_visual();
         if state.mode == Mode::Insert && state.current_tx.is_some() {
             if state.current_anchor.is_none() {
                 self.update_state(|state| state.current_anchor = Some(newest));
@@ -807,18 +803,11 @@ impl Vim {
             } else {
                 self.switch_mode(Mode::Visual, false, cx)
             }
-            is_visual = true;
         } else if newest.start == newest.end
             && !is_multicursor
             && [Mode::Visual, Mode::VisualLine, Mode::VisualBlock].contains(&state.mode)
         {
             self.switch_mode(Mode::Normal, true, cx);
-            is_visual = false;
-        }
-
-        if is_visual {
-            create_mark_before(self, ">".into(), cx);
-            create_mark(self, "<".into(), true, cx)
         }
     }
 

crates/vim/src/visual.rs 🔗

@@ -5,7 +5,7 @@ use editor::{
     display_map::{DisplaySnapshot, ToDisplayPoint},
     movement,
     scroll::Autoscroll,
-    Bias, DisplayPoint, Editor,
+    Bias, DisplayPoint, Editor, ToOffset,
 };
 use gpui::{actions, ViewContext, WindowContext};
 use language::{Point, Selection, SelectionGoal};
@@ -16,8 +16,8 @@ use workspace::{searchable::Direction, Workspace};
 
 use crate::{
     motion::{start_of_line, Motion},
-    normal::substitute::substitute,
     normal::yank::{copy_selections_content, yank_selections_content},
+    normal::{mark::create_visual_marks, substitute::substitute},
     object::Object,
     state::{Mode, Operator},
     Vim,
@@ -37,6 +37,7 @@ actions!(
         SelectPrevious,
         SelectNextMatch,
         SelectPreviousMatch,
+        RestoreVisualSelection,
     ]
 );
 
@@ -83,6 +84,52 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
             select_match(workspace, vim, Direction::Prev, cx);
         });
     });
+
+    workspace.register_action(|_, _: &RestoreVisualSelection, cx| {
+        Vim::update(cx, |vim, cx| {
+            let Some((stored_mode, reversed)) =
+                vim.update_state(|state| state.stored_visual_mode.take())
+            else {
+                return;
+            };
+            let Some((start, end)) = vim.state().marks.get("<").zip(vim.state().marks.get(">"))
+            else {
+                return;
+            };
+            let ranges = start
+                .into_iter()
+                .zip(end)
+                .zip(reversed)
+                .map(|((start, end), reversed)| (*start, *end, reversed))
+                .collect::<Vec<_>>();
+
+            if vim.state().mode.is_visual() {
+                create_visual_marks(vim, vim.state().mode, cx);
+            }
+
+            vim.update_active_editor(cx, |_, editor, cx| {
+                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                    let map = s.display_map();
+                    let ranges = ranges
+                        .into_iter()
+                        .map(|(start, end, reversed)| {
+                            let new_end =
+                                movement::saturating_right(&map, end.to_display_point(&map));
+                            Selection {
+                                id: s.new_selection_id(),
+                                start: start.to_offset(&map.buffer_snapshot),
+                                end: new_end.to_offset(&map, Bias::Left),
+                                reversed,
+                                goal: SelectionGoal::None,
+                            }
+                        })
+                        .collect();
+                    s.select(ranges);
+                })
+            });
+            vim.switch_mode(stored_mode, true, cx)
+        });
+    });
 }
 
 pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
@@ -483,6 +530,7 @@ pub fn select_next(_: &mut Workspace, _: &SelectNext, cx: &mut ViewContext<Works
             vim.take_count(cx)
                 .unwrap_or_else(|| if vim.state().mode.is_visual() { 1 } else { 2 });
         vim.update_active_editor(cx, |_, editor, cx| {
+            editor.set_clip_at_line_ends(false, cx);
             for _ in 0..count {
                 if editor
                     .select_next(&Default::default(), cx)
@@ -1166,6 +1214,17 @@ mod test {
         cx.shared_state().await.assert_eq("«ˇaa aa» aa aa aa");
     }
 
+    #[gpui::test]
+    async fn test_gl(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        cx.set_state("aaˇ aa\naa", Mode::Normal);
+        cx.simulate_keystrokes("g l");
+        cx.assert_state("«aaˇ» «aaˇ»\naa", Mode::Visual);
+        cx.simulate_keystrokes("g >");
+        cx.assert_state("«aaˇ» aa\n«aaˇ»", Mode::Visual);
+    }
+
     #[gpui::test]
     async fn test_dgn_repeat(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;
@@ -1247,4 +1306,56 @@ mod test {
             "
         });
     }
+
+    #[gpui::test]
+    async fn test_gv(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {
+            "The ˇquick brown"
+        })
+        .await;
+        cx.simulate_shared_keystrokes("v i w escape g v").await;
+        cx.shared_state().await.assert_eq(indoc! {
+            "The «quickˇ» brown"
+        });
+
+        cx.simulate_shared_keystrokes("o escape g v").await;
+        cx.shared_state().await.assert_eq(indoc! {
+            "The «ˇquick» brown"
+        });
+
+        cx.simulate_shared_keystrokes("escape ^ ctrl-v l").await;
+        cx.shared_state().await.assert_eq(indoc! {
+            "«Thˇ»e quick brown"
+        });
+        cx.simulate_shared_keystrokes("g v").await;
+        cx.shared_state().await.assert_eq(indoc! {
+            "The «ˇquick» brown"
+        });
+        cx.simulate_shared_keystrokes("g v").await;
+        cx.shared_state().await.assert_eq(indoc! {
+            "«Thˇ»e quick brown"
+        });
+
+        cx.set_state(
+            indoc! {"
+            fiˇsh one
+            fish two
+            fish red
+            fish blue
+        "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("4 g l escape escape g v");
+        cx.assert_state(
+            indoc! {"
+                «fishˇ» one
+                «fishˇ» two
+                «fishˇ» red
+                «fishˇ» blue
+            "},
+            Mode::Visual,
+        );
+    }
 }

crates/vim/test_data/test_caret_mark.json 🔗

@@ -24,3 +24,12 @@
 {"Key":"`"}
 {"Key":"^"}
 {"Get":{"state":"Line one\nLine two\nLine three\nStraight thingˇ four\nLine five\n","mode":"Normal"}}
+{"Key":"k"}
+{"Key":"a"}
+{"Key":"!"}
+{"Key":"escape"}
+{"Key":"k"}
+{"Key":"g"}
+{"Key":"i"}
+{"Key":"?"}
+{"Get":{"state":"Line one\nLine two\nLine three!?ˇ\nStraight thing four\nLine five\n","mode":"Insert"}}

crates/vim/test_data/test_gv.json 🔗

@@ -0,0 +1,24 @@
+{"Put":{"state":"The ˇquick brown"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"escape"}
+{"Key":"g"}
+{"Key":"v"}
+{"Get":{"state":"The «quickˇ» brown","mode":"Visual"}}
+{"Key":"o"}
+{"Key":"escape"}
+{"Key":"g"}
+{"Key":"v"}
+{"Get":{"state":"The «ˇquick» brown","mode":"Visual"}}
+{"Key":"escape"}
+{"Key":"^"}
+{"Key":"ctrl-v"}
+{"Key":"l"}
+{"Get":{"state":"«Thˇ»e quick brown","mode":"VisualBlock"}}
+{"Key":"g"}
+{"Key":"v"}
+{"Get":{"state":"The «ˇquick» brown","mode":"Visual"}}
+{"Key":"g"}
+{"Key":"v"}
+{"Get":{"state":"«Thˇ»e quick brown","mode":"VisualBlock"}}

crates/vim/test_data/test_lt_gt_marks.json 🔗

@@ -16,3 +16,14 @@
 {"Key":"`"}
 {"Key":">"}
 {"Get":{"state":"Line one\nLine two\nLine three\nLine ˇfour\nLine five\n","mode":"Normal"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"o"}
+{"Key":"escape"}
+{"Key":"`"}
+{"Key":">"}
+{"Get":{"state":"Line one\nLine two\nLine three\nLine fouˇr\nLine five\n","mode":"Normal"}}
+{"Key":"`"}
+{"Key":"<"}
+{"Get":{"state":"Line one\nLine two\nLine three\nLine ˇfour\nLine five\n","mode":"Normal"}}