vim: Single quote mark (#27231)

AidanV and Conrad Irwin created

Closes #22398

Release Notes:

- vim: Adds `'` and `"` marks (last location jumped from in the current
buffer, and location when last exiting a buffer)

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/editor/src/editor.rs               | 14 ++++++++
crates/editor/src/items.rs                |  2 
crates/vim/src/normal/mark.rs             | 39 +++++++++++++++++++++++-
crates/vim/src/vim.rs                     | 13 ++++++++
crates/vim/test_data/test_quote_mark.json | 23 ++++++++++++++
5 files changed, 88 insertions(+), 3 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -2147,6 +2147,7 @@ impl Editor {
         self.push_to_nav_history(
             *old_cursor_position,
             Some(new_cursor_position.to_point(buffer)),
+            false,
             cx,
         );
 
@@ -10809,10 +10810,15 @@ impl Editor {
         self.nav_history.as_ref()
     }
 
+    pub fn create_nav_history_entry(&mut self, cx: &mut Context<Self>) {
+        self.push_to_nav_history(self.selections.newest_anchor().head(), None, false, cx);
+    }
+
     fn push_to_nav_history(
         &mut self,
         cursor_anchor: Anchor,
         new_position: Option<Point>,
+        is_deactivate: bool,
         cx: &mut Context<Self>,
     ) {
         if let Some(nav_history) = self.nav_history.as_mut() {
@@ -10838,6 +10844,10 @@ impl Editor {
                 }),
                 cx,
             );
+            cx.emit(EditorEvent::PushedToNavHistory {
+                anchor: cursor_anchor,
+                is_deactivate,
+            })
         }
     }
 
@@ -18617,6 +18627,10 @@ pub enum EditorEvent {
     },
     Reloaded,
     CursorShapeChanged,
+    PushedToNavHistory {
+        anchor: Anchor,
+        is_deactivate: bool,
+    },
 }
 
 impl EventEmitter<EditorEvent> for Editor {}

crates/editor/src/items.rs 🔗

@@ -737,7 +737,7 @@ impl Item for Editor {
 
     fn deactivated(&mut self, _: &mut Window, cx: &mut Context<Self>) {
         let selection = self.selections.newest_anchor();
-        self.push_to_nav_history(selection.head(), None, cx);
+        self.push_to_nav_history(selection.head(), None, true, cx);
     }
 
     fn workspace_deactivated(&mut self, _: &mut Window, cx: &mut Context<Self>) {

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

@@ -210,6 +210,9 @@ impl Vim {
 
         let Some(mut anchors) = anchors else { return };
 
+        self.update_editor(window, cx, |_, editor, _, cx| {
+            editor.create_nav_history_entry(cx);
+        });
         let is_active_operator = self.active_operator().is_some();
         if is_active_operator {
             if let Some(anchor) = anchors.last() {
@@ -264,7 +267,7 @@ impl Vim {
 
     pub fn set_mark(
         &mut self,
-        name: String,
+        mut name: String,
         anchors: Vec<Anchor>,
         buffer_entity: &Entity<MultiBuffer>,
         window: &mut Window,
@@ -273,6 +276,9 @@ impl Vim {
         let Some(workspace) = self.workspace(window) else {
             return;
         };
+        if name == "`" {
+            name = "'".to_string();
+        }
         let entity_id = workspace.entity_id();
         Vim::update_globals(cx, |vim_globals, cx| {
             let Some(marks_state) = vim_globals.marks.get(&entity_id) else {
@@ -286,11 +292,14 @@ impl Vim {
 
     pub fn get_mark(
         &self,
-        name: &str,
+        mut name: &str,
         editor: &mut Editor,
         window: &mut Window,
         cx: &mut App,
     ) -> Option<Mark> {
+        if name == "`" {
+            name = "'";
+        }
         if matches!(name, "{" | "}" | "(" | ")") {
             let (map, selections) = editor.selections.all_display(cx);
             let anchors = selections
@@ -331,3 +340,29 @@ pub fn jump_motion(
 
     (point, SelectionGoal::None)
 }
+
+#[cfg(test)]
+mod test {
+    use gpui::TestAppContext;
+
+    use crate::test::NeovimBackedTestContext;
+
+    #[gpui::test]
+    async fn test_quote_mark(cx: &mut TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇHello, world!").await;
+        cx.simulate_shared_keystrokes("w m o").await;
+        cx.shared_state().await.assert_eq("Helloˇ, world!");
+        cx.simulate_shared_keystrokes("$ ` o").await;
+        cx.shared_state().await.assert_eq("Helloˇ, world!");
+        cx.simulate_shared_keystrokes("` `").await;
+        cx.shared_state().await.assert_eq("Hello, worldˇ!");
+        cx.simulate_shared_keystrokes("` `").await;
+        cx.shared_state().await.assert_eq("Helloˇ, world!");
+        cx.simulate_shared_keystrokes("$ m '").await;
+        cx.shared_state().await.assert_eq("Hello, worldˇ!");
+        cx.simulate_shared_keystrokes("^ ` `").await;
+        cx.shared_state().await.assert_eq("Hello, worldˇ!");
+    }
+}

crates/vim/src/vim.rs 🔗

@@ -822,6 +822,19 @@ impl Vim {
             EditorEvent::Edited { .. } => self.push_to_change_list(window, cx),
             EditorEvent::FocusedIn => self.sync_vim_settings(window, cx),
             EditorEvent::CursorShapeChanged => self.cursor_shape_changed(window, cx),
+            EditorEvent::PushedToNavHistory {
+                anchor,
+                is_deactivate,
+            } => {
+                self.update_editor(window, cx, |vim, editor, window, cx| {
+                    let mark = if *is_deactivate {
+                        "\"".to_string()
+                    } else {
+                        "'".to_string()
+                    };
+                    vim.set_mark(mark, vec![*anchor], editor.buffer(), window, cx);
+                });
+            }
             _ => {}
         }
     }

crates/vim/test_data/test_quote_mark.json 🔗

@@ -0,0 +1,23 @@
+{"Put":{"state":"ˇHello, world!"}}
+{"Key":"w"}
+{"Key":"m"}
+{"Key":"o"}
+{"Get":{"state":"Helloˇ, world!","mode":"Normal"}}
+{"Key":"$"}
+{"Key":"`"}
+{"Key":"o"}
+{"Get":{"state":"Helloˇ, world!","mode":"Normal"}}
+{"Key":"`"}
+{"Key":"`"}
+{"Get":{"state":"Hello, worldˇ!","mode":"Normal"}}
+{"Key":"`"}
+{"Key":"`"}
+{"Get":{"state":"Helloˇ, world!","mode":"Normal"}}
+{"Key":"$"}
+{"Key":"m"}
+{"Key":"'"}
+{"Get":{"state":"Hello, worldˇ!","mode":"Normal"}}
+{"Key":"^"}
+{"Key":"`"}
+{"Key":"`"}
+{"Get":{"state":"Hello, worldˇ!","mode":"Normal"}}