Merge pull request #2025 from zed-industries/vim-r

Kay Simmons created

Vim replace

Change summary

assets/keymaps/vim.json       | 10 +++++
crates/editor/src/movement.rs | 23 ++++++++++++++
crates/vim/src/motion.rs      | 23 --------------
crates/vim/src/normal.rs      | 57 +++++++++++++++++++++++++++++++++++++
crates/vim/src/state.rs       |  6 +++
crates/vim/src/vim.rs         | 35 ++++++++++++++++++++++
crates/vim/src/visual.rs      | 51 ++++++++++++++++++++++++++++++++
7 files changed, 179 insertions(+), 26 deletions(-)

Detailed changes

assets/keymaps/vim.json ๐Ÿ”—

@@ -209,6 +209,10 @@
             "ctrl-e": [
                 "vim::Scroll",
                 "LineDown"
+            ],
+            "r": [
+                "vim::PushOperator",
+                "Replace"
             ]
         }
     },
@@ -294,7 +298,11 @@
             "d": "vim::VisualDelete",
             "x": "vim::VisualDelete",
             "y": "vim::VisualYank",
-            "p": "vim::VisualPaste"
+            "p": "vim::VisualPaste",
+            "r": [
+                "vim::PushOperator",
+                "Replace"
+            ]
         }
     },
     {

crates/editor/src/movement.rs ๐Ÿ”—

@@ -352,6 +352,29 @@ pub fn surrounding_word(map: &DisplaySnapshot, position: DisplayPoint) -> Range<
     start..end
 }
 
+pub fn split_display_range_by_lines(
+    map: &DisplaySnapshot,
+    range: Range<DisplayPoint>,
+) -> Vec<Range<DisplayPoint>> {
+    let mut result = Vec::new();
+
+    let mut start = range.start;
+    // Loop over all the covered rows until the one containing the range end
+    for row in range.start.row()..range.end.row() {
+        let row_end_column = map.line_len(row);
+        let end = map.clip_point(DisplayPoint::new(row, row_end_column), Bias::Left);
+        if start != end {
+            result.push(start..end);
+        }
+        start = map.clip_point(DisplayPoint::new(row + 1, 0), Bias::Left);
+    }
+
+    // Add the final range from the start of the last end to the original range end.
+    result.push(start..range.end);
+
+    result
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

crates/vim/src/motion.rs ๐Ÿ”—

@@ -3,7 +3,7 @@ use editor::{
     display_map::{DisplaySnapshot, ToDisplayPoint},
     movement, Bias, CharKind, DisplayPoint,
 };
-use gpui::{actions, impl_actions, keymap_matcher::KeyPressed, MutableAppContext};
+use gpui::{actions, impl_actions, MutableAppContext};
 use language::{Point, Selection, SelectionGoal};
 use serde::Deserialize;
 use workspace::Workspace;
@@ -109,27 +109,6 @@ pub fn init(cx: &mut MutableAppContext) {
          &PreviousWordStart { ignore_punctuation }: &PreviousWordStart,
          cx: _| { motion(Motion::PreviousWordStart { ignore_punctuation }, cx) },
     );
-    cx.add_action(
-        |_: &mut Workspace, KeyPressed { keystroke }: &KeyPressed, cx| match Vim::read(cx)
-            .active_operator()
-        {
-            Some(Operator::FindForward { before }) => motion(
-                Motion::FindForward {
-                    before,
-                    character: keystroke.key.chars().next().unwrap(),
-                },
-                cx,
-            ),
-            Some(Operator::FindBackward { after }) => motion(
-                Motion::FindBackward {
-                    after,
-                    character: keystroke.key.chars().next().unwrap(),
-                },
-                cx,
-            ),
-            _ => cx.propagate_action(),
-        },
-    )
 }
 
 pub(crate) fn motion(motion: Motion, cx: &mut MutableAppContext) {

crates/vim/src/normal.rs ๐Ÿ”—

@@ -424,6 +424,53 @@ fn scroll(editor: &mut Editor, amount: &ScrollAmount, cx: &mut ViewContext<Edito
     }
 }
 
+pub(crate) fn normal_replace(text: &str, cx: &mut MutableAppContext) {
+    Vim::update(cx, |vim, cx| {
+        vim.update_active_editor(cx, |editor, cx| {
+            editor.transact(cx, |editor, cx| {
+                editor.set_clip_at_line_ends(false, cx);
+                let (map, display_selections) = editor.selections.all_display(cx);
+                // Selections are biased right at the start. So we need to store
+                // anchors that are biased left so that we can restore the selections
+                // after the change
+                let stable_anchors = editor
+                    .selections
+                    .disjoint_anchors()
+                    .into_iter()
+                    .map(|selection| {
+                        let start = selection.start.bias_left(&map.buffer_snapshot);
+                        start..start
+                    })
+                    .collect::<Vec<_>>();
+
+                let edits = display_selections
+                    .into_iter()
+                    .map(|selection| {
+                        let mut range = selection.range();
+                        *range.end.column_mut() += 1;
+                        range.end = map.clip_point(range.end, Bias::Right);
+
+                        (
+                            range.start.to_offset(&map, Bias::Left)
+                                ..range.end.to_offset(&map, Bias::Left),
+                            text,
+                        )
+                    })
+                    .collect::<Vec<_>>();
+
+                editor.buffer().update(cx, |buffer, cx| {
+                    buffer.edit(edits, None, cx);
+                });
+                editor.set_clip_at_line_ends(true, cx);
+                editor.change_selections(None, cx, |s| {
+                    s.select_anchor_ranges(stable_anchors);
+                });
+            });
+        });
+        vim.pop_operator(cx)
+    });
+}
+
 #[cfg(test)]
 mod test {
     use indoc::indoc;
@@ -468,6 +515,16 @@ mod test {
         .await;
     }
 
+    // #[gpui::test]
+    // async fn test_enter(cx: &mut gpui::TestAppContext) {
+    //     let mut cx = NeovimBackedTestContext::new(cx).await.binding(["enter"]);
+    //     cx.assert_all(indoc! {"
+    //         ห‡The qห‡uick broห‡wn
+    //         ห‡fox jumps"
+    //     })
+    //     .await;
+    // }
+
     #[gpui::test]
     async fn test_k(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await.binding(["k"]);

crates/vim/src/state.rs ๐Ÿ”—

@@ -28,6 +28,7 @@ pub enum Operator {
     Change,
     Delete,
     Yank,
+    Replace,
     Object { around: bool },
     FindForward { before: bool },
     FindBackward { after: bool },
@@ -117,6 +118,7 @@ impl Operator {
             Operator::Change => "c",
             Operator::Delete => "d",
             Operator::Yank => "y",
+            Operator::Replace => "r",
             Operator::FindForward { before: false } => "f",
             Operator::FindForward { before: true } => "t",
             Operator::FindBackward { after: false } => "F",
@@ -127,7 +129,9 @@ impl Operator {
     pub fn context_flags(&self) -> &'static [&'static str] {
         match self {
             Operator::Object { .. } => &["VimObject"],
-            Operator::FindForward { .. } | Operator::FindBackward { .. } => &["VimWaiting"],
+            Operator::FindForward { .. } | Operator::FindBackward { .. } | Operator::Replace => {
+                &["VimWaiting"]
+            }
             _ => &[],
         }
     }

crates/vim/src/vim.rs ๐Ÿ”—

@@ -13,11 +13,18 @@ mod visual;
 use collections::HashMap;
 use command_palette::CommandPaletteFilter;
 use editor::{Bias, Cancel, Editor};
-use gpui::{impl_actions, MutableAppContext, Subscription, ViewContext, WeakViewHandle};
+use gpui::{
+    impl_actions,
+    keymap_matcher::{KeyPressed, Keystroke},
+    MutableAppContext, Subscription, ViewContext, WeakViewHandle,
+};
 use language::CursorShape;
+use motion::Motion;
+use normal::normal_replace;
 use serde::Deserialize;
 use settings::Settings;
 use state::{Mode, Operator, VimState};
+use visual::visual_replace;
 use workspace::{self, Workspace};
 
 #[derive(Clone, Deserialize, PartialEq)]
@@ -51,6 +58,11 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(|_: &mut Workspace, n: &Number, cx: _| {
         Vim::update(cx, |vim, cx| vim.push_number(n, cx));
     });
+    cx.add_action(
+        |_: &mut Workspace, KeyPressed { keystroke }: &KeyPressed, cx| {
+            Vim::key_pressed(keystroke, cx);
+        },
+    );
 
     // Editor Actions
     cx.add_action(|_: &mut Editor, _: &Cancel, cx| {
@@ -208,6 +220,27 @@ impl Vim {
         self.state.operator_stack.last().copied()
     }
 
+    fn key_pressed(keystroke: &Keystroke, cx: &mut ViewContext<Workspace>) {
+        match Vim::read(cx).active_operator() {
+            Some(Operator::FindForward { before }) => {
+                if let Some(character) = keystroke.key.chars().next() {
+                    motion::motion(Motion::FindForward { before, character }, cx)
+                }
+            }
+            Some(Operator::FindBackward { after }) => {
+                if let Some(character) = keystroke.key.chars().next() {
+                    motion::motion(Motion::FindBackward { after, character }, cx)
+                }
+            }
+            Some(Operator::Replace) => match Vim::read(cx).state.mode {
+                Mode::Normal => normal_replace(&keystroke.key, cx),
+                Mode::Visual { line } => visual_replace(&keystroke.key, line, cx),
+                _ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)),
+            },
+            _ => cx.propagate_action(),
+        }
+    }
+
     fn set_enabled(&mut self, enabled: bool, cx: &mut MutableAppContext) {
         if self.enabled != enabled {
             self.enabled = enabled;

crates/vim/src/visual.rs ๐Ÿ”—

@@ -2,7 +2,7 @@ use std::borrow::Cow;
 
 use collections::HashMap;
 use editor::{
-    display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Bias, ClipboardSelection,
+    display_map::ToDisplayPoint, movement, scroll::autoscroll::Autoscroll, Bias, ClipboardSelection,
 };
 use gpui::{actions, MutableAppContext, ViewContext};
 use language::{AutoindentMode, SelectionGoal};
@@ -313,6 +313,55 @@ pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext<Workspace>
     });
 }
 
+pub(crate) fn visual_replace(text: &str, line: bool, cx: &mut MutableAppContext) {
+    Vim::update(cx, |vim, cx| {
+        vim.update_active_editor(cx, |editor, cx| {
+            editor.transact(cx, |editor, cx| {
+                let (display_map, selections) = editor.selections.all_adjusted_display(cx);
+
+                // Selections are biased right at the start. So we need to store
+                // anchors that are biased left so that we can restore the selections
+                // after the change
+                let stable_anchors = editor
+                    .selections
+                    .disjoint_anchors()
+                    .into_iter()
+                    .map(|selection| {
+                        let start = selection.start.bias_left(&display_map.buffer_snapshot);
+                        start..start
+                    })
+                    .collect::<Vec<_>>();
+
+                let mut edits = Vec::new();
+                for selection in selections.iter() {
+                    let mut selection = selection.clone();
+                    if !line && !selection.reversed {
+                        // Head is at the end of the selection. Adjust the end position to
+                        // to include the character under the cursor.
+                        *selection.end.column_mut() = selection.end.column() + 1;
+                        selection.end = display_map.clip_point(selection.end, Bias::Right);
+                    }
+
+                    for row_range in
+                        movement::split_display_range_by_lines(&display_map, selection.range())
+                    {
+                        let range = row_range.start.to_offset(&display_map, Bias::Right)
+                            ..row_range.end.to_offset(&display_map, Bias::Right);
+                        let text = text.repeat(range.len());
+                        edits.push((range, text));
+                    }
+                }
+
+                editor.buffer().update(cx, |buffer, cx| {
+                    buffer.edit(edits, None, cx);
+                });
+                editor.change_selections(None, cx, |s| s.select_ranges(stable_anchors));
+            });
+        });
+        vim.switch_mode(Mode::Normal, false, cx);
+    });
+}
+
 #[cfg(test)]
 mod test {
     use indoc::indoc;