Merge pull request #1019 from zed-industries/vim-visual-line-mode

Keith Simmons created

Vim visual line mode

Change summary

assets/keymaps/vim.json                    |  33 +
crates/editor/src/display_map.rs           |  17 
crates/editor/src/editor.rs                | 624 ++++++++++-------------
crates/editor/src/element.rs               |  63 +
crates/editor/src/items.rs                 |   6 
crates/editor/src/multi_buffer.rs          |   8 
crates/editor/src/selections_collection.rs |  91 ++-
crates/editor/src/test.rs                  | 314 +++++++++++
crates/gpui/src/app.rs                     |  50 -
crates/language/src/buffer.rs              |  27 
crates/language/src/proto.rs               |   3 
crates/language/src/tests.rs               |   4 
crates/rpc/proto/zed.proto                 |   2 
crates/rpc/src/rpc.rs                      |   2 
crates/text/src/selection.rs               |   5 
crates/util/src/test/marked_text.rs        |  96 ++-
crates/vim/src/editor_events.rs            |  31 
crates/vim/src/motion.rs                   |   4 
crates/vim/src/normal.rs                   | 181 ++++++
crates/vim/src/normal/change.rs            |   4 
crates/vim/src/normal/delete.rs            |   3 
crates/vim/src/normal/yank.rs              |  26 +
crates/vim/src/state.rs                    |  20 
crates/vim/src/utils.rs                    |  25 
crates/vim/src/vim.rs                      |  45 +
crates/vim/src/vim_test_context.rs         | 274 +---------
crates/vim/src/visual.rs                   | 355 ++++++++++++
crates/zed/src/main.rs                     |   4 
28 files changed, 1,533 insertions(+), 784 deletions(-)

Detailed changes

assets/keymaps/vim.json ๐Ÿ”—

@@ -57,6 +57,10 @@
                 "Delete"
             ],
             "shift-D": "vim::DeleteToEndOfLine",
+            "y": [
+                "vim::PushOperator",
+                "Yank"
+            ],
             "i": [
                 "vim::SwitchMode",
                 "Insert"
@@ -71,8 +75,24 @@
             "shift-O": "vim::InsertLineAbove",
             "v": [
                 "vim::SwitchMode",
-                "Visual"
-            ]
+                {
+                    "Visual": {
+                        "line": false
+                    }
+                }
+            ],
+            "shift-V": [
+                "vim::SwitchMode",
+                {
+                    "Visual": {
+                        "line": true
+                    }
+                }
+            ],
+            "p": "vim::Paste",
+            "u": "editor::Undo",
+            "ctrl-r": "editor::Redo",
+            "ctrl-o": "pane::GoBack"
         }
     },
     {
@@ -104,12 +124,19 @@
             "d": "vim::CurrentLine"
         }
     },
+    {
+        "context": "Editor && vim_operator == y",
+        "bindings": {
+            "y": "vim::CurrentLine"
+        }
+    },
     {
         "context": "Editor && vim_mode == visual",
         "bindings": {
             "c": "vim::VisualChange",
             "d": "vim::VisualDelete",
-            "x": "vim::VisualDelete"
+            "x": "vim::VisualDelete",
+            "y": "vim::VisualYank"
         }
     },
     {

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

@@ -279,6 +279,23 @@ impl DisplaySnapshot {
         }
     }
 
+    pub fn expand_to_line(&self, range: Range<Point>) -> Range<Point> {
+        let mut new_start = self.prev_line_boundary(range.start).0;
+        let mut new_end = self.next_line_boundary(range.end).0;
+
+        if new_start.row == range.start.row && new_end.row == range.end.row {
+            if new_end.row < self.buffer_snapshot.max_point().row {
+                new_end.row += 1;
+                new_end.column = 0;
+            } else if new_start.row > 0 {
+                new_start.row -= 1;
+                new_start.column = self.buffer_snapshot.line_len(new_start.row);
+            }
+        }
+
+        new_start..new_end
+    }
+
     fn point_to_display_point(&self, point: Point, bias: Bias) -> DisplayPoint {
         let fold_point = self.folds_snapshot.to_fold_point(point, bias);
         let tab_point = self.tabs_snapshot.to_tab_point(fold_point);

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

@@ -3,10 +3,10 @@ mod element;
 pub mod items;
 pub mod movement;
 mod multi_buffer;
-mod selections_collection;
+pub mod selections_collection;
 
-#[cfg(test)]
-mod test;
+#[cfg(any(test, feature = "test-support"))]
+pub mod test;
 
 use aho_corasick::AhoCorasick;
 use anyhow::Result;
@@ -837,9 +837,9 @@ struct ActiveDiagnosticGroup {
 }
 
 #[derive(Serialize, Deserialize)]
-struct ClipboardSelection {
-    len: usize,
-    is_entire_line: bool,
+pub struct ClipboardSelection {
+    pub len: usize,
+    pub is_entire_line: bool,
 }
 
 #[derive(Debug)]
@@ -1025,6 +1025,10 @@ impl Editor {
         self.buffer.read(cx).replica_id()
     }
 
+    pub fn leader_replica_id(&self) -> Option<ReplicaId> {
+        self.leader_replica_id
+    }
+
     pub fn buffer(&self) -> &ModelHandle<MultiBuffer> {
         &self.buffer
     }
@@ -1319,7 +1323,11 @@ impl Editor {
     ) {
         if self.focused && self.leader_replica_id.is_none() {
             self.buffer.update(cx, |buffer, cx| {
-                buffer.set_active_selections(&self.selections.disjoint_anchors(), cx)
+                buffer.set_active_selections(
+                    &self.selections.disjoint_anchors(),
+                    self.selections.line_mode,
+                    cx,
+                )
             });
         }
 
@@ -1393,12 +1401,14 @@ impl Editor {
         let old_cursor_position = self.selections.newest_anchor().head();
         self.push_to_selection_history();
 
-        let result = self.selections.change_with(cx, change);
+        let (changed, result) = self.selections.change_with(cx, change);
 
-        if let Some(autoscroll) = autoscroll {
-            self.request_autoscroll(autoscroll, cx);
+        if changed {
+            if let Some(autoscroll) = autoscroll {
+                self.request_autoscroll(autoscroll, cx);
+            }
+            self.selections_did_change(true, &old_cursor_position, cx);
         }
-        self.selections_did_change(true, &old_cursor_position, cx);
 
         result
     }
@@ -1538,12 +1548,10 @@ impl Editor {
         }
 
         self.change_selections(Some(Autoscroll::Fit), cx, |s| {
-            if add {
-                if click_count > 1 {
-                    s.delete(newest_selection.id);
-                }
-            } else {
+            if !add {
                 s.clear_disjoint();
+            } else if click_count > 1 {
+                s.delete(newest_selection.id)
             }
 
             s.set_pending_range(start..end, mode);
@@ -1856,13 +1864,16 @@ impl Editor {
     pub fn insert(&mut self, text: &str, cx: &mut ViewContext<Self>) {
         let text: Arc<str> = text.into();
         self.transact(cx, |this, cx| {
-            let old_selections = this.selections.all::<usize>(cx);
+            let old_selections = this.selections.all_adjusted(cx);
             let selection_anchors = this.buffer.update(cx, |buffer, cx| {
                 let anchors = {
                     let snapshot = buffer.read(cx);
                     old_selections
                         .iter()
-                        .map(|s| (s.id, s.goal, snapshot.anchor_after(s.end)))
+                        .map(|s| {
+                            let anchor = snapshot.anchor_after(s.end);
+                            s.map(|_| anchor.clone())
+                        })
                         .collect::<Vec<_>>()
                 };
                 buffer.edit_with_autoindent(
@@ -1874,25 +1885,8 @@ impl Editor {
                 anchors
             });
 
-            let selections = {
-                let snapshot = this.buffer.read(cx).read(cx);
-                selection_anchors
-                    .into_iter()
-                    .map(|(id, goal, position)| {
-                        let position = position.to_offset(&snapshot);
-                        Selection {
-                            id,
-                            start: position,
-                            end: position,
-                            goal,
-                            reversed: false,
-                        }
-                    })
-                    .collect()
-            };
-
             this.change_selections(Some(Autoscroll::Fit), cx, |s| {
-                s.select(selections);
+                s.select_anchors(selection_anchors);
             })
         });
     }
@@ -2745,28 +2739,31 @@ impl Editor {
     pub fn backspace(&mut self, _: &Backspace, cx: &mut ViewContext<Self>) {
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
         let mut selections = self.selections.all::<Point>(cx);
-        for selection in &mut selections {
-            if selection.is_empty() {
-                let old_head = selection.head();
-                let mut new_head =
-                    movement::left(&display_map, old_head.to_display_point(&display_map))
-                        .to_point(&display_map);
-                if let Some((buffer, line_buffer_range)) = display_map
-                    .buffer_snapshot
-                    .buffer_line_for_row(old_head.row)
-                {
-                    let indent_column = buffer.indent_column_for_line(line_buffer_range.start.row);
-                    let language_name = buffer.language().map(|language| language.name());
-                    let indent = cx.global::<Settings>().tab_size(language_name.as_deref());
-                    if old_head.column <= indent_column && old_head.column > 0 {
-                        new_head = cmp::min(
-                            new_head,
-                            Point::new(old_head.row, ((old_head.column - 1) / indent) * indent),
-                        );
+        if !self.selections.line_mode {
+            for selection in &mut selections {
+                if selection.is_empty() {
+                    let old_head = selection.head();
+                    let mut new_head =
+                        movement::left(&display_map, old_head.to_display_point(&display_map))
+                            .to_point(&display_map);
+                    if let Some((buffer, line_buffer_range)) = display_map
+                        .buffer_snapshot
+                        .buffer_line_for_row(old_head.row)
+                    {
+                        let indent_column =
+                            buffer.indent_column_for_line(line_buffer_range.start.row);
+                        let language_name = buffer.language().map(|language| language.name());
+                        let indent = cx.global::<Settings>().tab_size(language_name.as_deref());
+                        if old_head.column <= indent_column && old_head.column > 0 {
+                            new_head = cmp::min(
+                                new_head,
+                                Point::new(old_head.row, ((old_head.column - 1) / indent) * indent),
+                            );
+                        }
                     }
-                }
 
-                selection.set_head(new_head, SelectionGoal::None);
+                    selection.set_head(new_head, SelectionGoal::None);
+                }
             }
         }
 
@@ -2779,8 +2776,9 @@ impl Editor {
     pub fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) {
         self.transact(cx, |this, cx| {
             this.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                let line_mode = s.line_mode;
                 s.move_with(|map, selection| {
-                    if selection.is_empty() {
+                    if selection.is_empty() && !line_mode {
                         let cursor = movement::right(map, selection.head());
                         selection.set_head(cursor, SelectionGoal::None);
                     }
@@ -2803,7 +2801,7 @@ impl Editor {
             return;
         }
 
-        let mut selections = self.selections.all::<Point>(cx);
+        let mut selections = self.selections.all_adjusted(cx);
         if selections.iter().all(|s| s.is_empty()) {
             self.transact(cx, |this, cx| {
                 this.buffer.update(cx, |buffer, cx| {
@@ -3289,8 +3287,9 @@ impl Editor {
         self.transact(cx, |this, cx| {
             let edits = this.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 let mut edits: Vec<(Range<usize>, String)> = Default::default();
+                let line_mode = s.line_mode;
                 s.move_with(|display_map, selection| {
-                    if !selection.is_empty() {
+                    if !selection.is_empty() || line_mode {
                         return;
                     }
 
@@ -3343,7 +3342,7 @@ impl Editor {
         {
             let max_point = buffer.max_point();
             for selection in &mut selections {
-                let is_entire_line = selection.is_empty();
+                let is_entire_line = selection.is_empty() || self.selections.line_mode;
                 if is_entire_line {
                     selection.start = Point::new(selection.start.row, 0);
                     selection.end = cmp::min(max_point, Point::new(selection.end.row + 1, 0));
@@ -3374,16 +3373,17 @@ impl Editor {
         let selections = self.selections.all::<Point>(cx);
         let buffer = self.buffer.read(cx).read(cx);
         let mut text = String::new();
+
         let mut clipboard_selections = Vec::with_capacity(selections.len());
         {
             let max_point = buffer.max_point();
             for selection in selections.iter() {
                 let mut start = selection.start;
                 let mut end = selection.end;
-                let is_entire_line = selection.is_empty();
+                let is_entire_line = selection.is_empty() || self.selections.line_mode;
                 if is_entire_line {
                     start = Point::new(start.row, 0);
-                    end = cmp::min(max_point, Point::new(start.row + 1, 0));
+                    end = cmp::min(max_point, Point::new(end.row + 1, 0));
                 }
                 let mut len = 0;
                 for chunk in buffer.text_for_range(start..end) {
@@ -3427,6 +3427,7 @@ impl Editor {
                         let snapshot = buffer.read(cx);
                         let mut start_offset = 0;
                         let mut edits = Vec::new();
+                        let line_mode = this.selections.line_mode;
                         for (ix, selection) in old_selections.iter().enumerate() {
                             let to_insert;
                             let entire_line;
@@ -3444,12 +3445,12 @@ impl Editor {
                             // clipboard text was written, then the entire line containing the
                             // selection was copied. If this selection is also currently empty,
                             // then paste the line before the current line of the buffer.
-                            let range = if selection.is_empty() && entire_line {
+                            let range = if selection.is_empty() && !line_mode && entire_line {
                                 let column = selection.start.to_point(&snapshot).column as usize;
                                 let line_start = selection.start - column;
                                 line_start..line_start
                             } else {
-                                selection.start..selection.end
+                                selection.range()
                             };
 
                             edits.push((range, to_insert));
@@ -3499,8 +3500,9 @@ impl Editor {
 
     pub fn move_left(&mut self, _: &MoveLeft, cx: &mut ViewContext<Self>) {
         self.change_selections(Some(Autoscroll::Fit), cx, |s| {
+            let line_mode = s.line_mode;
             s.move_with(|map, selection| {
-                let cursor = if selection.is_empty() {
+                let cursor = if selection.is_empty() && !line_mode {
                     movement::left(map, selection.start)
                 } else {
                     selection.start
@@ -3518,8 +3520,9 @@ impl Editor {
 
     pub fn move_right(&mut self, _: &MoveRight, cx: &mut ViewContext<Self>) {
         self.change_selections(Some(Autoscroll::Fit), cx, |s| {
+            let line_mode = s.line_mode;
             s.move_with(|map, selection| {
-                let cursor = if selection.is_empty() {
+                let cursor = if selection.is_empty() && !line_mode {
                     movement::right(map, selection.end)
                 } else {
                     selection.end
@@ -3552,8 +3555,9 @@ impl Editor {
         }
 
         self.change_selections(Some(Autoscroll::Fit), cx, |s| {
+            let line_mode = s.line_mode;
             s.move_with(|map, selection| {
-                if !selection.is_empty() {
+                if !selection.is_empty() && !line_mode {
                     selection.goal = SelectionGoal::None;
                 }
                 let (cursor, goal) = movement::up(&map, selection.start, selection.goal, false);
@@ -3583,8 +3587,9 @@ impl Editor {
         }
 
         self.change_selections(Some(Autoscroll::Fit), cx, |s| {
+            let line_mode = s.line_mode;
             s.move_with(|map, selection| {
-                if !selection.is_empty() {
+                if !selection.is_empty() && !line_mode {
                     selection.goal = SelectionGoal::None;
                 }
                 let (cursor, goal) = movement::down(&map, selection.end, selection.goal, false);
@@ -3666,8 +3671,9 @@ impl Editor {
     ) {
         self.transact(cx, |this, cx| {
             this.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                let line_mode = s.line_mode;
                 s.move_with(|map, selection| {
-                    if selection.is_empty() {
+                    if selection.is_empty() && !line_mode {
                         let cursor = movement::previous_word_start(map, selection.head());
                         selection.set_head(cursor, SelectionGoal::None);
                     }
@@ -3684,8 +3690,9 @@ impl Editor {
     ) {
         self.transact(cx, |this, cx| {
             this.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                let line_mode = s.line_mode;
                 s.move_with(|map, selection| {
-                    if selection.is_empty() {
+                    if selection.is_empty() && !line_mode {
                         let cursor = movement::previous_subword_start(map, selection.head());
                         selection.set_head(cursor, SelectionGoal::None);
                     }
@@ -3738,8 +3745,9 @@ impl Editor {
     pub fn delete_to_next_word_end(&mut self, _: &DeleteToNextWordEnd, cx: &mut ViewContext<Self>) {
         self.transact(cx, |this, cx| {
             this.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                let line_mode = s.line_mode;
                 s.move_with(|map, selection| {
-                    if selection.is_empty() {
+                    if selection.is_empty() && !line_mode {
                         let cursor = movement::next_word_end(map, selection.head());
                         selection.set_head(cursor, SelectionGoal::None);
                     }
@@ -4685,6 +4693,7 @@ impl Editor {
                     // Position the selection in the rename editor so that it matches the current selection.
                     this.show_local_selections = false;
                     let rename_editor = cx.add_view(|cx| {
+                        println!("Rename editor created.");
                         let mut editor = Editor::single_line(None, cx);
                         if let Some(old_highlight_id) = old_highlight_id {
                             editor.override_text_style =
@@ -5599,7 +5608,11 @@ impl View for Editor {
             self.buffer.update(cx, |buffer, cx| {
                 buffer.finalize_last_transaction(cx);
                 if self.leader_replica_id.is_none() {
-                    buffer.set_active_selections(&self.selections.disjoint_anchors(), cx);
+                    buffer.set_active_selections(
+                        &self.selections.disjoint_anchors(),
+                        self.selections.line_mode,
+                        cx,
+                    );
                 }
             });
         }
@@ -6020,7 +6033,9 @@ pub fn styled_runs_for_code_label<'a>(
 
 #[cfg(test)]
 mod tests {
-    use crate::test::{assert_text_with_selections, select_ranges};
+    use crate::test::{
+        assert_text_with_selections, build_editor, select_ranges, EditorTestContext,
+    };
 
     use super::*;
     use gpui::{
@@ -7292,117 +7307,62 @@ mod tests {
     }
 
     #[gpui::test]
-    fn test_indent_outdent(cx: &mut gpui::MutableAppContext) {
-        cx.set_global(Settings::test(cx));
-        let buffer = MultiBuffer::build_simple(
-            indoc! {"
-                  one two
-                three
-                 four"},
-            cx,
-        );
-        let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx));
+    async fn test_indent_outdent(cx: &mut gpui::TestAppContext) {
+        let mut cx = EditorTestContext::new(cx).await;
 
-        view.update(cx, |view, cx| {
-            // two selections on the same line
-            select_ranges(
-                view,
-                indoc! {"
-                      [one] [two]
-                    three
-                     four"},
-                cx,
-            );
-
-            // indent from mid-tabstop to full tabstop
-            view.tab(&Tab, cx);
-            assert_text_with_selections(
-                view,
-                indoc! {"
-                        [one] [two]
-                    three
-                     four"},
-                cx,
-            );
-
-            // outdent from 1 tabstop to 0 tabstops
-            view.tab_prev(&TabPrev, cx);
-            assert_text_with_selections(
-                view,
-                indoc! {"
-                    [one] [two]
-                    three
-                     four"},
-                cx,
-            );
-
-            // select across line ending
-            select_ranges(
-                view,
-                indoc! {"
-                    one two
-                    t[hree
-                    ] four"},
-                cx,
-            );
-
-            // indent and outdent affect only the preceding line
-            view.tab(&Tab, cx);
-            assert_text_with_selections(
-                view,
-                indoc! {"
-                    one two
-                        t[hree
-                    ] four"},
-                cx,
-            );
-            view.tab_prev(&TabPrev, cx);
-            assert_text_with_selections(
-                view,
-                indoc! {"
-                    one two
-                    t[hree
-                    ] four"},
-                cx,
-            );
-
-            // Ensure that indenting/outdenting works when the cursor is at column 0.
-            select_ranges(
-                view,
-                indoc! {"
-                    one two
-                    []three
-                     four"},
-                cx,
-            );
-            view.tab(&Tab, cx);
-            assert_text_with_selections(
-                view,
-                indoc! {"
-                    one two
-                        []three
-                     four"},
-                cx,
-            );
+        cx.set_state(indoc! {"
+              [one} [two}
+            three
+             four"});
+        cx.update_editor(|e, cx| e.tab(&Tab, cx));
+        cx.assert_editor_state(indoc! {"
+                [one} [two}
+            three
+             four"});
 
-            select_ranges(
-                view,
-                indoc! {"
-                    one two
-                    []    three
-                     four"},
-                cx,
-            );
-            view.tab_prev(&TabPrev, cx);
-            assert_text_with_selections(
-                view,
-                indoc! {"
-                    one two
-                    []three
-                     four"},
-                cx,
-            );
-        });
+        cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx));
+        cx.assert_editor_state(indoc! {"
+            [one} [two}
+            three
+             four"});
+
+        // select across line ending
+        cx.set_state(indoc! {"
+            one two
+            t[hree
+            } four"});
+        cx.update_editor(|e, cx| e.tab(&Tab, cx));
+        cx.assert_editor_state(indoc! {"
+            one two
+                t[hree
+            } four"});
+
+        cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx));
+        cx.assert_editor_state(indoc! {"
+            one two
+            t[hree
+            } four"});
+
+        // Ensure that indenting/outdenting works when the cursor is at column 0.
+        cx.set_state(indoc! {"
+            one two
+            |three
+                four"});
+        cx.update_editor(|e, cx| e.tab(&Tab, cx));
+        cx.assert_editor_state(indoc! {"
+            one two
+                |three
+                four"});
+
+        cx.set_state(indoc! {"
+            one two
+            |    three
+             four"});
+        cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx));
+        cx.assert_editor_state(indoc! {"
+            one two
+            |three
+             four"});
     }
 
     #[gpui::test]
@@ -7511,73 +7471,71 @@ mod tests {
     }
 
     #[gpui::test]
-    fn test_backspace(cx: &mut gpui::MutableAppContext) {
-        cx.set_global(Settings::test(cx));
-        let (_, view) = cx.add_window(Default::default(), |cx| {
-            build_editor(MultiBuffer::build_simple("", cx), cx)
-        });
-
-        view.update(cx, |view, cx| {
-            view.set_text("one two three\nfour five six\nseven eight nine\nten\n", cx);
-            view.change_selections(None, cx, |s| {
-                s.select_display_ranges([
-                    // an empty selection - the preceding character is deleted
-                    DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
-                    // one character selected - it is deleted
-                    DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3),
-                    // a line suffix selected - it is deleted
-                    DisplayPoint::new(2, 6)..DisplayPoint::new(3, 0),
-                ])
-            });
-            view.backspace(&Backspace, cx);
-            assert_eq!(view.text(cx), "oe two three\nfou five six\nseven ten\n");
-
-            view.set_text("    one\n        two\n        three\n   four", cx);
-            view.change_selections(None, cx, |s| {
-                s.select_display_ranges([
-                    // cursors at the the end of leading indent - last indent is deleted
-                    DisplayPoint::new(0, 4)..DisplayPoint::new(0, 4),
-                    DisplayPoint::new(1, 8)..DisplayPoint::new(1, 8),
-                    // cursors inside leading indent - overlapping indent deletions are coalesced
-                    DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4),
-                    DisplayPoint::new(2, 5)..DisplayPoint::new(2, 5),
-                    DisplayPoint::new(2, 6)..DisplayPoint::new(2, 6),
-                    // cursor at the beginning of a line - preceding newline is deleted
-                    DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0),
-                    // selection inside leading indent - only the selected character is deleted
-                    DisplayPoint::new(3, 2)..DisplayPoint::new(3, 3),
-                ])
-            });
-            view.backspace(&Backspace, cx);
-            assert_eq!(view.text(cx), "one\n    two\n  three  four");
-        });
+    async fn test_backspace(cx: &mut gpui::TestAppContext) {
+        let mut cx = EditorTestContext::new(cx).await;
+        // Basic backspace
+        cx.set_state(indoc! {"
+            on|e two three
+            fou[r} five six
+            seven {eight nine
+            ]ten"});
+        cx.update_editor(|e, cx| e.backspace(&Backspace, cx));
+        cx.assert_editor_state(indoc! {"
+            o|e two three
+            fou| five six
+            seven |ten"});
+
+        // Test backspace inside and around indents
+        cx.set_state(indoc! {"
+            zero
+                |one
+                    |two
+                | | |  three
+            |  |  four"});
+        cx.update_editor(|e, cx| e.backspace(&Backspace, cx));
+        cx.assert_editor_state(indoc! {"
+            zero
+            |one
+                |two
+            |  three|  four"});
+
+        // Test backspace with line_mode set to true
+        cx.update_editor(|e, _| e.selections.line_mode = true);
+        cx.set_state(indoc! {"
+            The |quick |brown
+            fox jumps over
+            the lazy dog
+            |The qu[ick b}rown"});
+        cx.update_editor(|e, cx| e.backspace(&Backspace, cx));
+        cx.assert_editor_state(indoc! {"
+            |fox jumps over
+            the lazy dog|"});
     }
 
     #[gpui::test]
-    fn test_delete(cx: &mut gpui::MutableAppContext) {
-        cx.set_global(Settings::test(cx));
-        let buffer =
-            MultiBuffer::build_simple("one two three\nfour five six\nseven eight nine\nten\n", cx);
-        let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx));
-
-        view.update(cx, |view, cx| {
-            view.change_selections(None, cx, |s| {
-                s.select_display_ranges([
-                    // an empty selection - the following character is deleted
-                    DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
-                    // one character selected - it is deleted
-                    DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3),
-                    // a line suffix selected - it is deleted
-                    DisplayPoint::new(2, 6)..DisplayPoint::new(3, 0),
-                ])
-            });
-            view.delete(&Delete, cx);
-        });
-
-        assert_eq!(
-            buffer.read(cx).read(cx).text(),
-            "on two three\nfou five six\nseven ten\n"
-        );
+    async fn test_delete(cx: &mut gpui::TestAppContext) {
+        let mut cx = EditorTestContext::new(cx).await;
+
+        cx.set_state(indoc! {"
+            on|e two three
+            fou[r} five six
+            seven {eight nine
+            ]ten"});
+        cx.update_editor(|e, cx| e.delete(&Delete, cx));
+        cx.assert_editor_state(indoc! {"
+            on| two three
+            fou| five six
+            seven |ten"});
+
+        // Test backspace with line_mode set to true
+        cx.update_editor(|e, _| e.selections.line_mode = true);
+        cx.set_state(indoc! {"
+            The |quick |brown
+            fox {jum]ps over
+            the lazy dog
+            |The qu[ick b}rown"});
+        cx.update_editor(|e, cx| e.backspace(&Backspace, cx));
+        cx.assert_editor_state("|the lazy dog|");
     }
 
     #[gpui::test]
@@ -7885,131 +7843,79 @@ mod tests {
     }
 
     #[gpui::test]
-    fn test_clipboard(cx: &mut gpui::MutableAppContext) {
-        cx.set_global(Settings::test(cx));
-        let buffer = MultiBuffer::build_simple("oneโœ… two three four five six ", cx);
-        let view = cx
-            .add_window(Default::default(), |cx| build_editor(buffer.clone(), cx))
-            .1;
+    async fn test_clipboard(cx: &mut gpui::TestAppContext) {
+        let mut cx = EditorTestContext::new(cx).await;
 
-        // Cut with three selections. Clipboard text is divided into three slices.
-        view.update(cx, |view, cx| {
-            view.change_selections(None, cx, |s| s.select_ranges(vec![0..7, 11..17, 22..27]));
-            view.cut(&Cut, cx);
-            assert_eq!(view.display_text(cx), "two four six ");
-        });
+        cx.set_state("[oneโœ… }two [three }four [five }six ");
+        cx.update_editor(|e, cx| e.cut(&Cut, cx));
+        cx.assert_editor_state("|two |four |six ");
 
         // Paste with three cursors. Each cursor pastes one slice of the clipboard text.
-        view.update(cx, |view, cx| {
-            view.change_selections(None, cx, |s| s.select_ranges(vec![4..4, 9..9, 13..13]));
-            view.paste(&Paste, cx);
-            assert_eq!(view.display_text(cx), "two oneโœ… four three six five ");
-            assert_eq!(
-                view.selections.display_ranges(cx),
-                &[
-                    DisplayPoint::new(0, 11)..DisplayPoint::new(0, 11),
-                    DisplayPoint::new(0, 22)..DisplayPoint::new(0, 22),
-                    DisplayPoint::new(0, 31)..DisplayPoint::new(0, 31)
-                ]
-            );
-        });
+        cx.set_state("two |four |six |");
+        cx.update_editor(|e, cx| e.paste(&Paste, cx));
+        cx.assert_editor_state("two oneโœ… |four three |six five |");
 
         // Paste again but with only two cursors. Since the number of cursors doesn't
         // match the number of slices in the clipboard, the entire clipboard text
         // is pasted at each cursor.
-        view.update(cx, |view, cx| {
-            view.change_selections(None, cx, |s| s.select_ranges(vec![0..0, 31..31]));
-            view.handle_input(&Input("( ".into()), cx);
-            view.paste(&Paste, cx);
-            view.handle_input(&Input(") ".into()), cx);
-            assert_eq!(
-                view.display_text(cx),
-                "( oneโœ… \nthree \nfive ) two oneโœ… four three six five ( oneโœ… \nthree \nfive ) "
-            );
-        });
-
-        view.update(cx, |view, cx| {
-            view.change_selections(None, cx, |s| s.select_ranges(vec![0..0]));
-            view.handle_input(&Input("123\n4567\n89\n".into()), cx);
-            assert_eq!(
-                view.display_text(cx),
-                "123\n4567\n89\n( oneโœ… \nthree \nfive ) two oneโœ… four three six five ( oneโœ… \nthree \nfive ) "
-            );
-        });
+        cx.set_state("|two oneโœ… four three six five |");
+        cx.update_editor(|e, cx| {
+            e.handle_input(&Input("( ".into()), cx);
+            e.paste(&Paste, cx);
+            e.handle_input(&Input(") ".into()), cx);
+        });
+        cx.assert_editor_state(indoc! {"
+            ( oneโœ… 
+            three 
+            five ) |two oneโœ… four three six five ( oneโœ… 
+            three 
+            five ) |"});
 
         // Cut with three selections, one of which is full-line.
-        view.update(cx, |view, cx| {
-            view.change_selections(None, cx, |s| s.select_display_ranges(
-                [
-                    DisplayPoint::new(0, 1)..DisplayPoint::new(0, 2),
-                    DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1),
-                    DisplayPoint::new(2, 0)..DisplayPoint::new(2, 1),
-                ],
-            ));
-            view.cut(&Cut, cx);
-            assert_eq!(
-                view.display_text(cx),
-                "13\n9\n( oneโœ… \nthree \nfive ) two oneโœ… four three six five ( oneโœ… \nthree \nfive ) "
-            );
-        });
+        cx.set_state(indoc! {"
+            1[2}3
+            4|567
+            [8}9"});
+        cx.update_editor(|e, cx| e.cut(&Cut, cx));
+        cx.assert_editor_state(indoc! {"
+            1|3
+            |9"});
 
         // Paste with three selections, noticing how the copied selection that was full-line
         // gets inserted before the second cursor.
-        view.update(cx, |view, cx| {
-            view.change_selections(None, cx, |s| s.select_display_ranges(
-                [
-                    DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1),
-                    DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1),
-                    DisplayPoint::new(2, 2)..DisplayPoint::new(2, 3),
-                ],
-            ));
-            view.paste(&Paste, cx);
-            assert_eq!(
-                view.display_text(cx),
-                "123\n4567\n9\n( 8neโœ… \nthree \nfive ) two oneโœ… four three six five ( oneโœ… \nthree \nfive ) "
-            );
-            assert_eq!(
-                view.selections.display_ranges(cx),
-                &[
-                    DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
-                    DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1),
-                    DisplayPoint::new(3, 3)..DisplayPoint::new(3, 3),
-                ]
-            );
-        });
+        cx.set_state(indoc! {"
+            1|3
+            9|
+            [o}ne"});
+        cx.update_editor(|e, cx| e.paste(&Paste, cx));
+        cx.assert_editor_state(indoc! {"
+            12|3
+            4567
+            9|
+            8|ne"});
 
         // Copy with a single cursor only, which writes the whole line into the clipboard.
-        view.update(cx, |view, cx| {
-            view.change_selections(None, cx, |s| {
-                s.select_display_ranges([DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1)])
-            });
-            view.copy(&Copy, cx);
-        });
+        cx.set_state(indoc! {"
+            The quick brown
+            fox ju|mps over
+            the lazy dog"});
+        cx.update_editor(|e, cx| e.copy(&Copy, cx));
+        cx.assert_clipboard_content(Some("fox jumps over\n"));
 
         // Paste with three selections, noticing how the copied full-line selection is inserted
         // before the empty selections but replaces the selection that is non-empty.
-        view.update(cx, |view, cx| {
-            view.change_selections(None, cx, |s| s.select_display_ranges(
-                [
-                    DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1),
-                    DisplayPoint::new(1, 0)..DisplayPoint::new(1, 2),
-                    DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1),
-                ],
-            ));
-            view.paste(&Paste, cx);
-            assert_eq!(
-                view.display_text(cx),
-                "123\n123\n123\n67\n123\n9\n( 8neโœ… \nthree \nfive ) two oneโœ… four three six five ( oneโœ… \nthree \nfive ) "
-            );
-            assert_eq!(
-                view.selections.display_ranges(cx),
-                &[
-                    DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1),
-                    DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0),
-                    DisplayPoint::new(5, 1)..DisplayPoint::new(5, 1),
-                ]
-            );
-        });
+        cx.set_state(indoc! {"
+            T|he quick brown
+            [fo}x jumps over
+            t|he lazy dog"});
+        cx.update_editor(|e, cx| e.paste(&Paste, cx));
+        cx.assert_editor_state(indoc! {"
+            fox jumps over
+            T|he quick brown
+            fox jumps over
+            |x jumps over
+            fox jumps over
+            t|he lazy dog"});
     }
 
     #[gpui::test]
@@ -8748,8 +8654,10 @@ mod tests {
             fn assert(editor: &mut Editor, cx: &mut ViewContext<Editor>, marked_text_ranges: &str) {
                 let range_markers = ('<', '>');
                 let (expected_text, mut selection_ranges_lookup) =
-                    marked_text_ranges_by(marked_text_ranges, vec![range_markers.clone()]);
-                let selection_ranges = selection_ranges_lookup.remove(&range_markers).unwrap();
+                    marked_text_ranges_by(marked_text_ranges, vec![range_markers.clone().into()]);
+                let selection_ranges = selection_ranges_lookup
+                    .remove(&range_markers.into())
+                    .unwrap();
                 assert_eq!(editor.text(cx), expected_text);
                 assert_eq!(editor.selections.ranges::<usize>(cx), selection_ranges);
             }
@@ -9798,10 +9706,6 @@ mod tests {
         point..point
     }
 
-    fn build_editor(buffer: ModelHandle<MultiBuffer>, cx: &mut ViewContext<Editor>) -> Editor {
-        Editor::new(EditorMode::Full, buffer, None, None, None, cx)
-    }
-
     fn assert_selection_ranges(
         marked_text: &str,
         selection_marker_pairs: Vec<(char, char)>,

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

@@ -3,7 +3,10 @@ use super::{
     Anchor, DisplayPoint, Editor, EditorMode, EditorSnapshot, Input, Scroll, Select, SelectPhase,
     SoftWrap, ToPoint, MAX_LINE_LEN,
 };
-use crate::{display_map::TransformBlock, EditorStyle};
+use crate::{
+    display_map::{DisplaySnapshot, TransformBlock},
+    EditorStyle,
+};
 use clock::ReplicaId;
 use collections::{BTreeMap, HashMap};
 use gpui::{
@@ -22,7 +25,7 @@ use gpui::{
     MutableAppContext, PaintContext, Quad, Scene, SizeConstraint, ViewContext, WeakViewHandle,
 };
 use json::json;
-use language::{Bias, DiagnosticSeverity};
+use language::{Bias, DiagnosticSeverity, Selection};
 use settings::Settings;
 use smallvec::SmallVec;
 use std::{
@@ -32,6 +35,35 @@ use std::{
     ops::Range,
 };
 
+struct SelectionLayout {
+    head: DisplayPoint,
+    range: Range<DisplayPoint>,
+}
+
+impl SelectionLayout {
+    fn new<T: ToPoint + ToDisplayPoint + Clone>(
+        selection: Selection<T>,
+        line_mode: bool,
+        map: &DisplaySnapshot,
+    ) -> Self {
+        if line_mode {
+            let selection = selection.map(|p| p.to_point(&map.buffer_snapshot));
+            let point_range = map.expand_to_line(selection.range());
+            Self {
+                head: selection.head().to_display_point(map),
+                range: point_range.start.to_display_point(map)
+                    ..point_range.end.to_display_point(map),
+            }
+        } else {
+            let selection = selection.map(|p| p.to_display_point(map));
+            Self {
+                head: selection.head(),
+                range: selection.range(),
+            }
+        }
+    }
+}
+
 pub struct EditorElement {
     view: WeakViewHandle<Editor>,
     style: EditorStyle,
@@ -356,7 +388,7 @@ impl EditorElement {
 
             for selection in selections {
                 self.paint_highlighted_range(
-                    selection.start..selection.end,
+                    selection.range.clone(),
                     start_row,
                     end_row,
                     selection_style.selection,
@@ -371,7 +403,7 @@ impl EditorElement {
                 );
 
                 if view.show_local_cursors() || *replica_id != local_replica_id {
-                    let cursor_position = selection.head();
+                    let cursor_position = selection.head;
                     if (start_row..end_row).contains(&cursor_position.row()) {
                         let cursor_row_layout =
                             &layout.line_layouts[(cursor_position.row() - start_row) as usize];
@@ -918,7 +950,7 @@ impl Element for EditorElement {
                 .anchor_before(DisplayPoint::new(end_row, 0).to_offset(&snapshot, Bias::Right))
         };
 
-        let mut selections = Vec::new();
+        let mut selections: Vec<(ReplicaId, Vec<SelectionLayout>)> = Vec::new();
         let mut active_rows = BTreeMap::new();
         let mut highlighted_rows = None;
         let mut highlighted_ranges = Vec::new();
@@ -934,7 +966,7 @@ impl Element for EditorElement {
             );
 
             let mut remote_selections = HashMap::default();
-            for (replica_id, selection) in display_map
+            for (replica_id, line_mode, selection) in display_map
                 .buffer_snapshot
                 .remote_selections_in_range(&(start_anchor.clone()..end_anchor.clone()))
             {
@@ -942,17 +974,10 @@ impl Element for EditorElement {
                 if Some(replica_id) == view.leader_replica_id {
                     continue;
                 }
-
                 remote_selections
                     .entry(replica_id)
                     .or_insert(Vec::new())
-                    .push(crate::Selection {
-                        id: selection.id,
-                        goal: selection.goal,
-                        reversed: selection.reversed,
-                        start: selection.start.to_display_point(&display_map),
-                        end: selection.end.to_display_point(&display_map),
-                    });
+                    .push(SelectionLayout::new(selection, line_mode, &display_map));
             }
             selections.extend(remote_selections);
 
@@ -981,12 +1006,8 @@ impl Element for EditorElement {
                     local_replica_id,
                     local_selections
                         .into_iter()
-                        .map(|selection| crate::Selection {
-                            id: selection.id,
-                            goal: selection.goal,
-                            reversed: selection.reversed,
-                            start: selection.start.to_display_point(&display_map),
-                            end: selection.end.to_display_point(&display_map),
+                        .map(|selection| {
+                            SelectionLayout::new(selection, view.selections.line_mode, &display_map)
                         })
                         .collect(),
                 ));
@@ -1237,7 +1258,7 @@ pub struct LayoutState {
     em_width: f32,
     em_advance: f32,
     highlighted_ranges: Vec<(Range<DisplayPoint>, Color)>,
-    selections: Vec<(ReplicaId, Vec<text::Selection<DisplayPoint>>)>,
+    selections: Vec<(ReplicaId, Vec<SelectionLayout>)>,
     context_menu: Option<(DisplayPoint, ElementBox)>,
     code_actions_indicator: Option<(u32, ElementBox)>,
 }

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

@@ -103,7 +103,11 @@ impl FollowableItem for Editor {
         } else {
             self.buffer.update(cx, |buffer, cx| {
                 if self.focused {
-                    buffer.set_active_selections(&self.selections.disjoint_anchors(), cx);
+                    buffer.set_active_selections(
+                        &self.selections.disjoint_anchors(),
+                        self.selections.line_mode,
+                        cx,
+                    );
                 }
             });
         }

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

@@ -509,6 +509,7 @@ impl MultiBuffer {
     pub fn set_active_selections(
         &mut self,
         selections: &[Selection<Anchor>],
+        line_mode: bool,
         cx: &mut ModelContext<Self>,
     ) {
         let mut selections_by_buffer: HashMap<usize, Vec<Selection<text::Anchor>>> =
@@ -573,7 +574,7 @@ impl MultiBuffer {
                         }
                         Some(selection)
                     }));
-                    buffer.set_active_selections(merged_selections, cx);
+                    buffer.set_active_selections(merged_selections, line_mode, cx);
                 });
         }
     }
@@ -2397,7 +2398,7 @@ impl MultiBufferSnapshot {
     pub fn remote_selections_in_range<'a>(
         &'a self,
         range: &'a Range<Anchor>,
-    ) -> impl 'a + Iterator<Item = (ReplicaId, Selection<Anchor>)> {
+    ) -> impl 'a + Iterator<Item = (ReplicaId, bool, Selection<Anchor>)> {
         let mut cursor = self.excerpts.cursor::<Option<&ExcerptId>>();
         cursor.seek(&Some(&range.start.excerpt_id), Bias::Left, &());
         cursor
@@ -2414,7 +2415,7 @@ impl MultiBufferSnapshot {
                 excerpt
                     .buffer
                     .remote_selections_in_range(query_range)
-                    .flat_map(move |(replica_id, selections)| {
+                    .flat_map(move |(replica_id, line_mode, selections)| {
                         selections.map(move |selection| {
                             let mut start = Anchor {
                                 buffer_id: Some(excerpt.buffer_id),
@@ -2435,6 +2436,7 @@ impl MultiBufferSnapshot {
 
                             (
                                 replica_id,
+                                line_mode,
                                 Selection {
                                     id: selection.id,
                                     start,

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

@@ -27,6 +27,7 @@ pub struct SelectionsCollection {
     display_map: ModelHandle<DisplayMap>,
     buffer: ModelHandle<MultiBuffer>,
     pub next_selection_id: usize,
+    pub line_mode: bool,
     disjoint: Arc<[Selection<Anchor>]>,
     pending: Option<PendingSelection>,
 }
@@ -37,6 +38,7 @@ impl SelectionsCollection {
             display_map,
             buffer,
             next_selection_id: 1,
+            line_mode: false,
             disjoint: Arc::from([]),
             pending: Some(PendingSelection {
                 selection: Selection {
@@ -126,6 +128,20 @@ impl SelectionsCollection {
         .collect()
     }
 
+    // Returns all of the selections, adjusted to take into account the selection line_mode
+    pub fn all_adjusted(&self, cx: &mut MutableAppContext) -> Vec<Selection<Point>> {
+        let mut selections = self.all::<Point>(cx);
+        if self.line_mode {
+            let map = self.display_map(cx);
+            for selection in &mut selections {
+                let new_range = map.expand_to_line(selection.range());
+                selection.start = new_range.start;
+                selection.end = new_range.end;
+            }
+        }
+        selections
+    }
+
     pub fn disjoint_in_range<'a, D>(
         &self,
         range: Range<Anchor>,
@@ -273,9 +289,10 @@ impl SelectionsCollection {
         &mut self,
         cx: &mut MutableAppContext,
         change: impl FnOnce(&mut MutableSelectionsCollection) -> R,
-    ) -> R {
+    ) -> (bool, R) {
         let mut mutable_collection = MutableSelectionsCollection {
             collection: self,
+            selections_changed: false,
             cx,
         };
 
@@ -284,12 +301,13 @@ impl SelectionsCollection {
             !mutable_collection.disjoint.is_empty() || mutable_collection.pending.is_some(),
             "There must be at least one selection"
         );
-        result
+        (mutable_collection.selections_changed, result)
     }
 }
 
 pub struct MutableSelectionsCollection<'a> {
     collection: &'a mut SelectionsCollection,
+    selections_changed: bool,
     cx: &'a mut MutableAppContext,
 }
 
@@ -307,16 +325,26 @@ impl<'a> MutableSelectionsCollection<'a> {
     }
 
     pub fn delete(&mut self, selection_id: usize) {
+        let mut changed = false;
         self.collection.disjoint = self
             .disjoint
             .into_iter()
-            .filter(|selection| selection.id != selection_id)
+            .filter(|selection| {
+                let found = selection.id == selection_id;
+                changed |= found;
+                !found
+            })
             .cloned()
             .collect();
+
+        self.selections_changed |= changed;
     }
 
     pub fn clear_pending(&mut self) {
-        self.collection.pending = None;
+        if self.collection.pending.is_some() {
+            self.collection.pending = None;
+            self.selections_changed = true;
+        }
     }
 
     pub fn set_pending_range(&mut self, range: Range<Anchor>, mode: SelectMode) {
@@ -329,11 +357,13 @@ impl<'a> MutableSelectionsCollection<'a> {
                 goal: SelectionGoal::None,
             },
             mode,
-        })
+        });
+        self.selections_changed = true;
     }
 
     pub fn set_pending(&mut self, selection: Selection<Anchor>, mode: SelectMode) {
         self.collection.pending = Some(PendingSelection { selection, mode });
+        self.selections_changed = true;
     }
 
     pub fn try_cancel(&mut self) -> bool {
@@ -341,12 +371,14 @@ impl<'a> MutableSelectionsCollection<'a> {
             if self.disjoint.is_empty() {
                 self.collection.disjoint = Arc::from([pending.selection]);
             }
+            self.selections_changed = true;
             return true;
         }
 
         let mut oldest = self.oldest_anchor().clone();
         if self.count() > 1 {
             self.collection.disjoint = Arc::from([oldest]);
+            self.selections_changed = true;
             return true;
         }
 
@@ -355,27 +387,13 @@ impl<'a> MutableSelectionsCollection<'a> {
             oldest.start = head.clone();
             oldest.end = head;
             self.collection.disjoint = Arc::from([oldest]);
+            self.selections_changed = true;
             return true;
         }
 
         return false;
     }
 
-    pub fn reset_biases(&mut self) {
-        let buffer = self.buffer.read(self.cx).snapshot(self.cx);
-        self.collection.disjoint = self
-            .collection
-            .disjoint
-            .into_iter()
-            .cloned()
-            .map(|selection| reset_biases(selection, &buffer))
-            .collect();
-
-        if let Some(pending) = self.collection.pending.as_mut() {
-            pending.selection = reset_biases(pending.selection.clone(), &buffer);
-        }
-    }
-
     pub fn insert_range<T>(&mut self, range: Range<T>)
     where
         T: 'a + ToOffset + ToPoint + TextDimension + Ord + Sub<T, Output = T> + std::marker::Copy,
@@ -437,6 +455,7 @@ impl<'a> MutableSelectionsCollection<'a> {
         }));
 
         self.collection.pending = None;
+        self.selections_changed = true;
     }
 
     pub fn select_anchors(&mut self, selections: Vec<Selection<Anchor>>) {
@@ -535,18 +554,27 @@ impl<'a> MutableSelectionsCollection<'a> {
         &mut self,
         mut move_selection: impl FnMut(&DisplaySnapshot, &mut Selection<DisplayPoint>),
     ) {
+        let mut changed = false;
         let display_map = self.display_map();
         let selections = self
             .all::<Point>(self.cx)
             .into_iter()
             .map(|selection| {
-                let mut selection = selection.map(|point| point.to_display_point(&display_map));
-                move_selection(&display_map, &mut selection);
-                selection.map(|display_point| display_point.to_point(&display_map))
+                let mut moved_selection =
+                    selection.map(|point| point.to_display_point(&display_map));
+                move_selection(&display_map, &mut moved_selection);
+                let moved_selection =
+                    moved_selection.map(|display_point| display_point.to_point(&display_map));
+                if selection != moved_selection {
+                    changed = true;
+                }
+                moved_selection
             })
             .collect();
 
-        self.select(selections)
+        if changed {
+            self.select(selections)
+        }
     }
 
     pub fn move_heads_with(
@@ -670,6 +698,7 @@ impl<'a> MutableSelectionsCollection<'a> {
             pending.selection.end = end;
         }
         self.collection.pending = pending;
+        self.selections_changed = true;
 
         selections_with_lost_position
     }
@@ -714,17 +743,3 @@ fn resolve<D: TextDimension + Ord + Sub<D, Output = D>>(
 ) -> Selection<D> {
     selection.map(|p| p.summary::<D>(&buffer))
 }
-
-fn reset_biases(
-    mut selection: Selection<Anchor>,
-    buffer: &MultiBufferSnapshot,
-) -> Selection<Anchor> {
-    let end_bias = if selection.end.to_offset(buffer) > selection.start.to_offset(buffer) {
-        Bias::Left
-    } else {
-        Bias::Right
-    };
-    selection.start = buffer.anchor_after(selection.start);
-    selection.end = buffer.anchor_at(selection.end, end_bias);
-    selection
-}

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

@@ -1,9 +1,19 @@
-use gpui::ViewContext;
-use util::test::{marked_text, marked_text_ranges};
+use std::ops::{Deref, DerefMut, Range};
+
+use indoc::indoc;
+
+use collections::BTreeMap;
+use gpui::{keymap::Keystroke, ModelHandle, ViewContext, ViewHandle};
+use language::Selection;
+use settings::Settings;
+use util::{
+    set_eq,
+    test::{marked_text, marked_text_ranges, marked_text_ranges_by, SetEqError},
+};
 
 use crate::{
     display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint},
-    DisplayPoint, Editor, MultiBuffer,
+    Autoscroll, DisplayPoint, Editor, EditorMode, MultiBuffer,
 };
 
 #[cfg(test)]
@@ -56,3 +66,301 @@ pub fn assert_text_with_selections(
     assert_eq!(editor.text(cx), unmarked_text);
     assert_eq!(editor.selections.ranges(cx), text_ranges);
 }
+
+pub(crate) fn build_editor(
+    buffer: ModelHandle<MultiBuffer>,
+    cx: &mut ViewContext<Editor>,
+) -> Editor {
+    Editor::new(EditorMode::Full, buffer, None, None, None, cx)
+}
+
+pub struct EditorTestContext<'a> {
+    pub cx: &'a mut gpui::TestAppContext,
+    pub window_id: usize,
+    pub editor: ViewHandle<Editor>,
+}
+
+impl<'a> EditorTestContext<'a> {
+    pub async fn new(cx: &'a mut gpui::TestAppContext) -> EditorTestContext<'a> {
+        let (window_id, editor) = cx.update(|cx| {
+            cx.set_global(Settings::test(cx));
+            crate::init(cx);
+
+            let (window_id, editor) = cx.add_window(Default::default(), |cx| {
+                build_editor(MultiBuffer::build_simple("", cx), cx)
+            });
+
+            editor.update(cx, |_, cx| cx.focus_self());
+
+            (window_id, editor)
+        });
+
+        Self {
+            cx,
+            window_id,
+            editor,
+        }
+    }
+
+    pub fn update_editor<F, T>(&mut self, update: F) -> T
+    where
+        F: FnOnce(&mut Editor, &mut ViewContext<Editor>) -> T,
+    {
+        self.editor.update(self.cx, update)
+    }
+
+    pub fn editor_text(&mut self) -> String {
+        self.editor
+            .update(self.cx, |editor, cx| editor.snapshot(cx).text())
+    }
+
+    pub fn simulate_keystroke(&mut self, keystroke_text: &str) {
+        let keystroke = Keystroke::parse(keystroke_text).unwrap();
+        let input = if keystroke.modified() {
+            None
+        } else {
+            Some(keystroke.key.clone())
+        };
+        self.cx
+            .dispatch_keystroke(self.window_id, keystroke, input, false);
+    }
+
+    pub fn simulate_keystrokes<const COUNT: usize>(&mut self, keystroke_texts: [&str; COUNT]) {
+        for keystroke_text in keystroke_texts.into_iter() {
+            self.simulate_keystroke(keystroke_text);
+        }
+    }
+
+    // Sets the editor state via a marked string.
+    // `|` characters represent empty selections
+    // `[` to `}` represents a non empty selection with the head at `}`
+    // `{` to `]` represents a non empty selection with the head at `{`
+    pub fn set_state(&mut self, text: &str) {
+        self.editor.update(self.cx, |editor, cx| {
+            let (unmarked_text, mut selection_ranges) = marked_text_ranges_by(
+                &text,
+                vec!['|'.into(), ('[', '}').into(), ('{', ']').into()],
+            );
+            editor.set_text(unmarked_text, cx);
+
+            let mut selections: Vec<Range<usize>> =
+                selection_ranges.remove(&'|'.into()).unwrap_or_default();
+            selections.extend(
+                selection_ranges
+                    .remove(&('{', ']').into())
+                    .unwrap_or_default()
+                    .into_iter()
+                    .map(|range| range.end..range.start),
+            );
+            selections.extend(
+                selection_ranges
+                    .remove(&('[', '}').into())
+                    .unwrap_or_default(),
+            );
+
+            editor.change_selections(Some(Autoscroll::Fit), cx, |s| s.select_ranges(selections));
+        })
+    }
+
+    // Asserts the editor state via a marked string.
+    // `|` characters represent empty selections
+    // `[` to `}` represents a non empty selection with the head at `}`
+    // `{` to `]` represents a non empty selection with the head at `{`
+    pub fn assert_editor_state(&mut self, text: &str) {
+        let (unmarked_text, mut selection_ranges) = marked_text_ranges_by(
+            &text,
+            vec!['|'.into(), ('[', '}').into(), ('{', ']').into()],
+        );
+        let editor_text = self.editor_text();
+        assert_eq!(
+            editor_text, unmarked_text,
+            "Unmarked text doesn't match editor text"
+        );
+
+        let expected_empty_selections = selection_ranges.remove(&'|'.into()).unwrap_or_default();
+        let expected_reverse_selections = selection_ranges
+            .remove(&('{', ']').into())
+            .unwrap_or_default();
+        let expected_forward_selections = selection_ranges
+            .remove(&('[', '}').into())
+            .unwrap_or_default();
+
+        self.assert_selections(
+            expected_empty_selections,
+            expected_reverse_selections,
+            expected_forward_selections,
+            Some(text.to_string()),
+        )
+    }
+
+    pub fn assert_editor_selections(&mut self, expected_selections: Vec<Selection<usize>>) {
+        let mut empty_selections = Vec::new();
+        let mut reverse_selections = Vec::new();
+        let mut forward_selections = Vec::new();
+
+        for selection in expected_selections {
+            let range = selection.range();
+            if selection.is_empty() {
+                empty_selections.push(range);
+            } else if selection.reversed {
+                reverse_selections.push(range);
+            } else {
+                forward_selections.push(range)
+            }
+        }
+
+        self.assert_selections(
+            empty_selections,
+            reverse_selections,
+            forward_selections,
+            None,
+        )
+    }
+
+    fn assert_selections(
+        &mut self,
+        expected_empty_selections: Vec<Range<usize>>,
+        expected_reverse_selections: Vec<Range<usize>>,
+        expected_forward_selections: Vec<Range<usize>>,
+        asserted_text: Option<String>,
+    ) {
+        let (empty_selections, reverse_selections, forward_selections) =
+            self.editor.read_with(self.cx, |editor, cx| {
+                let mut empty_selections = Vec::new();
+                let mut reverse_selections = Vec::new();
+                let mut forward_selections = Vec::new();
+
+                for selection in editor.selections.all::<usize>(cx) {
+                    let range = selection.range();
+                    if selection.is_empty() {
+                        empty_selections.push(range);
+                    } else if selection.reversed {
+                        reverse_selections.push(range);
+                    } else {
+                        forward_selections.push(range)
+                    }
+                }
+
+                (empty_selections, reverse_selections, forward_selections)
+            });
+
+        let asserted_selections = asserted_text.unwrap_or_else(|| {
+            self.insert_markers(
+                &expected_empty_selections,
+                &expected_reverse_selections,
+                &expected_forward_selections,
+            )
+        });
+        let actual_selections =
+            self.insert_markers(&empty_selections, &reverse_selections, &forward_selections);
+
+        let unmarked_text = self.editor_text();
+        let all_eq: Result<(), SetEqError<String>> =
+            set_eq!(expected_empty_selections, empty_selections)
+                .map_err(|err| {
+                    err.map(|missing| {
+                        let mut error_text = unmarked_text.clone();
+                        error_text.insert(missing.start, '|');
+                        error_text
+                    })
+                })
+                .and_then(|_| {
+                    set_eq!(expected_reverse_selections, reverse_selections).map_err(|err| {
+                        err.map(|missing| {
+                            let mut error_text = unmarked_text.clone();
+                            error_text.insert(missing.start, '{');
+                            error_text.insert(missing.end, ']');
+                            error_text
+                        })
+                    })
+                })
+                .and_then(|_| {
+                    set_eq!(expected_forward_selections, forward_selections).map_err(|err| {
+                        err.map(|missing| {
+                            let mut error_text = unmarked_text.clone();
+                            error_text.insert(missing.start, '[');
+                            error_text.insert(missing.end, '}');
+                            error_text
+                        })
+                    })
+                });
+
+        match all_eq {
+            Err(SetEqError::LeftMissing(location_text)) => {
+                panic!(
+                    indoc! {"
+                        Editor has extra selection
+                        Extra Selection Location:
+                        {}
+                        Asserted selections:
+                        {}
+                        Actual selections:
+                        {}"},
+                    location_text, asserted_selections, actual_selections,
+                );
+            }
+            Err(SetEqError::RightMissing(location_text)) => {
+                panic!(
+                    indoc! {"
+                        Editor is missing empty selection
+                        Missing Selection Location:
+                        {}
+                        Asserted selections:
+                        {}
+                        Actual selections:
+                        {}"},
+                    location_text, asserted_selections, actual_selections,
+                );
+            }
+            _ => {}
+        }
+    }
+
+    fn insert_markers(
+        &mut self,
+        empty_selections: &Vec<Range<usize>>,
+        reverse_selections: &Vec<Range<usize>>,
+        forward_selections: &Vec<Range<usize>>,
+    ) -> String {
+        let mut editor_text_with_selections = self.editor_text();
+        let mut selection_marks = BTreeMap::new();
+        for range in empty_selections {
+            selection_marks.insert(&range.start, '|');
+        }
+        for range in reverse_selections {
+            selection_marks.insert(&range.start, '{');
+            selection_marks.insert(&range.end, ']');
+        }
+        for range in forward_selections {
+            selection_marks.insert(&range.start, '[');
+            selection_marks.insert(&range.end, '}');
+        }
+        for (offset, mark) in selection_marks.into_iter().rev() {
+            editor_text_with_selections.insert(*offset, mark);
+        }
+
+        editor_text_with_selections
+    }
+
+    pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) {
+        self.cx.update(|cx| {
+            let actual_content = cx.read_from_clipboard().map(|item| item.text().to_owned());
+            let expected_content = expected_content.map(|content| content.to_owned());
+            assert_eq!(actual_content, expected_content);
+        })
+    }
+}
+
+impl<'a> Deref for EditorTestContext<'a> {
+    type Target = gpui::TestAppContext;
+
+    fn deref(&self) -> &Self::Target {
+        self.cx
+    }
+}
+
+impl<'a> DerefMut for EditorTestContext<'a> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.cx
+    }
+}

crates/gpui/src/app.rs ๐Ÿ”—

@@ -766,7 +766,7 @@ type SubscriptionCallback = Box<dyn FnMut(&dyn Any, &mut MutableAppContext) -> b
 type GlobalSubscriptionCallback = Box<dyn FnMut(&dyn Any, &mut MutableAppContext)>;
 type ObservationCallback = Box<dyn FnMut(&mut MutableAppContext) -> bool>;
 type FocusObservationCallback = Box<dyn FnMut(&mut MutableAppContext) -> bool>;
-type GlobalObservationCallback = Box<dyn FnMut(&dyn Any, &mut MutableAppContext)>;
+type GlobalObservationCallback = Box<dyn FnMut(&mut MutableAppContext)>;
 type ReleaseObservationCallback = Box<dyn FnOnce(&dyn Any, &mut MutableAppContext)>;
 type DeserializeActionCallback = fn(json: &str) -> anyhow::Result<Box<dyn Action>>;
 
@@ -1274,7 +1274,7 @@ impl MutableAppContext {
     pub fn observe_global<G, F>(&mut self, mut observe: F) -> Subscription
     where
         G: Any,
-        F: 'static + FnMut(&G, &mut MutableAppContext),
+        F: 'static + FnMut(&mut MutableAppContext),
     {
         let type_id = TypeId::of::<G>();
         let id = post_inc(&mut self.next_subscription_id);
@@ -1285,11 +1285,8 @@ impl MutableAppContext {
             .or_default()
             .insert(
                 id,
-                Some(
-                    Box::new(move |global: &dyn Any, cx: &mut MutableAppContext| {
-                        observe(global.downcast_ref().unwrap(), cx)
-                    }) as GlobalObservationCallback,
-                ),
+                Some(Box::new(move |cx: &mut MutableAppContext| observe(cx))
+                    as GlobalObservationCallback),
             );
 
         Subscription::GlobalObservation {
@@ -2272,27 +2269,24 @@ impl MutableAppContext {
     fn handle_global_notification_effect(&mut self, observed_type_id: TypeId) {
         let callbacks = self.global_observations.lock().remove(&observed_type_id);
         if let Some(callbacks) = callbacks {
-            if let Some(global) = self.cx.globals.remove(&observed_type_id) {
-                for (id, callback) in callbacks {
-                    if let Some(mut callback) = callback {
-                        callback(global.as_ref(), self);
-                        match self
-                            .global_observations
-                            .lock()
-                            .entry(observed_type_id)
-                            .or_default()
-                            .entry(id)
-                        {
-                            collections::btree_map::Entry::Vacant(entry) => {
-                                entry.insert(Some(callback));
-                            }
-                            collections::btree_map::Entry::Occupied(entry) => {
-                                entry.remove();
-                            }
+            for (id, callback) in callbacks {
+                if let Some(mut callback) = callback {
+                    callback(self);
+                    match self
+                        .global_observations
+                        .lock()
+                        .entry(observed_type_id)
+                        .or_default()
+                        .entry(id)
+                    {
+                        collections::btree_map::Entry::Vacant(entry) => {
+                            entry.insert(Some(callback));
+                        }
+                        collections::btree_map::Entry::Occupied(entry) => {
+                            entry.remove();
                         }
                     }
                 }
-                self.cx.globals.insert(observed_type_id, global);
             }
         }
     }
@@ -5617,7 +5611,7 @@ mod tests {
         let observation_count = Rc::new(RefCell::new(0));
         let subscription = cx.observe_global::<Global, _>({
             let observation_count = observation_count.clone();
-            move |_, _| {
+            move |_| {
                 *observation_count.borrow_mut() += 1;
             }
         });
@@ -5647,7 +5641,7 @@ mod tests {
         let observation_count = Rc::new(RefCell::new(0));
         cx.observe_global::<OtherGlobal, _>({
             let observation_count = observation_count.clone();
-            move |_, _| {
+            move |_| {
                 *observation_count.borrow_mut() += 1;
             }
         })
@@ -6021,7 +6015,7 @@ mod tests {
         *subscription.borrow_mut() = Some(cx.observe_global::<(), _>({
             let observation_count = observation_count.clone();
             let subscription = subscription.clone();
-            move |_, _| {
+            move |_| {
                 subscription.borrow_mut().take();
                 *observation_count.borrow_mut() += 1;
             }

crates/language/src/buffer.rs ๐Ÿ”—

@@ -83,6 +83,7 @@ pub struct BufferSnapshot {
 
 #[derive(Clone, Debug)]
 struct SelectionSet {
+    line_mode: bool,
     selections: Arc<[Selection<Anchor>]>,
     lamport_timestamp: clock::Lamport,
 }
@@ -129,6 +130,7 @@ pub enum Operation {
     UpdateSelections {
         selections: Arc<[Selection<Anchor>]>,
         lamport_timestamp: clock::Lamport,
+        line_mode: bool,
     },
     UpdateCompletionTriggers {
         triggers: Vec<String>,
@@ -343,6 +345,7 @@ impl Buffer {
             this.remote_selections.insert(
                 selection_set.replica_id as ReplicaId,
                 SelectionSet {
+                    line_mode: selection_set.line_mode,
                     selections: proto::deserialize_selections(selection_set.selections),
                     lamport_timestamp,
                 },
@@ -385,6 +388,7 @@ impl Buffer {
                     replica_id: *replica_id as u32,
                     selections: proto::serialize_selections(&set.selections),
                     lamport_timestamp: set.lamport_timestamp.value,
+                    line_mode: set.line_mode,
                 })
                 .collect(),
             diagnostics: proto::serialize_diagnostics(self.diagnostics.iter()),
@@ -1030,6 +1034,7 @@ impl Buffer {
     pub fn set_active_selections(
         &mut self,
         selections: Arc<[Selection<Anchor>]>,
+        line_mode: bool,
         cx: &mut ModelContext<Self>,
     ) {
         let lamport_timestamp = self.text.lamport_clock.tick();
@@ -1038,11 +1043,13 @@ impl Buffer {
             SelectionSet {
                 selections: selections.clone(),
                 lamport_timestamp,
+                line_mode,
             },
         );
         self.send_operation(
             Operation::UpdateSelections {
                 selections,
+                line_mode,
                 lamport_timestamp,
             },
             cx,
@@ -1050,7 +1057,7 @@ impl Buffer {
     }
 
     pub fn remove_active_selections(&mut self, cx: &mut ModelContext<Self>) {
-        self.set_active_selections(Arc::from([]), cx);
+        self.set_active_selections(Arc::from([]), false, cx);
     }
 
     pub fn set_text<T>(&mut self, text: T, cx: &mut ModelContext<Self>) -> Option<clock::Local>
@@ -1287,6 +1294,7 @@ impl Buffer {
             Operation::UpdateSelections {
                 selections,
                 lamport_timestamp,
+                line_mode,
             } => {
                 if let Some(set) = self.remote_selections.get(&lamport_timestamp.replica_id) {
                     if set.lamport_timestamp > lamport_timestamp {
@@ -1299,6 +1307,7 @@ impl Buffer {
                     SelectionSet {
                         selections,
                         lamport_timestamp,
+                        line_mode,
                     },
                 );
                 self.text.lamport_clock.observe(lamport_timestamp);
@@ -1890,8 +1899,14 @@ impl BufferSnapshot {
     pub fn remote_selections_in_range<'a>(
         &'a self,
         range: Range<Anchor>,
-    ) -> impl 'a + Iterator<Item = (ReplicaId, impl 'a + Iterator<Item = &'a Selection<Anchor>>)>
-    {
+    ) -> impl 'a
+           + Iterator<
+        Item = (
+            ReplicaId,
+            bool,
+            impl 'a + Iterator<Item = &'a Selection<Anchor>>,
+        ),
+    > {
         self.remote_selections
             .iter()
             .filter(|(replica_id, set)| {
@@ -1909,7 +1924,11 @@ impl BufferSnapshot {
                     Ok(ix) | Err(ix) => ix,
                 };
 
-                (*replica_id, set.selections[start_ix..end_ix].iter())
+                (
+                    *replica_id,
+                    set.line_mode,
+                    set.selections[start_ix..end_ix].iter(),
+                )
             })
     }
 

crates/language/src/proto.rs ๐Ÿ”—

@@ -43,11 +43,13 @@ pub fn serialize_operation(operation: &Operation) -> proto::Operation {
             }),
             Operation::UpdateSelections {
                 selections,
+                line_mode,
                 lamport_timestamp,
             } => proto::operation::Variant::UpdateSelections(proto::operation::UpdateSelections {
                 replica_id: lamport_timestamp.replica_id as u32,
                 lamport_timestamp: lamport_timestamp.value,
                 selections: serialize_selections(selections),
+                line_mode: *line_mode,
             }),
             Operation::UpdateDiagnostics {
                 diagnostics,
@@ -217,6 +219,7 @@ pub fn deserialize_operation(message: proto::Operation) -> Result<Operation> {
                         value: message.lamport_timestamp,
                     },
                     selections: Arc::from(selections),
+                    line_mode: message.line_mode,
                 }
             }
             proto::operation::Variant::UpdateDiagnostics(message) => Operation::UpdateDiagnostics {

crates/language/src/tests.rs ๐Ÿ”—

@@ -828,7 +828,7 @@ fn test_random_collaboration(cx: &mut MutableAppContext, mut rng: StdRng) {
                         selections
                     );
                     active_selections.insert(replica_id, selections.clone());
-                    buffer.set_active_selections(selections, cx);
+                    buffer.set_active_selections(selections, false, cx);
                 });
                 mutation_count -= 1;
             }
@@ -984,7 +984,7 @@ fn test_random_collaboration(cx: &mut MutableAppContext, mut rng: StdRng) {
         let buffer = buffer.read(cx).snapshot();
         let actual_remote_selections = buffer
             .remote_selections_in_range(Anchor::MIN..Anchor::MAX)
-            .map(|(replica_id, selections)| (replica_id, selections.collect::<Vec<_>>()))
+            .map(|(replica_id, _, selections)| (replica_id, selections.collect::<Vec<_>>()))
             .collect::<Vec<_>>();
         let expected_remote_selections = active_selections
             .iter()

crates/rpc/proto/zed.proto ๐Ÿ”—

@@ -779,6 +779,7 @@ message SelectionSet {
     uint32 replica_id = 1;
     repeated Selection selections = 2;
     uint32 lamport_timestamp = 3;
+    bool line_mode = 4;
 }
 
 message Selection {
@@ -854,6 +855,7 @@ message Operation {
         uint32 replica_id = 1;
         uint32 lamport_timestamp = 2;
         repeated Selection selections = 3;
+        bool line_mode = 4;
     }
 
     message UpdateCompletionTriggers {

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

@@ -6,4 +6,4 @@ pub use conn::Connection;
 pub use peer::*;
 mod macros;
 
-pub const PROTOCOL_VERSION: u32 = 19;
+pub const PROTOCOL_VERSION: u32 = 20;

crates/text/src/selection.rs ๐Ÿ”—

@@ -1,6 +1,7 @@
 use crate::Anchor;
 use crate::{rope::TextDimension, BufferSnapshot};
 use std::cmp::Ordering;
+use std::ops::Range;
 
 #[derive(Copy, Clone, Debug, Eq, PartialEq)]
 pub enum SelectionGoal {
@@ -83,6 +84,10 @@ impl<T: Copy + Ord> Selection<T> {
         self.goal = new_goal;
         self.reversed = false;
     }
+
+    pub fn range(&self) -> Range<T> {
+        self.start..self.end
+    }
 }
 
 impl Selection<usize> {

crates/util/src/test/marked_text.rs ๐Ÿ”—

@@ -24,31 +24,67 @@ pub fn marked_text(marked_text: &str) -> (String, Vec<usize>) {
     (unmarked_text, markers.remove(&'|').unwrap_or_default())
 }
 
+#[derive(Eq, PartialEq, Hash)]
+pub enum TextRangeMarker {
+    Empty(char),
+    Range(char, char),
+}
+
+impl TextRangeMarker {
+    fn markers(&self) -> Vec<char> {
+        match self {
+            Self::Empty(m) => vec![*m],
+            Self::Range(l, r) => vec![*l, *r],
+        }
+    }
+}
+
+impl From<char> for TextRangeMarker {
+    fn from(marker: char) -> Self {
+        Self::Empty(marker)
+    }
+}
+
+impl From<(char, char)> for TextRangeMarker {
+    fn from((left_marker, right_marker): (char, char)) -> Self {
+        Self::Range(left_marker, right_marker)
+    }
+}
+
 pub fn marked_text_ranges_by(
     marked_text: &str,
-    delimiters: Vec<(char, char)>,
-) -> (String, HashMap<(char, char), Vec<Range<usize>>>) {
-    let all_markers = delimiters
-        .iter()
-        .flat_map(|(start, end)| [*start, *end])
-        .collect();
-    let (unmarked_text, mut markers) = marked_text_by(marked_text, all_markers);
-    let range_lookup = delimiters
+    markers: Vec<TextRangeMarker>,
+) -> (String, HashMap<TextRangeMarker, Vec<Range<usize>>>) {
+    let all_markers = markers.iter().flat_map(|m| m.markers()).collect();
+
+    let (unmarked_text, mut marker_offsets) = marked_text_by(marked_text, all_markers);
+    let range_lookup = markers
         .into_iter()
-        .map(|(start_marker, end_marker)| {
-            let starts = markers.remove(&start_marker).unwrap_or_default();
-            let ends = markers.remove(&end_marker).unwrap_or_default();
-            assert_eq!(starts.len(), ends.len(), "marked ranges are unbalanced");
+        .map(|marker| match marker {
+            TextRangeMarker::Empty(empty_marker_char) => {
+                let ranges = marker_offsets
+                    .remove(&empty_marker_char)
+                    .unwrap_or_default()
+                    .into_iter()
+                    .map(|empty_index| empty_index..empty_index)
+                    .collect::<Vec<Range<usize>>>();
+                (marker, ranges)
+            }
+            TextRangeMarker::Range(start_marker, end_marker) => {
+                let starts = marker_offsets.remove(&start_marker).unwrap_or_default();
+                let ends = marker_offsets.remove(&end_marker).unwrap_or_default();
+                assert_eq!(starts.len(), ends.len(), "marked ranges are unbalanced");
 
-            let ranges = starts
-                .into_iter()
-                .zip(ends)
-                .map(|(start, end)| {
-                    assert!(end >= start, "marked ranges must be disjoint");
-                    start..end
-                })
-                .collect::<Vec<Range<usize>>>();
-            ((start_marker, end_marker), ranges)
+                let ranges = starts
+                    .into_iter()
+                    .zip(ends)
+                    .map(|(start, end)| {
+                        assert!(end >= start, "marked ranges must be disjoint");
+                        start..end
+                    })
+                    .collect::<Vec<Range<usize>>>();
+                (marker, ranges)
+            }
         })
         .collect();
 
@@ -58,14 +94,16 @@ pub fn marked_text_ranges_by(
 // Returns ranges delimited by (), [], and <> ranges. Ranges using the same markers
 // must not be overlapping. May also include | for empty ranges
 pub fn marked_text_ranges(full_marked_text: &str) -> (String, Vec<Range<usize>>) {
-    let (range_marked_text, empty_offsets) = marked_text(full_marked_text);
-    let (unmarked, range_lookup) =
-        marked_text_ranges_by(&range_marked_text, vec![('[', ']'), ('(', ')'), ('<', '>')]);
-    let mut combined_ranges: Vec<_> = range_lookup
-        .into_values()
-        .flatten()
-        .chain(empty_offsets.into_iter().map(|offset| offset..offset))
-        .collect();
+    let (unmarked, range_lookup) = marked_text_ranges_by(
+        &full_marked_text,
+        vec![
+            '|'.into(),
+            ('[', ']').into(),
+            ('(', ')').into(),
+            ('<', '>').into(),
+        ],
+    );
+    let mut combined_ranges: Vec<_> = range_lookup.into_values().flatten().collect();
 
     combined_ranges.sort_by_key(|range| range.start);
     (unmarked, combined_ranges)

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

@@ -18,22 +18,31 @@ fn editor_created(EditorCreated(editor): &EditorCreated, cx: &mut MutableAppCont
 }
 
 fn editor_focused(EditorFocused(editor): &EditorFocused, cx: &mut MutableAppContext) {
-    Vim::update(cx, |state, cx| {
-        state.active_editor = Some(editor.downgrade());
+    Vim::update(cx, |vim, cx| {
+        vim.active_editor = Some(editor.downgrade());
+        vim.selection_subscription = Some(cx.subscribe(editor, |editor, event, cx| {
+            if editor.read(cx).leader_replica_id().is_none() {
+                if let editor::Event::SelectionsChanged { local: true } = event {
+                    let newest_empty = editor.read(cx).selections.newest::<usize>(cx).is_empty();
+                    editor_local_selections_changed(newest_empty, cx);
+                }
+            }
+        }));
+
         if editor.read(cx).mode() != EditorMode::Full {
-            state.switch_mode(Mode::Insert, cx);
+            vim.switch_mode(Mode::Insert, cx);
         }
     });
 }
 
 fn editor_blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut MutableAppContext) {
-    Vim::update(cx, |state, cx| {
-        if let Some(previous_editor) = state.active_editor.clone() {
+    Vim::update(cx, |vim, cx| {
+        if let Some(previous_editor) = vim.active_editor.clone() {
             if previous_editor == editor.clone() {
-                state.active_editor = None;
+                vim.active_editor = None;
             }
         }
-        state.sync_editor_options(cx);
+        vim.sync_editor_options(cx);
     })
 }
 
@@ -47,3 +56,11 @@ fn editor_released(EditorReleased(editor): &EditorReleased, cx: &mut MutableAppC
         }
     });
 }
+
+fn editor_local_selections_changed(newest_empty: bool, cx: &mut MutableAppContext) {
+    Vim::update(cx, |vim, cx| {
+        if vim.enabled && vim.state.mode == Mode::Normal && !newest_empty {
+            vim.switch_mode(Mode::Visual { line: false }, cx)
+        }
+    })
+}

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

@@ -111,7 +111,7 @@ fn motion(motion: Motion, cx: &mut MutableAppContext) {
     });
     match Vim::read(cx).state.mode {
         Mode::Normal => normal_motion(motion, cx),
-        Mode::Visual => visual_motion(motion, cx),
+        Mode::Visual { .. } => visual_motion(motion, cx),
         Mode::Insert => {
             // Shouldn't execute a motion in insert mode. Ignoring
         }
@@ -192,11 +192,13 @@ impl Motion {
                 if selection.end.row() < map.max_point().row() {
                     *selection.end.row_mut() += 1;
                     *selection.end.column_mut() = 0;
+                    selection.end = map.clip_point(selection.end, Bias::Right);
                     // Don't reset the end here
                     return;
                 } else if selection.start.row() > 0 {
                     *selection.start.row_mut() -= 1;
                     *selection.start.column_mut() = map.line_len(selection.start.row());
+                    selection.start = map.clip_point(selection.start, Bias::Left);
                 }
             }
 

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

@@ -1,5 +1,8 @@
 mod change;
 mod delete;
+mod yank;
+
+use std::borrow::Cow;
 
 use crate::{
     motion::Motion,
@@ -8,12 +11,12 @@ use crate::{
 };
 use change::init as change_init;
 use collections::HashSet;
-use editor::{Autoscroll, Bias, DisplayPoint};
+use editor::{Autoscroll, Bias, ClipboardSelection, DisplayPoint};
 use gpui::{actions, MutableAppContext, ViewContext};
-use language::SelectionGoal;
+use language::{Point, SelectionGoal};
 use workspace::Workspace;
 
-use self::{change::change_over, delete::delete_over};
+use self::{change::change_over, delete::delete_over, yank::yank_over};
 
 actions!(
     vim,
@@ -27,6 +30,8 @@ actions!(
         DeleteRight,
         ChangeToEndOfLine,
         DeleteToEndOfLine,
+        Paste,
+        Yank,
     ]
 );
 
@@ -56,6 +61,7 @@ pub fn init(cx: &mut MutableAppContext) {
             delete_over(vim, Motion::EndOfLine, cx);
         })
     });
+    cx.add_action(paste);
 
     change_init(cx);
 }
@@ -64,11 +70,12 @@ pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) {
     Vim::update(cx, |vim, cx| {
         match vim.state.operator_stack.pop() {
             None => move_cursor(vim, motion, cx),
-            Some(Operator::Change) => change_over(vim, motion, cx),
-            Some(Operator::Delete) => delete_over(vim, motion, cx),
             Some(Operator::Namespace(_)) => {
                 // Can't do anything for a namespace operator. Ignoring
             }
+            Some(Operator::Change) => change_over(vim, motion, cx),
+            Some(Operator::Delete) => delete_over(vim, motion, cx),
+            Some(Operator::Yank) => yank_over(vim, motion, cx),
         }
         vim.clear_operator(cx);
     });
@@ -187,6 +194,116 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex
     });
 }
 
+// Supports non empty selections so it can be bound and called from visual mode
+fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
+    Vim::update(cx, |vim, cx| {
+        vim.update_active_editor(cx, |editor, cx| {
+            editor.transact(cx, |editor, cx| {
+                if let Some(item) = cx.as_mut().read_from_clipboard() {
+                    let mut clipboard_text = Cow::Borrowed(item.text());
+                    if let Some(mut clipboard_selections) =
+                        item.metadata::<Vec<ClipboardSelection>>()
+                    {
+                        let (display_map, selections) = editor.selections.all_display(cx);
+                        let all_selections_were_entire_line =
+                            clipboard_selections.iter().all(|s| s.is_entire_line);
+                        if clipboard_selections.len() != selections.len() {
+                            let mut newline_separated_text = String::new();
+                            let mut clipboard_selections =
+                                clipboard_selections.drain(..).peekable();
+                            let mut ix = 0;
+                            while let Some(clipboard_selection) = clipboard_selections.next() {
+                                newline_separated_text
+                                    .push_str(&clipboard_text[ix..ix + clipboard_selection.len]);
+                                ix += clipboard_selection.len;
+                                if clipboard_selections.peek().is_some() {
+                                    newline_separated_text.push('\n');
+                                }
+                            }
+                            clipboard_text = Cow::Owned(newline_separated_text);
+                        }
+
+                        let mut new_selections = Vec::new();
+                        editor.buffer().update(cx, |buffer, cx| {
+                            let snapshot = buffer.snapshot(cx);
+                            let mut start_offset = 0;
+                            let mut edits = Vec::new();
+                            for (ix, selection) in selections.iter().enumerate() {
+                                let to_insert;
+                                let linewise;
+                                if let Some(clipboard_selection) = clipboard_selections.get(ix) {
+                                    let end_offset = start_offset + clipboard_selection.len;
+                                    to_insert = &clipboard_text[start_offset..end_offset];
+                                    linewise = clipboard_selection.is_entire_line;
+                                    start_offset = end_offset;
+                                } else {
+                                    to_insert = clipboard_text.as_str();
+                                    linewise = all_selections_were_entire_line;
+                                }
+
+                                // If the clipboard text was copied linewise, and the current selection
+                                // is empty, then paste the text after this line and move the selection
+                                // to the start of the pasted text
+                                let range = if selection.is_empty() && linewise {
+                                    let (point, _) = display_map
+                                        .next_line_boundary(selection.start.to_point(&display_map));
+
+                                    if !to_insert.starts_with('\n') {
+                                        // Add newline before pasted text so that it shows up
+                                        edits.push((point..point, "\n"));
+                                    }
+                                    // Drop selection at the start of the next line
+                                    let selection_point = Point::new(point.row + 1, 0);
+                                    new_selections.push(selection.map(|_| selection_point.clone()));
+                                    point..point
+                                } else {
+                                    let mut selection = selection.clone();
+                                    if !selection.reversed {
+                                        let mut adjusted = selection.end;
+                                        // Head is at the end of the selection. Adjust the end position to
+                                        // to include the character under the cursor.
+                                        *adjusted.column_mut() = adjusted.column() + 1;
+                                        adjusted = display_map.clip_point(adjusted, Bias::Right);
+                                        // If the selection is empty, move both the start and end forward one
+                                        // character
+                                        if selection.is_empty() {
+                                            selection.start = adjusted;
+                                            selection.end = adjusted;
+                                        } else {
+                                            selection.end = adjusted;
+                                        }
+                                    }
+
+                                    let range = selection.map(|p| p.to_point(&display_map)).range();
+                                    new_selections.push(selection.map(|_| range.start.clone()));
+                                    range
+                                };
+
+                                if linewise && to_insert.ends_with('\n') {
+                                    edits.push((
+                                        range,
+                                        &to_insert[0..to_insert.len().saturating_sub(1)],
+                                    ))
+                                } else {
+                                    edits.push((range, to_insert));
+                                }
+                            }
+                            drop(snapshot);
+                            buffer.edit_with_autoindent(edits, cx);
+                        });
+
+                        editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                            s.select(new_selections)
+                        });
+                    } else {
+                        editor.insert(&clipboard_text, cx);
+                    }
+                }
+            });
+        });
+    });
+}
+
 #[cfg(test)]
 mod test {
     use indoc::indoc;
@@ -678,14 +795,8 @@ mod test {
                 |
                 The quick"},
         );
-        cx.assert(
-            indoc! {"
-                    |
-                The quick"},
-            indoc! {"
-                    |
-                The quick"},
-        );
+        // Indoc disallows trailing whitspace.
+        cx.assert("   | \nThe quick", "   | \nThe quick");
     }
 
     #[gpui::test]
@@ -1026,4 +1137,48 @@ mod test {
                 brown fox"},
         );
     }
+
+    #[gpui::test]
+    async fn test_p(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.set_state(
+            indoc! {"
+                The quick brown
+                fox ju|mps over
+                the lazy dog"},
+            Mode::Normal,
+        );
+
+        cx.simulate_keystrokes(["d", "d"]);
+        cx.assert_editor_state(indoc! {"
+            The quick brown
+            the la|zy dog"});
+
+        cx.simulate_keystroke("p");
+        cx.assert_editor_state(indoc! {"
+            The quick brown
+            the lazy dog
+            |fox jumps over"});
+
+        cx.set_state(
+            indoc! {"
+                The quick brown
+                fox [jump}s over
+                the lazy dog"},
+            Mode::Normal,
+        );
+        cx.simulate_keystroke("y");
+        cx.set_state(
+            indoc! {"
+                The quick brown
+                fox jump|s over
+                the lazy dog"},
+            Mode::Normal,
+        );
+        cx.simulate_keystroke("p");
+        cx.assert_editor_state(indoc! {"
+            The quick brown
+            fox jumps|jumps over
+            the lazy dog"});
+    }
 }

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

@@ -1,4 +1,4 @@
-use crate::{motion::Motion, state::Mode, Vim};
+use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim};
 use editor::{char_kind, movement, Autoscroll};
 use gpui::{impl_actions, MutableAppContext, ViewContext};
 use serde::Deserialize;
@@ -27,6 +27,7 @@ pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
                     motion.expand_selection(map, selection, false);
                 });
             });
+            copy_selections_content(editor, motion.linewise(), cx);
             editor.insert(&"", cx);
         });
     });
@@ -65,6 +66,7 @@ fn change_word(
                             });
                     });
                 });
+                copy_selections_content(editor, false, cx);
                 editor.insert(&"", cx);
             });
         });

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

@@ -1,4 +1,4 @@
-use crate::{motion::Motion, Vim};
+use crate::{motion::Motion, utils::copy_selections_content, Vim};
 use collections::HashMap;
 use editor::{Autoscroll, Bias};
 use gpui::MutableAppContext;
@@ -15,6 +15,7 @@ pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
                     original_columns.insert(selection.id, original_head.column());
                 });
             });
+            copy_selections_content(editor, motion.linewise(), cx);
             editor.insert(&"", cx);
 
             // Fixup cursor position after the deletion

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

@@ -0,0 +1,26 @@
+use crate::{motion::Motion, utils::copy_selections_content, Vim};
+use collections::HashMap;
+use gpui::MutableAppContext;
+
+pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
+    vim.update_active_editor(cx, |editor, cx| {
+        editor.transact(cx, |editor, cx| {
+            editor.set_clip_at_line_ends(false, cx);
+            let mut original_positions: HashMap<_, _> = Default::default();
+            editor.change_selections(None, cx, |s| {
+                s.move_with(|map, selection| {
+                    let original_position = (selection.head(), selection.goal);
+                    motion.expand_selection(map, selection, true);
+                    original_positions.insert(selection.id, original_position);
+                });
+            });
+            copy_selections_content(editor, motion.linewise(), cx);
+            editor.change_selections(None, cx, |s| {
+                s.move_with(|_, selection| {
+                    let (head, goal) = original_positions.remove(&selection.id).unwrap();
+                    selection.collapse_to(head, goal);
+                });
+            });
+        });
+    });
+}

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

@@ -6,7 +6,7 @@ use serde::Deserialize;
 pub enum Mode {
     Normal,
     Insert,
-    Visual,
+    Visual { line: bool },
 }
 
 impl Default for Mode {
@@ -25,6 +25,7 @@ pub enum Operator {
     Namespace(Namespace),
     Change,
     Delete,
+    Yank,
 }
 
 #[derive(Default)]
@@ -36,8 +37,7 @@ pub struct VimState {
 impl VimState {
     pub fn cursor_shape(&self) -> CursorShape {
         match self.mode {
-            Mode::Normal => CursorShape::Block,
-            Mode::Visual => CursorShape::Block,
+            Mode::Normal | Mode::Visual { .. } => CursorShape::Block,
             Mode::Insert => CursorShape::Bar,
         }
     }
@@ -46,13 +46,24 @@ impl VimState {
         !matches!(self.mode, Mode::Insert)
     }
 
+    pub fn clip_at_line_end(&self) -> bool {
+        match self.mode {
+            Mode::Insert | Mode::Visual { .. } => false,
+            _ => true,
+        }
+    }
+
+    pub fn empty_selections_only(&self) -> bool {
+        !matches!(self.mode, Mode::Visual { .. })
+    }
+
     pub fn keymap_context_layer(&self) -> Context {
         let mut context = Context::default();
         context.map.insert(
             "vim_mode".to_string(),
             match self.mode {
                 Mode::Normal => "normal",
-                Mode::Visual => "visual",
+                Mode::Visual { .. } => "visual",
                 Mode::Insert => "insert",
             }
             .to_string(),
@@ -75,6 +86,7 @@ impl Operator {
             Operator::Namespace(Namespace::G) => "g",
             Operator::Change => "c",
             Operator::Delete => "d",
+            Operator::Yank => "y",
         }
         .to_owned();
 

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

@@ -0,0 +1,25 @@
+use editor::{ClipboardSelection, Editor};
+use gpui::{ClipboardItem, MutableAppContext};
+
+pub fn copy_selections_content(editor: &mut Editor, linewise: bool, cx: &mut MutableAppContext) {
+    let selections = editor.selections.all_adjusted(cx);
+    let buffer = editor.buffer().read(cx).snapshot(cx);
+    let mut text = String::new();
+    let mut clipboard_selections = Vec::with_capacity(selections.len());
+    {
+        for selection in selections.iter() {
+            let initial_len = text.len();
+            let start = selection.start;
+            let end = selection.end;
+            for chunk in buffer.text_for_range(start..end) {
+                text.push_str(chunk);
+            }
+            clipboard_selections.push(ClipboardSelection {
+                len: text.len() - initial_len,
+                is_entire_line: linewise,
+            });
+        }
+    }
+
+    cx.write_to_clipboard(ClipboardItem::new(text).with_metadata(clipboard_selections));
+}

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

@@ -6,11 +6,12 @@ mod insert;
 mod motion;
 mod normal;
 mod state;
+mod utils;
 mod visual;
 
 use collections::HashMap;
-use editor::{CursorShape, Editor};
-use gpui::{impl_actions, MutableAppContext, ViewContext, WeakViewHandle};
+use editor::{Bias, CursorShape, Editor, Input};
+use gpui::{impl_actions, MutableAppContext, Subscription, ViewContext, WeakViewHandle};
 use serde::Deserialize;
 
 use settings::Settings;
@@ -40,9 +41,19 @@ pub fn init(cx: &mut MutableAppContext) {
             Vim::update(cx, |vim, cx| vim.push_operator(operator, cx))
         },
     );
+    cx.add_action(|_: &mut Editor, _: &Input, cx| {
+        if Vim::read(cx).active_operator().is_some() {
+            // Defer without updating editor
+            MutableAppContext::defer(cx, |cx| Vim::update(cx, |vim, cx| vim.clear_operator(cx)))
+        } else {
+            cx.propagate_action()
+        }
+    });
 
-    cx.observe_global::<Settings, _>(|settings, cx| {
-        Vim::update(cx, |state, cx| state.set_enabled(settings.vim_mode, cx))
+    cx.observe_global::<Settings, _>(|cx| {
+        Vim::update(cx, |state, cx| {
+            state.set_enabled(cx.global::<Settings>().vim_mode, cx)
+        })
     })
     .detach();
 }
@@ -51,6 +62,7 @@ pub fn init(cx: &mut MutableAppContext) {
 pub struct Vim {
     editors: HashMap<usize, WeakViewHandle<Editor>>,
     active_editor: Option<WeakViewHandle<Editor>>,
+    selection_subscription: Option<Subscription>,
 
     enabled: bool,
     state: VimState,
@@ -101,7 +113,7 @@ impl Vim {
         self.sync_editor_options(cx);
     }
 
-    fn active_operator(&mut self) -> Option<Operator> {
+    fn active_operator(&self) -> Option<Operator> {
         self.state.operator_stack.last().copied()
     }
 
@@ -118,23 +130,38 @@ impl Vim {
 
     fn sync_editor_options(&self, cx: &mut MutableAppContext) {
         let state = &self.state;
-
         let cursor_shape = state.cursor_shape();
+
         for editor in self.editors.values() {
             if let Some(editor) = editor.upgrade(cx) {
                 editor.update(cx, |editor, cx| {
                     if self.enabled {
                         editor.set_cursor_shape(cursor_shape, cx);
-                        editor.set_clip_at_line_ends(cursor_shape == CursorShape::Block, cx);
+                        editor.set_clip_at_line_ends(state.clip_at_line_end(), cx);
                         editor.set_input_enabled(!state.vim_controlled());
+                        editor.selections.line_mode =
+                            matches!(state.mode, Mode::Visual { line: true });
                         let context_layer = state.keymap_context_layer();
                         editor.set_keymap_context_layer::<Self>(context_layer);
                     } else {
                         editor.set_cursor_shape(CursorShape::Bar, cx);
                         editor.set_clip_at_line_ends(false, cx);
                         editor.set_input_enabled(true);
+                        editor.selections.line_mode = false;
                         editor.remove_keymap_context_layer::<Self>();
                     }
+
+                    editor.change_selections(None, cx, |s| {
+                        s.move_with(|map, selection| {
+                            selection.set_head(
+                                map.clip_point(selection.head(), Bias::Left),
+                                selection.goal,
+                            );
+                            if state.empty_selections_only() {
+                                selection.collapse_to(selection.head(), selection.goal)
+                            }
+                        });
+                    })
                 });
             }
         }
@@ -169,9 +196,9 @@ mod test {
         assert_eq!(cx.mode(), Mode::Normal);
         cx.simulate_keystrokes(["h", "h", "h", "l"]);
         assert_eq!(cx.editor_text(), "hjkl".to_owned());
-        cx.assert_editor_state("hj|kl");
+        cx.assert_editor_state("h|jkl");
         cx.simulate_keystrokes(["i", "T", "e", "s", "t"]);
-        cx.assert_editor_state("hjTest|kl");
+        cx.assert_editor_state("hTest|jkl");
 
         // Disabling and enabling resets to normal mode
         assert_eq!(cx.mode(), Mode::Insert);

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

@@ -1,31 +1,21 @@
-use std::ops::{Deref, Range};
+use std::ops::{Deref, DerefMut};
 
-use collections::BTreeMap;
-use itertools::{Either, Itertools};
-
-use editor::{display_map::ToDisplayPoint, Autoscroll};
-use gpui::{json::json, keymap::Keystroke, ViewHandle};
-use indoc::indoc;
-use language::Selection;
+use editor::test::EditorTestContext;
+use gpui::json::json;
 use project::Project;
-use util::{
-    set_eq,
-    test::{marked_text, marked_text_ranges_by, SetEqError},
-};
-use workspace::{AppState, WorkspaceHandle};
+use workspace::{pane, AppState, WorkspaceHandle};
 
 use crate::{state::Operator, *};
 
 pub struct VimTestContext<'a> {
-    cx: &'a mut gpui::TestAppContext,
-    window_id: usize,
-    editor: ViewHandle<Editor>,
+    cx: EditorTestContext<'a>,
 }
 
 impl<'a> VimTestContext<'a> {
     pub async fn new(cx: &'a mut gpui::TestAppContext, enabled: bool) -> VimTestContext<'a> {
         cx.update(|cx| {
             editor::init(cx);
+            pane::init(cx);
             crate::init(cx);
 
             settings::KeymapFileContent::load("keymaps/vim.json", cx).unwrap();
@@ -69,9 +59,11 @@ impl<'a> VimTestContext<'a> {
         editor.update(cx, |_, cx| cx.focus_self());
 
         Self {
-            cx,
-            window_id,
-            editor,
+            cx: EditorTestContext {
+                cx,
+                window_id,
+                editor,
+            },
         }
     }
 
@@ -100,219 +92,13 @@ impl<'a> VimTestContext<'a> {
             .read(|cx| cx.global::<Vim>().state.operator_stack.last().copied())
     }
 
-    pub fn editor_text(&mut self) -> String {
-        self.editor
-            .update(self.cx, |editor, cx| editor.snapshot(cx).text())
-    }
-
-    pub fn simulate_keystroke(&mut self, keystroke_text: &str) {
-        let keystroke = Keystroke::parse(keystroke_text).unwrap();
-        let input = if keystroke.modified() {
-            None
-        } else {
-            Some(keystroke.key.clone())
-        };
-        self.cx
-            .dispatch_keystroke(self.window_id, keystroke, input, false);
-    }
-
-    pub fn simulate_keystrokes<const COUNT: usize>(&mut self, keystroke_texts: [&str; COUNT]) {
-        for keystroke_text in keystroke_texts.into_iter() {
-            self.simulate_keystroke(keystroke_text);
-        }
-    }
-
     pub fn set_state(&mut self, text: &str, mode: Mode) {
-        self.cx
-            .update(|cx| Vim::update(cx, |vim, cx| vim.switch_mode(mode, cx)));
-        self.editor.update(self.cx, |editor, cx| {
-            let (unmarked_text, markers) = marked_text(&text);
-            editor.set_text(unmarked_text, cx);
-            let cursor_offset = markers[0];
-            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
-                s.replace_cursors_with(|map| vec![cursor_offset.to_display_point(map)])
-            });
-        })
-    }
-
-    // Asserts the editor state via a marked string.
-    // `|` characters represent empty selections
-    // `[` to `}` represents a non empty selection with the head at `}`
-    // `{` to `]` represents a non empty selection with the head at `{`
-    pub fn assert_editor_state(&mut self, text: &str) {
-        let (text_with_ranges, expected_empty_selections) = marked_text(&text);
-        let (unmarked_text, mut selection_ranges) =
-            marked_text_ranges_by(&text_with_ranges, vec![('[', '}'), ('{', ']')]);
-        let editor_text = self.editor_text();
-        assert_eq!(
-            editor_text, unmarked_text,
-            "Unmarked text doesn't match editor text"
-        );
-
-        let expected_reverse_selections = selection_ranges.remove(&('{', ']')).unwrap_or_default();
-        let expected_forward_selections = selection_ranges.remove(&('[', '}')).unwrap_or_default();
-
-        self.assert_selections(
-            expected_empty_selections,
-            expected_reverse_selections,
-            expected_forward_selections,
-            Some(text.to_string()),
-        )
-    }
-
-    pub fn assert_editor_selections(&mut self, expected_selections: Vec<Selection<usize>>) {
-        let (expected_empty_selections, expected_non_empty_selections): (Vec<_>, Vec<_>) =
-            expected_selections.into_iter().partition_map(|selection| {
-                if selection.is_empty() {
-                    Either::Left(selection.head())
-                } else {
-                    Either::Right(selection)
-                }
-            });
-
-        let (expected_reverse_selections, expected_forward_selections): (Vec<_>, Vec<_>) =
-            expected_non_empty_selections
-                .into_iter()
-                .partition_map(|selection| {
-                    let range = selection.start..selection.end;
-                    if selection.reversed {
-                        Either::Left(range)
-                    } else {
-                        Either::Right(range)
-                    }
-                });
-
-        self.assert_selections(
-            expected_empty_selections,
-            expected_reverse_selections,
-            expected_forward_selections,
-            None,
-        )
-    }
-
-    fn assert_selections(
-        &mut self,
-        expected_empty_selections: Vec<usize>,
-        expected_reverse_selections: Vec<Range<usize>>,
-        expected_forward_selections: Vec<Range<usize>>,
-        asserted_text: Option<String>,
-    ) {
-        let (empty_selections, reverse_selections, forward_selections) =
-            self.editor.read_with(self.cx, |editor, cx| {
-                let (empty_selections, non_empty_selections): (Vec<_>, Vec<_>) = editor
-                    .selections
-                    .all::<usize>(cx)
-                    .into_iter()
-                    .partition_map(|selection| {
-                        if selection.is_empty() {
-                            Either::Left(selection.head())
-                        } else {
-                            Either::Right(selection)
-                        }
-                    });
-
-                let (reverse_selections, forward_selections): (Vec<_>, Vec<_>) =
-                    non_empty_selections.into_iter().partition_map(|selection| {
-                        let range = selection.start..selection.end;
-                        if selection.reversed {
-                            Either::Left(range)
-                        } else {
-                            Either::Right(range)
-                        }
-                    });
-                (empty_selections, reverse_selections, forward_selections)
-            });
-
-        let asserted_selections = asserted_text.unwrap_or_else(|| {
-            self.insert_markers(
-                &expected_empty_selections,
-                &expected_reverse_selections,
-                &expected_forward_selections,
-            )
+        self.cx.update(|cx| {
+            Vim::update(cx, |vim, cx| {
+                vim.switch_mode(mode, cx);
+            })
         });
-        let actual_selections =
-            self.insert_markers(&empty_selections, &reverse_selections, &forward_selections);
-
-        let unmarked_text = self.editor_text();
-        let all_eq: Result<(), SetEqError<String>> =
-            set_eq!(expected_empty_selections, empty_selections)
-                .map_err(|err| {
-                    err.map(|missing| {
-                        let mut error_text = unmarked_text.clone();
-                        error_text.insert(missing, '|');
-                        error_text
-                    })
-                })
-                .and_then(|_| {
-                    set_eq!(expected_reverse_selections, reverse_selections).map_err(|err| {
-                        err.map(|missing| {
-                            let mut error_text = unmarked_text.clone();
-                            error_text.insert(missing.start, '{');
-                            error_text.insert(missing.end, ']');
-                            error_text
-                        })
-                    })
-                })
-                .and_then(|_| {
-                    set_eq!(expected_forward_selections, forward_selections).map_err(|err| {
-                        err.map(|missing| {
-                            let mut error_text = unmarked_text.clone();
-                            error_text.insert(missing.start, '[');
-                            error_text.insert(missing.end, '}');
-                            error_text
-                        })
-                    })
-                });
-
-        match all_eq {
-            Err(SetEqError::LeftMissing(location_text)) => {
-                panic!(
-                    indoc! {"
-                        Editor has extra selection
-                        Extra Selection Location: {}
-                        Asserted selections: {}
-                        Actual selections: {}"},
-                    location_text, asserted_selections, actual_selections,
-                );
-            }
-            Err(SetEqError::RightMissing(location_text)) => {
-                panic!(
-                    indoc! {"
-                        Editor is missing empty selection
-                        Missing Selection Location: {}
-                        Asserted selections: {}
-                        Actual selections: {}"},
-                    location_text, asserted_selections, actual_selections,
-                );
-            }
-            _ => {}
-        }
-    }
-
-    fn insert_markers(
-        &mut self,
-        empty_selections: &Vec<usize>,
-        reverse_selections: &Vec<Range<usize>>,
-        forward_selections: &Vec<Range<usize>>,
-    ) -> String {
-        let mut editor_text_with_selections = self.editor_text();
-        let mut selection_marks = BTreeMap::new();
-        for offset in empty_selections {
-            selection_marks.insert(offset, '|');
-        }
-        for range in reverse_selections {
-            selection_marks.insert(&range.start, '{');
-            selection_marks.insert(&range.end, ']');
-        }
-        for range in forward_selections {
-            selection_marks.insert(&range.start, '[');
-            selection_marks.insert(&range.end, '}');
-        }
-        for (offset, mark) in selection_marks.into_iter().rev() {
-            editor_text_with_selections.insert(*offset, mark);
-        }
-
-        editor_text_with_selections
+        self.cx.set_state(text);
     }
 
     pub fn assert_binding<const COUNT: usize>(
@@ -324,8 +110,8 @@ impl<'a> VimTestContext<'a> {
         mode_after: Mode,
     ) {
         self.set_state(initial_state, initial_mode);
-        self.simulate_keystrokes(keystrokes);
-        self.assert_editor_state(state_after);
+        self.cx.simulate_keystrokes(keystrokes);
+        self.cx.assert_editor_state(state_after);
         assert_eq!(self.mode(), mode_after);
         assert_eq!(self.active_operator(), None);
     }
@@ -337,13 +123,27 @@ impl<'a> VimTestContext<'a> {
         let mode = self.mode();
         VimBindingTestContext::new(keystrokes, mode, mode, self)
     }
+
+    pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) {
+        self.cx.update(|cx| {
+            let actual_content = cx.read_from_clipboard().map(|item| item.text().to_owned());
+            let expected_content = expected_content.map(|content| content.to_owned());
+            assert_eq!(actual_content, expected_content);
+        })
+    }
 }
 
 impl<'a> Deref for VimTestContext<'a> {
-    type Target = gpui::TestAppContext;
+    type Target = EditorTestContext<'a>;
 
     fn deref(&self) -> &Self::Target {
-        self.cx
+        &self.cx
+    }
+}
+
+impl<'a> DerefMut for VimTestContext<'a> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.cx
     }
 }
 
@@ -404,3 +204,9 @@ impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> {
         &self.cx
     }
 }
+
+impl<'a, const COUNT: usize> DerefMut for VimBindingTestContext<'a, COUNT> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.cx
+    }
+}

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

@@ -1,14 +1,17 @@
-use editor::{Autoscroll, Bias};
+use collections::HashMap;
+use editor::{display_map::ToDisplayPoint, Autoscroll, Bias};
 use gpui::{actions, MutableAppContext, ViewContext};
+use language::SelectionGoal;
 use workspace::Workspace;
 
-use crate::{motion::Motion, state::Mode, Vim};
+use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim};
 
-actions!(vim, [VisualDelete, VisualChange]);
+actions!(vim, [VisualDelete, VisualChange, VisualYank]);
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(change);
     cx.add_action(delete);
+    cx.add_action(yank);
 }
 
 pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) {
@@ -17,7 +20,6 @@ pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) {
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_with(|map, selection| {
                     let (new_head, goal) = motion.move_point(map, selection.head(), selection.goal);
-                    let new_head = map.clip_at_line_end(new_head);
                     let was_reversed = selection.reversed;
                     selection.set_head(new_head, goal);
 
@@ -30,7 +32,7 @@ pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) {
                         // Head was at the end of the selection, and now is at the start. We need to move the end
                         // forward by one if possible in order to compensate for this change.
                         *selection.end.column_mut() = selection.end.column() + 1;
-                        selection.end = map.clip_point(selection.end, Bias::Left);
+                        selection.end = map.clip_point(selection.end, Bias::Right);
                     }
                 });
             });
@@ -42,17 +44,47 @@ pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext<Workspac
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
             editor.set_clip_at_line_ends(false, cx);
-            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+            // Compute edits and resulting anchor selections. If in line mode, adjust
+            // the anchor location and additional newline
+            let mut edits = Vec::new();
+            let mut new_selections = Vec::new();
+            let line_mode = editor.selections.line_mode;
+            editor.change_selections(None, cx, |s| {
                 s.move_with(|map, selection| {
                     if !selection.reversed {
-                        // Head was at the end of the selection, and now is at the start. We need to move the end
-                        // forward by one if possible in order to compensate for this change.
+                        // 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 = map.clip_point(selection.end, Bias::Left);
+                        selection.end = map.clip_point(selection.end, Bias::Right);
+                    }
+
+                    if line_mode {
+                        let range = selection.map(|p| p.to_point(map)).range();
+                        let expanded_range = map.expand_to_line(range);
+                        // If we are at the last line, the anchor needs to be after the newline so that
+                        // it is on a line of its own. Otherwise, the anchor may be after the newline
+                        let anchor = if expanded_range.end == map.buffer_snapshot.max_point() {
+                            map.buffer_snapshot.anchor_after(expanded_range.end)
+                        } else {
+                            map.buffer_snapshot.anchor_before(expanded_range.start)
+                        };
+
+                        edits.push((expanded_range, "\n"));
+                        new_selections.push(selection.map(|_| anchor.clone()));
+                    } else {
+                        let range = selection.map(|p| p.to_point(map)).range();
+                        let anchor = map.buffer_snapshot.anchor_after(range.end);
+                        edits.push((range, ""));
+                        new_selections.push(selection.map(|_| anchor.clone()));
                     }
+                    selection.goal = SelectionGoal::None;
                 });
             });
-            editor.insert("", cx);
+            copy_selections_content(editor, editor.selections.line_mode, cx);
+            editor.edit_with_autoindent(edits, cx);
+            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                s.select_anchors(new_selections);
+            });
         });
         vim.switch_mode(Mode::Insert, cx);
     });
@@ -60,31 +92,70 @@ pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext<Workspac
 
 pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
-        vim.switch_mode(Mode::Normal, cx);
         vim.update_active_editor(cx, |editor, cx| {
             editor.set_clip_at_line_ends(false, cx);
+            let mut original_columns: HashMap<_, _> = Default::default();
+            let line_mode = editor.selections.line_mode;
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_with(|map, selection| {
-                    if !selection.reversed {
-                        // Head was at the end of the selection, and now is at the start. We need to move the end
-                        // forward by one if possible in order to compensate for this change.
+                    if line_mode {
+                        original_columns
+                            .insert(selection.id, selection.head().to_point(&map).column);
+                    } else if !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 = map.clip_point(selection.end, Bias::Left);
+                        selection.end = map.clip_point(selection.end, Bias::Right);
                     }
+                    selection.goal = SelectionGoal::None;
                 });
             });
+            copy_selections_content(editor, line_mode, cx);
             editor.insert("", cx);
 
             // Fixup cursor position after the deletion
             editor.set_clip_at_line_ends(true, cx);
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_with(|map, selection| {
-                    let mut cursor = selection.head();
-                    cursor = map.clip_point(cursor, Bias::Left);
+                    let mut cursor = selection.head().to_point(map);
+
+                    if let Some(column) = original_columns.get(&selection.id) {
+                        cursor.column = *column
+                    }
+                    let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left);
                     selection.collapse_to(cursor, selection.goal)
                 });
             });
         });
+        vim.switch_mode(Mode::Normal, cx);
+    });
+}
+
+pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>) {
+    Vim::update(cx, |vim, cx| {
+        vim.update_active_editor(cx, |editor, cx| {
+            editor.set_clip_at_line_ends(false, cx);
+            let line_mode = editor.selections.line_mode;
+            if !editor.selections.line_mode {
+                editor.change_selections(None, cx, |s| {
+                    s.move_with(|map, selection| {
+                        if !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 = map.clip_point(selection.end, Bias::Right);
+                        }
+                    });
+                });
+            }
+            copy_selections_content(editor, line_mode, cx);
+            editor.change_selections(None, cx, |s| {
+                s.move_with(|_, selection| {
+                    selection.collapse_to(selection.start, SelectionGoal::None)
+                });
+            });
+        });
+        vim.switch_mode(Mode::Normal, cx);
     });
 }
 
@@ -97,7 +168,9 @@ mod test {
     #[gpui::test]
     async fn test_enter_visual_mode(cx: &mut gpui::TestAppContext) {
         let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["v", "w", "j"]).mode_after(Mode::Visual);
+        let mut cx = cx
+            .binding(["v", "w", "j"])
+            .mode_after(Mode::Visual { line: false });
         cx.assert(
             indoc! {"
                 The |quick brown
@@ -128,7 +201,9 @@ mod test {
                 fox jumps [over
                 }the lazy dog"},
         );
-        let mut cx = cx.binding(["v", "b", "k"]).mode_after(Mode::Visual);
+        let mut cx = cx
+            .binding(["v", "b", "k"])
+            .mode_after(Mode::Visual { line: false });
         cx.assert(
             indoc! {"
                 The |quick brown
@@ -176,6 +251,13 @@ mod test {
                 The |ver
                 the lazy dog"},
         );
+        // Test pasting code copied on delete
+        cx.simulate_keystrokes(["j", "p"]);
+        cx.assert_editor_state(indoc! {"
+            The ver
+            the l|quick brown
+            fox jumps oazy dog"});
+
         cx.assert(
             indoc! {"
                 The quick brown
@@ -226,6 +308,77 @@ mod test {
         );
     }
 
+    #[gpui::test]
+    async fn test_visual_line_delete(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["shift-V", "x"]);
+        cx.assert(
+            indoc! {"
+                The qu|ick brown
+                fox jumps over
+                the lazy dog"},
+            indoc! {"
+                fox ju|mps over
+                the lazy dog"},
+        );
+        // Test pasting code copied on delete
+        cx.simulate_keystroke("p");
+        cx.assert_editor_state(indoc! {"
+            fox jumps over
+            |The quick brown
+            the lazy dog"});
+
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox ju|mps over
+                the lazy dog"},
+            indoc! {"
+                The quick brown
+                the la|zy dog"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox jumps over
+                the la|zy dog"},
+            indoc! {"
+                The quick brown
+                fox ju|mps over"},
+        );
+        let mut cx = cx.binding(["shift-V", "j", "x"]);
+        cx.assert(
+            indoc! {"
+                The qu|ick brown
+                fox jumps over
+                the lazy dog"},
+            "the la|zy dog",
+        );
+        // Test pasting code copied on delete
+        cx.simulate_keystroke("p");
+        cx.assert_editor_state(indoc! {"
+            the lazy dog
+            |The quick brown
+            fox jumps over"});
+
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox ju|mps over
+                the lazy dog"},
+            "The qu|ick brown",
+        );
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox jumps over
+                the la|zy dog"},
+            indoc! {"
+                The quick brown
+                fox ju|mps over"},
+        );
+    }
+
     #[gpui::test]
     async fn test_visual_change(cx: &mut gpui::TestAppContext) {
         let cx = VimTestContext::new(cx, true).await;
@@ -290,4 +443,168 @@ mod test {
                 the lazy dog"},
         );
     }
+
+    #[gpui::test]
+    async fn test_visual_line_change(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["shift-V", "c"]).mode_after(Mode::Insert);
+        cx.assert(
+            indoc! {"
+                The qu|ick brown
+                fox jumps over
+                the lazy dog"},
+            indoc! {"
+                |
+                fox jumps over
+                the lazy dog"},
+        );
+        // Test pasting code copied on change
+        cx.simulate_keystrokes(["escape", "j", "p"]);
+        cx.assert_editor_state(indoc! {"
+            
+            fox jumps over
+            |The quick brown
+            the lazy dog"});
+
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox ju|mps over
+                the lazy dog"},
+            indoc! {"
+                The quick brown
+                |
+                the lazy dog"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox jumps over
+                the la|zy dog"},
+            indoc! {"
+                The quick brown
+                fox jumps over
+                |"},
+        );
+        let mut cx = cx.binding(["shift-V", "j", "c"]).mode_after(Mode::Insert);
+        cx.assert(
+            indoc! {"
+                The qu|ick brown
+                fox jumps over
+                the lazy dog"},
+            indoc! {"
+                |
+                the lazy dog"},
+        );
+        // Test pasting code copied on delete
+        cx.simulate_keystrokes(["escape", "j", "p"]);
+        cx.assert_editor_state(indoc! {"
+            
+            the lazy dog
+            |The quick brown
+            fox jumps over"});
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox ju|mps over
+                the lazy dog"},
+            indoc! {"
+                The quick brown
+                |"},
+        );
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox jumps over
+                the la|zy dog"},
+            indoc! {"
+                The quick brown
+                fox jumps over
+                |"},
+        );
+    }
+
+    #[gpui::test]
+    async fn test_visual_yank(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["v", "w", "y"]);
+        cx.assert("The quick |brown", "The quick |brown");
+        cx.assert_clipboard_content(Some("brown"));
+        let mut cx = cx.binding(["v", "w", "j", "y"]);
+        cx.assert(
+            indoc! {"
+                The |quick brown
+                fox jumps over
+                the lazy dog"},
+            indoc! {"
+                The |quick brown
+                fox jumps over
+                the lazy dog"},
+        );
+        cx.assert_clipboard_content(Some(indoc! {"
+            quick brown
+            fox jumps o"}));
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox jumps over
+                the |lazy dog"},
+            indoc! {"
+                The quick brown
+                fox jumps over
+                the |lazy dog"},
+        );
+        cx.assert_clipboard_content(Some("lazy d"));
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox jumps |over
+                the lazy dog"},
+            indoc! {"
+                The quick brown
+                fox jumps |over
+                the lazy dog"},
+        );
+        cx.assert_clipboard_content(Some(indoc! {"
+                over
+                t"}));
+        let mut cx = cx.binding(["v", "b", "k", "y"]);
+        cx.assert(
+            indoc! {"
+                The |quick brown
+                fox jumps over
+                the lazy dog"},
+            indoc! {"
+                |The quick brown
+                fox jumps over
+                the lazy dog"},
+        );
+        cx.assert_clipboard_content(Some("The q"));
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox jumps over
+                the |lazy dog"},
+            indoc! {"
+                The quick brown
+                |fox jumps over
+                the lazy dog"},
+        );
+        cx.assert_clipboard_content(Some(indoc! {"
+            fox jumps over
+            the l"}));
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox jumps |over
+                the lazy dog"},
+            indoc! {"
+                The |quick brown
+                fox jumps over
+                the lazy dog"},
+        );
+        cx.assert_clipboard_content(Some(indoc! {"
+            quick brown
+            fox jumps o"}));
+    }
 }

crates/zed/src/main.rs ๐Ÿ”—

@@ -179,8 +179,8 @@ fn main() {
 
         cx.observe_global::<Settings, _>({
             let languages = languages.clone();
-            move |settings, _| {
-                languages.set_theme(&settings.theme.editor.syntax);
+            move |cx| {
+                languages.set_theme(&cx.global::<Settings>().theme.editor.syntax);
             }
         })
         .detach();