Merge branch 'main' into test-branch

Mikayla Maki created

Change summary

.github/workflows/discord_webhook.yml                               |   22 
.github/workflows/release_actions.yml                               |   33 
.gitignore                                                          |    3 
assets/keymaps/vim.json                                             |   99 
crates/call/src/room.rs                                             |   10 
crates/collab/src/integration_tests.rs                              |    3 
crates/collab_ui/src/contact_list.rs                                |   24 
crates/editor/src/display_map.rs                                    |   85 
crates/editor/src/editor.rs                                         |   46 
crates/editor/src/editor_tests.rs                                   |   14 
crates/editor/src/element.rs                                        |  154 
crates/editor/src/highlight_matching_bracket.rs                     |    3 
crates/editor/src/hover_popover.rs                                  |    4 
crates/editor/src/link_go_to_definition.rs                          |    2 
crates/editor/src/mouse_context_menu.rs                             |    3 
crates/editor/src/movement.rs                                       |  147 
crates/editor/src/test.rs                                           |  457 
crates/editor/src/test/editor_lsp_test_context.rs                   |  208 
crates/editor/src/test/editor_test_context.rs                       |  273 
crates/gpui/Cargo.toml                                              |    1 
crates/gpui/src/app.rs                                              |  631 
crates/gpui/src/app/test_app_context.rs                             |  655 
crates/gpui/src/platform.rs                                         |    1 
crates/gpui/src/platform/mac/platform.rs                            |   10 
crates/gpui/src/platform/test.rs                                    |    4 
crates/gpui/src/test.rs                                             |    2 
crates/gpui_macros/src/gpui_macros.rs                               |    7 
crates/theme/src/theme.rs                                           |    9 
crates/vim/Cargo.toml                                               |   19 
crates/vim/src/insert.rs                                            |    2 
crates/vim/src/motion.rs                                            |  216 
crates/vim/src/normal.rs                                            | 1050 
crates/vim/src/normal/change.rs                                     |  199 
crates/vim/src/normal/delete.rs                                     |   71 
crates/vim/src/normal/yank.rs                                       |   29 
crates/vim/src/object.rs                                            |  640 
crates/vim/src/state.rs                                             |   17 
crates/vim/src/test.rs                                              |  103 
crates/vim/src/test/neovim_backed_binding_test_context.rs           |   80 
crates/vim/src/test/neovim_backed_test_context.rs                   |  158 
crates/vim/src/test/neovim_connection.rs                            |  383 
crates/vim/src/test/vim_binding_test_context.rs                     |   69 
crates/vim/src/test/vim_test_context.rs                             |   80 
crates/vim/src/vim.rs                                               |  115 
crates/vim/src/visual.rs                                            |  428 
crates/vim/test_data/neovim_backed_test_context_works.json          |    1 
crates/vim/test_data/test_a.json                                    |    1 
crates/vim/test_data/test_b.json                                    |    0 
crates/vim/test_data/test_backspace.json                            |    1 
crates/vim/test_data/test_cc.json                                   |    1 
crates/vim/test_data/test_change_sentence_object.json               |    0 
crates/vim/test_data/test_change_surrounding_character_objects.json |    0 
crates/vim/test_data/test_change_word_object.json                   |    0 
crates/vim/test_data/test_dd.json                                   |    1 
crates/vim/test_data/test_delete_left.json                          |    1 
crates/vim/test_data/test_delete_sentence_object.json               |    0 
crates/vim/test_data/test_delete_surrounding_character_objects.json |    0 
crates/vim/test_data/test_delete_to_end_of_line.json                |    1 
crates/vim/test_data/test_delete_word_object.json                   |    0 
crates/vim/test_data/test_e.json                                    |    0 
crates/vim/test_data/test_enter_visual_mode.json                    |    0 
crates/vim/test_data/test_gg.json                                   |    1 
crates/vim/test_data/test_h.json                                    |    1 
crates/vim/test_data/test_insert_end_of_line.json                   |    1 
crates/vim/test_data/test_insert_first_non_whitespace.json          |    1 
crates/vim/test_data/test_insert_line_above.json                    |    1 
crates/vim/test_data/test_j.json                                    |    1 
crates/vim/test_data/test_jump_to_end.json                          |    1 
crates/vim/test_data/test_jump_to_first_non_whitespace.json         |    1 
crates/vim/test_data/test_jump_to_line_boundaries.json              |    0 
crates/vim/test_data/test_k.json                                    |    1 
crates/vim/test_data/test_l.json                                    |    1 
crates/vim/test_data/test_neovim.json                               |    1 
crates/vim/test_data/test_o.json                                    |    1 
crates/vim/test_data/test_p.json                                    |    1 
crates/vim/test_data/test_repeated_cb.json                          |    0 
crates/vim/test_data/test_repeated_ce.json                          |    0 
crates/vim/test_data/test_repeated_cj.json                          |    0 
crates/vim/test_data/test_repeated_cl.json                          |    0 
crates/vim/test_data/test_repeated_word.json                        |    0 
crates/vim/test_data/test_visual_change.json                        |    1 
crates/vim/test_data/test_visual_delete.json                        |    1 
crates/vim/test_data/test_visual_line_change.json                   |    1 
crates/vim/test_data/test_visual_line_delete.json                   |    1 
crates/vim/test_data/test_visual_sentence_object.json               |    0 
crates/vim/test_data/test_visual_word_object.json                   |    0 
crates/vim/test_data/test_w.json                                    |    0 
crates/vim/test_data/test_x.json                                    |    1 
script/amplitude_release/main.py                                    |   30 
script/amplitude_release/requirements.txt                           |    1 
styles/src/styleTree/editor.ts                                      |   19 
styles/src/themes/common/base16.ts                                  |    2 
92 files changed, 4,137 insertions(+), 2,509 deletions(-)

Detailed changes

.github/workflows/discord_webhook.yml ๐Ÿ”—

@@ -1,22 +0,0 @@
-on:
-  release:
-    types: [published]
-    
-jobs:
-  message:
-    runs-on: ubuntu-latest
-    steps:
-    - name: Discord Webhook Action
-      uses: tsickert/discord-webhook@v5.3.0
-      with:
-        webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
-        content: |
-          ๐Ÿ“ฃ Zed ${{ github.event.release.name }} was just released!
-          
-          Restart your Zed or head to https://zed.dev/releases to grab it.
-        
-          ```md
-          ### Changelog
-          
-          ${{ github.event.release.body }}
-          ```

.github/workflows/release_actions.yml ๐Ÿ”—

@@ -0,0 +1,33 @@
+on:
+  release:
+    types: [published]
+
+jobs:
+  discord_release:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Discord Webhook Action
+      uses: tsickert/discord-webhook@v5.3.0
+      with:
+        webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
+        content: |
+          ๐Ÿ“ฃ Zed ${{ github.event.release.tag_name }} was just released!
+          
+          Restart your Zed or head to https://zed.dev/releases to grab it.
+        
+          ```md
+          ### Changelog
+          
+          ${{ github.event.release.body }}
+          ```
+  amplitude_release:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v4
+        with:
+          python-version: "3.10.5"
+          architecture: "x64"
+          cache: "pip"
+      - run: pip install -r script/amplitude_release/requirements.txt
+      - run: python script/amplitude_release/main.py ${{ github.event.release.tag_name }} ${{ secrets.ZED_AMPLITUDE_API_KEY }} ${{ secrets.ZED_AMPLITUDE_SECRET_KEY }}

.gitignore ๐Ÿ”—

@@ -8,4 +8,5 @@
 /vendor/bin
 /assets/themes/*.json
 /assets/themes/internal/*.json
-/assets/themes/experiments/*.json
+/assets/themes/experiments/*.json
+**/venv

assets/keymaps/vim.json ๐Ÿ”—

@@ -9,11 +9,10 @@
                 }
             ],
             "h": "vim::Left",
-            "backspace": "vim::Left",
+            "backspace": "vim::Backspace",
             "j": "vim::Down",
             "k": "vim::Up",
             "l": "vim::Right",
-            "0": "vim::StartOfLine",
             "$": "vim::EndOfLine",
             "shift-g": "vim::EndOfDocument",
             "w": "vim::NextWordStart",
@@ -38,7 +37,60 @@
                 }
             ],
             "%": "vim::Matching",
-            "escape": "editor::Cancel"
+            "escape": "editor::Cancel",
+            "i": [
+                "vim::PushOperator",
+                {
+                    "Object": {
+                        "around": false
+                    }
+                }
+            ],
+            "a": [
+                "vim::PushOperator",
+                {
+                    "Object": {
+                        "around": true
+                    }
+                }
+            ],
+            "0": "vim::StartOfLine", // When no number operator present, use start of line motion
+            "1": [
+                "vim::Number",
+                1
+            ],
+            "2": [
+                "vim::Number",
+                2
+            ],
+            "3": [
+                "vim::Number",
+                3
+            ],
+            "4": [
+                "vim::Number",
+                4
+            ],
+            "5": [
+                "vim::Number",
+                5
+            ],
+            "6": [
+                "vim::Number",
+                6
+            ],
+            "7": [
+                "vim::Number",
+                7
+            ],
+            "8": [
+                "vim::Number",
+                8
+            ],
+            "9": [
+                "vim::Number",
+                9
+            ]
         }
     },
     {
@@ -98,6 +150,15 @@
             ]
         }
     },
+    {
+        "context": "Editor && vim_operator == n",
+        "bindings": {
+            "0": [
+                "vim::Number",
+                0
+            ]
+        }
+    },
     {
         "context": "Editor && vim_operator == g",
         "bindings": {
@@ -112,13 +173,6 @@
     {
         "context": "Editor && vim_operator == c",
         "bindings": {
-            "w": "vim::ChangeWord",
-            "shift-w": [
-                "vim::ChangeWord",
-                {
-                    "ignorePunctuation": true
-                }
-            ],
             "c": "vim::CurrentLine"
         }
     },
@@ -134,9 +188,34 @@
             "y": "vim::CurrentLine"
         }
     },
+    {
+        "context": "Editor && VimObject",
+        "bindings": {
+            "w": "vim::Word",
+            "shift-w": [
+                "vim::Word",
+                {
+                    "ignorePunctuation": true
+                }
+            ],
+            "s": "vim::Sentence",
+            "'": "vim::Quotes",
+            "`": "vim::BackQuotes",
+            "\"": "vim::DoubleQuotes",
+            "(": "vim::Parentheses",
+            ")": "vim::Parentheses",
+            "[": "vim::SquareBrackets",
+            "]": "vim::SquareBrackets",
+            "{": "vim::CurlyBrackets",
+            "}": "vim::CurlyBrackets",
+            "<": "vim::AngleBrackets",
+            ">": "vim::AngleBrackets"
+        }
+    },
     {
         "context": "Editor && vim_mode == visual",
         "bindings": {
+            "u": "editor::Undo",
             "c": "vim::VisualChange",
             "d": "vim::VisualDelete",
             "x": "vim::VisualDelete",

crates/call/src/room.rs ๐Ÿ”—

@@ -398,11 +398,11 @@ impl Room {
         cx.spawn(|this, mut cx| async move {
             let response = request.await?;
 
-            project
-                .update(&mut cx, |project, cx| {
-                    project.shared(response.project_id, cx)
-                })
-                .await?;
+            project.update(&mut cx, |project, cx| {
+                project
+                    .shared(response.project_id, cx)
+                    .detach_and_log_err(cx)
+            });
 
             // If the user's location is in this project, it changes from UnsharedProject to SharedProject.
             this.update(&mut cx, |this, cx| {

crates/collab/src/integration_tests.rs ๐Ÿ”—

@@ -3874,6 +3874,7 @@ async fn test_language_server_statuses(
         .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
         .await
         .unwrap();
+    deterministic.run_until_parked();
     let project_b = client_b.build_remote_project(project_id, cx_b).await;
     project_b.read_with(cx_b, |project, _| {
         let status = project.language_server_statuses().next().unwrap();
@@ -5522,6 +5523,7 @@ async fn test_random_collaboration(
         cx.font_cache(),
         cx.leak_detector(),
         next_entity_id,
+        cx.function_name.clone(),
     );
     let host = server.create_client(&mut host_cx, "host").await;
     let host_project = host_cx.update(|cx| {
@@ -5763,6 +5765,7 @@ async fn test_random_collaboration(
                     cx.font_cache(),
                     cx.leak_detector(),
                     next_entity_id,
+                    cx.function_name.clone(),
                 );
 
                 deterministic.start_waiting();

crates/collab_ui/src/contact_list.rs ๐Ÿ”—

@@ -65,7 +65,6 @@ enum ContactEntry {
         project_id: u64,
         worktree_root_names: Vec<String>,
         host_user_id: u64,
-        is_host: bool,
         is_last: bool,
     },
     IncomingRequest(Arc<User>),
@@ -181,6 +180,7 @@ impl ContactList {
         let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
             let theme = cx.global::<Settings>().theme.clone();
             let is_selected = this.selection == Some(ix);
+            let current_project_id = this.project.read(cx).remote_id();
 
             match &this.entries[ix] {
                 ContactEntry::Header(section) => {
@@ -205,13 +205,12 @@ impl ContactList {
                     project_id,
                     worktree_root_names,
                     host_user_id,
-                    is_host,
                     is_last,
                 } => Self::render_participant_project(
                     *project_id,
                     worktree_root_names,
                     *host_user_id,
-                    *is_host,
+                    Some(*project_id) == current_project_id,
                     *is_last,
                     is_selected,
                     &theme.contact_list,
@@ -341,15 +340,12 @@ impl ContactList {
                     ContactEntry::ParticipantProject {
                         project_id,
                         host_user_id,
-                        is_host,
                         ..
                     } => {
-                        if !is_host {
-                            cx.dispatch_global_action(JoinProject {
-                                project_id: *project_id,
-                                follow_user_id: *host_user_id,
-                            });
-                        }
+                        cx.dispatch_global_action(JoinProject {
+                            project_id: *project_id,
+                            follow_user_id: *host_user_id,
+                        });
                     }
                     _ => {}
                 }
@@ -407,7 +403,6 @@ impl ContactList {
                             project_id: project.id,
                             worktree_root_names: project.worktree_root_names.clone(),
                             host_user_id: user_id,
-                            is_host: true,
                             is_last: projects.peek().is_none(),
                         });
                     }
@@ -448,7 +443,6 @@ impl ContactList {
                         project_id: project.id,
                         worktree_root_names: project.worktree_root_names.clone(),
                         host_user_id: participant.user.id,
-                        is_host: false,
                         is_last: projects.peek().is_none(),
                     });
                 }
@@ -667,7 +661,7 @@ impl ContactList {
         project_id: u64,
         worktree_root_names: &[String],
         host_user_id: u64,
-        is_host: bool,
+        is_current: bool,
         is_last: bool,
         is_selected: bool,
         theme: &theme::ContactList,
@@ -749,13 +743,13 @@ impl ContactList {
                 .with_style(row.container)
                 .boxed()
         })
-        .with_cursor_style(if !is_host {
+        .with_cursor_style(if !is_current {
             CursorStyle::PointingHand
         } else {
             CursorStyle::Arrow
         })
         .on_click(MouseButton::Left, move |_, cx| {
-            if !is_host {
+            if !is_current {
                 cx.dispatch_global_action(JoinProject {
                     project_id,
                     follow_user_id: host_user_id,

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

@@ -331,34 +331,91 @@ impl DisplaySnapshot {
         DisplayPoint(self.blocks_snapshot.max_point())
     }
 
+    /// Returns text chunks starting at the given display row until the end of the file
     pub fn text_chunks(&self, display_row: u32) -> impl Iterator<Item = &str> {
         self.blocks_snapshot
             .chunks(display_row..self.max_point().row() + 1, false, None)
             .map(|h| h.text)
     }
 
+    // Returns text chunks starting at the end of the given display row in reverse until the start of the file
+    pub fn reverse_text_chunks(&self, display_row: u32) -> impl Iterator<Item = &str> {
+        (0..=display_row).into_iter().rev().flat_map(|row| {
+            self.blocks_snapshot
+                .chunks(row..row + 1, false, None)
+                .map(|h| h.text)
+                .collect::<Vec<_>>()
+                .into_iter()
+                .rev()
+        })
+    }
+
     pub fn chunks(&self, display_rows: Range<u32>, language_aware: bool) -> DisplayChunks<'_> {
         self.blocks_snapshot
             .chunks(display_rows, language_aware, Some(&self.text_highlights))
     }
 
-    pub fn chars_at(&self, point: DisplayPoint) -> impl Iterator<Item = char> + '_ {
-        let mut column = 0;
-        let mut chars = self.text_chunks(point.row()).flat_map(str::chars);
-        while column < point.column() {
-            if let Some(c) = chars.next() {
-                column += c.len_utf8() as u32;
-            } else {
-                break;
-            }
-        }
-        chars
+    pub fn chars_at(
+        &self,
+        mut point: DisplayPoint,
+    ) -> impl Iterator<Item = (char, DisplayPoint)> + '_ {
+        point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left));
+        self.text_chunks(point.row())
+            .flat_map(str::chars)
+            .skip_while({
+                let mut column = 0;
+                move |char| {
+                    let at_point = column >= point.column();
+                    column += char.len_utf8() as u32;
+                    !at_point
+                }
+            })
+            .map(move |ch| {
+                let result = (ch, point);
+                if ch == '\n' {
+                    *point.row_mut() += 1;
+                    *point.column_mut() = 0;
+                } else {
+                    *point.column_mut() += ch.len_utf8() as u32;
+                }
+                result
+            })
+    }
+
+    pub fn reverse_chars_at(
+        &self,
+        mut point: DisplayPoint,
+    ) -> impl Iterator<Item = (char, DisplayPoint)> + '_ {
+        point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left));
+        self.reverse_text_chunks(point.row())
+            .flat_map(|chunk| chunk.chars().rev())
+            .skip_while({
+                let mut column = self.line_len(point.row());
+                if self.max_point().row() > point.row() {
+                    column += 1;
+                }
+
+                move |char| {
+                    let at_point = column <= point.column();
+                    column = column.saturating_sub(char.len_utf8() as u32);
+                    !at_point
+                }
+            })
+            .map(move |ch| {
+                if ch == '\n' {
+                    *point.row_mut() -= 1;
+                    *point.column_mut() = self.line_len(point.row());
+                } else {
+                    *point.column_mut() = point.column().saturating_sub(ch.len_utf8() as u32);
+                }
+                (ch, point)
+            })
     }
 
     pub fn column_to_chars(&self, display_row: u32, target: u32) -> u32 {
         let mut count = 0;
         let mut column = 0;
-        for c in self.chars_at(DisplayPoint::new(display_row, 0)) {
+        for (c, _) in self.chars_at(DisplayPoint::new(display_row, 0)) {
             if column >= target {
                 break;
             }
@@ -371,7 +428,7 @@ impl DisplaySnapshot {
     pub fn column_from_chars(&self, display_row: u32, char_count: u32) -> u32 {
         let mut column = 0;
 
-        for (count, c) in self.chars_at(DisplayPoint::new(display_row, 0)).enumerate() {
+        for (count, (c, _)) in self.chars_at(DisplayPoint::new(display_row, 0)).enumerate() {
             if c == '\n' || count >= char_count as usize {
                 break;
             }
@@ -455,7 +512,7 @@ impl DisplaySnapshot {
     pub fn line_indent(&self, display_row: u32) -> (u32, bool) {
         let mut indent = 0;
         let mut is_blank = true;
-        for c in self.chars_at(DisplayPoint::new(display_row, 0)) {
+        for (c, _) in self.chars_at(DisplayPoint::new(display_row, 0)) {
             if c == ' ' {
                 indent += 1;
             } else {

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

@@ -77,6 +77,7 @@ use util::{post_inc, ResultExt, TryFutureExt};
 use workspace::{ItemNavHistory, Workspace};
 
 const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
+const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
 const MAX_LINE_LEN: usize = 1024;
 const MIN_NAVIGATION_HISTORY_ROW_DELTA: i64 = 10;
 const MAX_SELECTION_HISTORY_LEN: usize = 1024;
@@ -239,6 +240,9 @@ pub enum Direction {
     Next,
 }
 
+#[derive(Default)]
+struct ScrollbarAutoHide(bool);
+
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(Editor::new_file);
     cx.add_action(|this: &mut Editor, action: &Scroll, cx| this.set_scroll_position(action.0, cx));
@@ -428,6 +432,8 @@ pub struct Editor {
     focused: bool,
     show_local_cursors: bool,
     show_local_selections: bool,
+    show_scrollbars: bool,
+    hide_scrollbar_task: Option<Task<()>>,
     blink_epoch: usize,
     blinking_paused: bool,
     mode: EditorMode,
@@ -1030,6 +1036,8 @@ impl Editor {
             focused: false,
             show_local_cursors: false,
             show_local_selections: true,
+            show_scrollbars: true,
+            hide_scrollbar_task: None,
             blink_epoch: 0,
             blinking_paused: false,
             mode,
@@ -1062,10 +1070,16 @@ impl Editor {
             ],
         };
         this.end_selection(cx);
+        this.make_scrollbar_visible(cx);
 
         let editor_created_event = EditorCreated(cx.handle());
         cx.emit_global(editor_created_event);
 
+        if mode == EditorMode::Full {
+            let should_auto_hide_scrollbars = cx.platform().should_auto_hide_scrollbars();
+            cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars));
+        }
+
         this.report_event("open editor", cx);
         this
     }
@@ -1182,6 +1196,7 @@ impl Editor {
             self.scroll_top_anchor = anchor;
         }
 
+        self.make_scrollbar_visible(cx);
         self.autoscroll_request.take();
         hide_hover(self, cx);
 
@@ -1257,7 +1272,7 @@ impl Editor {
         let max_scroll_top = if matches!(self.mode, EditorMode::AutoHeight { .. }) {
             (display_map.max_point().row() as f32 - visible_lines + 1.).max(0.)
         } else {
-            display_map.max_point().row().saturating_sub(1) as f32
+            display_map.max_point().row() as f32
         };
         if scroll_position.y() > max_scroll_top {
             scroll_position.set_y(max_scroll_top);
@@ -4081,7 +4096,7 @@ impl Editor {
         self.change_selections(Some(Autoscroll::Fit), cx, |s| {
             s.move_cursors_with(|map, head, _| {
                 (
-                    movement::line_beginning(map, head, true),
+                    movement::indented_line_beginning(map, head, true),
                     SelectionGoal::None,
                 )
             });
@@ -4096,7 +4111,7 @@ impl Editor {
         self.change_selections(Some(Autoscroll::Fit), cx, |s| {
             s.move_heads_with(|map, head, _| {
                 (
-                    movement::line_beginning(map, head, action.stop_at_soft_wraps),
+                    movement::indented_line_beginning(map, head, action.stop_at_soft_wraps),
                     SelectionGoal::None,
                 )
             });
@@ -5953,6 +5968,31 @@ impl Editor {
         self.show_local_cursors && self.focused
     }
 
+    pub fn show_scrollbars(&self) -> bool {
+        self.show_scrollbars
+    }
+
+    fn make_scrollbar_visible(&mut self, cx: &mut ViewContext<Self>) {
+        if !self.show_scrollbars {
+            self.show_scrollbars = true;
+            cx.notify();
+        }
+
+        if cx.default_global::<ScrollbarAutoHide>().0 {
+            self.hide_scrollbar_task = Some(cx.spawn_weak(|this, mut cx| async move {
+                Timer::after(SCROLLBAR_SHOW_INTERVAL).await;
+                if let Some(this) = this.upgrade(&cx) {
+                    this.update(&mut cx, |this, cx| {
+                        this.show_scrollbars = false;
+                        cx.notify();
+                    });
+                }
+            }));
+        } else {
+            self.hide_scrollbar_task = None;
+        }
+    }
+
     fn on_buffer_changed(&mut self, _: ModelHandle<MultiBuffer>, cx: &mut ViewContext<Self>) {
         cx.notify();
     }

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

@@ -1,20 +1,22 @@
+use std::{cell::RefCell, rc::Rc, time::Instant};
+
+use futures::StreamExt;
+use indoc::indoc;
+use unindent::Unindent;
+
 use super::*;
 use crate::test::{
-    assert_text_with_selections, build_editor, select_ranges, EditorLspTestContext,
-    EditorTestContext,
+    assert_text_with_selections, build_editor, editor_lsp_test_context::EditorLspTestContext,
+    editor_test_context::EditorTestContext, select_ranges,
 };
-use futures::StreamExt;
 use gpui::{
     geometry::rect::RectF,
     platform::{WindowBounds, WindowOptions},
 };
-use indoc::indoc;
 use language::{FakeLspAdapter, LanguageConfig, LanguageRegistry};
 use project::FakeFs;
 use rope::point::Point;
 use settings::EditorSettings;
-use std::{cell::RefCell, rc::Rc, time::Instant};
-use unindent::Unindent;
 use util::{
     assert_set_eq,
     test::{marked_text_ranges, marked_text_ranges_by, sample_text, TextRangeMarker},

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

@@ -44,7 +44,7 @@ use std::{
     cmp::{self, Ordering},
     fmt::Write,
     iter,
-    ops::Range,
+    ops::{DerefMut, Range},
     sync::Arc,
 };
 use theme::DiffStyle;
@@ -455,7 +455,6 @@ impl EditorElement {
         let bounds = gutter_bounds.union_rect(text_bounds);
         let scroll_top =
             layout.position_map.snapshot.scroll_position().y() * layout.position_map.line_height;
-        let editor = self.view(cx.app);
         cx.scene.push_quad(Quad {
             bounds: gutter_bounds,
             background: Some(self.style.gutter_background),
@@ -469,7 +468,7 @@ impl EditorElement {
             corner_radius: 0.,
         });
 
-        if let EditorMode::Full = editor.mode {
+        if let EditorMode::Full = layout.mode {
             let mut active_rows = layout.active_rows.iter().peekable();
             while let Some((start_row, contains_non_empty_selection)) = active_rows.next() {
                 let mut end_row = *start_row;
@@ -753,7 +752,7 @@ impl EditorElement {
                                 .snapshot
                                 .chars_at(cursor_position)
                                 .next()
-                                .and_then(|character| {
+                                .and_then(|(character, _)| {
                                     let font_id =
                                         cursor_row_layout.font_for_index(cursor_column)?;
                                     let text = character.to_string();
@@ -910,6 +909,119 @@ impl EditorElement {
         cx.scene.pop_layer();
     }
 
+    fn paint_scrollbar(&mut self, bounds: RectF, layout: &mut LayoutState, cx: &mut PaintContext) {
+        enum ScrollbarMouseHandlers {}
+        if layout.mode != EditorMode::Full {
+            return;
+        }
+
+        let view = self.view.clone();
+        let style = &self.style.theme.scrollbar;
+
+        let top = bounds.min_y();
+        let bottom = bounds.max_y();
+        let right = bounds.max_x();
+        let left = right - style.width;
+        let row_range = &layout.scrollbar_row_range;
+        let max_row = layout.max_row as f32 + (row_range.end - row_range.start);
+
+        let mut height = bounds.height();
+        let mut first_row_y_offset = 0.0;
+
+        // Impose a minimum height on the scrollbar thumb
+        let min_thumb_height =
+            style.min_height_factor * cx.font_cache.line_height(self.style.text.font_size);
+        let thumb_height = (row_range.end - row_range.start) * height / max_row;
+        if thumb_height < min_thumb_height {
+            first_row_y_offset = (min_thumb_height - thumb_height) / 2.0;
+            height -= min_thumb_height - thumb_height;
+        }
+
+        let y_for_row = |row: f32| -> f32 { top + first_row_y_offset + row * height / max_row };
+
+        let thumb_top = y_for_row(row_range.start) - first_row_y_offset;
+        let thumb_bottom = y_for_row(row_range.end) + first_row_y_offset;
+        let track_bounds = RectF::from_points(vec2f(left, top), vec2f(right, bottom));
+        let thumb_bounds = RectF::from_points(vec2f(left, thumb_top), vec2f(right, thumb_bottom));
+
+        if layout.show_scrollbars {
+            cx.scene.push_quad(Quad {
+                bounds: track_bounds,
+                border: style.track.border,
+                background: style.track.background_color,
+                ..Default::default()
+            });
+            cx.scene.push_quad(Quad {
+                bounds: thumb_bounds,
+                border: style.thumb.border,
+                background: style.thumb.background_color,
+                corner_radius: style.thumb.corner_radius,
+            });
+        }
+
+        cx.scene.push_cursor_region(CursorRegion {
+            bounds: track_bounds,
+            style: CursorStyle::Arrow,
+        });
+        cx.scene.push_mouse_region(
+            MouseRegion::new::<ScrollbarMouseHandlers>(view.id(), view.id(), track_bounds)
+                .on_move({
+                    let view = view.clone();
+                    move |_, cx| {
+                        if let Some(view) = view.upgrade(cx.deref_mut()) {
+                            view.update(cx.deref_mut(), |view, cx| {
+                                view.make_scrollbar_visible(cx);
+                            });
+                        }
+                    }
+                })
+                .on_down(MouseButton::Left, {
+                    let view = view.clone();
+                    let row_range = row_range.clone();
+                    move |e, cx| {
+                        let y = e.position.y();
+                        if let Some(view) = view.upgrade(cx.deref_mut()) {
+                            view.update(cx.deref_mut(), |view, cx| {
+                                if y < thumb_top || thumb_bottom < y {
+                                    let center_row =
+                                        ((y - top) * max_row as f32 / height).round() as u32;
+                                    let top_row = center_row.saturating_sub(
+                                        (row_range.end - row_range.start) as u32 / 2,
+                                    );
+                                    let mut position = view.scroll_position(cx);
+                                    position.set_y(top_row as f32);
+                                    view.set_scroll_position(position, cx);
+                                } else {
+                                    view.make_scrollbar_visible(cx);
+                                }
+                            });
+                        }
+                    }
+                })
+                .on_drag(MouseButton::Left, {
+                    let view = view.clone();
+                    move |e, cx| {
+                        let y = e.prev_mouse_position.y();
+                        let new_y = e.position.y();
+                        if thumb_top < y && y < thumb_bottom {
+                            if let Some(view) = view.upgrade(cx.deref_mut()) {
+                                view.update(cx.deref_mut(), |view, cx| {
+                                    let mut position = view.scroll_position(cx);
+                                    position.set_y(
+                                        position.y() + (new_y - y) * (max_row as f32) / height,
+                                    );
+                                    if position.y() < 0.0 {
+                                        position.set_y(0.);
+                                    }
+                                    view.set_scroll_position(position, cx);
+                                });
+                            }
+                        }
+                    }
+                }),
+        );
+    }
+
     #[allow(clippy::too_many_arguments)]
     fn paint_highlighted_range(
         &self,
@@ -1470,13 +1582,11 @@ impl Element for EditorElement {
         // The scroll position is a fractional point, the whole number of which represents
         // the top of the window in terms of display rows.
         let start_row = scroll_position.y() as u32;
-        let scroll_top = scroll_position.y() * line_height;
+        let visible_row_count = (size.y() / line_height).ceil() as u32;
+        let max_row = snapshot.max_point().row();
 
         // Add 1 to ensure selections bleed off screen
-        let end_row = 1 + cmp::min(
-            ((scroll_top + size.y()) / line_height).ceil() as u32,
-            snapshot.max_point().row(),
-        );
+        let end_row = 1 + cmp::min(start_row + visible_row_count, max_row);
 
         let start_anchor = if start_row == 0 {
             Anchor::min()
@@ -1485,7 +1595,7 @@ impl Element for EditorElement {
                 .buffer_snapshot
                 .anchor_before(DisplayPoint::new(start_row, 0).to_offset(&snapshot, Bias::Left))
         };
-        let end_anchor = if end_row > snapshot.max_point().row() {
+        let end_anchor = if end_row > max_row {
             Anchor::max()
         } else {
             snapshot
@@ -1497,6 +1607,7 @@ impl Element for EditorElement {
         let mut active_rows = BTreeMap::new();
         let mut highlighted_rows = None;
         let mut highlighted_ranges = Vec::new();
+        let mut show_scrollbars = false;
         self.update_view(cx.app, |view, cx| {
             let display_map = view.display_map.update(cx, |map, cx| map.snapshot(cx));
 
@@ -1557,6 +1668,8 @@ impl Element for EditorElement {
                         .collect(),
                 ));
             }
+
+            show_scrollbars = view.show_scrollbars();
         });
 
         let line_number_layouts =
@@ -1567,6 +1680,9 @@ impl Element for EditorElement {
             .git_diff_hunks_in_range(start_row..end_row)
             .collect();
 
+        let scrollbar_row_range =
+            scroll_position.y()..(scroll_position.y() + visible_row_count as f32);
+
         let mut max_visible_line_width = 0.0;
         let line_layouts = self.layout_lines(start_row..end_row, &snapshot, cx);
         for line in &line_layouts {
@@ -1600,10 +1716,9 @@ impl Element for EditorElement {
             cx,
         );
 
-        let max_row = snapshot.max_point().row();
         let scroll_max = vec2f(
             ((scroll_width - text_size.x()) / em_width).max(0.0),
-            max_row.saturating_sub(1) as f32,
+            max_row as f32,
         );
 
         self.update_view(cx.app, |view, cx| {
@@ -1630,6 +1745,7 @@ impl Element for EditorElement {
         let mut context_menu = None;
         let mut code_actions_indicator = None;
         let mut hover = None;
+        let mut mode = EditorMode::Full;
         cx.render(&self.view.upgrade(cx).unwrap(), |view, cx| {
             let newest_selection_head = view
                 .selections
@@ -1651,6 +1767,7 @@ impl Element for EditorElement {
 
             let visible_rows = start_row..start_row + line_layouts.len() as u32;
             hover = view.hover_state.render(&snapshot, &style, visible_rows, cx);
+            mode = view.mode;
         });
 
         if let Some((_, context_menu)) = context_menu.as_mut() {
@@ -1698,6 +1815,7 @@ impl Element for EditorElement {
         (
             size,
             LayoutState {
+                mode,
                 position_map: Arc::new(PositionMap {
                     size,
                     scroll_max,
@@ -1710,6 +1828,9 @@ impl Element for EditorElement {
                 gutter_size,
                 gutter_padding,
                 text_size,
+                scrollbar_row_range,
+                show_scrollbars,
+                max_row,
                 gutter_margin,
                 active_rows,
                 highlighted_rows,
@@ -1757,11 +1878,12 @@ impl Element for EditorElement {
         }
         self.paint_text(text_bounds, visible_bounds, layout, cx);
 
+        cx.scene.push_layer(Some(bounds));
         if !layout.blocks.is_empty() {
-            cx.scene.push_layer(Some(bounds));
             self.paint_blocks(bounds, visible_bounds, layout, cx);
-            cx.scene.pop_layer();
         }
+        self.paint_scrollbar(bounds, layout, cx);
+        cx.scene.pop_layer();
 
         cx.scene.pop_layer();
     }
@@ -1847,12 +1969,16 @@ pub struct LayoutState {
     gutter_padding: f32,
     gutter_margin: f32,
     text_size: Vector2F,
+    mode: EditorMode,
     active_rows: BTreeMap<u32, bool>,
     highlighted_rows: Option<Range<u32>>,
     line_number_layouts: Vec<Option<text_layout::Line>>,
     blocks: Vec<BlockLayout>,
     highlighted_ranges: Vec<(Range<DisplayPoint>, Color)>,
     selections: Vec<(ReplicaId, Vec<SelectionLayout>)>,
+    scrollbar_row_range: Range<f32>,
+    show_scrollbars: bool,
+    max_row: u32,
     context_menu: Option<(DisplayPoint, ElementBox)>,
     diff_hunks: Vec<DiffHunk<u32>>,
     code_actions_indicator: Option<(u32, ElementBox)>,

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

@@ -32,8 +32,9 @@ pub fn refresh_matching_bracket_highlights(editor: &mut Editor, cx: &mut ViewCon
 
 #[cfg(test)]
 mod tests {
+    use crate::test::editor_lsp_test_context::EditorLspTestContext;
+
     use super::*;
-    use crate::test::EditorLspTestContext;
     use indoc::indoc;
     use language::{BracketPair, Language, LanguageConfig};
 

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

@@ -427,13 +427,13 @@ impl DiagnosticPopover {
 
 #[cfg(test)]
 mod tests {
-    use futures::StreamExt;
     use indoc::indoc;
 
     use language::{Diagnostic, DiagnosticSet};
     use project::HoverBlock;
+    use smol::stream::StreamExt;
 
-    use crate::test::EditorLspTestContext;
+    use crate::test::editor_lsp_test_context::EditorLspTestContext;
 
     use super::*;
 
@@ -400,7 +400,7 @@ mod tests {
     use indoc::indoc;
     use lsp::request::{GotoDefinition, GotoTypeDefinition};
 
-    use crate::test::EditorLspTestContext;
+    use crate::test::editor_lsp_test_context::EditorLspTestContext;
 
     use super::*;
 

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

@@ -70,8 +70,9 @@ pub fn deploy_context_menu(
 
 #[cfg(test)]
 mod tests {
+    use crate::test::editor_lsp_test_context::EditorLspTestContext;
+
     use super::*;
-    use crate::test::EditorLspTestContext;
     use indoc::indoc;
 
     #[gpui::test]

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

@@ -102,6 +102,22 @@ pub fn line_beginning(
     map: &DisplaySnapshot,
     display_point: DisplayPoint,
     stop_at_soft_boundaries: bool,
+) -> DisplayPoint {
+    let point = display_point.to_point(map);
+    let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right);
+    let line_start = map.prev_line_boundary(point).1;
+
+    if stop_at_soft_boundaries && display_point != soft_line_start {
+        soft_line_start
+    } else {
+        line_start
+    }
+}
+
+pub fn indented_line_beginning(
+    map: &DisplaySnapshot,
+    display_point: DisplayPoint,
+    stop_at_soft_boundaries: bool,
 ) -> DisplayPoint {
     let point = display_point.to_point(map);
     let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right);
@@ -168,54 +184,79 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo
     })
 }
 
-/// Scans for a boundary from the start of each line preceding the given end point until a boundary
-/// is found, indicated by the given predicate returning true. The predicate is called with the
-/// character to the left and right of the candidate boundary location, and will be called with `\n`
-/// characters indicating the start or end of a line. If the predicate returns true multiple times
-/// on a line, the *rightmost* boundary is returned.
+/// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the
+/// given predicate returning true. The predicate is called with the character to the left and right
+/// of the candidate boundary location, and will be called with `\n` characters indicating the start
+/// or end of a line.
 pub fn find_preceding_boundary(
     map: &DisplaySnapshot,
-    end: DisplayPoint,
+    from: DisplayPoint,
     mut is_boundary: impl FnMut(char, char) -> bool,
 ) -> DisplayPoint {
-    let mut point = end;
-    loop {
-        *point.column_mut() = 0;
-        if point.row() > 0 {
-            if let Some(indent) = map.soft_wrap_indent(point.row() - 1) {
-                *point.column_mut() = indent;
+    let mut start_column = 0;
+    let mut soft_wrap_row = from.row() + 1;
+
+    let mut prev = None;
+    for (ch, point) in map.reverse_chars_at(from) {
+        // Recompute soft_wrap_indent if the row has changed
+        if point.row() != soft_wrap_row {
+            soft_wrap_row = point.row();
+
+            if point.row() == 0 {
+                start_column = 0;
+            } else if let Some(indent) = map.soft_wrap_indent(point.row() - 1) {
+                start_column = indent;
             }
         }
 
-        let mut boundary = None;
-        let mut prev_ch = if point.is_zero() { None } else { Some('\n') };
-        for ch in map.chars_at(point) {
-            if point >= end {
-                break;
-            }
+        // If the current point is in the soft_wrap, skip comparing it
+        if point.column() < start_column {
+            continue;
+        }
 
-            if let Some(prev_ch) = prev_ch {
-                if is_boundary(prev_ch, ch) {
-                    boundary = Some(point);
-                }
+        if let Some((prev_ch, prev_point)) = prev {
+            if is_boundary(ch, prev_ch) {
+                return prev_point;
             }
+        }
 
-            if ch == '\n' {
-                break;
-            }
+        prev = Some((ch, point));
+    }
+    DisplayPoint::zero()
+}
 
-            prev_ch = Some(ch);
-            *point.column_mut() += ch.len_utf8() as u32;
+/// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the
+/// given predicate returning true. The predicate is called with the character to the left and right
+/// of the candidate boundary location, and will be called with `\n` characters indicating the start
+/// or end of a line. If no boundary is found, the start of the line is returned.
+pub fn find_preceding_boundary_in_line(
+    map: &DisplaySnapshot,
+    from: DisplayPoint,
+    mut is_boundary: impl FnMut(char, char) -> bool,
+) -> DisplayPoint {
+    let mut start_column = 0;
+    if from.row() > 0 {
+        if let Some(indent) = map.soft_wrap_indent(from.row() - 1) {
+            start_column = indent;
         }
+    }
 
-        if let Some(boundary) = boundary {
-            return boundary;
-        } else if point.row() == 0 {
-            return DisplayPoint::zero();
-        } else {
-            *point.row_mut() -= 1;
+    let mut prev = None;
+    for (ch, point) in map.reverse_chars_at(from) {
+        if let Some((prev_ch, prev_point)) = prev {
+            if is_boundary(ch, prev_ch) {
+                return prev_point;
+            }
         }
+
+        if ch == '\n' || point.column() < start_column {
+            break;
+        }
+
+        prev = Some((ch, point));
     }
+
+    prev.map(|(_, point)| point).unwrap_or(from)
 }
 
 /// Scans for a boundary following the given start point until a boundary is found, indicated by the
@@ -224,26 +265,48 @@ pub fn find_preceding_boundary(
 /// or end of a line.
 pub fn find_boundary(
     map: &DisplaySnapshot,
-    mut point: DisplayPoint,
+    from: DisplayPoint,
     mut is_boundary: impl FnMut(char, char) -> bool,
 ) -> DisplayPoint {
     let mut prev_ch = None;
-    for ch in map.chars_at(point) {
+    for (ch, point) in map.chars_at(from) {
         if let Some(prev_ch) = prev_ch {
             if is_boundary(prev_ch, ch) {
-                break;
+                return map.clip_point(point, Bias::Right);
+            }
+        }
+
+        prev_ch = Some(ch);
+    }
+    map.clip_point(map.max_point(), Bias::Right)
+}
+
+/// Scans for a boundary following the given start point until a boundary is found, indicated by the
+/// given predicate returning true. The predicate is called with the character to the left and right
+/// of the candidate boundary location, and will be called with `\n` characters indicating the start
+/// or end of a line. If no boundary is found, the end of the line is returned
+pub fn find_boundary_in_line(
+    map: &DisplaySnapshot,
+    from: DisplayPoint,
+    mut is_boundary: impl FnMut(char, char) -> bool,
+) -> DisplayPoint {
+    let mut prev = None;
+    for (ch, point) in map.chars_at(from) {
+        if let Some((prev_ch, _)) = prev {
+            if is_boundary(prev_ch, ch) {
+                return map.clip_point(point, Bias::Right);
             }
         }
 
+        prev = Some((ch, point));
+
         if ch == '\n' {
-            *point.row_mut() += 1;
-            *point.column_mut() = 0;
-        } else {
-            *point.column_mut() += ch.len_utf8() as u32;
+            break;
         }
-        prev_ch = Some(ch);
     }
-    map.clip_point(point, Bias::Right)
+
+    // Return the last position checked so that we give a point right before the newline or eof.
+    map.clip_point(prev.map(|(_, point)| point).unwrap_or(from), Bias::Right)
 }
 
 pub fn is_inside_word(map: &DisplaySnapshot, point: DisplayPoint) -> bool {

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

@@ -1,28 +1,14 @@
+pub mod editor_lsp_test_context;
+pub mod editor_test_context;
+
 use crate::{
     display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint},
-    multi_buffer::ToPointUtf16,
-    AnchorRangeExt, Autoscroll, DisplayPoint, Editor, EditorMode, MultiBuffer, ToPoint,
-};
-use anyhow::Result;
-use futures::{Future, StreamExt};
-use gpui::{
-    json, keymap::Keystroke, AppContext, ModelContext, ModelHandle, ViewContext, ViewHandle,
-};
-use indoc::indoc;
-use language::{point_to_lsp, Buffer, BufferSnapshot, FakeLspAdapter, Language, LanguageConfig};
-use lsp::{notification, request};
-use project::Project;
-use settings::Settings;
-use std::{
-    any::TypeId,
-    ops::{Deref, DerefMut, Range},
-    sync::Arc,
-};
-use util::{
-    assert_set_eq, set_eq,
-    test::{generate_marked_text, marked_text_offsets, marked_text_ranges},
+    DisplayPoint, Editor, EditorMode, MultiBuffer,
 };
-use workspace::{pane, AppState, Workspace, WorkspaceHandle};
+
+use gpui::{ModelHandle, ViewContext};
+
+use util::test::{marked_text_offsets, marked_text_ranges};
 
 #[cfg(test)]
 #[ctor::ctor]
@@ -80,430 +66,3 @@ pub(crate) fn build_editor(
 ) -> Editor {
     Editor::new(EditorMode::Full, buffer, 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 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 condition(
-        &self,
-        predicate: impl FnMut(&Editor, &AppContext) -> bool,
-    ) -> impl Future<Output = ()> {
-        self.editor.condition(self.cx, predicate)
-    }
-
-    pub fn editor<F, T>(&self, read: F) -> T
-    where
-        F: FnOnce(&Editor, &AppContext) -> T,
-    {
-        self.editor.read_with(self.cx, read)
-    }
-
-    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 multibuffer<F, T>(&self, read: F) -> T
-    where
-        F: FnOnce(&MultiBuffer, &AppContext) -> T,
-    {
-        self.editor(|editor, cx| read(editor.buffer().read(cx), cx))
-    }
-
-    pub fn update_multibuffer<F, T>(&mut self, update: F) -> T
-    where
-        F: FnOnce(&mut MultiBuffer, &mut ModelContext<MultiBuffer>) -> T,
-    {
-        self.update_editor(|editor, cx| editor.buffer().update(cx, update))
-    }
-
-    pub fn buffer_text(&self) -> String {
-        self.multibuffer(|buffer, cx| buffer.snapshot(cx).text())
-    }
-
-    pub fn buffer<F, T>(&self, read: F) -> T
-    where
-        F: FnOnce(&Buffer, &AppContext) -> T,
-    {
-        self.multibuffer(|multibuffer, cx| {
-            let buffer = multibuffer.as_singleton().unwrap().read(cx);
-            read(buffer, cx)
-        })
-    }
-
-    pub fn update_buffer<F, T>(&mut self, update: F) -> T
-    where
-        F: FnOnce(&mut Buffer, &mut ModelContext<Buffer>) -> T,
-    {
-        self.update_multibuffer(|multibuffer, cx| {
-            let buffer = multibuffer.as_singleton().unwrap();
-            buffer.update(cx, update)
-        })
-    }
-
-    pub fn buffer_snapshot(&self) -> BufferSnapshot {
-        self.buffer(|buffer, _| buffer.snapshot())
-    }
-
-    pub fn simulate_keystroke(&mut self, keystroke_text: &str) {
-        let keystroke = Keystroke::parse(keystroke_text).unwrap();
-        self.cx.dispatch_keystroke(self.window_id, keystroke, 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 ranges(&self, marked_text: &str) -> Vec<Range<usize>> {
-        let (unmarked_text, ranges) = marked_text_ranges(marked_text, false);
-        assert_eq!(self.buffer_text(), unmarked_text);
-        ranges
-    }
-
-    pub fn display_point(&mut self, marked_text: &str) -> DisplayPoint {
-        let ranges = self.ranges(marked_text);
-        let snapshot = self
-            .editor
-            .update(self.cx, |editor, cx| editor.snapshot(cx));
-        ranges[0].start.to_display_point(&snapshot)
-    }
-
-    // Returns anchors for the current buffer using `ยซ` and `ยป`
-    pub fn text_anchor_range(&self, marked_text: &str) -> Range<language::Anchor> {
-        let ranges = self.ranges(marked_text);
-        let snapshot = self.buffer_snapshot();
-        snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end)
-    }
-
-    /// Change the editor's text and selections using a string containing
-    /// embedded range markers that represent the ranges and directions of
-    /// each selection.
-    ///
-    /// See the `util::test::marked_text_ranges` function for more information.
-    pub fn set_state(&mut self, marked_text: &str) {
-        let (unmarked_text, selection_ranges) = marked_text_ranges(marked_text, true);
-        self.editor.update(self.cx, |editor, cx| {
-            editor.set_text(unmarked_text, cx);
-            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
-                s.select_ranges(selection_ranges)
-            })
-        })
-    }
-
-    /// Make an assertion about the editor's text and the ranges and directions
-    /// of its selections using a string containing embedded range markers.
-    ///
-    /// See the `util::test::marked_text_ranges` function for more information.
-    pub fn assert_editor_state(&mut self, marked_text: &str) {
-        let (unmarked_text, expected_selections) = marked_text_ranges(marked_text, true);
-        let buffer_text = self.buffer_text();
-        assert_eq!(
-            buffer_text, unmarked_text,
-            "Unmarked text doesn't match buffer text"
-        );
-        self.assert_selections(expected_selections, marked_text.to_string())
-    }
-
-    pub fn assert_editor_background_highlights<Tag: 'static>(&mut self, marked_text: &str) {
-        let expected_ranges = self.ranges(marked_text);
-        let actual_ranges: Vec<Range<usize>> = self.update_editor(|editor, cx| {
-            let snapshot = editor.snapshot(cx);
-            editor
-                .background_highlights
-                .get(&TypeId::of::<Tag>())
-                .map(|h| h.1.clone())
-                .unwrap_or_default()
-                .into_iter()
-                .map(|range| range.to_offset(&snapshot.buffer_snapshot))
-                .collect()
-        });
-        assert_set_eq!(actual_ranges, expected_ranges);
-    }
-
-    pub fn assert_editor_text_highlights<Tag: ?Sized + 'static>(&mut self, marked_text: &str) {
-        let expected_ranges = self.ranges(marked_text);
-        let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
-        let actual_ranges: Vec<Range<usize>> = snapshot
-            .highlight_ranges::<Tag>()
-            .map(|ranges| ranges.as_ref().clone().1)
-            .unwrap_or_default()
-            .into_iter()
-            .map(|range| range.to_offset(&snapshot.buffer_snapshot))
-            .collect();
-        assert_set_eq!(actual_ranges, expected_ranges);
-    }
-
-    pub fn assert_editor_selections(&mut self, expected_selections: Vec<Range<usize>>) {
-        let expected_marked_text =
-            generate_marked_text(&self.buffer_text(), &expected_selections, true);
-        self.assert_selections(expected_selections, expected_marked_text)
-    }
-
-    fn assert_selections(
-        &mut self,
-        expected_selections: Vec<Range<usize>>,
-        expected_marked_text: String,
-    ) {
-        let actual_selections = self
-            .editor
-            .read_with(self.cx, |editor, cx| editor.selections.all::<usize>(cx))
-            .into_iter()
-            .map(|s| {
-                if s.reversed {
-                    s.end..s.start
-                } else {
-                    s.start..s.end
-                }
-            })
-            .collect::<Vec<_>>();
-        let actual_marked_text =
-            generate_marked_text(&self.buffer_text(), &actual_selections, true);
-        if expected_selections != actual_selections {
-            panic!(
-                indoc! {"
-                    Editor has unexpected selections.
-
-                    Expected selections:
-                    {}
-
-                    Actual selections:
-                    {}
-                "},
-                expected_marked_text, actual_marked_text,
-            );
-        }
-    }
-}
-
-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
-    }
-}
-
-pub struct EditorLspTestContext<'a> {
-    pub cx: EditorTestContext<'a>,
-    pub lsp: lsp::FakeLanguageServer,
-    pub workspace: ViewHandle<Workspace>,
-    pub buffer_lsp_url: lsp::Url,
-}
-
-impl<'a> EditorLspTestContext<'a> {
-    pub async fn new(
-        mut language: Language,
-        capabilities: lsp::ServerCapabilities,
-        cx: &'a mut gpui::TestAppContext,
-    ) -> EditorLspTestContext<'a> {
-        use json::json;
-
-        cx.update(|cx| {
-            crate::init(cx);
-            pane::init(cx);
-        });
-
-        let params = cx.update(AppState::test);
-
-        let file_name = format!(
-            "file.{}",
-            language
-                .path_suffixes()
-                .first()
-                .unwrap_or(&"txt".to_string())
-        );
-
-        let mut fake_servers = language
-            .set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
-                capabilities,
-                ..Default::default()
-            }))
-            .await;
-
-        let project = Project::test(params.fs.clone(), [], cx).await;
-        project.update(cx, |project, _| project.languages().add(Arc::new(language)));
-
-        params
-            .fs
-            .as_fake()
-            .insert_tree("/root", json!({ "dir": { file_name: "" }}))
-            .await;
-
-        let (window_id, workspace) =
-            cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
-        project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/root", true, cx)
-            })
-            .await
-            .unwrap();
-        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
-            .await;
-
-        let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
-        let item = workspace
-            .update(cx, |workspace, cx| workspace.open_path(file, true, cx))
-            .await
-            .expect("Could not open test file");
-
-        let editor = cx.update(|cx| {
-            item.act_as::<Editor>(cx)
-                .expect("Opened test file wasn't an editor")
-        });
-        editor.update(cx, |_, cx| cx.focus_self());
-
-        let lsp = fake_servers.next().await.unwrap();
-
-        Self {
-            cx: EditorTestContext {
-                cx,
-                window_id,
-                editor,
-            },
-            lsp,
-            workspace,
-            buffer_lsp_url: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
-        }
-    }
-
-    pub async fn new_rust(
-        capabilities: lsp::ServerCapabilities,
-        cx: &'a mut gpui::TestAppContext,
-    ) -> EditorLspTestContext<'a> {
-        let language = Language::new(
-            LanguageConfig {
-                name: "Rust".into(),
-                path_suffixes: vec!["rs".to_string()],
-                ..Default::default()
-            },
-            Some(tree_sitter_rust::language()),
-        );
-
-        Self::new(language, capabilities, cx).await
-    }
-
-    // Constructs lsp range using a marked string with '[', ']' range delimiters
-    pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range {
-        let ranges = self.ranges(marked_text);
-        self.to_lsp_range(ranges[0].clone())
-    }
-
-    pub fn to_lsp_range(&mut self, range: Range<usize>) -> lsp::Range {
-        let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
-        let start_point = range.start.to_point(&snapshot.buffer_snapshot);
-        let end_point = range.end.to_point(&snapshot.buffer_snapshot);
-
-        self.editor(|editor, cx| {
-            let buffer = editor.buffer().read(cx);
-            let start = point_to_lsp(
-                buffer
-                    .point_to_buffer_offset(start_point, cx)
-                    .unwrap()
-                    .1
-                    .to_point_utf16(&buffer.read(cx)),
-            );
-            let end = point_to_lsp(
-                buffer
-                    .point_to_buffer_offset(end_point, cx)
-                    .unwrap()
-                    .1
-                    .to_point_utf16(&buffer.read(cx)),
-            );
-
-            lsp::Range { start, end }
-        })
-    }
-
-    pub fn to_lsp(&mut self, offset: usize) -> lsp::Position {
-        let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
-        let point = offset.to_point(&snapshot.buffer_snapshot);
-
-        self.editor(|editor, cx| {
-            let buffer = editor.buffer().read(cx);
-            point_to_lsp(
-                buffer
-                    .point_to_buffer_offset(point, cx)
-                    .unwrap()
-                    .1
-                    .to_point_utf16(&buffer.read(cx)),
-            )
-        })
-    }
-
-    pub fn update_workspace<F, T>(&mut self, update: F) -> T
-    where
-        F: FnOnce(&mut Workspace, &mut ViewContext<Workspace>) -> T,
-    {
-        self.workspace.update(self.cx.cx, update)
-    }
-
-    pub fn handle_request<T, F, Fut>(
-        &self,
-        mut handler: F,
-    ) -> futures::channel::mpsc::UnboundedReceiver<()>
-    where
-        T: 'static + request::Request,
-        T::Params: 'static + Send,
-        F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncAppContext) -> Fut,
-        Fut: 'static + Send + Future<Output = Result<T::Result>>,
-    {
-        let url = self.buffer_lsp_url.clone();
-        self.lsp.handle_request::<T, _, _>(move |params, cx| {
-            let url = url.clone();
-            handler(url, params, cx)
-        })
-    }
-
-    pub fn notify<T: notification::Notification>(&self, params: T::Params) {
-        self.lsp.notify::<T>(params);
-    }
-}
-
-impl<'a> Deref for EditorLspTestContext<'a> {
-    type Target = EditorTestContext<'a>;
-
-    fn deref(&self) -> &Self::Target {
-        &self.cx
-    }
-}
-
-impl<'a> DerefMut for EditorLspTestContext<'a> {
-    fn deref_mut(&mut self) -> &mut Self::Target {
-        &mut self.cx
-    }
-}

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

@@ -0,0 +1,208 @@
+use std::{
+    ops::{Deref, DerefMut, Range},
+    sync::Arc,
+};
+
+use anyhow::Result;
+
+use futures::Future;
+use gpui::{json, ViewContext, ViewHandle};
+use language::{point_to_lsp, FakeLspAdapter, Language, LanguageConfig};
+use lsp::{notification, request};
+use project::Project;
+use smol::stream::StreamExt;
+use workspace::{pane, AppState, Workspace, WorkspaceHandle};
+
+use crate::{multi_buffer::ToPointUtf16, Editor, ToPoint};
+
+use super::editor_test_context::EditorTestContext;
+
+pub struct EditorLspTestContext<'a> {
+    pub cx: EditorTestContext<'a>,
+    pub lsp: lsp::FakeLanguageServer,
+    pub workspace: ViewHandle<Workspace>,
+    pub buffer_lsp_url: lsp::Url,
+}
+
+impl<'a> EditorLspTestContext<'a> {
+    pub async fn new(
+        mut language: Language,
+        capabilities: lsp::ServerCapabilities,
+        cx: &'a mut gpui::TestAppContext,
+    ) -> EditorLspTestContext<'a> {
+        use json::json;
+
+        cx.update(|cx| {
+            crate::init(cx);
+            pane::init(cx);
+        });
+
+        let params = cx.update(AppState::test);
+
+        let file_name = format!(
+            "file.{}",
+            language
+                .path_suffixes()
+                .first()
+                .unwrap_or(&"txt".to_string())
+        );
+
+        let mut fake_servers = language
+            .set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
+                capabilities,
+                ..Default::default()
+            }))
+            .await;
+
+        let project = Project::test(params.fs.clone(), [], cx).await;
+        project.update(cx, |project, _| project.languages().add(Arc::new(language)));
+
+        params
+            .fs
+            .as_fake()
+            .insert_tree("/root", json!({ "dir": { file_name: "" }}))
+            .await;
+
+        let (window_id, workspace) =
+            cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
+        project
+            .update(cx, |project, cx| {
+                project.find_or_create_local_worktree("/root", true, cx)
+            })
+            .await
+            .unwrap();
+        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
+            .await;
+
+        let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
+        let item = workspace
+            .update(cx, |workspace, cx| workspace.open_path(file, true, cx))
+            .await
+            .expect("Could not open test file");
+
+        let editor = cx.update(|cx| {
+            item.act_as::<Editor>(cx)
+                .expect("Opened test file wasn't an editor")
+        });
+        editor.update(cx, |_, cx| cx.focus_self());
+
+        let lsp = fake_servers.next().await.unwrap();
+
+        Self {
+            cx: EditorTestContext {
+                cx,
+                window_id,
+                editor,
+            },
+            lsp,
+            workspace,
+            buffer_lsp_url: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
+        }
+    }
+
+    pub async fn new_rust(
+        capabilities: lsp::ServerCapabilities,
+        cx: &'a mut gpui::TestAppContext,
+    ) -> EditorLspTestContext<'a> {
+        let language = Language::new(
+            LanguageConfig {
+                name: "Rust".into(),
+                path_suffixes: vec!["rs".to_string()],
+                ..Default::default()
+            },
+            Some(tree_sitter_rust::language()),
+        );
+
+        Self::new(language, capabilities, cx).await
+    }
+
+    // Constructs lsp range using a marked string with '[', ']' range delimiters
+    pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range {
+        let ranges = self.ranges(marked_text);
+        self.to_lsp_range(ranges[0].clone())
+    }
+
+    pub fn to_lsp_range(&mut self, range: Range<usize>) -> lsp::Range {
+        let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
+        let start_point = range.start.to_point(&snapshot.buffer_snapshot);
+        let end_point = range.end.to_point(&snapshot.buffer_snapshot);
+
+        self.editor(|editor, cx| {
+            let buffer = editor.buffer().read(cx);
+            let start = point_to_lsp(
+                buffer
+                    .point_to_buffer_offset(start_point, cx)
+                    .unwrap()
+                    .1
+                    .to_point_utf16(&buffer.read(cx)),
+            );
+            let end = point_to_lsp(
+                buffer
+                    .point_to_buffer_offset(end_point, cx)
+                    .unwrap()
+                    .1
+                    .to_point_utf16(&buffer.read(cx)),
+            );
+
+            lsp::Range { start, end }
+        })
+    }
+
+    pub fn to_lsp(&mut self, offset: usize) -> lsp::Position {
+        let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
+        let point = offset.to_point(&snapshot.buffer_snapshot);
+
+        self.editor(|editor, cx| {
+            let buffer = editor.buffer().read(cx);
+            point_to_lsp(
+                buffer
+                    .point_to_buffer_offset(point, cx)
+                    .unwrap()
+                    .1
+                    .to_point_utf16(&buffer.read(cx)),
+            )
+        })
+    }
+
+    pub fn update_workspace<F, T>(&mut self, update: F) -> T
+    where
+        F: FnOnce(&mut Workspace, &mut ViewContext<Workspace>) -> T,
+    {
+        self.workspace.update(self.cx.cx, update)
+    }
+
+    pub fn handle_request<T, F, Fut>(
+        &self,
+        mut handler: F,
+    ) -> futures::channel::mpsc::UnboundedReceiver<()>
+    where
+        T: 'static + request::Request,
+        T::Params: 'static + Send,
+        F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncAppContext) -> Fut,
+        Fut: 'static + Send + Future<Output = Result<T::Result>>,
+    {
+        let url = self.buffer_lsp_url.clone();
+        self.lsp.handle_request::<T, _, _>(move |params, cx| {
+            let url = url.clone();
+            handler(url, params, cx)
+        })
+    }
+
+    pub fn notify<T: notification::Notification>(&self, params: T::Params) {
+        self.lsp.notify::<T>(params);
+    }
+}
+
+impl<'a> Deref for EditorLspTestContext<'a> {
+    type Target = EditorTestContext<'a>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.cx
+    }
+}
+
+impl<'a> DerefMut for EditorLspTestContext<'a> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.cx
+    }
+}

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

@@ -0,0 +1,273 @@
+use std::{
+    any::TypeId,
+    ops::{Deref, DerefMut, Range},
+};
+
+use futures::Future;
+use indoc::indoc;
+
+use crate::{
+    display_map::ToDisplayPoint, AnchorRangeExt, Autoscroll, DisplayPoint, Editor, MultiBuffer,
+};
+use gpui::{keymap::Keystroke, AppContext, ContextHandle, ModelContext, ViewContext, ViewHandle};
+use language::{Buffer, BufferSnapshot};
+use settings::Settings;
+use util::{
+    assert_set_eq,
+    test::{generate_marked_text, marked_text_ranges},
+};
+
+use super::build_editor;
+
+pub struct EditorTestContext<'a> {
+    pub cx: &'a mut gpui::TestAppContext,
+    pub window_id: usize,
+    pub editor: ViewHandle<Editor>,
+}
+
+impl<'a> EditorTestContext<'a> {
+    pub 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 condition(
+        &self,
+        predicate: impl FnMut(&Editor, &AppContext) -> bool,
+    ) -> impl Future<Output = ()> {
+        self.editor.condition(self.cx, predicate)
+    }
+
+    pub fn editor<F, T>(&self, read: F) -> T
+    where
+        F: FnOnce(&Editor, &AppContext) -> T,
+    {
+        self.editor.read_with(self.cx, read)
+    }
+
+    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 multibuffer<F, T>(&self, read: F) -> T
+    where
+        F: FnOnce(&MultiBuffer, &AppContext) -> T,
+    {
+        self.editor(|editor, cx| read(editor.buffer().read(cx), cx))
+    }
+
+    pub fn update_multibuffer<F, T>(&mut self, update: F) -> T
+    where
+        F: FnOnce(&mut MultiBuffer, &mut ModelContext<MultiBuffer>) -> T,
+    {
+        self.update_editor(|editor, cx| editor.buffer().update(cx, update))
+    }
+
+    pub fn buffer_text(&self) -> String {
+        self.multibuffer(|buffer, cx| buffer.snapshot(cx).text())
+    }
+
+    pub fn buffer<F, T>(&self, read: F) -> T
+    where
+        F: FnOnce(&Buffer, &AppContext) -> T,
+    {
+        self.multibuffer(|multibuffer, cx| {
+            let buffer = multibuffer.as_singleton().unwrap().read(cx);
+            read(buffer, cx)
+        })
+    }
+
+    pub fn update_buffer<F, T>(&mut self, update: F) -> T
+    where
+        F: FnOnce(&mut Buffer, &mut ModelContext<Buffer>) -> T,
+    {
+        self.update_multibuffer(|multibuffer, cx| {
+            let buffer = multibuffer.as_singleton().unwrap();
+            buffer.update(cx, update)
+        })
+    }
+
+    pub fn buffer_snapshot(&self) -> BufferSnapshot {
+        self.buffer(|buffer, _| buffer.snapshot())
+    }
+
+    pub fn simulate_keystroke(&mut self, keystroke_text: &str) -> ContextHandle {
+        let keystroke_under_test_handle =
+            self.add_assertion_context(format!("Simulated Keystroke: {:?}", keystroke_text));
+        let keystroke = Keystroke::parse(keystroke_text).unwrap();
+        self.cx.dispatch_keystroke(self.window_id, keystroke, false);
+        keystroke_under_test_handle
+    }
+
+    pub fn simulate_keystrokes<const COUNT: usize>(
+        &mut self,
+        keystroke_texts: [&str; COUNT],
+    ) -> ContextHandle {
+        let keystrokes_under_test_handle =
+            self.add_assertion_context(format!("Simulated Keystrokes: {:?}", keystroke_texts));
+        for keystroke_text in keystroke_texts.into_iter() {
+            self.simulate_keystroke(keystroke_text);
+        }
+        keystrokes_under_test_handle
+    }
+
+    pub fn ranges(&self, marked_text: &str) -> Vec<Range<usize>> {
+        let (unmarked_text, ranges) = marked_text_ranges(marked_text, false);
+        assert_eq!(self.buffer_text(), unmarked_text);
+        ranges
+    }
+
+    pub fn display_point(&mut self, marked_text: &str) -> DisplayPoint {
+        let ranges = self.ranges(marked_text);
+        let snapshot = self
+            .editor
+            .update(self.cx, |editor, cx| editor.snapshot(cx));
+        ranges[0].start.to_display_point(&snapshot)
+    }
+
+    // Returns anchors for the current buffer using `ยซ` and `ยป`
+    pub fn text_anchor_range(&self, marked_text: &str) -> Range<language::Anchor> {
+        let ranges = self.ranges(marked_text);
+        let snapshot = self.buffer_snapshot();
+        snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end)
+    }
+
+    /// Change the editor's text and selections using a string containing
+    /// embedded range markers that represent the ranges and directions of
+    /// each selection.
+    ///
+    /// See the `util::test::marked_text_ranges` function for more information.
+    pub fn set_state(&mut self, marked_text: &str) -> ContextHandle {
+        let _state_context = self.add_assertion_context(format!(
+            "Editor State: \"{}\"",
+            marked_text.escape_debug().to_string()
+        ));
+        let (unmarked_text, selection_ranges) = marked_text_ranges(marked_text, true);
+        self.editor.update(self.cx, |editor, cx| {
+            editor.set_text(unmarked_text, cx);
+            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                s.select_ranges(selection_ranges)
+            })
+        });
+        _state_context
+    }
+
+    /// Make an assertion about the editor's text and the ranges and directions
+    /// of its selections using a string containing embedded range markers.
+    ///
+    /// See the `util::test::marked_text_ranges` function for more information.
+    pub fn assert_editor_state(&mut self, marked_text: &str) {
+        let (unmarked_text, expected_selections) = marked_text_ranges(marked_text, true);
+        let buffer_text = self.buffer_text();
+        assert_eq!(
+            buffer_text, unmarked_text,
+            "Unmarked text doesn't match buffer text"
+        );
+        self.assert_selections(expected_selections, marked_text.to_string())
+    }
+
+    pub fn assert_editor_background_highlights<Tag: 'static>(&mut self, marked_text: &str) {
+        let expected_ranges = self.ranges(marked_text);
+        let actual_ranges: Vec<Range<usize>> = self.update_editor(|editor, cx| {
+            let snapshot = editor.snapshot(cx);
+            editor
+                .background_highlights
+                .get(&TypeId::of::<Tag>())
+                .map(|h| h.1.clone())
+                .unwrap_or_default()
+                .into_iter()
+                .map(|range| range.to_offset(&snapshot.buffer_snapshot))
+                .collect()
+        });
+        assert_set_eq!(actual_ranges, expected_ranges);
+    }
+
+    pub fn assert_editor_text_highlights<Tag: ?Sized + 'static>(&mut self, marked_text: &str) {
+        let expected_ranges = self.ranges(marked_text);
+        let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
+        let actual_ranges: Vec<Range<usize>> = snapshot
+            .highlight_ranges::<Tag>()
+            .map(|ranges| ranges.as_ref().clone().1)
+            .unwrap_or_default()
+            .into_iter()
+            .map(|range| range.to_offset(&snapshot.buffer_snapshot))
+            .collect();
+        assert_set_eq!(actual_ranges, expected_ranges);
+    }
+
+    pub fn assert_editor_selections(&mut self, expected_selections: Vec<Range<usize>>) {
+        let expected_marked_text =
+            generate_marked_text(&self.buffer_text(), &expected_selections, true);
+        self.assert_selections(expected_selections, expected_marked_text)
+    }
+
+    fn assert_selections(
+        &mut self,
+        expected_selections: Vec<Range<usize>>,
+        expected_marked_text: String,
+    ) {
+        let actual_selections = self
+            .editor
+            .read_with(self.cx, |editor, cx| editor.selections.all::<usize>(cx))
+            .into_iter()
+            .map(|s| {
+                if s.reversed {
+                    s.end..s.start
+                } else {
+                    s.start..s.end
+                }
+            })
+            .collect::<Vec<_>>();
+        let actual_marked_text =
+            generate_marked_text(&self.buffer_text(), &actual_selections, true);
+        if expected_selections != actual_selections {
+            panic!(
+                indoc! {"
+                    {}Editor has unexpected selections.
+                    
+                    Expected selections:
+                    {}
+                    
+                    Actual selections:
+                    {}
+                    "},
+                self.assertion_context(),
+                expected_marked_text,
+                actual_marked_text,
+            );
+        }
+    }
+}
+
+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/Cargo.toml ๐Ÿ”—

@@ -25,6 +25,7 @@ env_logger = { version = "0.9", optional = true }
 etagere = "0.2"
 futures = "0.3"
 image = "0.23"
+itertools = "0.10"
 lazy_static = "1.4.0"
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 num_cpus = "1.13"

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

@@ -1,28 +1,8 @@
 pub mod action;
 mod callback_collection;
+#[cfg(any(test, feature = "test-support"))]
+pub mod test_app_context;
 
-use crate::{
-    elements::ElementBox,
-    executor::{self, Task},
-    geometry::rect::RectF,
-    keymap::{self, Binding, Keystroke},
-    platform::{self, KeyDownEvent, Platform, PromptLevel, WindowOptions},
-    presenter::Presenter,
-    util::post_inc,
-    Appearance, AssetCache, AssetSource, ClipboardItem, FontCache, InputHandler, MouseButton,
-    MouseRegionId, PathPromptOptions, TextLayoutCache,
-};
-pub use action::*;
-use anyhow::{anyhow, Context, Result};
-use callback_collection::CallbackCollection;
-use collections::{btree_map, hash_map::Entry, BTreeMap, HashMap, HashSet, VecDeque};
-use keymap::MatchResult;
-use lazy_static::lazy_static;
-use parking_lot::Mutex;
-use platform::Event;
-use postage::oneshot;
-use smallvec::SmallVec;
-use smol::prelude::*;
 use std::{
     any::{type_name, Any, TypeId},
     cell::RefCell,
@@ -38,7 +18,32 @@ use std::{
     time::Duration,
 };
 
-use self::callback_collection::Mapping;
+use anyhow::{anyhow, Context, Result};
+use lazy_static::lazy_static;
+use parking_lot::Mutex;
+use postage::oneshot;
+use smallvec::SmallVec;
+use smol::prelude::*;
+
+pub use action::*;
+use callback_collection::{CallbackCollection, Mapping};
+use collections::{btree_map, hash_map::Entry, BTreeMap, HashMap, HashSet, VecDeque};
+use keymap::MatchResult;
+use platform::Event;
+#[cfg(any(test, feature = "test-support"))]
+pub use test_app_context::{ContextHandle, TestAppContext};
+
+use crate::{
+    elements::ElementBox,
+    executor::{self, Task},
+    geometry::rect::RectF,
+    keymap::{self, Binding, Keystroke},
+    platform::{self, KeyDownEvent, Platform, PromptLevel, WindowOptions},
+    presenter::Presenter,
+    util::post_inc,
+    Appearance, AssetCache, AssetSource, ClipboardItem, FontCache, InputHandler, MouseButton,
+    MouseRegionId, PathPromptOptions, TextLayoutCache,
+};
 
 pub trait Entity: 'static {
     type Event;
@@ -177,13 +182,6 @@ pub struct App(Rc<RefCell<MutableAppContext>>);
 #[derive(Clone)]
 pub struct AsyncAppContext(Rc<RefCell<MutableAppContext>>);
 
-#[cfg(any(test, feature = "test-support"))]
-pub struct TestAppContext {
-    cx: Rc<RefCell<MutableAppContext>>,
-    foreground_platform: Rc<platform::test::ForegroundPlatform>,
-    condition_duration: Option<Duration>,
-}
-
 pub struct WindowInputHandler {
     app: Rc<RefCell<MutableAppContext>>,
     window_id: usize,
@@ -427,327 +425,6 @@ impl InputHandler for WindowInputHandler {
     }
 }
 
-#[cfg(any(test, feature = "test-support"))]
-impl TestAppContext {
-    pub fn new(
-        foreground_platform: Rc<platform::test::ForegroundPlatform>,
-        platform: Arc<dyn Platform>,
-        foreground: Rc<executor::Foreground>,
-        background: Arc<executor::Background>,
-        font_cache: Arc<FontCache>,
-        leak_detector: Arc<Mutex<LeakDetector>>,
-        first_entity_id: usize,
-    ) -> Self {
-        let mut cx = MutableAppContext::new(
-            foreground,
-            background,
-            platform,
-            foreground_platform.clone(),
-            font_cache,
-            RefCounts {
-                #[cfg(any(test, feature = "test-support"))]
-                leak_detector,
-                ..Default::default()
-            },
-            (),
-        );
-        cx.next_entity_id = first_entity_id;
-        let cx = TestAppContext {
-            cx: Rc::new(RefCell::new(cx)),
-            foreground_platform,
-            condition_duration: None,
-        };
-        cx.cx.borrow_mut().weak_self = Some(Rc::downgrade(&cx.cx));
-        cx
-    }
-
-    pub fn dispatch_action<A: Action>(&self, window_id: usize, action: A) {
-        let mut cx = self.cx.borrow_mut();
-        if let Some(view_id) = cx.focused_view_id(window_id) {
-            cx.handle_dispatch_action_from_effect(window_id, Some(view_id), &action);
-        }
-    }
-
-    pub fn dispatch_global_action<A: Action>(&self, action: A) {
-        self.cx.borrow_mut().dispatch_global_action(action);
-    }
-
-    pub fn dispatch_keystroke(&mut self, window_id: usize, keystroke: Keystroke, is_held: bool) {
-        let handled = self.cx.borrow_mut().update(|cx| {
-            let presenter = cx
-                .presenters_and_platform_windows
-                .get(&window_id)
-                .unwrap()
-                .0
-                .clone();
-
-            if cx.dispatch_keystroke(window_id, &keystroke) {
-                return true;
-            }
-
-            if presenter.borrow_mut().dispatch_event(
-                Event::KeyDown(KeyDownEvent {
-                    keystroke: keystroke.clone(),
-                    is_held,
-                }),
-                false,
-                cx,
-            ) {
-                return true;
-            }
-
-            false
-        });
-
-        if !handled && !keystroke.cmd && !keystroke.ctrl {
-            WindowInputHandler {
-                app: self.cx.clone(),
-                window_id,
-            }
-            .replace_text_in_range(None, &keystroke.key)
-        }
-    }
-
-    pub fn add_model<T, F>(&mut self, build_model: F) -> ModelHandle<T>
-    where
-        T: Entity,
-        F: FnOnce(&mut ModelContext<T>) -> T,
-    {
-        self.cx.borrow_mut().add_model(build_model)
-    }
-
-    pub fn add_window<T, F>(&mut self, build_root_view: F) -> (usize, ViewHandle<T>)
-    where
-        T: View,
-        F: FnOnce(&mut ViewContext<T>) -> T,
-    {
-        let (window_id, view) = self
-            .cx
-            .borrow_mut()
-            .add_window(Default::default(), build_root_view);
-        self.simulate_window_activation(Some(window_id));
-        (window_id, view)
-    }
-
-    pub fn add_view<T, F>(
-        &mut self,
-        parent_handle: impl Into<AnyViewHandle>,
-        build_view: F,
-    ) -> ViewHandle<T>
-    where
-        T: View,
-        F: FnOnce(&mut ViewContext<T>) -> T,
-    {
-        self.cx.borrow_mut().add_view(parent_handle, build_view)
-    }
-
-    pub fn window_ids(&self) -> Vec<usize> {
-        self.cx.borrow().window_ids().collect()
-    }
-
-    pub fn root_view<T: View>(&self, window_id: usize) -> Option<ViewHandle<T>> {
-        self.cx.borrow().root_view(window_id)
-    }
-
-    pub fn read<T, F: FnOnce(&AppContext) -> T>(&self, callback: F) -> T {
-        callback(self.cx.borrow().as_ref())
-    }
-
-    pub fn update<T, F: FnOnce(&mut MutableAppContext) -> T>(&mut self, callback: F) -> T {
-        let mut state = self.cx.borrow_mut();
-        // Don't increment pending flushes in order for effects to be flushed before the callback
-        // completes, which is helpful in tests.
-        let result = callback(&mut *state);
-        // Flush effects after the callback just in case there are any. This can happen in edge
-        // cases such as the closure dropping handles.
-        state.flush_effects();
-        result
-    }
-
-    pub fn render<F, V, T>(&mut self, handle: &ViewHandle<V>, f: F) -> T
-    where
-        F: FnOnce(&mut V, &mut RenderContext<V>) -> T,
-        V: View,
-    {
-        handle.update(&mut *self.cx.borrow_mut(), |view, cx| {
-            let mut render_cx = RenderContext {
-                app: cx,
-                window_id: handle.window_id(),
-                view_id: handle.id(),
-                view_type: PhantomData,
-                titlebar_height: 0.,
-                hovered_region_ids: Default::default(),
-                clicked_region_ids: None,
-                refreshing: false,
-                appearance: Appearance::Light,
-            };
-            f(view, &mut render_cx)
-        })
-    }
-
-    pub fn to_async(&self) -> AsyncAppContext {
-        AsyncAppContext(self.cx.clone())
-    }
-
-    pub fn font_cache(&self) -> Arc<FontCache> {
-        self.cx.borrow().cx.font_cache.clone()
-    }
-
-    pub fn foreground_platform(&self) -> Rc<platform::test::ForegroundPlatform> {
-        self.foreground_platform.clone()
-    }
-
-    pub fn platform(&self) -> Arc<dyn platform::Platform> {
-        self.cx.borrow().cx.platform.clone()
-    }
-
-    pub fn foreground(&self) -> Rc<executor::Foreground> {
-        self.cx.borrow().foreground().clone()
-    }
-
-    pub fn background(&self) -> Arc<executor::Background> {
-        self.cx.borrow().background().clone()
-    }
-
-    pub fn spawn<F, Fut, T>(&self, f: F) -> Task<T>
-    where
-        F: FnOnce(AsyncAppContext) -> Fut,
-        Fut: 'static + Future<Output = T>,
-        T: 'static,
-    {
-        let foreground = self.foreground();
-        let future = f(self.to_async());
-        let cx = self.to_async();
-        foreground.spawn(async move {
-            let result = future.await;
-            cx.0.borrow_mut().flush_effects();
-            result
-        })
-    }
-
-    pub fn simulate_new_path_selection(&self, result: impl FnOnce(PathBuf) -> Option<PathBuf>) {
-        self.foreground_platform.simulate_new_path_selection(result);
-    }
-
-    pub fn did_prompt_for_new_path(&self) -> bool {
-        self.foreground_platform.as_ref().did_prompt_for_new_path()
-    }
-
-    pub fn simulate_prompt_answer(&self, window_id: usize, answer: usize) {
-        use postage::prelude::Sink as _;
-
-        let mut done_tx = self
-            .window_mut(window_id)
-            .pending_prompts
-            .borrow_mut()
-            .pop_front()
-            .expect("prompt was not called");
-        let _ = done_tx.try_send(answer);
-    }
-
-    pub fn has_pending_prompt(&self, window_id: usize) -> bool {
-        let window = self.window_mut(window_id);
-        let prompts = window.pending_prompts.borrow_mut();
-        !prompts.is_empty()
-    }
-
-    pub fn current_window_title(&self, window_id: usize) -> Option<String> {
-        self.window_mut(window_id).title.clone()
-    }
-
-    pub fn simulate_window_close(&self, window_id: usize) -> bool {
-        let handler = self.window_mut(window_id).should_close_handler.take();
-        if let Some(mut handler) = handler {
-            let should_close = handler();
-            self.window_mut(window_id).should_close_handler = Some(handler);
-            should_close
-        } else {
-            false
-        }
-    }
-
-    pub fn simulate_window_activation(&self, to_activate: Option<usize>) {
-        let mut handlers = BTreeMap::new();
-        {
-            let mut cx = self.cx.borrow_mut();
-            for (window_id, (_, window)) in &mut cx.presenters_and_platform_windows {
-                let window = window
-                    .as_any_mut()
-                    .downcast_mut::<platform::test::Window>()
-                    .unwrap();
-                handlers.insert(
-                    *window_id,
-                    mem::take(&mut window.active_status_change_handlers),
-                );
-            }
-        };
-        let mut handlers = handlers.into_iter().collect::<Vec<_>>();
-        handlers.sort_unstable_by_key(|(window_id, _)| Some(*window_id) == to_activate);
-
-        for (window_id, mut window_handlers) in handlers {
-            for window_handler in &mut window_handlers {
-                window_handler(Some(window_id) == to_activate);
-            }
-
-            self.window_mut(window_id)
-                .active_status_change_handlers
-                .extend(window_handlers);
-        }
-    }
-
-    pub fn is_window_edited(&self, window_id: usize) -> bool {
-        self.window_mut(window_id).edited
-    }
-
-    pub fn leak_detector(&self) -> Arc<Mutex<LeakDetector>> {
-        self.cx.borrow().leak_detector()
-    }
-
-    pub fn assert_dropped(&self, handle: impl WeakHandle) {
-        self.cx
-            .borrow()
-            .leak_detector()
-            .lock()
-            .assert_dropped(handle.id())
-    }
-
-    fn window_mut(&self, window_id: usize) -> std::cell::RefMut<platform::test::Window> {
-        std::cell::RefMut::map(self.cx.borrow_mut(), |state| {
-            let (_, window) = state
-                .presenters_and_platform_windows
-                .get_mut(&window_id)
-                .unwrap();
-            let test_window = window
-                .as_any_mut()
-                .downcast_mut::<platform::test::Window>()
-                .unwrap();
-            test_window
-        })
-    }
-
-    pub fn set_condition_duration(&mut self, duration: Option<Duration>) {
-        self.condition_duration = duration;
-    }
-
-    pub fn condition_duration(&self) -> Duration {
-        self.condition_duration.unwrap_or_else(|| {
-            if std::env::var("CI").is_ok() {
-                Duration::from_secs(2)
-            } else {
-                Duration::from_millis(500)
-            }
-        })
-    }
-
-    pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) {
-        self.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 AsyncAppContext {
     pub fn spawn<F, Fut, T>(&self, f: F) -> Task<T>
     where
@@ -894,60 +571,6 @@ impl ReadViewWith for AsyncAppContext {
     }
 }
 
-#[cfg(any(test, feature = "test-support"))]
-impl UpdateModel for TestAppContext {
-    fn update_model<T: Entity, O>(
-        &mut self,
-        handle: &ModelHandle<T>,
-        update: &mut dyn FnMut(&mut T, &mut ModelContext<T>) -> O,
-    ) -> O {
-        self.cx.borrow_mut().update_model(handle, update)
-    }
-}
-
-#[cfg(any(test, feature = "test-support"))]
-impl ReadModelWith for TestAppContext {
-    fn read_model_with<E: Entity, T>(
-        &self,
-        handle: &ModelHandle<E>,
-        read: &mut dyn FnMut(&E, &AppContext) -> T,
-    ) -> T {
-        let cx = self.cx.borrow();
-        let cx = cx.as_ref();
-        read(handle.read(cx), cx)
-    }
-}
-
-#[cfg(any(test, feature = "test-support"))]
-impl UpdateView for TestAppContext {
-    fn update_view<T, S>(
-        &mut self,
-        handle: &ViewHandle<T>,
-        update: &mut dyn FnMut(&mut T, &mut ViewContext<T>) -> S,
-    ) -> S
-    where
-        T: View,
-    {
-        self.cx.borrow_mut().update_view(handle, update)
-    }
-}
-
-#[cfg(any(test, feature = "test-support"))]
-impl ReadViewWith for TestAppContext {
-    fn read_view_with<V, T>(
-        &self,
-        handle: &ViewHandle<V>,
-        read: &mut dyn FnMut(&V, &AppContext) -> T,
-    ) -> T
-    where
-        V: View,
-    {
-        let cx = self.cx.borrow();
-        let cx = cx.as_ref();
-        read(handle.read(cx), cx)
-    }
-}
-
 type ActionCallback =
     dyn FnMut(&mut dyn AnyView, &dyn Action, &mut MutableAppContext, usize, usize);
 type GlobalActionCallback = dyn FnMut(&dyn Action, &mut MutableAppContext);
@@ -4446,117 +4069,6 @@ impl<T: Entity> ModelHandle<T> {
             update(model, cx)
         })
     }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
-        let (tx, mut rx) = futures::channel::mpsc::unbounded();
-        let mut cx = cx.cx.borrow_mut();
-        let subscription = cx.observe(self, move |_, _| {
-            tx.unbounded_send(()).ok();
-        });
-
-        let duration = if std::env::var("CI").is_ok() {
-            Duration::from_secs(5)
-        } else {
-            Duration::from_secs(1)
-        };
-
-        async move {
-            let notification = crate::util::timeout(duration, rx.next())
-                .await
-                .expect("next notification timed out");
-            drop(subscription);
-            notification.expect("model dropped while test was waiting for its next notification")
-        }
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn next_event(&self, cx: &TestAppContext) -> impl Future<Output = T::Event>
-    where
-        T::Event: Clone,
-    {
-        let (tx, mut rx) = futures::channel::mpsc::unbounded();
-        let mut cx = cx.cx.borrow_mut();
-        let subscription = cx.subscribe(self, move |_, event, _| {
-            tx.unbounded_send(event.clone()).ok();
-        });
-
-        let duration = if std::env::var("CI").is_ok() {
-            Duration::from_secs(5)
-        } else {
-            Duration::from_secs(1)
-        };
-
-        cx.foreground.start_waiting();
-        async move {
-            let event = crate::util::timeout(duration, rx.next())
-                .await
-                .expect("next event timed out");
-            drop(subscription);
-            event.expect("model dropped while test was waiting for its next event")
-        }
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn condition(
-        &self,
-        cx: &TestAppContext,
-        mut predicate: impl FnMut(&T, &AppContext) -> bool,
-    ) -> impl Future<Output = ()> {
-        let (tx, mut rx) = futures::channel::mpsc::unbounded();
-
-        let mut cx = cx.cx.borrow_mut();
-        let subscriptions = (
-            cx.observe(self, {
-                let tx = tx.clone();
-                move |_, _| {
-                    tx.unbounded_send(()).ok();
-                }
-            }),
-            cx.subscribe(self, {
-                move |_, _, _| {
-                    tx.unbounded_send(()).ok();
-                }
-            }),
-        );
-
-        let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap();
-        let handle = self.downgrade();
-        let duration = if std::env::var("CI").is_ok() {
-            Duration::from_secs(5)
-        } else {
-            Duration::from_secs(1)
-        };
-
-        async move {
-            crate::util::timeout(duration, async move {
-                loop {
-                    {
-                        let cx = cx.borrow();
-                        let cx = cx.as_ref();
-                        if predicate(
-                            handle
-                                .upgrade(cx)
-                                .expect("model dropped with pending condition")
-                                .read(cx),
-                            cx,
-                        ) {
-                            break;
-                        }
-                    }
-
-                    cx.borrow().foreground().start_waiting();
-                    rx.next()
-                        .await
-                        .expect("model dropped with pending condition");
-                    cx.borrow().foreground().finish_waiting();
-                }
-            })
-            .await
-            .expect("condition timed out");
-            drop(subscriptions);
-        }
-    }
 }
 
 impl<T: Entity> Clone for ModelHandle<T> {
@@ -4789,93 +4301,6 @@ impl<T: View> ViewHandle<T> {
         cx.focused_view_id(self.window_id)
             .map_or(false, |focused_id| focused_id == self.view_id)
     }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
-        use postage::prelude::{Sink as _, Stream as _};
-
-        let (mut tx, mut rx) = postage::mpsc::channel(1);
-        let mut cx = cx.cx.borrow_mut();
-        let subscription = cx.observe(self, move |_, _| {
-            tx.try_send(()).ok();
-        });
-
-        let duration = if std::env::var("CI").is_ok() {
-            Duration::from_secs(5)
-        } else {
-            Duration::from_secs(1)
-        };
-
-        async move {
-            let notification = crate::util::timeout(duration, rx.recv())
-                .await
-                .expect("next notification timed out");
-            drop(subscription);
-            notification.expect("model dropped while test was waiting for its next notification")
-        }
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn condition(
-        &self,
-        cx: &TestAppContext,
-        mut predicate: impl FnMut(&T, &AppContext) -> bool,
-    ) -> impl Future<Output = ()> {
-        use postage::prelude::{Sink as _, Stream as _};
-
-        let (tx, mut rx) = postage::mpsc::channel(1024);
-        let timeout_duration = cx.condition_duration();
-
-        let mut cx = cx.cx.borrow_mut();
-        let subscriptions = self.update(&mut *cx, |_, cx| {
-            (
-                cx.observe(self, {
-                    let mut tx = tx.clone();
-                    move |_, _, _| {
-                        tx.blocking_send(()).ok();
-                    }
-                }),
-                cx.subscribe(self, {
-                    let mut tx = tx.clone();
-                    move |_, _, _, _| {
-                        tx.blocking_send(()).ok();
-                    }
-                }),
-            )
-        });
-
-        let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap();
-        let handle = self.downgrade();
-
-        async move {
-            crate::util::timeout(timeout_duration, async move {
-                loop {
-                    {
-                        let cx = cx.borrow();
-                        let cx = cx.as_ref();
-                        if predicate(
-                            handle
-                                .upgrade(cx)
-                                .expect("view dropped with pending condition")
-                                .read(cx),
-                            cx,
-                        ) {
-                            break;
-                        }
-                    }
-
-                    cx.borrow().foreground().start_waiting();
-                    rx.recv()
-                        .await
-                        .expect("view dropped with pending condition");
-                    cx.borrow().foreground().finish_waiting();
-                }
-            })
-            .await
-            .expect("condition timed out");
-            drop(subscriptions);
-        }
-    }
 }
 
 impl<T: View> Clone for ViewHandle<T> {

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

@@ -0,0 +1,655 @@
+use std::{
+    cell::RefCell,
+    marker::PhantomData,
+    mem,
+    path::PathBuf,
+    rc::Rc,
+    sync::{
+        atomic::{AtomicUsize, Ordering},
+        Arc,
+    },
+    time::Duration,
+};
+
+use futures::Future;
+use itertools::Itertools;
+use parking_lot::{Mutex, RwLock};
+use smol::stream::StreamExt;
+
+use crate::{
+    executor, keymap::Keystroke, platform, Action, AnyViewHandle, AppContext, Appearance, Entity,
+    Event, FontCache, InputHandler, KeyDownEvent, LeakDetector, ModelContext, ModelHandle,
+    MutableAppContext, Platform, ReadModelWith, ReadViewWith, RenderContext, Task, UpdateModel,
+    UpdateView, View, ViewContext, ViewHandle, WeakHandle, WindowInputHandler,
+};
+use collections::BTreeMap;
+
+use super::{AsyncAppContext, RefCounts};
+
+pub struct TestAppContext {
+    cx: Rc<RefCell<MutableAppContext>>,
+    foreground_platform: Rc<platform::test::ForegroundPlatform>,
+    condition_duration: Option<Duration>,
+    pub function_name: String,
+    assertion_context: AssertionContextManager,
+}
+
+impl TestAppContext {
+    pub fn new(
+        foreground_platform: Rc<platform::test::ForegroundPlatform>,
+        platform: Arc<dyn Platform>,
+        foreground: Rc<executor::Foreground>,
+        background: Arc<executor::Background>,
+        font_cache: Arc<FontCache>,
+        leak_detector: Arc<Mutex<LeakDetector>>,
+        first_entity_id: usize,
+        function_name: String,
+    ) -> Self {
+        let mut cx = MutableAppContext::new(
+            foreground,
+            background,
+            platform,
+            foreground_platform.clone(),
+            font_cache,
+            RefCounts {
+                #[cfg(any(test, feature = "test-support"))]
+                leak_detector,
+                ..Default::default()
+            },
+            (),
+        );
+        cx.next_entity_id = first_entity_id;
+        let cx = TestAppContext {
+            cx: Rc::new(RefCell::new(cx)),
+            foreground_platform,
+            condition_duration: None,
+            function_name,
+            assertion_context: AssertionContextManager::new(),
+        };
+        cx.cx.borrow_mut().weak_self = Some(Rc::downgrade(&cx.cx));
+        cx
+    }
+
+    pub fn dispatch_action<A: Action>(&self, window_id: usize, action: A) {
+        let mut cx = self.cx.borrow_mut();
+        if let Some(view_id) = cx.focused_view_id(window_id) {
+            cx.handle_dispatch_action_from_effect(window_id, Some(view_id), &action);
+        }
+    }
+
+    pub fn dispatch_global_action<A: Action>(&self, action: A) {
+        self.cx.borrow_mut().dispatch_global_action(action);
+    }
+
+    pub fn dispatch_keystroke(&mut self, window_id: usize, keystroke: Keystroke, is_held: bool) {
+        let handled = self.cx.borrow_mut().update(|cx| {
+            let presenter = cx
+                .presenters_and_platform_windows
+                .get(&window_id)
+                .unwrap()
+                .0
+                .clone();
+
+            if cx.dispatch_keystroke(window_id, &keystroke) {
+                return true;
+            }
+
+            if presenter.borrow_mut().dispatch_event(
+                Event::KeyDown(KeyDownEvent {
+                    keystroke: keystroke.clone(),
+                    is_held,
+                }),
+                false,
+                cx,
+            ) {
+                return true;
+            }
+
+            false
+        });
+
+        if !handled && !keystroke.cmd && !keystroke.ctrl {
+            WindowInputHandler {
+                app: self.cx.clone(),
+                window_id,
+            }
+            .replace_text_in_range(None, &keystroke.key)
+        }
+    }
+
+    pub fn add_model<T, F>(&mut self, build_model: F) -> ModelHandle<T>
+    where
+        T: Entity,
+        F: FnOnce(&mut ModelContext<T>) -> T,
+    {
+        self.cx.borrow_mut().add_model(build_model)
+    }
+
+    pub fn add_window<T, F>(&mut self, build_root_view: F) -> (usize, ViewHandle<T>)
+    where
+        T: View,
+        F: FnOnce(&mut ViewContext<T>) -> T,
+    {
+        let (window_id, view) = self
+            .cx
+            .borrow_mut()
+            .add_window(Default::default(), build_root_view);
+        self.simulate_window_activation(Some(window_id));
+        (window_id, view)
+    }
+
+    pub fn add_view<T, F>(
+        &mut self,
+        parent_handle: impl Into<AnyViewHandle>,
+        build_view: F,
+    ) -> ViewHandle<T>
+    where
+        T: View,
+        F: FnOnce(&mut ViewContext<T>) -> T,
+    {
+        self.cx.borrow_mut().add_view(parent_handle, build_view)
+    }
+
+    pub fn window_ids(&self) -> Vec<usize> {
+        self.cx.borrow().window_ids().collect()
+    }
+
+    pub fn root_view<T: View>(&self, window_id: usize) -> Option<ViewHandle<T>> {
+        self.cx.borrow().root_view(window_id)
+    }
+
+    pub fn read<T, F: FnOnce(&AppContext) -> T>(&self, callback: F) -> T {
+        callback(self.cx.borrow().as_ref())
+    }
+
+    pub fn update<T, F: FnOnce(&mut MutableAppContext) -> T>(&mut self, callback: F) -> T {
+        let mut state = self.cx.borrow_mut();
+        // Don't increment pending flushes in order for effects to be flushed before the callback
+        // completes, which is helpful in tests.
+        let result = callback(&mut *state);
+        // Flush effects after the callback just in case there are any. This can happen in edge
+        // cases such as the closure dropping handles.
+        state.flush_effects();
+        result
+    }
+
+    pub fn render<F, V, T>(&mut self, handle: &ViewHandle<V>, f: F) -> T
+    where
+        F: FnOnce(&mut V, &mut RenderContext<V>) -> T,
+        V: View,
+    {
+        handle.update(&mut *self.cx.borrow_mut(), |view, cx| {
+            let mut render_cx = RenderContext {
+                app: cx,
+                window_id: handle.window_id(),
+                view_id: handle.id(),
+                view_type: PhantomData,
+                titlebar_height: 0.,
+                hovered_region_ids: Default::default(),
+                clicked_region_ids: None,
+                refreshing: false,
+                appearance: Appearance::Light,
+            };
+            f(view, &mut render_cx)
+        })
+    }
+
+    pub fn to_async(&self) -> AsyncAppContext {
+        AsyncAppContext(self.cx.clone())
+    }
+
+    pub fn font_cache(&self) -> Arc<FontCache> {
+        self.cx.borrow().cx.font_cache.clone()
+    }
+
+    pub fn foreground_platform(&self) -> Rc<platform::test::ForegroundPlatform> {
+        self.foreground_platform.clone()
+    }
+
+    pub fn platform(&self) -> Arc<dyn platform::Platform> {
+        self.cx.borrow().cx.platform.clone()
+    }
+
+    pub fn foreground(&self) -> Rc<executor::Foreground> {
+        self.cx.borrow().foreground().clone()
+    }
+
+    pub fn background(&self) -> Arc<executor::Background> {
+        self.cx.borrow().background().clone()
+    }
+
+    pub fn spawn<F, Fut, T>(&self, f: F) -> Task<T>
+    where
+        F: FnOnce(AsyncAppContext) -> Fut,
+        Fut: 'static + Future<Output = T>,
+        T: 'static,
+    {
+        let foreground = self.foreground();
+        let future = f(self.to_async());
+        let cx = self.to_async();
+        foreground.spawn(async move {
+            let result = future.await;
+            cx.0.borrow_mut().flush_effects();
+            result
+        })
+    }
+
+    pub fn simulate_new_path_selection(&self, result: impl FnOnce(PathBuf) -> Option<PathBuf>) {
+        self.foreground_platform.simulate_new_path_selection(result);
+    }
+
+    pub fn did_prompt_for_new_path(&self) -> bool {
+        self.foreground_platform.as_ref().did_prompt_for_new_path()
+    }
+
+    pub fn simulate_prompt_answer(&self, window_id: usize, answer: usize) {
+        use postage::prelude::Sink as _;
+
+        let mut done_tx = self
+            .window_mut(window_id)
+            .pending_prompts
+            .borrow_mut()
+            .pop_front()
+            .expect("prompt was not called");
+        let _ = done_tx.try_send(answer);
+    }
+
+    pub fn has_pending_prompt(&self, window_id: usize) -> bool {
+        let window = self.window_mut(window_id);
+        let prompts = window.pending_prompts.borrow_mut();
+        !prompts.is_empty()
+    }
+
+    pub fn current_window_title(&self, window_id: usize) -> Option<String> {
+        self.window_mut(window_id).title.clone()
+    }
+
+    pub fn simulate_window_close(&self, window_id: usize) -> bool {
+        let handler = self.window_mut(window_id).should_close_handler.take();
+        if let Some(mut handler) = handler {
+            let should_close = handler();
+            self.window_mut(window_id).should_close_handler = Some(handler);
+            should_close
+        } else {
+            false
+        }
+    }
+
+    pub fn simulate_window_activation(&self, to_activate: Option<usize>) {
+        let mut handlers = BTreeMap::new();
+        {
+            let mut cx = self.cx.borrow_mut();
+            for (window_id, (_, window)) in &mut cx.presenters_and_platform_windows {
+                let window = window
+                    .as_any_mut()
+                    .downcast_mut::<platform::test::Window>()
+                    .unwrap();
+                handlers.insert(
+                    *window_id,
+                    mem::take(&mut window.active_status_change_handlers),
+                );
+            }
+        };
+        let mut handlers = handlers.into_iter().collect::<Vec<_>>();
+        handlers.sort_unstable_by_key(|(window_id, _)| Some(*window_id) == to_activate);
+
+        for (window_id, mut window_handlers) in handlers {
+            for window_handler in &mut window_handlers {
+                window_handler(Some(window_id) == to_activate);
+            }
+
+            self.window_mut(window_id)
+                .active_status_change_handlers
+                .extend(window_handlers);
+        }
+    }
+
+    pub fn is_window_edited(&self, window_id: usize) -> bool {
+        self.window_mut(window_id).edited
+    }
+
+    pub fn leak_detector(&self) -> Arc<Mutex<LeakDetector>> {
+        self.cx.borrow().leak_detector()
+    }
+
+    pub fn assert_dropped(&self, handle: impl WeakHandle) {
+        self.cx
+            .borrow()
+            .leak_detector()
+            .lock()
+            .assert_dropped(handle.id())
+    }
+
+    fn window_mut(&self, window_id: usize) -> std::cell::RefMut<platform::test::Window> {
+        std::cell::RefMut::map(self.cx.borrow_mut(), |state| {
+            let (_, window) = state
+                .presenters_and_platform_windows
+                .get_mut(&window_id)
+                .unwrap();
+            let test_window = window
+                .as_any_mut()
+                .downcast_mut::<platform::test::Window>()
+                .unwrap();
+            test_window
+        })
+    }
+
+    pub fn set_condition_duration(&mut self, duration: Option<Duration>) {
+        self.condition_duration = duration;
+    }
+
+    pub fn condition_duration(&self) -> Duration {
+        self.condition_duration.unwrap_or_else(|| {
+            if std::env::var("CI").is_ok() {
+                Duration::from_secs(2)
+            } else {
+                Duration::from_millis(500)
+            }
+        })
+    }
+
+    pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) {
+        self.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);
+        })
+    }
+
+    pub fn add_assertion_context(&self, context: String) -> ContextHandle {
+        self.assertion_context.add_context(context)
+    }
+
+    pub fn assertion_context(&self) -> String {
+        self.assertion_context.context()
+    }
+}
+
+impl UpdateModel for TestAppContext {
+    fn update_model<T: Entity, O>(
+        &mut self,
+        handle: &ModelHandle<T>,
+        update: &mut dyn FnMut(&mut T, &mut ModelContext<T>) -> O,
+    ) -> O {
+        self.cx.borrow_mut().update_model(handle, update)
+    }
+}
+
+impl ReadModelWith for TestAppContext {
+    fn read_model_with<E: Entity, T>(
+        &self,
+        handle: &ModelHandle<E>,
+        read: &mut dyn FnMut(&E, &AppContext) -> T,
+    ) -> T {
+        let cx = self.cx.borrow();
+        let cx = cx.as_ref();
+        read(handle.read(cx), cx)
+    }
+}
+
+impl UpdateView for TestAppContext {
+    fn update_view<T, S>(
+        &mut self,
+        handle: &ViewHandle<T>,
+        update: &mut dyn FnMut(&mut T, &mut ViewContext<T>) -> S,
+    ) -> S
+    where
+        T: View,
+    {
+        self.cx.borrow_mut().update_view(handle, update)
+    }
+}
+
+impl ReadViewWith for TestAppContext {
+    fn read_view_with<V, T>(
+        &self,
+        handle: &ViewHandle<V>,
+        read: &mut dyn FnMut(&V, &AppContext) -> T,
+    ) -> T
+    where
+        V: View,
+    {
+        let cx = self.cx.borrow();
+        let cx = cx.as_ref();
+        read(handle.read(cx), cx)
+    }
+}
+
+impl<T: Entity> ModelHandle<T> {
+    pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
+        let (tx, mut rx) = futures::channel::mpsc::unbounded();
+        let mut cx = cx.cx.borrow_mut();
+        let subscription = cx.observe(self, move |_, _| {
+            tx.unbounded_send(()).ok();
+        });
+
+        let duration = if std::env::var("CI").is_ok() {
+            Duration::from_secs(5)
+        } else {
+            Duration::from_secs(1)
+        };
+
+        async move {
+            let notification = crate::util::timeout(duration, rx.next())
+                .await
+                .expect("next notification timed out");
+            drop(subscription);
+            notification.expect("model dropped while test was waiting for its next notification")
+        }
+    }
+
+    pub fn next_event(&self, cx: &TestAppContext) -> impl Future<Output = T::Event>
+    where
+        T::Event: Clone,
+    {
+        let (tx, mut rx) = futures::channel::mpsc::unbounded();
+        let mut cx = cx.cx.borrow_mut();
+        let subscription = cx.subscribe(self, move |_, event, _| {
+            tx.unbounded_send(event.clone()).ok();
+        });
+
+        let duration = if std::env::var("CI").is_ok() {
+            Duration::from_secs(5)
+        } else {
+            Duration::from_secs(1)
+        };
+
+        cx.foreground.start_waiting();
+        async move {
+            let event = crate::util::timeout(duration, rx.next())
+                .await
+                .expect("next event timed out");
+            drop(subscription);
+            event.expect("model dropped while test was waiting for its next event")
+        }
+    }
+
+    pub fn condition(
+        &self,
+        cx: &TestAppContext,
+        mut predicate: impl FnMut(&T, &AppContext) -> bool,
+    ) -> impl Future<Output = ()> {
+        let (tx, mut rx) = futures::channel::mpsc::unbounded();
+
+        let mut cx = cx.cx.borrow_mut();
+        let subscriptions = (
+            cx.observe(self, {
+                let tx = tx.clone();
+                move |_, _| {
+                    tx.unbounded_send(()).ok();
+                }
+            }),
+            cx.subscribe(self, {
+                move |_, _, _| {
+                    tx.unbounded_send(()).ok();
+                }
+            }),
+        );
+
+        let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap();
+        let handle = self.downgrade();
+        let duration = if std::env::var("CI").is_ok() {
+            Duration::from_secs(5)
+        } else {
+            Duration::from_secs(1)
+        };
+
+        async move {
+            crate::util::timeout(duration, async move {
+                loop {
+                    {
+                        let cx = cx.borrow();
+                        let cx = cx.as_ref();
+                        if predicate(
+                            handle
+                                .upgrade(cx)
+                                .expect("model dropped with pending condition")
+                                .read(cx),
+                            cx,
+                        ) {
+                            break;
+                        }
+                    }
+
+                    cx.borrow().foreground().start_waiting();
+                    rx.next()
+                        .await
+                        .expect("model dropped with pending condition");
+                    cx.borrow().foreground().finish_waiting();
+                }
+            })
+            .await
+            .expect("condition timed out");
+            drop(subscriptions);
+        }
+    }
+}
+
+impl<T: View> ViewHandle<T> {
+    pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
+        use postage::prelude::{Sink as _, Stream as _};
+
+        let (mut tx, mut rx) = postage::mpsc::channel(1);
+        let mut cx = cx.cx.borrow_mut();
+        let subscription = cx.observe(self, move |_, _| {
+            tx.try_send(()).ok();
+        });
+
+        let duration = if std::env::var("CI").is_ok() {
+            Duration::from_secs(5)
+        } else {
+            Duration::from_secs(1)
+        };
+
+        async move {
+            let notification = crate::util::timeout(duration, rx.recv())
+                .await
+                .expect("next notification timed out");
+            drop(subscription);
+            notification.expect("model dropped while test was waiting for its next notification")
+        }
+    }
+
+    pub fn condition(
+        &self,
+        cx: &TestAppContext,
+        mut predicate: impl FnMut(&T, &AppContext) -> bool,
+    ) -> impl Future<Output = ()> {
+        use postage::prelude::{Sink as _, Stream as _};
+
+        let (tx, mut rx) = postage::mpsc::channel(1024);
+        let timeout_duration = cx.condition_duration();
+
+        let mut cx = cx.cx.borrow_mut();
+        let subscriptions = self.update(&mut *cx, |_, cx| {
+            (
+                cx.observe(self, {
+                    let mut tx = tx.clone();
+                    move |_, _, _| {
+                        tx.blocking_send(()).ok();
+                    }
+                }),
+                cx.subscribe(self, {
+                    let mut tx = tx.clone();
+                    move |_, _, _, _| {
+                        tx.blocking_send(()).ok();
+                    }
+                }),
+            )
+        });
+
+        let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap();
+        let handle = self.downgrade();
+
+        async move {
+            crate::util::timeout(timeout_duration, async move {
+                loop {
+                    {
+                        let cx = cx.borrow();
+                        let cx = cx.as_ref();
+                        if predicate(
+                            handle
+                                .upgrade(cx)
+                                .expect("view dropped with pending condition")
+                                .read(cx),
+                            cx,
+                        ) {
+                            break;
+                        }
+                    }
+
+                    cx.borrow().foreground().start_waiting();
+                    rx.recv()
+                        .await
+                        .expect("view dropped with pending condition");
+                    cx.borrow().foreground().finish_waiting();
+                }
+            })
+            .await
+            .expect("condition timed out");
+            drop(subscriptions);
+        }
+    }
+}
+
+#[derive(Clone)]
+pub struct AssertionContextManager {
+    id: Arc<AtomicUsize>,
+    contexts: Arc<RwLock<BTreeMap<usize, String>>>,
+}
+
+impl AssertionContextManager {
+    pub fn new() -> Self {
+        Self {
+            id: Arc::new(AtomicUsize::new(0)),
+            contexts: Arc::new(RwLock::new(BTreeMap::new())),
+        }
+    }
+
+    pub fn add_context(&self, context: String) -> ContextHandle {
+        let id = self.id.fetch_add(1, Ordering::Relaxed);
+        let mut contexts = self.contexts.write();
+        contexts.insert(id, context);
+        ContextHandle {
+            id,
+            manager: self.clone(),
+        }
+    }
+
+    pub fn context(&self) -> String {
+        let contexts = self.contexts.read();
+        format!("\n{}\n", contexts.values().join("\n"))
+    }
+}
+
+pub struct ContextHandle {
+    id: usize,
+    manager: AssertionContextManager,
+}
+
+impl Drop for ContextHandle {
+    fn drop(&mut self) {
+        let mut contexts = self.manager.contexts.write();
+        contexts.remove(&self.id);
+    }
+}

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

@@ -65,6 +65,7 @@ pub trait Platform: Send + Sync {
     fn delete_credentials(&self, url: &str) -> Result<()>;
 
     fn set_cursor_style(&self, style: CursorStyle);
+    fn should_auto_hide_scrollbars(&self) -> bool;
 
     fn local_timezone(&self) -> UtcOffset;
 

crates/gpui/src/platform/mac/platform.rs ๐Ÿ”—

@@ -709,6 +709,16 @@ impl platform::Platform for MacPlatform {
         }
     }
 
+    fn should_auto_hide_scrollbars(&self) -> bool {
+        #[allow(non_upper_case_globals)]
+        const NSScrollerStyleOverlay: NSInteger = 1;
+
+        unsafe {
+            let style: NSInteger = msg_send![class!(NSScroller), preferredScrollerStyle];
+            style == NSScrollerStyleOverlay
+        }
+    }
+
     fn local_timezone(&self) -> UtcOffset {
         unsafe {
             let local_timezone: id = msg_send![class!(NSTimeZone), localTimeZone];

crates/gpui/src/platform/test.rs ๐Ÿ”—

@@ -181,6 +181,10 @@ impl super::Platform for Platform {
         *self.cursor.lock() = style;
     }
 
+    fn should_auto_hide_scrollbars(&self) -> bool {
+        false
+    }
+
     fn local_timezone(&self) -> UtcOffset {
         UtcOffset::UTC
     }

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

@@ -37,6 +37,7 @@ pub fn run_test(
         u64,
         bool,
     )),
+    fn_name: String,
 ) {
     // let _profiler = dhat::Profiler::new_heap();
 
@@ -78,6 +79,7 @@ pub fn run_test(
                     font_cache.clone(),
                     leak_detector.clone(),
                     0,
+                    fn_name.clone(),
                 );
                 cx.update(|cx| {
                     test_fn(

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

@@ -117,6 +117,7 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
                                     cx.font_cache().clone(),
                                     cx.leak_detector(),
                                     #first_entity_id,
+                                    stringify!(#outer_fn_name).to_string(),
                                 );
                             ));
                             cx_teardowns.extend(quote!(
@@ -149,7 +150,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
                         #cx_vars
                         cx.foreground().run(#inner_fn_name(#inner_fn_args));
                         #cx_teardowns
-                    }
+                    },
+                    stringify!(#outer_fn_name).to_string(),
                 );
             }
         }
@@ -187,7 +189,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
                     #num_iterations as u64,
                     #starting_seed as u64,
                     #max_retries,
-                    &mut |cx, _, _, seed, is_last_iteration| #inner_fn_name(#inner_fn_args)
+                    &mut |cx, _, _, seed, is_last_iteration| #inner_fn_name(#inner_fn_args),
+                    stringify!(#outer_fn_name).to_string(),
                 );
             }
         }

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

@@ -554,6 +554,15 @@ pub struct Editor {
     pub link_definition: HighlightStyle,
     pub composition_mark: HighlightStyle,
     pub jump_icon: Interactive<IconButton>,
+    pub scrollbar: Scrollbar,
+}
+
+#[derive(Clone, Deserialize, Default)]
+pub struct Scrollbar {
+    pub track: ContainerStyle,
+    pub thumb: ContainerStyle,
+    pub width: f32,
+    pub min_height_factor: f32,
 }
 
 #[derive(Clone, Deserialize, Default)]

crates/vim/Cargo.toml ๐Ÿ”—

@@ -7,7 +7,20 @@ edition = "2021"
 path = "src/vim.rs"
 doctest = false
 
+[features]
+neovim = ["nvim-rs", "async-compat", "async-trait", "tokio"]
+
 [dependencies]
+serde = { version = "1.0", features = ["derive", "rc"] }
+itertools = "0.10"
+log = { version = "0.4.16", features = ["kv_unstable_serde"] }
+
+async-compat = { version = "0.2.1", "optional" = true }
+async-trait = { version = "0.1", "optional" = true }
+nvim-rs = { git = "https://github.com/KillTheMule/nvim-rs", branch = "master", features = ["use_tokio"], optional = true }
+tokio = { version = "1.15", "optional" = true }
+serde_json = { version = "1.0", features = ["preserve_order"] }
+
 assets = { path = "../assets" }
 collections = { path = "../collections" }
 command_palette = { path = "../command_palette" }
@@ -16,14 +29,14 @@ gpui = { path = "../gpui" }
 language = { path = "../language" }
 rope = { path = "../rope" }
 search = { path = "../search" }
-serde = { version = "1.0", features = ["derive", "rc"] }
 settings = { path = "../settings" }
 workspace = { path = "../workspace" }
-itertools = "0.10"
-log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 
 [dev-dependencies]
 indoc = "1.0.4"
+parking_lot = "0.11.1"
+lazy_static = "1.4"
+
 editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 language = { path = "../language", features = ["test-support"] }

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

@@ -26,7 +26,7 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Works
 
 #[cfg(test)]
 mod test {
-    use crate::{state::Mode, vim_test_context::VimTestContext};
+    use crate::{state::Mode, test::VimTestContext};
 
     #[gpui::test]
     async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) {

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

@@ -18,6 +18,7 @@ use crate::{
 #[derive(Copy, Clone, Debug)]
 pub enum Motion {
     Left,
+    Backspace,
     Down,
     Up,
     Right,
@@ -58,6 +59,7 @@ actions!(
     vim,
     [
         Left,
+        Backspace,
         Down,
         Up,
         Right,
@@ -74,6 +76,7 @@ impl_actions!(vim, [NextWordStart, NextWordEnd, PreviousWordStart]);
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(|_: &mut Workspace, _: &Left, cx: _| motion(Motion::Left, cx));
+    cx.add_action(|_: &mut Workspace, _: &Backspace, cx: _| motion(Motion::Backspace, cx));
     cx.add_action(|_: &mut Workspace, _: &Down, cx: _| motion(Motion::Down, cx));
     cx.add_action(|_: &mut Workspace, _: &Up, cx: _| motion(Motion::Up, cx));
     cx.add_action(|_: &mut Workspace, _: &Right, cx: _| motion(Motion::Right, cx));
@@ -106,19 +109,21 @@ pub fn init(cx: &mut MutableAppContext) {
     );
 }
 
-fn motion(motion: Motion, cx: &mut MutableAppContext) {
-    Vim::update(cx, |vim, cx| {
-        if let Some(Operator::Namespace(_)) = vim.active_operator() {
-            vim.pop_operator(cx);
-        }
-    });
+pub(crate) fn motion(motion: Motion, cx: &mut MutableAppContext) {
+    if let Some(Operator::Namespace(_)) = Vim::read(cx).active_operator() {
+        Vim::update(cx, |vim, cx| vim.pop_operator(cx));
+    }
+
+    let times = Vim::update(cx, |vim, cx| vim.pop_number_operator(cx));
+    let operator = Vim::read(cx).active_operator();
     match Vim::read(cx).state.mode {
-        Mode::Normal => normal_motion(motion, cx),
-        Mode::Visual { .. } => visual_motion(motion, cx),
+        Mode::Normal => normal_motion(motion, operator, times, cx),
+        Mode::Visual { .. } => visual_motion(motion, times, cx),
         Mode::Insert => {
             // Shouldn't execute a motion in insert mode. Ignoring
         }
     }
+    Vim::update(cx, |vim, cx| vim.clear_operator(cx));
 }
 
 // Motion handling is specified here:
@@ -150,30 +155,32 @@ impl Motion {
         map: &DisplaySnapshot,
         point: DisplayPoint,
         goal: SelectionGoal,
+        times: usize,
     ) -> (DisplayPoint, SelectionGoal) {
         use Motion::*;
         match self {
-            Left => (left(map, point), SelectionGoal::None),
-            Down => movement::down(map, point, goal, true),
-            Up => movement::up(map, point, goal, true),
-            Right => (right(map, point), SelectionGoal::None),
+            Left => (left(map, point, times), SelectionGoal::None),
+            Backspace => (backspace(map, point, times), SelectionGoal::None),
+            Down => down(map, point, goal, times),
+            Up => up(map, point, goal, times),
+            Right => (right(map, point, times), SelectionGoal::None),
             NextWordStart { ignore_punctuation } => (
-                next_word_start(map, point, ignore_punctuation),
+                next_word_start(map, point, ignore_punctuation, times),
                 SelectionGoal::None,
             ),
             NextWordEnd { ignore_punctuation } => (
-                next_word_end(map, point, ignore_punctuation),
+                next_word_end(map, point, ignore_punctuation, times),
                 SelectionGoal::None,
             ),
             PreviousWordStart { ignore_punctuation } => (
-                previous_word_start(map, point, ignore_punctuation),
+                previous_word_start(map, point, ignore_punctuation, times),
                 SelectionGoal::None,
             ),
             FirstNonWhitespace => (first_non_whitespace(map, point), SelectionGoal::None),
             StartOfLine => (start_of_line(map, point), SelectionGoal::None),
             EndOfLine => (end_of_line(map, point), SelectionGoal::None),
             CurrentLine => (end_of_line(map, point), SelectionGoal::None),
-            StartOfDocument => (start_of_document(map, point), SelectionGoal::None),
+            StartOfDocument => (start_of_document(map, point, times), SelectionGoal::None),
             EndOfDocument => (end_of_document(map, point), SelectionGoal::None),
             Matching => (matching(map, point), SelectionGoal::None),
         }
@@ -184,9 +191,10 @@ impl Motion {
         self,
         map: &DisplaySnapshot,
         selection: &mut Selection<DisplayPoint>,
+        times: usize,
         expand_to_surrounding_newline: bool,
     ) {
-        let (head, goal) = self.move_point(map, selection.head(), selection.goal);
+        let (head, goal) = self.move_point(map, selection.head(), selection.goal, times);
         selection.set_head(head, goal);
 
         if self.linewise() {
@@ -206,7 +214,7 @@ impl Motion {
                 }
             }
 
-            selection.end = map.next_line_boundary(selection.end.to_point(map)).1;
+            (_, selection.end) = map.next_line_boundary(selection.end.to_point(map));
         } else {
             // If the motion is exclusive and the end of the motion is in column 1, the
             // end of the motion is moved to the end of the previous line and the motion
@@ -234,95 +242,151 @@ impl Motion {
     }
 }
 
-fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
-    *point.column_mut() = point.column().saturating_sub(1);
-    map.clip_point(point, Bias::Left)
+fn left(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
+    for _ in 0..times {
+        *point.column_mut() = point.column().saturating_sub(1);
+        point = map.clip_point(point, Bias::Right);
+        if point.column() == 0 {
+            break;
+        }
+    }
+    point
+}
+
+fn backspace(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
+    for _ in 0..times {
+        point = movement::left(map, point);
+    }
+    point
 }
 
-fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
-    *point.column_mut() += 1;
-    map.clip_point(point, Bias::Right)
+fn down(
+    map: &DisplaySnapshot,
+    mut point: DisplayPoint,
+    mut goal: SelectionGoal,
+    times: usize,
+) -> (DisplayPoint, SelectionGoal) {
+    for _ in 0..times {
+        (point, goal) = movement::down(map, point, goal, true);
+    }
+    (point, goal)
 }
 
-fn next_word_start(
+fn up(
     map: &DisplaySnapshot,
-    point: DisplayPoint,
+    mut point: DisplayPoint,
+    mut goal: SelectionGoal,
+    times: usize,
+) -> (DisplayPoint, SelectionGoal) {
+    for _ in 0..times {
+        (point, goal) = movement::up(map, point, goal, true);
+    }
+    (point, goal)
+}
+
+pub(crate) fn right(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
+    for _ in 0..times {
+        let mut new_point = point;
+        *new_point.column_mut() += 1;
+        let new_point = map.clip_point(new_point, Bias::Right);
+        if point == new_point {
+            break;
+        }
+        point = new_point;
+    }
+    point
+}
+
+pub(crate) fn next_word_start(
+    map: &DisplaySnapshot,
+    mut point: DisplayPoint,
     ignore_punctuation: bool,
+    times: usize,
 ) -> DisplayPoint {
-    let mut crossed_newline = false;
-    movement::find_boundary(map, point, |left, right| {
-        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
-        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
-        let at_newline = right == '\n';
-
-        let found = (left_kind != right_kind && !right.is_whitespace())
-            || at_newline && crossed_newline
-            || at_newline && left == '\n'; // Prevents skipping repeated empty lines
-
-        if at_newline {
-            crossed_newline = true;
-        }
-        found
-    })
+    for _ in 0..times {
+        let mut crossed_newline = false;
+        point = movement::find_boundary(map, point, |left, right| {
+            let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
+            let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+            let at_newline = right == '\n';
+
+            let found = (left_kind != right_kind && right_kind != CharKind::Whitespace)
+                || at_newline && crossed_newline
+                || at_newline && left == '\n'; // Prevents skipping repeated empty lines
+
+            if at_newline {
+                crossed_newline = true;
+            }
+            found
+        })
+    }
+    point
 }
 
 fn next_word_end(
     map: &DisplaySnapshot,
     mut point: DisplayPoint,
     ignore_punctuation: bool,
+    times: usize,
 ) -> DisplayPoint {
-    *point.column_mut() += 1;
-    point = movement::find_boundary(map, point, |left, right| {
-        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
-        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
-
-        left_kind != right_kind && !left.is_whitespace()
-    });
-    // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know
-    // we have backtraced already
-    if !map
-        .chars_at(point)
-        .nth(1)
-        .map(|c| c == '\n')
-        .unwrap_or(true)
-    {
-        *point.column_mut() = point.column().saturating_sub(1);
+    for _ in 0..times {
+        *point.column_mut() += 1;
+        point = movement::find_boundary(map, point, |left, right| {
+            let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
+            let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+
+            left_kind != right_kind && left_kind != CharKind::Whitespace
+        });
+
+        // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know
+        // we have backtraced already
+        if !map
+            .chars_at(point)
+            .nth(1)
+            .map(|(c, _)| c == '\n')
+            .unwrap_or(true)
+        {
+            *point.column_mut() = point.column().saturating_sub(1);
+        }
+        point = map.clip_point(point, Bias::Left);
     }
-    map.clip_point(point, Bias::Left)
+    point
 }
 
 fn previous_word_start(
     map: &DisplaySnapshot,
     mut point: DisplayPoint,
     ignore_punctuation: bool,
+    times: usize,
 ) -> DisplayPoint {
-    // This works even though find_preceding_boundary is called for every character in the line containing
-    // cursor because the newline is checked only once.
-    point = movement::find_preceding_boundary(map, point, |left, right| {
-        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
-        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
-
-        (left_kind != right_kind && !right.is_whitespace()) || left == '\n'
-    });
+    for _ in 0..times {
+        // This works even though find_preceding_boundary is called for every character in the line containing
+        // cursor because the newline is checked only once.
+        point = movement::find_preceding_boundary(map, point, |left, right| {
+            let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
+            let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+
+            (left_kind != right_kind && !right.is_whitespace()) || left == '\n'
+        });
+    }
     point
 }
 
-fn first_non_whitespace(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
-    let mut column = 0;
-    for ch in map.chars_at(DisplayPoint::new(point.row(), 0)) {
+fn first_non_whitespace(map: &DisplaySnapshot, from: DisplayPoint) -> DisplayPoint {
+    let mut last_point = DisplayPoint::new(from.row(), 0);
+    for (ch, point) in map.chars_at(last_point) {
         if ch == '\n' {
-            return point;
+            return from;
         }
 
+        last_point = point;
+
         if char_kind(ch) != CharKind::Whitespace {
             break;
         }
-
-        column += ch.len_utf8() as u32;
     }
 
-    *point.column_mut() = column;
-    map.clip_point(point, Bias::Left)
+    map.clip_point(last_point, Bias::Left)
 }
 
 fn start_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
@@ -333,8 +397,8 @@ fn end_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
     map.clip_point(map.next_line_boundary(point.to_point(map)).1, Bias::Left)
 }
 
-fn start_of_document(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
-    let mut new_point = 0usize.to_display_point(map);
+fn start_of_document(map: &DisplaySnapshot, point: DisplayPoint, line: usize) -> DisplayPoint {
+    let mut new_point = (line - 1).to_display_point(map);
     *new_point.column_mut() = point.column();
     map.clip_point(new_point, Bias::Left)
 }

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

@@ -6,18 +6,24 @@ use std::borrow::Cow;
 
 use crate::{
     motion::Motion,
+    object::Object,
     state::{Mode, Operator},
     Vim,
 };
-use change::init as change_init;
-use collections::HashSet;
-use editor::{Autoscroll, Bias, ClipboardSelection, DisplayPoint};
+use collections::{HashMap, HashSet};
+use editor::{
+    display_map::ToDisplayPoint, Anchor, Autoscroll, Bias, ClipboardSelection, DisplayPoint,
+};
 use gpui::{actions, MutableAppContext, ViewContext};
 use language::{AutoindentMode, SelectionGoal};
 use rope::point::Point;
 use workspace::Workspace;
 
-use self::{change::change_over, delete::delete_over, yank::yank_over};
+use self::{
+    change::{change_motion, change_object},
+    delete::{delete_motion, delete_object},
+    yank::{yank_motion, yank_object},
+};
 
 actions!(
     vim,
@@ -44,48 +50,73 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(insert_line_below);
     cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| {
         Vim::update(cx, |vim, cx| {
-            delete_over(vim, Motion::Left, cx);
+            let times = vim.pop_number_operator(cx);
+            delete_motion(vim, Motion::Left, times, cx);
         })
     });
     cx.add_action(|_: &mut Workspace, _: &DeleteRight, cx| {
         Vim::update(cx, |vim, cx| {
-            delete_over(vim, Motion::Right, cx);
+            let times = vim.pop_number_operator(cx);
+            delete_motion(vim, Motion::Right, times, cx);
         })
     });
     cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| {
         Vim::update(cx, |vim, cx| {
-            change_over(vim, Motion::EndOfLine, cx);
+            let times = vim.pop_number_operator(cx);
+            change_motion(vim, Motion::EndOfLine, times, cx);
         })
     });
     cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| {
         Vim::update(cx, |vim, cx| {
-            delete_over(vim, Motion::EndOfLine, cx);
+            let times = vim.pop_number_operator(cx);
+            delete_motion(vim, Motion::EndOfLine, times, cx);
         })
     });
     cx.add_action(paste);
+}
 
-    change_init(cx);
+pub fn normal_motion(
+    motion: Motion,
+    operator: Option<Operator>,
+    times: usize,
+    cx: &mut MutableAppContext,
+) {
+    Vim::update(cx, |vim, cx| {
+        match operator {
+            None => move_cursor(vim, motion, times, cx),
+            Some(Operator::Change) => change_motion(vim, motion, times, cx),
+            Some(Operator::Delete) => delete_motion(vim, motion, times, cx),
+            Some(Operator::Yank) => yank_motion(vim, motion, times, cx),
+            _ => {
+                // Can't do anything for text objects or namespace operators. Ignoring
+            }
+        }
+    });
 }
 
-pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) {
+pub fn normal_object(object: Object, cx: &mut MutableAppContext) {
     Vim::update(cx, |vim, cx| {
         match vim.state.operator_stack.pop() {
-            None => move_cursor(vim, motion, cx),
-            Some(Operator::Namespace(_)) => {
-                // Can't do anything for a namespace operator. Ignoring
+            Some(Operator::Object { around }) => match vim.state.operator_stack.pop() {
+                Some(Operator::Change) => change_object(vim, object, around, cx),
+                Some(Operator::Delete) => delete_object(vim, object, around, cx),
+                Some(Operator::Yank) => yank_object(vim, object, around, cx),
+                _ => {
+                    // Can't do anything for namespace operators. Ignoring
+                }
+            },
+            _ => {
+                // Can't do anything with change/delete/yank and text objects. 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);
-    });
+    })
 }
 
-fn move_cursor(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
+fn move_cursor(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) {
     vim.update_active_editor(cx, |editor, cx| {
         editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
-            s.move_cursors_with(|map, cursor, goal| motion.move_point(map, cursor, goal))
+            s.move_cursors_with(|map, cursor, goal| motion.move_point(map, cursor, goal, times))
         })
     });
 }
@@ -96,7 +127,7 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext<Workspa
         vim.update_active_editor(cx, |editor, cx| {
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_cursors_with(|map, cursor, goal| {
-                    Motion::Right.move_point(map, cursor, goal)
+                    Motion::Right.move_point(map, cursor, goal, 1)
                 });
             });
         });
@@ -113,7 +144,7 @@ fn insert_first_non_whitespace(
         vim.update_active_editor(cx, |editor, cx| {
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_cursors_with(|map, cursor, goal| {
-                    Motion::FirstNonWhitespace.move_point(map, cursor, goal)
+                    Motion::FirstNonWhitespace.move_point(map, cursor, goal, 1)
                 });
             });
         });
@@ -126,7 +157,7 @@ fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewConte
         vim.update_active_editor(cx, |editor, cx| {
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_cursors_with(|map, cursor, goal| {
-                    Motion::EndOfLine.move_point(map, cursor, goal)
+                    Motion::EndOfLine.move_point(map, cursor, goal, 1)
                 });
             });
         });
@@ -186,7 +217,7 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex
                 });
                 editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                     s.move_cursors_with(|map, cursor, goal| {
-                        Motion::EndOfLine.move_point(map, cursor, goal)
+                        Motion::EndOfLine.move_point(map, cursor, goal, 1)
                     });
                 });
                 editor.edit_with_autoindent(edits, cx);
@@ -224,7 +255,18 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
                             clipboard_text = Cow::Owned(newline_separated_text);
                         }
 
-                        let mut new_selections = Vec::new();
+                        // If the pasted text is a single line, the cursor should be placed after
+                        // the newly pasted text. This is easiest done with an anchor after the
+                        // insertion, and then with a fixup to move the selection back one position.
+                        // However if the pasted text is linewise, the cursor should be placed at the start
+                        // of the new text on the following line. This is easiest done with a manually adjusted
+                        // point.
+                        // This enum lets us represent both cases
+                        enum NewPosition {
+                            Inside(Point),
+                            After(Anchor),
+                        }
+                        let mut new_selections: HashMap<usize, NewPosition> = Default::default();
                         editor.buffer().update(cx, |buffer, cx| {
                             let snapshot = buffer.snapshot(cx);
                             let mut start_offset = 0;
@@ -254,8 +296,10 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
                                         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));
+                                    new_selections.insert(
+                                        selection.id,
+                                        NewPosition::Inside(Point::new(point.row + 1, 0)),
+                                    );
                                     point
                                 } else {
                                     let mut point = selection.end;
@@ -265,7 +309,14 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
                                         .clip_point(point, Bias::Right)
                                         .to_point(&display_map);
 
-                                    new_selections.push(selection.map(|_| point));
+                                    new_selections.insert(
+                                        selection.id,
+                                        if to_insert.contains('\n') {
+                                            NewPosition::Inside(point)
+                                        } else {
+                                            NewPosition::After(snapshot.anchor_after(point))
+                                        },
+                                    );
                                     point
                                 };
 
@@ -283,7 +334,25 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
                         });
 
                         editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
-                            s.select(new_selections)
+                            s.move_with(|map, selection| {
+                                if let Some(new_position) = new_selections.get(&selection.id) {
+                                    match new_position {
+                                        NewPosition::Inside(new_point) => {
+                                            selection.collapse_to(
+                                                new_point.to_display_point(map),
+                                                SelectionGoal::None,
+                                            );
+                                        }
+                                        NewPosition::After(after_point) => {
+                                            let mut new_point = after_point.to_display_point(map);
+                                            *new_point.column_mut() =
+                                                new_point.column().saturating_sub(1);
+                                            new_point = map.clip_point(new_point, Bias::Left);
+                                            selection.collapse_to(new_point, SelectionGoal::None);
+                                        }
+                                    }
+                                }
+                            });
                         });
                     } else {
                         editor.insert(&clipboard_text, cx);
@@ -298,364 +367,165 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
 #[cfg(test)]
 mod test {
     use indoc::indoc;
-    use util::test::marked_text_offsets;
 
     use crate::{
         state::{
             Mode::{self, *},
             Namespace, Operator,
         },
-        vim_test_context::VimTestContext,
+        test::{NeovimBackedTestContext, VimTestContext},
     };
 
     #[gpui::test]
     async fn test_h(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["h"]);
-        cx.assert("The qห‡uick", "The ห‡quick");
-        cx.assert("ห‡The quick", "ห‡The quick");
-        cx.assert(
-            indoc! {"
-                The quick
-                ห‡brown"},
-            indoc! {"
-                The quick
-                ห‡brown"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["h"]);
+        cx.assert_all(indoc! {"
+            ห‡The qห‡uick
+            ห‡brown"
+        })
+        .await;
     }
 
     #[gpui::test]
     async fn test_backspace(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["backspace"]);
-        cx.assert("The qห‡uick", "The ห‡quick");
-        cx.assert("ห‡The quick", "ห‡The quick");
-        cx.assert(
-            indoc! {"
-                The quick
-                ห‡brown"},
-            indoc! {"
-                The quick
-                ห‡brown"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["backspace"]);
+        cx.assert_all(indoc! {"
+            ห‡The qห‡uick
+            ห‡brown"
+        })
+        .await;
     }
 
     #[gpui::test]
     async fn test_j(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["j"]);
-        cx.assert(
-            indoc! {"
-                The ห‡quick
-                brown fox"},
-            indoc! {"
-                The quick
-                browห‡n fox"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                browห‡n fox"},
-            indoc! {"
-                The quick
-                browห‡n fox"},
-        );
-        cx.assert(
-            indoc! {"
-                The quicห‡k
-                brown"},
-            indoc! {"
-                The quick
-                browห‡n"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                ห‡brown"},
-            indoc! {"
-                The quick
-                ห‡brown"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["j"]);
+        cx.assert_all(indoc! {"
+            ห‡The qห‡uick broห‡wn
+            ห‡fox jumps"
+        })
+        .await;
     }
 
     #[gpui::test]
     async fn test_k(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["k"]);
-        cx.assert(
-            indoc! {"
-                The ห‡quick
-                brown fox"},
-            indoc! {"
-                The ห‡quick
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                browห‡n fox"},
-            indoc! {"
-                The ห‡quick
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
-                The
-                quicห‡k"},
-            indoc! {"
-                Thห‡e
-                quick"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["k"]);
+        cx.assert_all(indoc! {"
+            ห‡The qห‡uick
+            ห‡brown fห‡ox jumห‡ps"
+        })
+        .await;
     }
 
     #[gpui::test]
     async fn test_l(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["l"]);
-        cx.assert("The qห‡uick", "The quห‡ick");
-        cx.assert("The quicห‡k", "The quicห‡k");
-        cx.assert(
-            indoc! {"
-                The quicห‡k
-                brown"},
-            indoc! {"
-                The quicห‡k
-                brown"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["l"]);
+        cx.assert_all(indoc! {"
+            ห‡The qห‡uicห‡k
+            ห‡browห‡n"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_jump_to_line_boundaries(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["$"]);
-        cx.assert("Tห‡est test", "Test tesห‡t");
-        cx.assert("Test tesห‡t", "Test tesห‡t");
-        cx.assert(
-            indoc! {"
-                The ห‡quick
-                brown"},
-            indoc! {"
-                The quicห‡k
-                brown"},
-        );
-        cx.assert(
-            indoc! {"
-                The quicห‡k
-                brown"},
-            indoc! {"
-                The quicห‡k
-                brown"},
-        );
-
-        let mut cx = cx.binding(["0"]);
-        cx.assert("Test ห‡test", "ห‡Test test");
-        cx.assert("ห‡Test test", "ห‡Test test");
-        cx.assert(
-            indoc! {"
-                The ห‡quick
-                brown"},
-            indoc! {"
-                ห‡The quick
-                brown"},
-        );
-        cx.assert(
-            indoc! {"
-                ห‡The quick
-                brown"},
-            indoc! {"
-                ห‡The quick
-                brown"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.assert_binding_matches_all(
+            ["$"],
+            indoc! {"
+            ห‡The qห‡uicห‡k
+            ห‡browห‡n"},
+        )
+        .await;
+        cx.assert_binding_matches_all(
+            ["0"],
+            indoc! {"
+                ห‡The qห‡uicห‡k
+                ห‡browห‡n"},
+        )
+        .await;
     }
 
     #[gpui::test]
     async fn test_jump_to_end(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-g"]);
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-g"]);
 
-        cx.assert(
-            indoc! {"
+        cx.assert_all(indoc! {"
                 The ห‡quick
                 
                 brown fox jumps
-                over the lazy dog"},
-            indoc! {"
-                The quick
-                
-                brown fox jumps
-                overห‡ the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                
-                brown fox jumps
-                overห‡ the lazy dog"},
-            indoc! {"
-                The quick
-                
-                brown fox jumps
-                overห‡ the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
+                overห‡ the lazy doห‡g"})
+            .await;
+        cx.assert(indoc! {"
             The quiห‡ck
             
-            brown"},
-            indoc! {"
-            The quick
-            
-            browห‡n"},
-        );
-        cx.assert(
-            indoc! {"
+            brown"})
+            .await;
+        cx.assert(indoc! {"
             The quiห‡ck
             
-            "},
-            indoc! {"
-            The quick
-            
-            ห‡"},
-        );
+            "})
+            .await;
     }
 
     #[gpui::test]
     async fn test_w(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true).await;
-        let (_, cursor_offsets) = marked_text_offsets(indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["w"]);
+        cx.assert_all(indoc! {"
             The ห‡quickห‡-ห‡brown
             ห‡
             ห‡
             ห‡fox_jumps ห‡over
-            ห‡thห‡ห‡e"});
-        cx.set_state(
-            indoc! {"
-            ห‡The quick-brown
-            
-            
-            fox_jumps over
-            the"},
-            Mode::Normal,
-        );
-
-        for cursor_offset in cursor_offsets {
-            cx.simulate_keystroke("w");
-            cx.assert_editor_selections(vec![cursor_offset..cursor_offset]);
-        }
-
-        // Reset and test ignoring punctuation
-        let (_, cursor_offsets) = marked_text_offsets(indoc! {"
-            The ห‡quick-brown
+            ห‡thห‡e"})
+            .await;
+        let mut cx = cx.binding(["shift-w"]);
+        cx.assert_all(indoc! {"
+            The ห‡quickห‡-ห‡brown
             ห‡
             ห‡
             ห‡fox_jumps ห‡over
-            ห‡thห‡ห‡e"});
-        cx.set_state(
-            indoc! {"
-            ห‡The quick-brown
-            
-            
-            fox_jumps over
-            the"},
-            Mode::Normal,
-        );
-
-        for cursor_offset in cursor_offsets {
-            cx.simulate_keystroke("shift-w");
-            cx.assert_editor_selections(vec![cursor_offset..cursor_offset]);
-        }
+            ห‡thห‡e"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_e(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true).await;
-        let (_, cursor_offsets) = marked_text_offsets(indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["e"]);
+        cx.assert_all(indoc! {"
             Thห‡e quicห‡kห‡-browห‡n
             
             
             fox_jumpห‡s oveห‡r
-            thห‡e"});
-        cx.set_state(
-            indoc! {"
-            ห‡The quick-brown
-            
-            
-            fox_jumps over
-            the"},
-            Mode::Normal,
-        );
-
-        for cursor_offset in cursor_offsets {
-            cx.simulate_keystroke("e");
-            cx.assert_editor_selections(vec![cursor_offset..cursor_offset]);
-        }
-
-        // Reset and test ignoring punctuation
-        let (_, cursor_offsets) = marked_text_offsets(indoc! {"
-            Thห‡e quick-browห‡n
+            thห‡e"})
+            .await;
+        let mut cx = cx.binding(["shift-e"]);
+        cx.assert_all(indoc! {"
+            Thห‡e quicห‡kห‡-browห‡n
             
             
             fox_jumpห‡s oveห‡r
-            thห‡ห‡e"});
-        cx.set_state(
-            indoc! {"
-            ห‡The quick-brown
-            
-            
-            fox_jumps over
-            the"},
-            Mode::Normal,
-        );
-        for cursor_offset in cursor_offsets {
-            cx.simulate_keystroke("shift-e");
-            cx.assert_editor_selections(vec![cursor_offset..cursor_offset]);
-        }
+            thห‡e"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_b(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true).await;
-        let (_, cursor_offsets) = marked_text_offsets(indoc! {"
-            ห‡ห‡The ห‡quickห‡-ห‡brown
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["b"]);
+        cx.assert_all(indoc! {"
+            ห‡The ห‡quickห‡-ห‡brown
             ห‡
             ห‡
             ห‡fox_jumps ห‡over
-            ห‡the"});
-        cx.set_state(
-            indoc! {"
-            The quick-brown
-            
-            
-            fox_jumps over
-            thห‡e"},
-            Mode::Normal,
-        );
-
-        for cursor_offset in cursor_offsets.into_iter().rev() {
-            cx.simulate_keystroke("b");
-            cx.assert_editor_selections(vec![cursor_offset..cursor_offset]);
-        }
-
-        // Reset and test ignoring punctuation
-        let (_, cursor_offsets) = marked_text_offsets(indoc! {"
-            ห‡ห‡The ห‡quick-brown
+            ห‡the"})
+            .await;
+        let mut cx = cx.binding(["shift-b"]);
+        cx.assert_all(indoc! {"
+            ห‡The ห‡quickห‡-ห‡brown
             ห‡
             ห‡
             ห‡fox_jumps ห‡over
-            ห‡the"});
-        cx.set_state(
-            indoc! {"
-            The quick-brown
-            
-            
-            fox_jumps over
-            thห‡e"},
-            Mode::Normal,
-        );
-        for cursor_offset in cursor_offsets.into_iter().rev() {
-            cx.simulate_keystroke("shift-b");
-            cx.assert_editor_selections(vec![cursor_offset..cursor_offset]);
-        }
+            ห‡the"})
+            .await;
     }
 
     #[gpui::test]
@@ -676,513 +546,271 @@ mod test {
 
     #[gpui::test]
     async fn test_gg(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["g", "g"]);
-        cx.assert(
-            indoc! {"
-                The quick
-            
-                brown fox jumps
-                over ห‡the lazy dog"},
-            indoc! {"
-                The qห‡uick
-            
-                brown fox jumps
-                over the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The qห‡uick
-            
-                brown fox jumps
-                over the lazy dog"},
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.assert_binding_matches_all(
+            ["g", "g"],
             indoc! {"
                 The qห‡uick
             
                 brown fox jumps
-                over the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-            
-                brown fox jumps
-                over the laห‡zy dog"},
-            indoc! {"
-                The quicห‡k
-            
-                brown fox jumps
-                over the lazy dog"},
-        );
-        cx.assert(
+                over ห‡the laห‡zy dog"},
+        )
+        .await;
+        cx.assert_binding_matches(
+            ["g", "g"],
             indoc! {"
                 
             
                 brown fox jumps
                 over the laห‡zy dog"},
+        )
+        .await;
+        cx.assert_binding_matches(
+            ["2", "g", "g"],
             indoc! {"
-                ห‡
-            
-                brown fox jumps
-                over the lazy dog"},
-        );
+                
+                
+                brown fox juห‡mps
+                over the lazydog"},
+        )
+        .await;
     }
 
     #[gpui::test]
     async fn test_a(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["a"]).mode_after(Mode::Insert);
-
-        cx.assert("The qห‡uick", "The quห‡ick");
-        cx.assert("The quicห‡k", "The quickห‡");
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["a"]);
+        cx.assert_all("The qห‡uicห‡k").await;
     }
 
     #[gpui::test]
     async fn test_insert_end_of_line(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-a"]).mode_after(Mode::Insert);
-        cx.assert("The qห‡uick", "The quickห‡");
-        cx.assert("The qห‡uick ", "The quick ห‡");
-        cx.assert("ห‡", "ห‡");
-        cx.assert(
-            indoc! {"
-                The qห‡uick
-                brown fox"},
-            indoc! {"
-                The quickห‡
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
-                ห‡
-                The quick"},
-            indoc! {"
-                ห‡
-                The quick"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-a"]);
+        cx.assert_all(indoc! {"
+            ห‡
+            The qห‡uick
+            brown ห‡fox "})
+            .await;
     }
 
     #[gpui::test]
     async fn test_jump_to_first_non_whitespace(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["^"]);
-        cx.assert("The qห‡uick", "ห‡The quick");
-        cx.assert(" The qห‡uick", " ห‡The quick");
-        cx.assert("ห‡", "ห‡");
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["^"]);
+        cx.assert("The qห‡uick").await;
+        cx.assert(" The qห‡uick").await;
+        cx.assert("ห‡").await;
+        cx.assert(indoc! {"
                 The qห‡uick
-                brown fox"},
-            indoc! {"
-                ห‡The quick
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
-                ห‡
-                The quick"},
-            indoc! {"
+                brown fox"})
+            .await;
+        cx.assert(indoc! {"
                 ห‡
-                The quick"},
-        );
+                The quick"})
+            .await;
         // Indoc disallows trailing whitspace.
-        cx.assert("   ห‡ \nThe quick", "   ห‡ \nThe quick");
+        cx.assert("   ห‡ \nThe quick").await;
     }
 
     #[gpui::test]
     async fn test_insert_first_non_whitespace(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-i"]).mode_after(Mode::Insert);
-        cx.assert("The qห‡uick", "ห‡The quick");
-        cx.assert(" The qห‡uick", " ห‡The quick");
-        cx.assert("ห‡", "ห‡");
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-i"]);
+        cx.assert("The qห‡uick").await;
+        cx.assert(" The qห‡uick").await;
+        cx.assert("ห‡").await;
+        cx.assert(indoc! {"
                 The qห‡uick
-                brown fox"},
-            indoc! {"
-                ห‡The quick
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
+                brown fox"})
+            .await;
+        cx.assert(indoc! {"
                 ห‡
-                The quick"},
-            indoc! {"
-                ห‡
-                The quick"},
-        );
+                The quick"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_delete_to_end_of_line(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-d"]);
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-d"]);
+        cx.assert(indoc! {"
                 The qห‡uick
-                brown fox"},
-            indoc! {"
-                The ห‡q
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                ห‡
-                brown fox"},
-            indoc! {"
+                brown fox"})
+            .await;
+        cx.assert(indoc! {"
                 The quick
                 ห‡
-                brown fox"},
-        );
+                brown fox"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_x(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["x"]);
-        cx.assert("ห‡Test", "ห‡est");
-        cx.assert("Teห‡st", "Teห‡t");
-        cx.assert("Tesห‡t", "Teห‡s");
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["x"]);
+        cx.assert_all("ห‡Teห‡sห‡t").await;
+        cx.assert(indoc! {"
                 Tesห‡t
-                test"},
-            indoc! {"
-                Teห‡s
-                test"},
-        );
+                test"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_delete_left(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-x"]);
-        cx.assert("Teห‡st", "Tห‡st");
-        cx.assert("Tห‡est", "ห‡est");
-        cx.assert("ห‡Test", "ห‡Test");
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-x"]);
+        cx.assert_all("ห‡Tห‡eห‡sห‡t").await;
+        cx.assert(indoc! {"
                 Test
-                ห‡test"},
-            indoc! {"
-                Test
-                ห‡test"},
-        );
+                ห‡test"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_o(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["o"]).mode_after(Mode::Insert);
-
-        cx.assert(
-            "ห‡",
-            indoc! {"
-                
-                ห‡"},
-        );
-        cx.assert(
-            "The ห‡quick",
-            indoc! {"
-                The quick
-                ห‡"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                brown ห‡fox
-                jumps over"},
-            indoc! {"
-                The quick
-                brown fox
-                ห‡
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                brown fox
-                jumps ห‡over"},
-            indoc! {"
-                The quick
-                brown fox
-                jumps over
-                ห‡"},
-        );
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["o"]);
+        cx.assert("ห‡").await;
+        cx.assert("The ห‡quick").await;
+        cx.assert_all(indoc! {"
                 The qห‡uick
-                brown fox
-                jumps over"},
-            indoc! {"
-                The quick
-                ห‡
-                brown fox
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
+                brown ห‡fox
+                jumps ห‡over"})
+            .await;
+        cx.assert(indoc! {"
                 The quick
                 ห‡
-                brown fox"},
-            indoc! {"
-                The quick
-                
-                ห‡
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
+                brown fox"})
+            .await;
+        cx.assert(indoc! {"
                 fn test() {
                     println!(ห‡);
                 }
-            "},
-            indoc! {"
-                fn test() {
-                    println!();
-                    ห‡
-                }
-            "},
-        );
-        cx.assert(
-            indoc! {"
+            "})
+            .await;
+        cx.assert(indoc! {"
                 fn test(ห‡) {
                     println!();
-                }"},
-            indoc! {"
-                fn test() {
-                ห‡
-                    println!();
-                }"},
-        );
+                }"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_insert_line_above(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-o"]).mode_after(Mode::Insert);
+        let cx = NeovimBackedTestContext::new(cx).await;
+        let mut cx = cx.binding(["shift-o"]);
+        cx.assert("ห‡").await;
+        cx.assert("The ห‡quick").await;
+        cx.assert_all(indoc! {"
+            The qห‡uick
+            brown ห‡fox
+            jumps ห‡over"})
+            .await;
+        cx.assert(indoc! {"
+            The quick
+            ห‡
+            brown fox"})
+            .await;
 
-        cx.assert(
-            "ห‡",
-            indoc! {"
-                ห‡
-                "},
-        );
-        cx.assert(
-            "The ห‡quick",
-            indoc! {"
-                ห‡
-                The quick"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                brown ห‡fox
-                jumps over"},
-            indoc! {"
-                The quick
-                ห‡
-                brown fox
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                brown fox
-                jumps ห‡over"},
-            indoc! {"
-                The quick
-                brown fox
-                ห‡
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
-                The qห‡uick
-                brown fox
-                jumps over"},
-            indoc! {"
-                ห‡
-                The quick
-                brown fox
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                ห‡
-                brown fox"},
-            indoc! {"
-                The quick
-                ห‡
-                
-                brown fox"},
-        );
-        cx.assert(
+        // Our indentation is smarter than vims. So we don't match here
+        cx.assert_manual(
             indoc! {"
                 fn test()
                     println!(ห‡);"},
+            Mode::Normal,
             indoc! {"
                 fn test()
                     ห‡
                     println!();"},
+            Mode::Insert,
         );
-        cx.assert(
+        cx.assert_manual(
             indoc! {"
                 fn test(ห‡) {
                     println!();
                 }"},
+            Mode::Normal,
             indoc! {"
                 ห‡
                 fn test() {
                     println!();
                 }"},
+            Mode::Insert,
         );
     }
 
     #[gpui::test]
     async fn test_dd(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["d", "d"]);
-
-        cx.assert("ห‡", "ห‡");
-        cx.assert("The ห‡quick", "ห‡");
-        cx.assert(
-            indoc! {"
-                The quick
-                brown ห‡fox
-                jumps over"},
-            indoc! {"
-                The quick
-                jumps ห‡over"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                brown fox
-                jumps ห‡over"},
-            indoc! {"
-                The quick
-                brown ห‡fox"},
-        );
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["d", "d"]);
+        cx.assert("ห‡").await;
+        cx.assert("The ห‡quick").await;
+        cx.assert_all(indoc! {"
                 The qห‡uick
-                brown fox
-                jumps over"},
-            indoc! {"
-                brownห‡ fox
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
+                brown ห‡fox
+                jumps ห‡over"})
+            .await;
+        cx.assert(indoc! {"
                 The quick
                 ห‡
-                brown fox"},
-            indoc! {"
-                The quick
-                ห‡brown fox"},
-        );
+                brown fox"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_cc(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["c", "c"]).mode_after(Mode::Insert);
-
-        cx.assert("ห‡", "ห‡");
-        cx.assert("The ห‡quick", "ห‡");
-        cx.assert(
-            indoc! {"
-                The quick
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["c", "c"]);
+        cx.assert("ห‡").await;
+        cx.assert("The ห‡quick").await;
+        cx.assert_all(indoc! {"
+                The quห‡ick
                 brown ห‡fox
-                jumps over"},
-            indoc! {"
+                jumps ห‡over"})
+            .await;
+        cx.assert(indoc! {"
                 The quick
                 ห‡
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                brown fox
-                jumps ห‡over"},
-            indoc! {"
-                The quick
-                brown fox
-                ห‡"},
-        );
-        cx.assert(
-            indoc! {"
-                The qห‡uick
-                brown fox
-                jumps over"},
-            indoc! {"
-                ห‡
-                brown fox
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                ห‡
-                brown fox"},
-            indoc! {"
-                The quick
-                ห‡
-                brown fox"},
-        );
+                brown fox"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_p(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true).await;
-        cx.set_state(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {"
                 The quick brown
                 fox juห‡mps over
-                the lazy dog"},
-            Mode::Normal,
-        );
+                the lazy dog"})
+            .await;
 
-        cx.simulate_keystrokes(["d", "d"]);
-        cx.assert_editor_state(indoc! {"
-            The quick brown
-            the laห‡zy dog"});
+        cx.simulate_shared_keystrokes(["d", "d"]).await;
+        cx.assert_state_matches().await;
 
-        cx.simulate_keystroke("p");
-        cx.assert_state(
-            indoc! {"
-                The quick brown
-                the lazy dog
-                ห‡fox jumps over"},
-            Mode::Normal,
-        );
+        cx.simulate_shared_keystroke("p").await;
+        cx.assert_state_matches().await;
 
-        cx.set_state(
-            indoc! {"
+        cx.set_shared_state(indoc! {"
                 The quick brown
-                fox ยซjumpห‡ยปs over
-                the lazy dog"},
-            Mode::Visual { line: false },
-        );
-        cx.simulate_keystroke("y");
-        cx.set_state(
-            indoc! {"
+                fox ห‡jumps over
+                the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["v", "w", "y"]).await;
+        cx.set_shared_state(indoc! {"
                 The quick brown
                 fox jumps oveห‡r
-                the lazy dog"},
-            Mode::Normal,
-        );
-        cx.simulate_keystroke("p");
-        cx.assert_state(
-            indoc! {"
-                The quick brown
-                fox jumps overห‡jumps
-                the lazy dog"},
-            Mode::Normal,
-        );
+                the lazy dog"})
+            .await;
+        cx.simulate_shared_keystroke("p").await;
+        cx.assert_state_matches().await;
+    }
+
+    #[gpui::test]
+    async fn test_repeated_word(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        for count in 1..=5 {
+            cx.assert_binding_matches_all(
+                [&count.to_string(), "w"],
+                indoc! {"
+                    ห‡The quห‡ickห‡ browห‡n
+                    ห‡
+                    ห‡fox ห‡jumpsห‡-ห‡oห‡ver
+                    ห‡the lazy dog
+                "},
+            )
+            .await;
+        }
     }
 }

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

@@ -1,30 +1,20 @@
-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;
-use workspace::Workspace;
-
-#[derive(Clone, Deserialize, PartialEq)]
-#[serde(rename_all = "camelCase")]
-struct ChangeWord {
-    #[serde(default)]
-    ignore_punctuation: bool,
-}
+use crate::{motion::Motion, object::Object, state::Mode, utils::copy_selections_content, Vim};
+use editor::{char_kind, display_map::DisplaySnapshot, movement, Autoscroll, DisplayPoint};
+use gpui::MutableAppContext;
+use language::Selection;
 
-impl_actions!(vim, [ChangeWord]);
-
-pub fn init(cx: &mut MutableAppContext) {
-    cx.add_action(change_word);
-}
-
-pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
+pub fn change_motion(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) {
     vim.update_active_editor(cx, |editor, cx| {
         editor.transact(cx, |editor, cx| {
             // We are swapping to insert mode anyway. Just set the line end clipping behavior now
             editor.set_clip_at_line_ends(false, cx);
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_with(|map, selection| {
-                    motion.expand_selection(map, selection, false);
+                    if let Motion::NextWordStart { ignore_punctuation } = motion {
+                        expand_changed_word_selection(map, selection, times, ignore_punctuation);
+                    } else {
+                        motion.expand_selection(map, selection, times, false);
+                    }
                 });
             });
             copy_selections_content(editor, motion.linewise(), cx);
@@ -34,43 +24,60 @@ pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
     vim.switch_mode(Mode::Insert, false, cx)
 }
 
+pub fn change_object(vim: &mut Vim, object: Object, around: bool, cx: &mut MutableAppContext) {
+    let mut objects_found = false;
+    vim.update_active_editor(cx, |editor, cx| {
+        // We are swapping to insert mode anyway. Just set the line end clipping behavior now
+        editor.set_clip_at_line_ends(false, cx);
+        editor.transact(cx, |editor, cx| {
+            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                s.move_with(|map, selection| {
+                    objects_found |= object.expand_selection(map, selection, around);
+                });
+            });
+            if objects_found {
+                copy_selections_content(editor, false, cx);
+                editor.insert("", cx);
+            }
+        });
+    });
+
+    if objects_found {
+        vim.switch_mode(Mode::Insert, false, cx);
+    } else {
+        vim.switch_mode(Mode::Normal, false, cx);
+    }
+}
+
 // From the docs https://vimhelp.org/change.txt.html#cw
 // Special case: When the cursor is in a word, "cw" and "cW" do not include the
 // white space after a word, they only change up to the end of the word. This is
 // because Vim interprets "cw" as change-word, and a word does not include the
 // following white space.
-fn change_word(
-    _: &mut Workspace,
-    &ChangeWord { ignore_punctuation }: &ChangeWord,
-    cx: &mut ViewContext<Workspace>,
+fn expand_changed_word_selection(
+    map: &DisplaySnapshot,
+    selection: &mut Selection<DisplayPoint>,
+    times: usize,
+    ignore_punctuation: bool,
 ) {
-    Vim::update(cx, |vim, cx| {
-        vim.update_active_editor(cx, |editor, cx| {
-            editor.transact(cx, |editor, cx| {
-                // We are swapping to insert mode anyway. Just set the line end clipping behavior now
-                editor.set_clip_at_line_ends(false, cx);
-                editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
-                    s.move_with(|map, selection| {
-                        if selection.end.column() == map.line_len(selection.end.row()) {
-                            return;
-                        }
-
-                        selection.end =
-                            movement::find_boundary(map, selection.end, |left, right| {
-                                let left_kind =
-                                    char_kind(left).coerce_punctuation(ignore_punctuation);
-                                let right_kind =
-                                    char_kind(right).coerce_punctuation(ignore_punctuation);
-
-                                left_kind != right_kind || left == '\n' || right == '\n'
-                            });
-                    });
-                });
-                copy_selections_content(editor, false, cx);
-                editor.insert("", cx);
-            });
-        });
-        vim.switch_mode(Mode::Insert, false, cx);
+    if times > 1 {
+        Motion::NextWordStart { ignore_punctuation }.expand_selection(
+            map,
+            selection,
+            times - 1,
+            false,
+        );
+    }
+
+    if times == 1 && selection.end.column() == map.line_len(selection.end.row()) {
+        return;
+    }
+
+    selection.end = movement::find_boundary(map, selection.end, |left, right| {
+        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
+        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+
+        left_kind != right_kind || left == '\n' || right == '\n'
     });
 }
 
@@ -78,7 +85,10 @@ fn change_word(
 mod test {
     use indoc::indoc;
 
-    use crate::{state::Mode, vim_test_context::VimTestContext};
+    use crate::{
+        state::Mode,
+        test::{NeovimBackedTestContext, VimTestContext},
+    };
 
     #[gpui::test]
     async fn test_change_h(cx: &mut gpui::TestAppContext) {
@@ -170,8 +180,7 @@ mod test {
                 test"},
             indoc! {"
                 Test test
-                ห‡
-                test"},
+                ห‡"},
         );
 
         let mut cx = cx.binding(["c", "shift-e"]);
@@ -193,6 +202,7 @@ mod test {
                 Test ห‡
                 test"},
         );
+        println!("Marker");
         cx.assert(
             indoc! {"
                 Test test
@@ -442,4 +452,85 @@ mod test {
                 the lazy"},
         );
     }
+
+    #[gpui::test]
+    async fn test_repeated_cj(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        for count in 1..=5 {
+            cx.assert_binding_matches_all(
+                ["c", &count.to_string(), "j"],
+                indoc! {"
+                    ห‡The quห‡ickห‡ browห‡n
+                    ห‡
+                    ห‡fox ห‡jumpsห‡-ห‡oห‡ver
+                    ห‡the lazy dog
+                    "},
+            )
+            .await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_repeated_cl(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        for count in 1..=5 {
+            cx.assert_binding_matches_all(
+                ["c", &count.to_string(), "l"],
+                indoc! {"
+                    ห‡The quห‡ickห‡ browห‡n
+                    ห‡
+                    ห‡fox ห‡jumpsห‡-ห‡oห‡ver
+                    ห‡the lazy dog
+                    "},
+            )
+            .await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_repeated_cb(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        // Changing back any number of times from the start of the file doesn't
+        // switch to insert mode in vim. This is weird and painful to implement
+        cx.add_initial_state_exemption(indoc! {"
+            ห‡The quick brown
+            
+            fox jumps-over
+            the lazy dog
+            "});
+
+        for count in 1..=5 {
+            cx.assert_binding_matches_all(
+                ["c", &count.to_string(), "b"],
+                indoc! {"
+                    ห‡The quห‡ickห‡ browห‡n
+                    ห‡
+                    ห‡fox ห‡jumpsห‡-ห‡oห‡ver
+                    ห‡the lazy dog
+                    "},
+            )
+            .await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_repeated_ce(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        for count in 1..=5 {
+            cx.assert_binding_matches_all(
+                ["c", &count.to_string(), "e"],
+                indoc! {"
+                    ห‡The quห‡ickห‡ browห‡n
+                    ห‡
+                    ห‡fox ห‡jumpsห‡-ห‡oห‡ver
+                    ห‡the lazy dog
+                    "},
+            )
+            .await;
+        }
+    }
 }

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

@@ -1,9 +1,9 @@
-use crate::{motion::Motion, utils::copy_selections_content, Vim};
-use collections::HashMap;
-use editor::{Autoscroll, Bias};
+use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim};
+use collections::{HashMap, HashSet};
+use editor::{display_map::ToDisplayPoint, Autoscroll, Bias};
 use gpui::MutableAppContext;
 
-pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
+pub fn delete_motion(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) {
     vim.update_active_editor(cx, |editor, cx| {
         editor.transact(cx, |editor, cx| {
             editor.set_clip_at_line_ends(false, cx);
@@ -11,8 +11,8 @@ pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_with(|map, selection| {
                     let original_head = selection.head();
-                    motion.expand_selection(map, selection, true);
                     original_columns.insert(selection.id, original_head.column());
+                    motion.expand_selection(map, selection, times, true);
                 });
             });
             copy_selections_content(editor, motion.linewise(), cx);
@@ -36,11 +36,67 @@ pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
     });
 }
 
+pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut MutableAppContext) {
+    vim.update_active_editor(cx, |editor, cx| {
+        editor.transact(cx, |editor, cx| {
+            editor.set_clip_at_line_ends(false, cx);
+            // Emulates behavior in vim where if we expanded backwards to include a newline
+            // the cursor gets set back to the start of the line
+            let mut should_move_to_start: HashSet<_> = Default::default();
+            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                s.move_with(|map, selection| {
+                    object.expand_selection(map, selection, around);
+                    let offset_range = selection.map(|p| p.to_offset(map, Bias::Left)).range();
+                    let contains_only_newlines = map
+                        .chars_at(selection.start)
+                        .take_while(|(_, p)| p < &selection.end)
+                        .all(|(char, _)| char == '\n')
+                        && !offset_range.is_empty();
+                    let end_at_newline = map
+                        .chars_at(selection.end)
+                        .next()
+                        .map(|(c, _)| c == '\n')
+                        .unwrap_or(false);
+
+                    // If expanded range contains only newlines and
+                    // the object is around or sentence, expand to include a newline
+                    // at the end or start
+                    if (around || object == Object::Sentence) && contains_only_newlines {
+                        if end_at_newline {
+                            selection.end =
+                                (offset_range.end + '\n'.len_utf8()).to_display_point(map);
+                        } else if selection.start.row() > 0 {
+                            should_move_to_start.insert(selection.id);
+                            selection.start =
+                                (offset_range.start - '\n'.len_utf8()).to_display_point(map);
+                        }
+                    }
+                });
+            });
+            copy_selections_content(editor, false, 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();
+                    if should_move_to_start.contains(&selection.id) {
+                        *cursor.column_mut() = 0;
+                    }
+                    cursor = map.clip_point(cursor, Bias::Left);
+                    selection.collapse_to(cursor, selection.goal)
+                });
+            });
+        });
+    });
+}
+
 #[cfg(test)]
 mod test {
     use indoc::indoc;
 
-    use crate::{state::Mode, vim_test_context::VimTestContext};
+    use crate::{state::Mode, test::VimTestContext};
 
     #[gpui::test]
     async fn test_delete_h(cx: &mut gpui::TestAppContext) {
@@ -140,8 +196,7 @@ mod test {
                 test"},
             indoc! {"
                 Test test
-                ห‡
-                test"},
+                ห‡"},
         );
 
         let mut cx = cx.binding(["d", "shift-e"]);

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

@@ -1,8 +1,8 @@
-use crate::{motion::Motion, utils::copy_selections_content, Vim};
+use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim};
 use collections::HashMap;
 use gpui::MutableAppContext;
 
-pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
+pub fn yank_motion(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) {
     vim.update_active_editor(cx, |editor, cx| {
         editor.transact(cx, |editor, cx| {
             editor.set_clip_at_line_ends(false, cx);
@@ -10,8 +10,8 @@ pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
             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);
+                    motion.expand_selection(map, selection, times, true);
                 });
             });
             copy_selections_content(editor, motion.linewise(), cx);
@@ -24,3 +24,26 @@ pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
         });
     });
 }
+
+pub fn yank_object(vim: &mut Vim, object: Object, around: bool, 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);
+                    object.expand_selection(map, selection, around);
+                    original_positions.insert(selection.id, original_position);
+                });
+            });
+            copy_selections_content(editor, false, 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/object.rs ๐Ÿ”—

@@ -0,0 +1,640 @@
+use std::ops::Range;
+
+use editor::{char_kind, display_map::DisplaySnapshot, movement, Bias, CharKind, DisplayPoint};
+use gpui::{actions, impl_actions, MutableAppContext};
+use language::Selection;
+use serde::Deserialize;
+use workspace::Workspace;
+
+use crate::{motion::right, normal::normal_object, state::Mode, visual::visual_object, Vim};
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum Object {
+    Word { ignore_punctuation: bool },
+    Sentence,
+    Quotes,
+    BackQuotes,
+    DoubleQuotes,
+    Parentheses,
+    SquareBrackets,
+    CurlyBrackets,
+    AngleBrackets,
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct Word {
+    #[serde(default)]
+    ignore_punctuation: bool,
+}
+
+actions!(
+    vim,
+    [
+        Sentence,
+        Quotes,
+        BackQuotes,
+        DoubleQuotes,
+        Parentheses,
+        SquareBrackets,
+        CurlyBrackets,
+        AngleBrackets
+    ]
+);
+impl_actions!(vim, [Word]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(
+        |_: &mut Workspace, &Word { ignore_punctuation }: &Word, cx: _| {
+            object(Object::Word { ignore_punctuation }, cx)
+        },
+    );
+    cx.add_action(|_: &mut Workspace, _: &Sentence, cx: _| object(Object::Sentence, cx));
+    cx.add_action(|_: &mut Workspace, _: &Quotes, cx: _| object(Object::Quotes, cx));
+    cx.add_action(|_: &mut Workspace, _: &BackQuotes, cx: _| object(Object::BackQuotes, cx));
+    cx.add_action(|_: &mut Workspace, _: &DoubleQuotes, cx: _| object(Object::DoubleQuotes, cx));
+    cx.add_action(|_: &mut Workspace, _: &Parentheses, cx: _| object(Object::Parentheses, cx));
+    cx.add_action(|_: &mut Workspace, _: &SquareBrackets, cx: _| {
+        object(Object::SquareBrackets, cx)
+    });
+    cx.add_action(|_: &mut Workspace, _: &CurlyBrackets, cx: _| object(Object::CurlyBrackets, cx));
+    cx.add_action(|_: &mut Workspace, _: &AngleBrackets, cx: _| object(Object::AngleBrackets, cx));
+}
+
+fn object(object: Object, cx: &mut MutableAppContext) {
+    match Vim::read(cx).state.mode {
+        Mode::Normal => normal_object(object, cx),
+        Mode::Visual { .. } => visual_object(object, cx),
+        Mode::Insert => {
+            // Shouldn't execute a text object in insert mode. Ignoring
+        }
+    }
+}
+
+impl Object {
+    pub fn range(
+        self,
+        map: &DisplaySnapshot,
+        relative_to: DisplayPoint,
+        around: bool,
+    ) -> Option<Range<DisplayPoint>> {
+        match self {
+            Object::Word { ignore_punctuation } => {
+                if around {
+                    around_word(map, relative_to, ignore_punctuation)
+                } else {
+                    in_word(map, relative_to, ignore_punctuation)
+                }
+            }
+            Object::Sentence => sentence(map, relative_to, around),
+            Object::Quotes => surrounding_markers(map, relative_to, around, false, '\'', '\''),
+            Object::BackQuotes => surrounding_markers(map, relative_to, around, false, '`', '`'),
+            Object::DoubleQuotes => surrounding_markers(map, relative_to, around, false, '"', '"'),
+            Object::Parentheses => surrounding_markers(map, relative_to, around, true, '(', ')'),
+            Object::SquareBrackets => surrounding_markers(map, relative_to, around, true, '[', ']'),
+            Object::CurlyBrackets => surrounding_markers(map, relative_to, around, true, '{', '}'),
+            Object::AngleBrackets => surrounding_markers(map, relative_to, around, true, '<', '>'),
+        }
+    }
+
+    pub fn expand_selection(
+        self,
+        map: &DisplaySnapshot,
+        selection: &mut Selection<DisplayPoint>,
+        around: bool,
+    ) -> bool {
+        if let Some(range) = self.range(map, selection.head(), around) {
+            selection.start = range.start;
+            selection.end = range.end;
+            true
+        } else {
+            false
+        }
+    }
+}
+
+/// Return a range that surrounds the word relative_to is in
+/// If relative_to is at the start of a word, return the word.
+/// If relative_to is between words, return the space between
+fn in_word(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    ignore_punctuation: bool,
+) -> Option<Range<DisplayPoint>> {
+    // Use motion::right so that we consider the character under the cursor when looking for the start
+    let start = movement::find_preceding_boundary_in_line(
+        map,
+        right(map, relative_to, 1),
+        |left, right| {
+            char_kind(left).coerce_punctuation(ignore_punctuation)
+                != char_kind(right).coerce_punctuation(ignore_punctuation)
+        },
+    );
+    let end = movement::find_boundary_in_line(map, relative_to, |left, right| {
+        char_kind(left).coerce_punctuation(ignore_punctuation)
+            != char_kind(right).coerce_punctuation(ignore_punctuation)
+    });
+
+    Some(start..end)
+}
+
+/// Return a range that surrounds the word and following whitespace
+/// relative_to is in.
+/// If relative_to is at the start of a word, return the word and following whitespace.
+/// If relative_to is between words, return the whitespace back and the following word
+
+/// if in word
+///   delete that word
+///   if there is whitespace following the word, delete that as well
+///   otherwise, delete any preceding whitespace
+/// otherwise
+///   delete whitespace around cursor
+///   delete word following the cursor
+fn around_word(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    ignore_punctuation: bool,
+) -> Option<Range<DisplayPoint>> {
+    let in_word = map
+        .chars_at(relative_to)
+        .next()
+        .map(|(c, _)| char_kind(c) != CharKind::Whitespace)
+        .unwrap_or(false);
+
+    if in_word {
+        around_containing_word(map, relative_to, ignore_punctuation)
+    } else {
+        around_next_word(map, relative_to, ignore_punctuation)
+    }
+}
+
+fn around_containing_word(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    ignore_punctuation: bool,
+) -> Option<Range<DisplayPoint>> {
+    in_word(map, relative_to, ignore_punctuation)
+        .map(|range| expand_to_include_whitespace(map, range, true))
+}
+
+fn around_next_word(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    ignore_punctuation: bool,
+) -> Option<Range<DisplayPoint>> {
+    // Get the start of the word
+    let start = movement::find_preceding_boundary_in_line(
+        map,
+        right(map, relative_to, 1),
+        |left, right| {
+            char_kind(left).coerce_punctuation(ignore_punctuation)
+                != char_kind(right).coerce_punctuation(ignore_punctuation)
+        },
+    );
+
+    let mut word_found = false;
+    let end = movement::find_boundary(map, relative_to, |left, right| {
+        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
+        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+
+        let found = (word_found && left_kind != right_kind) || right == '\n' && left == '\n';
+
+        if right_kind != CharKind::Whitespace {
+            word_found = true;
+        }
+
+        found
+    });
+
+    Some(start..end)
+}
+
+fn sentence(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    around: bool,
+) -> Option<Range<DisplayPoint>> {
+    let mut start = None;
+    let mut previous_end = relative_to;
+
+    let mut chars = map.chars_at(relative_to).peekable();
+
+    // Search backwards for the previous sentence end or current sentence start. Include the character under relative_to
+    for (char, point) in chars
+        .peek()
+        .cloned()
+        .into_iter()
+        .chain(map.reverse_chars_at(relative_to))
+    {
+        if is_sentence_end(map, point) {
+            break;
+        }
+
+        if is_possible_sentence_start(char) {
+            start = Some(point);
+        }
+
+        previous_end = point;
+    }
+
+    // Search forward for the end of the current sentence or if we are between sentences, the start of the next one
+    let mut end = relative_to;
+    for (char, point) in chars {
+        if start.is_none() && is_possible_sentence_start(char) {
+            if around {
+                start = Some(point);
+                continue;
+            } else {
+                end = point;
+                break;
+            }
+        }
+
+        end = point;
+        *end.column_mut() += char.len_utf8() as u32;
+        end = map.clip_point(end, Bias::Left);
+
+        if is_sentence_end(map, end) {
+            break;
+        }
+    }
+
+    let mut range = start.unwrap_or(previous_end)..end;
+    if around {
+        range = expand_to_include_whitespace(map, range, false);
+    }
+
+    Some(range)
+}
+
+fn is_possible_sentence_start(character: char) -> bool {
+    !character.is_whitespace() && character != '.'
+}
+
+const SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?'];
+const SENTENCE_END_FILLERS: &[char] = &[')', ']', '"', '\''];
+const SENTENCE_END_WHITESPACE: &[char] = &[' ', '\t', '\n'];
+fn is_sentence_end(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
+    let mut next_chars = map.chars_at(point).peekable();
+    if let Some((char, _)) = next_chars.next() {
+        // We are at a double newline. This position is a sentence end.
+        if char == '\n' && next_chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) {
+            return true;
+        }
+
+        // The next text is not a valid whitespace. This is not a sentence end
+        if !SENTENCE_END_WHITESPACE.contains(&char) {
+            return false;
+        }
+    }
+
+    for (char, _) in map.reverse_chars_at(point) {
+        if SENTENCE_END_PUNCTUATION.contains(&char) {
+            return true;
+        }
+
+        if !SENTENCE_END_FILLERS.contains(&char) {
+            return false;
+        }
+    }
+
+    return false;
+}
+
+/// Expands the passed range to include whitespace on one side or the other in a line. Attempts to add the
+/// whitespace to the end first and falls back to the start if there was none.
+fn expand_to_include_whitespace(
+    map: &DisplaySnapshot,
+    mut range: Range<DisplayPoint>,
+    stop_at_newline: bool,
+) -> Range<DisplayPoint> {
+    let mut whitespace_included = false;
+
+    let mut chars = map.chars_at(range.end).peekable();
+    while let Some((char, point)) = chars.next() {
+        if char == '\n' && stop_at_newline {
+            break;
+        }
+
+        if char.is_whitespace() {
+            // Set end to the next display_point or the character position after the current display_point
+            range.end = chars.peek().map(|(_, point)| *point).unwrap_or_else(|| {
+                let mut end = point;
+                *end.column_mut() += char.len_utf8() as u32;
+                map.clip_point(end, Bias::Left)
+            });
+
+            if char != '\n' {
+                whitespace_included = true;
+            }
+        } else {
+            // Found non whitespace. Quit out.
+            break;
+        }
+    }
+
+    if !whitespace_included {
+        for (char, point) in map.reverse_chars_at(range.start) {
+            if char == '\n' && stop_at_newline {
+                break;
+            }
+
+            if !char.is_whitespace() {
+                break;
+            }
+
+            range.start = point;
+        }
+    }
+
+    range
+}
+
+fn surrounding_markers(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    around: bool,
+    search_across_lines: bool,
+    start_marker: char,
+    end_marker: char,
+) -> Option<Range<DisplayPoint>> {
+    let mut matched_ends = 0;
+    let mut start = None;
+    for (char, mut point) in map.reverse_chars_at(relative_to) {
+        if char == start_marker {
+            if matched_ends > 0 {
+                matched_ends -= 1;
+            } else {
+                if around {
+                    start = Some(point)
+                } else {
+                    *point.column_mut() += char.len_utf8() as u32;
+                    start = Some(point);
+                }
+                break;
+            }
+        } else if char == end_marker {
+            matched_ends += 1;
+        } else if char == '\n' && !search_across_lines {
+            break;
+        }
+    }
+
+    let mut matched_starts = 0;
+    let mut end = None;
+    for (char, mut point) in map.chars_at(relative_to) {
+        if char == end_marker {
+            if start.is_none() {
+                break;
+            }
+
+            if matched_starts > 0 {
+                matched_starts -= 1;
+            } else {
+                if around {
+                    *point.column_mut() += char.len_utf8() as u32;
+                    end = Some(point);
+                } else {
+                    end = Some(point);
+                }
+
+                break;
+            }
+        }
+
+        if char == start_marker {
+            if start.is_none() {
+                if around {
+                    start = Some(point);
+                } else {
+                    *point.column_mut() += char.len_utf8() as u32;
+                    start = Some(point);
+                }
+            } else {
+                matched_starts += 1;
+            }
+        }
+
+        if char == '\n' && !search_across_lines {
+            break;
+        }
+    }
+
+    if let (Some(start), Some(end)) = (start, end) {
+        Some(start..end)
+    } else {
+        None
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+
+    use crate::test::NeovimBackedTestContext;
+
+    const WORD_LOCATIONS: &'static str = indoc! {"
+        The quick ห‡browห‡nห‡   
+        fox ห‡juห‡mpsห‡ over
+        the lazy dogห‡  
+        ห‡
+        ห‡
+        ห‡
+        Thห‡eห‡-ห‡quห‡ickห‡ ห‡brownห‡ 
+        ห‡  
+        ห‡  
+        ห‡  fox-jumpห‡s over
+        the lazy dogห‡ 
+        ห‡
+        "};
+
+    #[gpui::test]
+    async fn test_change_word_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.assert_binding_matches_all(["c", "i", "w"], WORD_LOCATIONS)
+            .await;
+        cx.assert_binding_matches_all(["c", "i", "shift-w"], WORD_LOCATIONS)
+            .await;
+        cx.assert_binding_matches_all(["c", "a", "w"], WORD_LOCATIONS)
+            .await;
+        cx.assert_binding_matches_all(["c", "a", "shift-w"], WORD_LOCATIONS)
+            .await;
+    }
+
+    #[gpui::test]
+    async fn test_delete_word_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.assert_binding_matches_all(["d", "i", "w"], WORD_LOCATIONS)
+            .await;
+        cx.assert_binding_matches_all(["d", "i", "shift-w"], WORD_LOCATIONS)
+            .await;
+        cx.assert_binding_matches_all(["d", "a", "w"], WORD_LOCATIONS)
+            .await;
+        cx.assert_binding_matches_all(["d", "a", "shift-w"], WORD_LOCATIONS)
+            .await;
+    }
+
+    #[gpui::test]
+    async fn test_visual_word_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.assert_binding_matches_all(["v", "i", "w"], WORD_LOCATIONS)
+            .await;
+        // Visual text objects are slightly broken when used with non empty selections
+        // cx.assert_binding_matches_all(["v", "h", "i", "w"], WORD_LOCATIONS)
+        //     .await;
+        // cx.assert_binding_matches_all(["v", "l", "i", "w"], WORD_LOCATIONS)
+        //     .await;
+        cx.assert_binding_matches_all(["v", "i", "shift-w"], WORD_LOCATIONS)
+            .await;
+
+        // Visual text objects are slightly broken when used with non empty selections
+        // cx.assert_binding_matches_all(["v", "i", "h", "shift-w"], WORD_LOCATIONS)
+        //     .await;
+        // cx.assert_binding_matches_all(["v", "i", "l", "shift-w"], WORD_LOCATIONS)
+        //     .await;
+
+        // Visual around words is somewhat broken right now when it comes to newlines
+        // cx.assert_binding_matches_all(["v", "a", "w"], WORD_LOCATIONS)
+        //     .await;
+        // cx.assert_binding_matches_all(["v", "a", "shift-w"], WORD_LOCATIONS)
+        //     .await;
+    }
+
+    const SENTENCE_EXAMPLES: &[&'static str] = &[
+        "ห‡The quick ห‡brownห‡?ห‡ ห‡Fox Jห‡umpsห‡!ห‡ Ovห‡er theห‡ lazyห‡.",
+        indoc! {"
+            ห‡The quick ห‡brownห‡   
+            fox jumps over
+            the lazy doห‡gห‡.ห‡ ห‡The quick ห‡
+            brown fox jumps over
+        "},
+        // Position of the cursor after deletion between lines isn't quite right.
+        // Deletion in a sentence at the start of a line with whitespace is incorrect.
+        // indoc! {"
+        //     The quick brown fox jumps.
+        //     Over the lazy dog
+        //     ห‡
+        //     ห‡
+        //     ห‡  fox-jumpห‡s over
+        //     the lazy dog.ห‡
+        //     ห‡
+        // "},
+        r#"ห‡The ห‡quick brownห‡.)ห‡]ห‡'ห‡" Brown ห‡fox jumpsห‡.ห‡ "#,
+    ];
+
+    #[gpui::test]
+    async fn test_change_sentence_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["c", "i", "s"]);
+        for sentence_example in SENTENCE_EXAMPLES {
+            cx.assert_all(sentence_example).await;
+        }
+
+        let mut cx = cx.binding(["c", "a", "s"]);
+        // Resulting position is slightly incorrect for unintuitive reasons.
+        cx.add_initial_state_exemption("The quick brown?ห‡ Fox Jumps! Over the lazy.");
+        // Changing around the sentence at the end of the line doesn't remove whitespace.'
+        cx.add_initial_state_exemption("The quick brown.)]\'\" Brown fox jumps.ห‡ ");
+
+        for sentence_example in SENTENCE_EXAMPLES {
+            cx.assert_all(sentence_example).await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_delete_sentence_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["d", "i", "s"]);
+        for sentence_example in SENTENCE_EXAMPLES {
+            cx.assert_all(sentence_example).await;
+        }
+
+        let mut cx = cx.binding(["d", "a", "s"]);
+        // Resulting position is slightly incorrect for unintuitive reasons.
+        cx.add_initial_state_exemption("The quick brown?ห‡ Fox Jumps! Over the lazy.");
+        // Changing around the sentence at the end of the line doesn't remove whitespace.'
+        cx.add_initial_state_exemption("The quick brown.)]\'\" Brown fox jumps.ห‡ ");
+
+        for sentence_example in SENTENCE_EXAMPLES {
+            cx.assert_all(sentence_example).await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_visual_sentence_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["v", "i", "s"]);
+        for sentence_example in SENTENCE_EXAMPLES {
+            cx.assert_all(sentence_example).await;
+        }
+
+        // Visual around sentences is somewhat broken right now when it comes to newlines
+        // let mut cx = cx.binding(["d", "a", "s"]);
+        // for sentence_example in SENTENCE_EXAMPLES {
+        //     cx.assert_all(sentence_example).await;
+        // }
+    }
+
+    // Test string with "`" for opening surrounders and "'" for closing surrounders
+    const SURROUNDING_MARKER_STRING: &str = indoc! {"
+        ห‡Th'ห‡e ห‡`ห‡'ห‡quห‡i`ห‡ck broห‡'wn`
+        'ห‡fox juห‡mps ovห‡`ห‡er
+        the ห‡lazy dห‡'ห‡oห‡`ห‡g"};
+
+    const SURROUNDING_OBJECTS: &[(char, char)] = &[
+        // ('\'', '\''), // Quote,
+        // ('`', '`'),   // Back Quote
+        // ('"', '"'),   // Double Quote
+        // ('"', '"'),   // Double Quote
+        ('(', ')'), // Parentheses
+        ('[', ']'), // SquareBrackets
+        ('{', '}'), // CurlyBrackets
+        ('<', '>'), // AngleBrackets
+    ];
+
+    #[gpui::test]
+    async fn test_change_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        for (start, end) in SURROUNDING_OBJECTS {
+            let marked_string = SURROUNDING_MARKER_STRING
+                .replace('`', &start.to_string())
+                .replace('\'', &end.to_string());
+
+            // cx.assert_binding_matches_all(["c", "i", &start.to_string()], &marked_string)
+            //     .await;
+            cx.assert_binding_matches_all(["c", "i", &end.to_string()], &marked_string)
+                .await;
+            // cx.assert_binding_matches_all(["c", "a", &start.to_string()], &marked_string)
+            //     .await;
+            cx.assert_binding_matches_all(["c", "a", &end.to_string()], &marked_string)
+                .await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        for (start, end) in SURROUNDING_OBJECTS {
+            let marked_string = SURROUNDING_MARKER_STRING
+                .replace('`', &start.to_string())
+                .replace('\'', &end.to_string());
+
+            // cx.assert_binding_matches_all(["d", "i", &start.to_string()], &marked_string)
+            //     .await;
+            cx.assert_binding_matches_all(["d", "i", &end.to_string()], &marked_string)
+                .await;
+            // cx.assert_binding_matches_all(["d", "a", &start.to_string()], &marked_string)
+            //     .await;
+            cx.assert_binding_matches_all(["d", "a", &end.to_string()], &marked_string)
+                .await;
+        }
+    }
+}

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

@@ -1,8 +1,8 @@
 use editor::CursorShape;
 use gpui::keymap::Context;
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
 pub enum Mode {
     Normal,
     Insert,
@@ -22,10 +22,12 @@ pub enum Namespace {
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
 pub enum Operator {
+    Number(usize),
     Namespace(Namespace),
     Change,
     Delete,
     Yank,
+    Object { around: bool },
 }
 
 #[derive(Default)]
@@ -77,7 +79,12 @@ impl VimState {
             context.set.insert("VimControl".to_string());
         }
 
-        Operator::set_context(self.operator_stack.last(), &mut context);
+        let active_operator = self.operator_stack.last();
+        if matches!(active_operator, Some(Operator::Object { .. })) {
+            context.set.insert("VimObject".to_string());
+        }
+
+        Operator::set_context(active_operator, &mut context);
 
         context
     }
@@ -86,10 +93,14 @@ impl VimState {
 impl Operator {
     pub fn set_context(operator: Option<&Operator>, context: &mut Context) {
         let operator_context = match operator {
+            Some(Operator::Number(_)) => "n",
             Some(Operator::Namespace(Namespace::G)) => "g",
+            Some(Operator::Object { around: false }) => "i",
+            Some(Operator::Object { around: true }) => "a",
             Some(Operator::Change) => "c",
             Some(Operator::Delete) => "d",
             Some(Operator::Yank) => "y",
+
             None => "none",
         }
         .to_owned();

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

@@ -0,0 +1,103 @@
+mod neovim_backed_binding_test_context;
+mod neovim_backed_test_context;
+mod neovim_connection;
+mod vim_binding_test_context;
+mod vim_test_context;
+
+pub use neovim_backed_binding_test_context::*;
+pub use neovim_backed_test_context::*;
+pub use vim_binding_test_context::*;
+pub use vim_test_context::*;
+
+use indoc::indoc;
+use search::BufferSearchBar;
+
+use crate::state::Mode;
+
+#[gpui::test]
+async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new(cx, false).await;
+    cx.simulate_keystrokes(["h", "j", "k", "l"]);
+    cx.assert_editor_state("hjklห‡");
+}
+
+#[gpui::test]
+async fn test_neovim(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.simulate_shared_keystroke("i").await;
+    cx.assert_state_matches().await;
+    cx.simulate_shared_keystrokes([
+        "shift-T", "e", "s", "t", " ", "t", "e", "s", "t", "escape", "0", "d", "w",
+    ])
+    .await;
+    cx.assert_state_matches().await;
+    cx.assert_editor_state("ห‡test");
+}
+
+#[gpui::test]
+async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new(cx, true).await;
+
+    cx.simulate_keystroke("i");
+    assert_eq!(cx.mode(), Mode::Insert);
+
+    // Editor acts as though vim is disabled
+    cx.disable_vim();
+    cx.simulate_keystrokes(["h", "j", "k", "l"]);
+    cx.assert_editor_state("hjklห‡");
+
+    // Selections aren't changed if editor is blurred but vim-mode is still disabled.
+    cx.set_state("ยซhjklห‡ยป", Mode::Normal);
+    cx.assert_editor_state("ยซhjklห‡ยป");
+    cx.update_editor(|_, cx| cx.blur());
+    cx.assert_editor_state("ยซhjklห‡ยป");
+    cx.update_editor(|_, cx| cx.focus_self());
+    cx.assert_editor_state("ยซhjklห‡ยป");
+
+    // Enabling dynamically sets vim mode again and restores normal mode
+    cx.enable_vim();
+    assert_eq!(cx.mode(), Mode::Normal);
+    cx.simulate_keystrokes(["h", "h", "h", "l"]);
+    assert_eq!(cx.buffer_text(), "hjkl".to_owned());
+    cx.assert_editor_state("hห‡jkl");
+    cx.simulate_keystrokes(["i", "T", "e", "s", "t"]);
+    cx.assert_editor_state("hTestห‡jkl");
+
+    // Disabling and enabling resets to normal mode
+    assert_eq!(cx.mode(), Mode::Insert);
+    cx.disable_vim();
+    cx.enable_vim();
+    assert_eq!(cx.mode(), Mode::Normal);
+}
+
+#[gpui::test]
+async fn test_buffer_search(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_keystroke("/");
+
+    // We now use a weird insert mode with selection when jumping to a single line editor
+    assert_eq!(cx.mode(), Mode::Insert);
+
+    let search_bar = cx.workspace(|workspace, cx| {
+        workspace
+            .active_pane()
+            .read(cx)
+            .toolbar()
+            .read(cx)
+            .item_of_type::<BufferSearchBar>()
+            .expect("Buffer search bar should be deployed")
+    });
+
+    search_bar.read_with(cx.cx, |bar, cx| {
+        assert_eq!(bar.query_editor.read(cx).text(cx), "jumps");
+    })
+}

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

@@ -0,0 +1,80 @@
+use std::ops::{Deref, DerefMut};
+
+use gpui::ContextHandle;
+
+use crate::state::Mode;
+
+use super::NeovimBackedTestContext;
+
+pub struct NeovimBackedBindingTestContext<'a, const COUNT: usize> {
+    cx: NeovimBackedTestContext<'a>,
+    keystrokes_under_test: [&'static str; COUNT],
+}
+
+impl<'a, const COUNT: usize> NeovimBackedBindingTestContext<'a, COUNT> {
+    pub fn new(
+        keystrokes_under_test: [&'static str; COUNT],
+        cx: NeovimBackedTestContext<'a>,
+    ) -> Self {
+        Self {
+            cx,
+            keystrokes_under_test,
+        }
+    }
+
+    pub fn consume(self) -> NeovimBackedTestContext<'a> {
+        self.cx
+    }
+
+    pub fn binding<const NEW_COUNT: usize>(
+        self,
+        keystrokes: [&'static str; NEW_COUNT],
+    ) -> NeovimBackedBindingTestContext<'a, NEW_COUNT> {
+        self.consume().binding(keystrokes)
+    }
+
+    pub async fn assert(
+        &mut self,
+        marked_positions: &str,
+    ) -> Option<(ContextHandle, ContextHandle)> {
+        self.cx
+            .assert_binding_matches(self.keystrokes_under_test, marked_positions)
+            .await
+    }
+
+    pub fn assert_manual(
+        &mut self,
+        initial_state: &str,
+        mode_before: Mode,
+        state_after: &str,
+        mode_after: Mode,
+    ) {
+        self.cx.assert_binding(
+            self.keystrokes_under_test,
+            initial_state,
+            mode_before,
+            state_after,
+            mode_after,
+        );
+    }
+
+    pub async fn assert_all(&mut self, marked_positions: &str) {
+        self.cx
+            .assert_binding_matches_all(self.keystrokes_under_test, marked_positions)
+            .await
+    }
+}
+
+impl<'a, const COUNT: usize> Deref for NeovimBackedBindingTestContext<'a, COUNT> {
+    type Target = NeovimBackedTestContext<'a>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.cx
+    }
+}
+
+impl<'a, const COUNT: usize> DerefMut for NeovimBackedBindingTestContext<'a, COUNT> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.cx
+    }
+}

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

@@ -0,0 +1,158 @@
+use std::ops::{Deref, DerefMut};
+
+use collections::{HashMap, HashSet};
+use gpui::ContextHandle;
+use language::OffsetRangeExt;
+use util::test::marked_text_offsets;
+
+use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext};
+use crate::state::Mode;
+
+pub struct NeovimBackedTestContext<'a> {
+    cx: VimTestContext<'a>,
+    // Lookup for exempted assertions. Keyed by the insertion text, and with a value indicating which
+    // bindings are exempted. If None, all bindings are ignored for that insertion text.
+    exemptions: HashMap<String, Option<HashSet<String>>>,
+    neovim: NeovimConnection,
+}
+
+impl<'a> NeovimBackedTestContext<'a> {
+    pub async fn new(cx: &'a mut gpui::TestAppContext) -> NeovimBackedTestContext<'a> {
+        let function_name = cx.function_name.clone();
+        let cx = VimTestContext::new(cx, true).await;
+        Self {
+            cx,
+            exemptions: Default::default(),
+            neovim: NeovimConnection::new(function_name).await,
+        }
+    }
+
+    pub fn add_initial_state_exemption(&mut self, initial_state: &str) {
+        let initial_state = initial_state.to_string();
+        // None represents all keybindings being exempted for that initial state
+        self.exemptions.insert(initial_state, None);
+    }
+
+    pub async fn simulate_shared_keystroke(&mut self, keystroke_text: &str) -> ContextHandle {
+        self.neovim.send_keystroke(keystroke_text).await;
+        self.simulate_keystroke(keystroke_text)
+    }
+
+    pub async fn simulate_shared_keystrokes<const COUNT: usize>(
+        &mut self,
+        keystroke_texts: [&str; COUNT],
+    ) -> ContextHandle {
+        for keystroke_text in keystroke_texts.into_iter() {
+            self.neovim.send_keystroke(keystroke_text).await;
+        }
+        self.simulate_keystrokes(keystroke_texts)
+    }
+
+    pub async fn set_shared_state(&mut self, marked_text: &str) -> ContextHandle {
+        let context_handle = self.set_state(marked_text, Mode::Normal);
+
+        let selection = self.editor(|editor, cx| editor.selections.newest::<language::Point>(cx));
+        let text = self.buffer_text();
+        self.neovim.set_state(selection, &text).await;
+
+        context_handle
+    }
+
+    pub async fn assert_state_matches(&mut self) {
+        assert_eq!(
+            self.neovim.text().await,
+            self.buffer_text(),
+            "{}",
+            self.assertion_context()
+        );
+
+        let mut neovim_selection = self.neovim.selection().await;
+        // Zed selections adjust themselves to make the end point visually make sense
+        if neovim_selection.start > neovim_selection.end {
+            neovim_selection.start.column += 1;
+        }
+        let neovim_selection = neovim_selection.to_offset(&self.buffer_snapshot());
+        self.assert_editor_selections(vec![neovim_selection]);
+
+        if let Some(neovim_mode) = self.neovim.mode().await {
+            assert_eq!(neovim_mode, self.mode(), "{}", self.assertion_context(),);
+        }
+    }
+
+    pub async fn assert_binding_matches<const COUNT: usize>(
+        &mut self,
+        keystrokes: [&str; COUNT],
+        initial_state: &str,
+    ) -> Option<(ContextHandle, ContextHandle)> {
+        if let Some(possible_exempted_keystrokes) = self.exemptions.get(initial_state) {
+            match possible_exempted_keystrokes {
+                Some(exempted_keystrokes) => {
+                    if exempted_keystrokes.contains(&format!("{keystrokes:?}")) {
+                        // This keystroke was exempted for this insertion text
+                        return None;
+                    }
+                }
+                None => {
+                    // All keystrokes for this insertion text are exempted
+                    return None;
+                }
+            }
+        }
+
+        let _state_context = self.set_shared_state(initial_state).await;
+        let _keystroke_context = self.simulate_shared_keystrokes(keystrokes).await;
+        self.assert_state_matches().await;
+        Some((_state_context, _keystroke_context))
+    }
+
+    pub async fn assert_binding_matches_all<const COUNT: usize>(
+        &mut self,
+        keystrokes: [&str; COUNT],
+        marked_positions: &str,
+    ) {
+        let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
+
+        for cursor_offset in cursor_offsets.iter() {
+            let mut marked_text = unmarked_text.clone();
+            marked_text.insert(*cursor_offset, 'ห‡');
+
+            self.assert_binding_matches(keystrokes, &marked_text).await;
+        }
+    }
+
+    pub fn binding<const COUNT: usize>(
+        self,
+        keystrokes: [&'static str; COUNT],
+    ) -> NeovimBackedBindingTestContext<'a, COUNT> {
+        NeovimBackedBindingTestContext::new(keystrokes, self)
+    }
+}
+
+impl<'a> Deref for NeovimBackedTestContext<'a> {
+    type Target = VimTestContext<'a>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.cx
+    }
+}
+
+impl<'a> DerefMut for NeovimBackedTestContext<'a> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.cx
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use gpui::TestAppContext;
+
+    use crate::test::NeovimBackedTestContext;
+
+    #[gpui::test]
+    async fn neovim_backed_test_context_works(cx: &mut TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.assert_state_matches().await;
+        cx.set_shared_state("This is a tesห‡t").await;
+        cx.assert_state_matches().await;
+    }
+}

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

@@ -0,0 +1,383 @@
+#[cfg(feature = "neovim")]
+use std::ops::{Deref, DerefMut};
+use std::{ops::Range, path::PathBuf};
+
+#[cfg(feature = "neovim")]
+use async_compat::Compat;
+#[cfg(feature = "neovim")]
+use async_trait::async_trait;
+#[cfg(feature = "neovim")]
+use gpui::keymap::Keystroke;
+use language::{Point, Selection};
+#[cfg(feature = "neovim")]
+use lazy_static::lazy_static;
+#[cfg(feature = "neovim")]
+use nvim_rs::{
+    create::tokio::new_child_cmd, error::LoopError, Handler, Neovim, UiAttachOptions, Value,
+};
+#[cfg(feature = "neovim")]
+use parking_lot::ReentrantMutex;
+use serde::{Deserialize, Serialize};
+#[cfg(feature = "neovim")]
+use tokio::{
+    process::{Child, ChildStdin, Command},
+    task::JoinHandle,
+};
+
+use crate::state::Mode;
+use collections::VecDeque;
+
+// Neovim doesn't like to be started simultaneously from multiple threads. We use thsi lock
+// to ensure we are only constructing one neovim connection at a time.
+#[cfg(feature = "neovim")]
+lazy_static! {
+    static ref NEOVIM_LOCK: ReentrantMutex<()> = ReentrantMutex::new(());
+}
+
+#[derive(Serialize, Deserialize)]
+pub enum NeovimData {
+    Text(String),
+    Selection { start: (u32, u32), end: (u32, u32) },
+    Mode(Option<Mode>),
+}
+
+pub struct NeovimConnection {
+    data: VecDeque<NeovimData>,
+    #[cfg(feature = "neovim")]
+    test_case_id: String,
+    #[cfg(feature = "neovim")]
+    nvim: Neovim<nvim_rs::compat::tokio::Compat<ChildStdin>>,
+    #[cfg(feature = "neovim")]
+    _join_handle: JoinHandle<Result<(), Box<LoopError>>>,
+    #[cfg(feature = "neovim")]
+    _child: Child,
+}
+
+impl NeovimConnection {
+    pub async fn new(test_case_id: String) -> Self {
+        #[cfg(feature = "neovim")]
+        let handler = NvimHandler {};
+        #[cfg(feature = "neovim")]
+        let (nvim, join_handle, child) = Compat::new(async {
+            // Ensure we don't create neovim connections in parallel
+            let _lock = NEOVIM_LOCK.lock();
+            let (nvim, join_handle, child) = new_child_cmd(
+                &mut Command::new("nvim").arg("--embed").arg("--clean"),
+                handler,
+            )
+            .await
+            .expect("Could not connect to neovim process");
+
+            nvim.ui_attach(100, 100, &UiAttachOptions::default())
+                .await
+                .expect("Could not attach to ui");
+
+            // Makes system act a little more like zed in terms of indentation
+            nvim.set_option("smartindent", nvim_rs::Value::Boolean(true))
+                .await
+                .expect("Could not set smartindent on startup");
+
+            (nvim, join_handle, child)
+        })
+        .await;
+
+        Self {
+            #[cfg(feature = "neovim")]
+            data: Default::default(),
+            #[cfg(not(feature = "neovim"))]
+            data: Self::read_test_data(&test_case_id),
+            #[cfg(feature = "neovim")]
+            test_case_id,
+            #[cfg(feature = "neovim")]
+            nvim,
+            #[cfg(feature = "neovim")]
+            _join_handle: join_handle,
+            #[cfg(feature = "neovim")]
+            _child: child,
+        }
+    }
+
+    // Sends a keystroke to the neovim process.
+    #[cfg(feature = "neovim")]
+    pub async fn send_keystroke(&mut self, keystroke_text: &str) {
+        let keystroke = Keystroke::parse(keystroke_text).unwrap();
+        let special = keystroke.shift
+            || keystroke.ctrl
+            || keystroke.alt
+            || keystroke.cmd
+            || keystroke.key.len() > 1;
+        let start = if special { "<" } else { "" };
+        let shift = if keystroke.shift { "S-" } else { "" };
+        let ctrl = if keystroke.ctrl { "C-" } else { "" };
+        let alt = if keystroke.alt { "M-" } else { "" };
+        let cmd = if keystroke.cmd { "D-" } else { "" };
+        let end = if special { ">" } else { "" };
+
+        let key = format!("{start}{shift}{ctrl}{alt}{cmd}{}{end}", keystroke.key);
+
+        self.nvim
+            .input(&key)
+            .await
+            .expect("Could not input keystroke");
+    }
+
+    // If not running with a live neovim connection, this is a no-op
+    #[cfg(not(feature = "neovim"))]
+    pub async fn send_keystroke(&mut self, _keystroke_text: &str) {}
+
+    #[cfg(feature = "neovim")]
+    pub async fn set_state(&mut self, selection: Selection<Point>, text: &str) {
+        let nvim_buffer = self
+            .nvim
+            .get_current_buf()
+            .await
+            .expect("Could not get neovim buffer");
+        let lines = text
+            .split('\n')
+            .map(|line| line.to_string())
+            .collect::<Vec<_>>();
+
+        nvim_buffer
+            .set_lines(0, -1, false, lines)
+            .await
+            .expect("Could not set nvim buffer text");
+
+        self.nvim
+            .input("<escape>")
+            .await
+            .expect("Could not send escape to nvim");
+        self.nvim
+            .input("<escape>")
+            .await
+            .expect("Could not send escape to nvim");
+
+        let nvim_window = self
+            .nvim
+            .get_current_win()
+            .await
+            .expect("Could not get neovim window");
+
+        if !selection.is_empty() {
+            panic!("Setting neovim state with non empty selection not yet supported");
+        }
+        let cursor = selection.head();
+        nvim_window
+            .set_cursor((cursor.row as i64 + 1, cursor.column as i64))
+            .await
+            .expect("Could not set nvim cursor position");
+    }
+
+    #[cfg(not(feature = "neovim"))]
+    pub async fn set_state(&mut self, _selection: Selection<Point>, _text: &str) {}
+
+    #[cfg(feature = "neovim")]
+    pub async fn text(&mut self) -> String {
+        let nvim_buffer = self
+            .nvim
+            .get_current_buf()
+            .await
+            .expect("Could not get neovim buffer");
+        let text = nvim_buffer
+            .get_lines(0, -1, false)
+            .await
+            .expect("Could not get buffer text")
+            .join("\n");
+
+        self.data.push_back(NeovimData::Text(text.clone()));
+
+        text
+    }
+
+    #[cfg(not(feature = "neovim"))]
+    pub async fn text(&mut self) -> String {
+        if let Some(NeovimData::Text(text)) = self.data.pop_front() {
+            text
+        } else {
+            panic!("Invalid test data. Is test deterministic? Try running with '--features neovim' to regenerate");
+        }
+    }
+
+    #[cfg(feature = "neovim")]
+    pub async fn selection(&mut self) -> Range<Point> {
+        let cursor_row: u32 = self
+            .nvim
+            .command_output("echo line('.')")
+            .await
+            .unwrap()
+            .parse::<u32>()
+            .unwrap()
+            - 1; // Neovim rows start at 1
+        let cursor_col: u32 = self
+            .nvim
+            .command_output("echo col('.')")
+            .await
+            .unwrap()
+            .parse::<u32>()
+            .unwrap()
+            - 1; // Neovim columns start at 1
+
+        let (start, end) = if let Some(Mode::Visual { .. }) = self.mode().await {
+            self.nvim
+                .input("<escape>")
+                .await
+                .expect("Could not exit visual mode");
+            let nvim_buffer = self
+                .nvim
+                .get_current_buf()
+                .await
+                .expect("Could not get neovim buffer");
+            let (start_row, start_col) = nvim_buffer
+                .get_mark("<")
+                .await
+                .expect("Could not get selection start");
+            let (end_row, end_col) = nvim_buffer
+                .get_mark(">")
+                .await
+                .expect("Could not get selection end");
+            self.nvim
+                .input("gv")
+                .await
+                .expect("Could not reselect visual selection");
+
+            if cursor_row == start_row as u32 - 1 && cursor_col == start_col as u32 {
+                (
+                    (end_row as u32 - 1, end_col as u32),
+                    (start_row as u32 - 1, start_col as u32),
+                )
+            } else {
+                (
+                    (start_row as u32 - 1, start_col as u32),
+                    (end_row as u32 - 1, end_col as u32),
+                )
+            }
+        } else {
+            ((cursor_row, cursor_col), (cursor_row, cursor_col))
+        };
+
+        self.data.push_back(NeovimData::Selection { start, end });
+
+        Point::new(start.0, start.1)..Point::new(end.0, end.1)
+    }
+
+    #[cfg(not(feature = "neovim"))]
+    pub async fn selection(&mut self) -> Range<Point> {
+        // Selection code fetches the mode. This emulates that.
+        let _mode = self.mode().await;
+        if let Some(NeovimData::Selection { start, end }) = self.data.pop_front() {
+            Point::new(start.0, start.1)..Point::new(end.0, end.1)
+        } else {
+            panic!("Invalid test data. Is test deterministic? Try running with '--features neovim' to regenerate");
+        }
+    }
+
+    #[cfg(feature = "neovim")]
+    pub async fn mode(&mut self) -> Option<Mode> {
+        let nvim_mode_text = self
+            .nvim
+            .get_mode()
+            .await
+            .expect("Could not get mode")
+            .into_iter()
+            .find_map(|(key, value)| {
+                if key.as_str() == Some("mode") {
+                    Some(value.as_str().unwrap().to_owned())
+                } else {
+                    None
+                }
+            })
+            .expect("Could not find mode value");
+
+        let mode = match nvim_mode_text.as_ref() {
+            "i" => Some(Mode::Insert),
+            "n" => Some(Mode::Normal),
+            "v" => Some(Mode::Visual { line: false }),
+            "V" => Some(Mode::Visual { line: true }),
+            _ => None,
+        };
+
+        self.data.push_back(NeovimData::Mode(mode.clone()));
+
+        mode
+    }
+
+    #[cfg(not(feature = "neovim"))]
+    pub async fn mode(&mut self) -> Option<Mode> {
+        if let Some(NeovimData::Mode(mode)) = self.data.pop_front() {
+            mode
+        } else {
+            panic!("Invalid test data. Is test deterministic? Try running with '--features neovim' to regenerate");
+        }
+    }
+
+    fn test_data_path(test_case_id: &str) -> PathBuf {
+        let mut data_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+        data_path.push("test_data");
+        data_path.push(format!("{}.json", test_case_id));
+        data_path
+    }
+
+    #[cfg(not(feature = "neovim"))]
+    fn read_test_data(test_case_id: &str) -> VecDeque<NeovimData> {
+        let path = Self::test_data_path(test_case_id);
+        let json = std::fs::read_to_string(path).expect(
+            "Could not read test data. Is it generated? Try running test with '--features neovim'",
+        );
+
+        serde_json::from_str(&json)
+            .expect("Test data corrupted. Try regenerating it with '--features neovim'")
+    }
+}
+
+#[cfg(feature = "neovim")]
+impl Deref for NeovimConnection {
+    type Target = Neovim<nvim_rs::compat::tokio::Compat<ChildStdin>>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.nvim
+    }
+}
+
+#[cfg(feature = "neovim")]
+impl DerefMut for NeovimConnection {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.nvim
+    }
+}
+
+#[cfg(feature = "neovim")]
+impl Drop for NeovimConnection {
+    fn drop(&mut self) {
+        let path = Self::test_data_path(&self.test_case_id);
+        std::fs::create_dir_all(path.parent().unwrap())
+            .expect("Could not create test data directory");
+        let json = serde_json::to_string(&self.data).expect("Could not serialize test data");
+        std::fs::write(path, json).expect("Could not write out test data");
+    }
+}
+
+#[cfg(feature = "neovim")]
+#[derive(Clone)]
+struct NvimHandler {}
+
+#[cfg(feature = "neovim")]
+#[async_trait]
+impl Handler for NvimHandler {
+    type Writer = nvim_rs::compat::tokio::Compat<ChildStdin>;
+
+    async fn handle_request(
+        &self,
+        _event_name: String,
+        _arguments: Vec<Value>,
+        _neovim: Neovim<Self::Writer>,
+    ) -> Result<Value, Value> {
+        unimplemented!();
+    }
+
+    async fn handle_notify(
+        &self,
+        _event_name: String,
+        _arguments: Vec<Value>,
+        _neovim: Neovim<Self::Writer>,
+    ) {
+    }
+}

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

@@ -0,0 +1,69 @@
+use std::ops::{Deref, DerefMut};
+
+use crate::*;
+
+use super::VimTestContext;
+
+pub struct VimBindingTestContext<'a, const COUNT: usize> {
+    cx: VimTestContext<'a>,
+    keystrokes_under_test: [&'static str; COUNT],
+    mode_before: Mode,
+    mode_after: Mode,
+}
+
+impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> {
+    pub fn new(
+        keystrokes_under_test: [&'static str; COUNT],
+        mode_before: Mode,
+        mode_after: Mode,
+        cx: VimTestContext<'a>,
+    ) -> Self {
+        Self {
+            cx,
+            keystrokes_under_test,
+            mode_before,
+            mode_after,
+        }
+    }
+
+    pub fn binding<const NEW_COUNT: usize>(
+        self,
+        keystrokes_under_test: [&'static str; NEW_COUNT],
+    ) -> VimBindingTestContext<'a, NEW_COUNT> {
+        VimBindingTestContext {
+            keystrokes_under_test,
+            cx: self.cx,
+            mode_before: self.mode_before,
+            mode_after: self.mode_after,
+        }
+    }
+
+    pub fn mode_after(mut self, mode_after: Mode) -> Self {
+        self.mode_after = mode_after;
+        self
+    }
+
+    pub fn assert(&mut self, initial_state: &str, state_after: &str) {
+        self.cx.assert_binding(
+            self.keystrokes_under_test,
+            initial_state,
+            self.mode_before,
+            state_after,
+            self.mode_after,
+        )
+    }
+}
+
+impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> {
+    type Target = VimTestContext<'a>;
+
+    fn deref(&self) -> &Self::Target {
+        &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/vim_test_context.rs โ†’ crates/vim/src/test/vim_test_context.rs ๐Ÿ”—

@@ -1,13 +1,15 @@
 use std::ops::{Deref, DerefMut};
 
-use editor::test::EditorTestContext;
-use gpui::{json::json, AppContext, ViewHandle};
+use editor::test::editor_test_context::EditorTestContext;
+use gpui::{json::json, AppContext, ContextHandle, ViewHandle};
 use project::Project;
 use search::{BufferSearchBar, ProjectSearchBar};
 use workspace::{pane, AppState, WorkspaceHandle};
 
 use crate::{state::Operator, *};
 
+use super::VimBindingTestContext;
+
 pub struct VimTestContext<'a> {
     cx: EditorTestContext<'a>,
     workspace: ViewHandle<Workspace>,
@@ -117,18 +119,18 @@ impl<'a> VimTestContext<'a> {
             .read(|cx| cx.global::<Vim>().state.operator_stack.last().copied())
     }
 
-    pub fn set_state(&mut self, text: &str, mode: Mode) {
+    pub fn set_state(&mut self, text: &str, mode: Mode) -> ContextHandle {
         self.cx.update(|cx| {
             Vim::update(cx, |vim, cx| {
                 vim.switch_mode(mode, false, cx);
             })
         });
-        self.cx.set_state(text);
+        self.cx.set_state(text)
     }
 
     pub fn assert_state(&mut self, text: &str, mode: Mode) {
         self.assert_editor_state(text);
-        assert_eq!(self.mode(), mode);
+        assert_eq!(self.mode(), mode, "{}", self.assertion_context());
     }
 
     pub fn assert_binding<const COUNT: usize>(
@@ -142,8 +144,8 @@ impl<'a> VimTestContext<'a> {
         self.set_state(initial_state, initial_mode);
         self.cx.simulate_keystrokes(keystrokes);
         self.cx.assert_editor_state(state_after);
-        assert_eq!(self.mode(), mode_after);
-        assert_eq!(self.active_operator(), None);
+        assert_eq!(self.mode(), mode_after, "{}", self.assertion_context());
+        assert_eq!(self.active_operator(), None, "{}", self.assertion_context());
     }
 
     pub fn binding<const COUNT: usize>(
@@ -168,67 +170,3 @@ impl<'a> DerefMut for VimTestContext<'a> {
         &mut self.cx
     }
 }
-
-pub struct VimBindingTestContext<'a, const COUNT: usize> {
-    cx: VimTestContext<'a>,
-    keystrokes_under_test: [&'static str; COUNT],
-    mode_before: Mode,
-    mode_after: Mode,
-}
-
-impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> {
-    pub fn new(
-        keystrokes_under_test: [&'static str; COUNT],
-        mode_before: Mode,
-        mode_after: Mode,
-        cx: VimTestContext<'a>,
-    ) -> Self {
-        Self {
-            cx,
-            keystrokes_under_test,
-            mode_before,
-            mode_after,
-        }
-    }
-
-    pub fn binding<const NEW_COUNT: usize>(
-        self,
-        keystrokes_under_test: [&'static str; NEW_COUNT],
-    ) -> VimBindingTestContext<'a, NEW_COUNT> {
-        VimBindingTestContext {
-            keystrokes_under_test,
-            cx: self.cx,
-            mode_before: self.mode_before,
-            mode_after: self.mode_after,
-        }
-    }
-
-    pub fn mode_after(mut self, mode_after: Mode) -> Self {
-        self.mode_after = mode_after;
-        self
-    }
-
-    pub fn assert(&mut self, initial_state: &str, state_after: &str) {
-        self.cx.assert_binding(
-            self.keystrokes_under_test,
-            initial_state,
-            self.mode_before,
-            state_after,
-            self.mode_after,
-        )
-    }
-}
-
-impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> {
-    type Target = VimTestContext<'a>;
-
-    fn deref(&self) -> &Self::Target {
-        &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/vim.rs ๐Ÿ”—

@@ -1,10 +1,11 @@
 #[cfg(test)]
-mod vim_test_context;
+mod test;
 
 mod editor_events;
 mod insert;
 mod motion;
 mod normal;
+mod object;
 mod state;
 mod utils;
 mod visual;
@@ -25,13 +26,17 @@ pub struct SwitchMode(pub Mode);
 #[derive(Clone, Deserialize, PartialEq)]
 pub struct PushOperator(pub Operator);
 
-impl_actions!(vim, [SwitchMode, PushOperator]);
+#[derive(Clone, Deserialize, PartialEq)]
+struct Number(u8);
+
+impl_actions!(vim, [Number, SwitchMode, PushOperator]);
 
 pub fn init(cx: &mut MutableAppContext) {
     editor_events::init(cx);
     normal::init(cx);
     visual::init(cx);
     insert::init(cx);
+    object::init(cx);
     motion::init(cx);
 
     // Vim Actions
@@ -43,6 +48,9 @@ pub fn init(cx: &mut MutableAppContext) {
             Vim::update(cx, |vim, cx| vim.push_operator(operator, cx))
         },
     );
+    cx.add_action(|_: &mut Workspace, n: &Number, cx: _| {
+        Vim::update(cx, |vim, cx| vim.push_number(n, cx));
+    });
 
     // Editor Actions
     cx.add_action(|_: &mut Editor, _: &Cancel, cx| {
@@ -143,12 +151,31 @@ impl Vim {
         self.sync_vim_settings(cx);
     }
 
+    fn push_number(&mut self, Number(number): &Number, cx: &mut MutableAppContext) {
+        if let Some(Operator::Number(current_number)) = self.active_operator() {
+            self.pop_operator(cx);
+            self.push_operator(Operator::Number(current_number * 10 + *number as usize), cx);
+        } else {
+            self.push_operator(Operator::Number(*number as usize), cx);
+        }
+    }
+
     fn pop_operator(&mut self, cx: &mut MutableAppContext) -> Operator {
-        let popped_operator = self.state.operator_stack.pop().expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config");
+        let popped_operator = self.state.operator_stack.pop()
+            .expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config");
         self.sync_vim_settings(cx);
         popped_operator
     }
 
+    fn pop_number_operator(&mut self, cx: &mut MutableAppContext) -> usize {
+        let mut times = 1;
+        if let Some(Operator::Number(number)) = self.active_operator() {
+            times = number;
+            self.pop_operator(cx);
+        }
+        times
+    }
+
     fn clear_operator(&mut self, cx: &mut MutableAppContext) {
         self.state.operator_stack.clear();
         self.sync_vim_settings(cx);
@@ -204,85 +231,3 @@ impl Vim {
         }
     }
 }
-
-#[cfg(test)]
-mod test {
-    use indoc::indoc;
-    use search::BufferSearchBar;
-
-    use crate::{state::Mode, vim_test_context::VimTestContext};
-
-    #[gpui::test]
-    async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, false).await;
-        cx.simulate_keystrokes(["h", "j", "k", "l"]);
-        cx.assert_editor_state("hjklห‡");
-    }
-
-    #[gpui::test]
-    async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true).await;
-
-        cx.simulate_keystroke("i");
-        assert_eq!(cx.mode(), Mode::Insert);
-
-        // Editor acts as though vim is disabled
-        cx.disable_vim();
-        cx.simulate_keystrokes(["h", "j", "k", "l"]);
-        cx.assert_editor_state("hjklห‡");
-
-        // Selections aren't changed if editor is blurred but vim-mode is still disabled.
-        cx.set_state("ยซhjklห‡ยป", Mode::Normal);
-        cx.assert_editor_state("ยซhjklห‡ยป");
-        cx.update_editor(|_, cx| cx.blur());
-        cx.assert_editor_state("ยซhjklห‡ยป");
-        cx.update_editor(|_, cx| cx.focus_self());
-        cx.assert_editor_state("ยซhjklห‡ยป");
-
-        // Enabling dynamically sets vim mode again and restores normal mode
-        cx.enable_vim();
-        assert_eq!(cx.mode(), Mode::Normal);
-        cx.simulate_keystrokes(["h", "h", "h", "l"]);
-        assert_eq!(cx.buffer_text(), "hjkl".to_owned());
-        cx.assert_editor_state("hห‡jkl");
-        cx.simulate_keystrokes(["i", "T", "e", "s", "t"]);
-        cx.assert_editor_state("hTestห‡jkl");
-
-        // Disabling and enabling resets to normal mode
-        assert_eq!(cx.mode(), Mode::Insert);
-        cx.disable_vim();
-        cx.enable_vim();
-        assert_eq!(cx.mode(), Mode::Normal);
-    }
-
-    #[gpui::test]
-    async fn test_buffer_search(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_keystroke("/");
-
-        // We now use a weird insert mode with selection when jumping to a single line editor
-        assert_eq!(cx.mode(), Mode::Insert);
-
-        let search_bar = cx.workspace(|workspace, cx| {
-            workspace
-                .active_pane()
-                .read(cx)
-                .toolbar()
-                .read(cx)
-                .item_of_type::<BufferSearchBar>()
-                .expect("Buffer search bar should be deployed")
-        });
-
-        search_bar.read_with(cx.cx, |bar, cx| {
-            assert_eq!(bar.query_editor.read(cx).text(cx), "jumps");
-        })
-    }
-}

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

@@ -6,7 +6,13 @@ use gpui::{actions, MutableAppContext, ViewContext};
 use language::{AutoindentMode, SelectionGoal};
 use workspace::Workspace;
 
-use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim};
+use crate::{
+    motion::Motion,
+    object::Object,
+    state::{Mode, Operator},
+    utils::copy_selections_content,
+    Vim,
+};
 
 actions!(vim, [VisualDelete, VisualChange, VisualYank, VisualPaste]);
 
@@ -17,13 +23,15 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(paste);
 }
 
-pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) {
+pub fn visual_motion(motion: Motion, times: usize, cx: &mut MutableAppContext) {
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
             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 was_reversed = selection.reversed;
+
+                    let (new_head, goal) =
+                        motion.move_point(map, selection.head(), selection.goal, times);
                     selection.set_head(new_head, goal);
 
                     if was_reversed && !selection.reversed {
@@ -43,6 +51,36 @@ pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) {
     });
 }
 
+pub fn visual_object(object: Object, cx: &mut MutableAppContext) {
+    Vim::update(cx, |vim, cx| {
+        if let Operator::Object { around } = vim.pop_operator(cx) {
+            vim.update_active_editor(cx, |editor, cx| {
+                editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                    s.move_with(|map, selection| {
+                        let head = selection.head();
+                        if let Some(mut range) = object.range(map, head, around) {
+                            if !range.is_empty() {
+                                if let Some((_, end)) = map.reverse_chars_at(range.end).next() {
+                                    range.end = end;
+                                }
+
+                                if selection.is_empty() {
+                                    selection.start = range.start;
+                                    selection.end = range.end;
+                                } else if selection.reversed {
+                                    selection.start = range.start;
+                                } else {
+                                    selection.end = range.end;
+                                }
+                            }
+                        }
+                    });
+                });
+            });
+        }
+    });
+}
+
 pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
@@ -274,365 +312,151 @@ pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext<Workspace>
 mod test {
     use indoc::indoc;
 
-    use crate::{state::Mode, vim_test_context::VimTestContext};
+    use crate::{
+        state::Mode,
+        test::{NeovimBackedTestContext, VimTestContext},
+    };
 
     #[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 { line: false });
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["v", "w", "j"]);
+        cx.assert_all(indoc! {"
                 The ห‡quick brown
-                fox jumps over
-                the lazy dog"},
-            indoc! {"
-                The ยซquick brown
-                fox jumps ห‡ยปover
-                the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ห‡lazy dog"},
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ยซlazy ห‡ยปdog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
                 fox jumps ห‡over
-                the lazy dog"},
-            indoc! {"
-                The quick brown
-                fox jumps ยซover
-                ห‡ยปthe lazy dog"},
-        );
-        let mut cx = cx
-            .binding(["v", "b", "k"])
-            .mode_after(Mode::Visual { line: false });
-        cx.assert(
-            indoc! {"
+                the ห‡lazy dog"})
+            .await;
+        let mut cx = cx.binding(["v", "b", "k"]);
+        cx.assert_all(indoc! {"
                 The ห‡quick brown
-                fox jumps over
-                the lazy dog"},
-            indoc! {"
-                ยซห‡The qยปuick brown
-                fox jumps over
-                the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ห‡lazy dog"},
-            indoc! {"
-                The quick brown
-                ยซห‡fox jumps over
-                the lยปazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
                 fox jumps ห‡over
-                the lazy dog"},
-            indoc! {"
-                The ยซห‡quick brown
-                fox jumps oยปver
-                the lazy dog"},
-        );
+                the ห‡lazy dog"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_visual_delete(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["v", "w", "x"]);
-        cx.assert("The quick ห‡brown", "The quickห‡ ");
-        let mut cx = cx.binding(["v", "w", "j", "x"]);
-        cx.assert(
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.assert_binding_matches(["v", "w", "x"], "The quick ห‡brown")
+            .await;
+        cx.assert_binding_matches(
+            ["v", "w", "j", "x"],
             indoc! {"
                 The ห‡quick brown
                 fox jumps over
                 the lazy dog"},
-            indoc! {"
-                The ห‡ver
-                the lazy dog"},
-        );
+        )
+        .await;
         // 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.simulate_shared_keystrokes(["j", "p"]).await;
+        cx.assert_state_matches().await;
 
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ห‡lazy dog"},
-            indoc! {"
-                The quick brown
+        let mut cx = cx.binding(["v", "w", "j", "x"]);
+        cx.assert_all(indoc! {"
+                The ห‡quick brown
                 fox jumps over
-                the ห‡og"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps ห‡over
-                the lazy dog"},
-            indoc! {"
-                The quick brown
-                fox jumps ห‡he lazy dog"},
-        );
+                the ห‡lazy dog"})
+            .await;
         let mut cx = cx.binding(["v", "b", "k", "x"]);
-        cx.assert(
-            indoc! {"
+        cx.assert_all(indoc! {"
                 The ห‡quick brown
-                fox jumps over
-                the lazy dog"},
-            indoc! {"
-                ห‡uick brown
-                fox jumps over
-                the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ห‡lazy dog"},
-            indoc! {"
-                The quick brown
-                ห‡azy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
                 fox jumps ห‡over
-                the lazy dog"},
-            indoc! {"
-                The ห‡ver
-                the lazy dog"},
-        );
+                the ห‡lazy dog"})
+            .await;
     }
 
     #[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! {"
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .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"},
-        );
+                the lazy dog"})
+            .await;
         // 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.simulate_shared_keystroke("p").await;
+        cx.assert_state_matches().await;
 
-        cx.assert(
-            indoc! {"
+        cx.assert_all(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"},
-        );
+                the laห‡zy dog"})
+            .await;
         let mut cx = cx.binding(["shift-v", "j", "x"]);
-        cx.assert(
-            indoc! {"
+        cx.assert(indoc! {"
                 The quห‡ick brown
                 fox jumps over
-                the lazy dog"},
-            "the laห‡zy dog",
-        );
+                the lazy dog"})
+            .await;
         // 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.simulate_shared_keystroke("p").await;
+        cx.assert_state_matches().await;
 
-        cx.assert(
-            indoc! {"
+        cx.assert_all(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"},
-        );
+                the laห‡zy dog"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_visual_change(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["v", "w", "c"]).mode_after(Mode::Insert);
-        cx.assert("The quick ห‡brown", "The quick ห‡");
-        let mut cx = cx.binding(["v", "w", "j", "c"]).mode_after(Mode::Insert);
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["v", "w", "c"]);
+        cx.assert("The quick ห‡brown").await;
+        let mut cx = cx.binding(["v", "w", "j", "c"]);
+        cx.assert_all(indoc! {"
                 The ห‡quick brown
-                fox jumps over
-                the lazy dog"},
-            indoc! {"
-                The ห‡ver
-                the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ห‡lazy dog"},
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ห‡og"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
                 fox jumps ห‡over
-                the lazy dog"},
-            indoc! {"
-                The quick brown
-                fox jumps ห‡he lazy dog"},
-        );
-        let mut cx = cx.binding(["v", "b", "k", "c"]).mode_after(Mode::Insert);
-        cx.assert(
-            indoc! {"
+                the ห‡lazy dog"})
+            .await;
+        let mut cx = cx.binding(["v", "b", "k", "c"]);
+        cx.assert_all(indoc! {"
                 The ห‡quick brown
-                fox jumps over
-                the lazy dog"},
-            indoc! {"
-                ห‡uick brown
-                fox jumps over
-                the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ห‡lazy dog"},
-            indoc! {"
-                The quick brown
-                ห‡azy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
                 fox jumps ห‡over
-                the lazy dog"},
-            indoc! {"
-                The ห‡ver
-                the lazy dog"},
-        );
+                the ห‡lazy dog"})
+            .await;
     }
 
     #[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! {"
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["shift-v", "c"]);
+        cx.assert(indoc! {"
                 The quห‡ick brown
                 fox jumps over
-                the lazy dog"},
-            indoc! {"
-                ห‡
-                fox jumps over
-                the lazy dog"},
-        );
+                the lazy dog"})
+            .await;
         // 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.simulate_shared_keystrokes(["escape", "j", "p"]).await;
+        cx.assert_state_matches().await;
 
-        cx.assert(
-            indoc! {"
+        cx.assert_all(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 laห‡zy dog"})
+            .await;
+        let mut cx = cx.binding(["shift-v", "j", "c"]);
+        cx.assert(indoc! {"
                 The quห‡ick brown
                 fox jumps over
-                the lazy dog"},
-            indoc! {"
-                ห‡
-                the lazy dog"},
-        );
+                the lazy dog"})
+            .await;
         // 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! {"
+        cx.simulate_shared_keystrokes(["escape", "j", "p"]).await;
+        cx.assert_state_matches().await;
+
+        cx.assert_all(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
-                ห‡"},
-        );
+                the laห‡zy dog"})
+            .await;
     }
 
     #[gpui::test]
@@ -741,7 +565,7 @@ mod test {
         cx.assert_state(
             indoc! {"
                 The quick brown
-                fox jumpsห‡jumps over
+                fox jumpsjumpห‡s over
                 the lazy dog"},
             Mode::Normal,
         );

crates/vim/test_data/test_a.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,9],"end":[0,9]}},{"Mode":"Insert"}]

crates/vim/test_data/test_backspace.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"}]

crates/vim/test_data/test_cc.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]

crates/vim/test_data/test_dd.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"brown fox\njumps over"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\njumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_delete_left.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"Test"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"est"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Tst"},{"Mode":"Normal"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Normal"},{"Text":"Tet"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"},{"Text":"Test\ntest"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_gg.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"},{"Text":"\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"\n\nbrown fox jumps\nover the lazydog"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_h.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_insert_end_of_line.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"\nThe quick\nbrown fox "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick\nbrown fox "},{"Mode":"Insert"},{"Selection":{"start":[1,9],"end":[1,9]}},{"Mode":"Insert"},{"Text":"\nThe quick\nbrown fox "},{"Mode":"Insert"},{"Selection":{"start":[2,10],"end":[2,10]}},{"Mode":"Insert"}]

crates/vim/test_data/test_insert_first_non_whitespace.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Insert"},{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"}]

crates/vim/test_data/test_insert_line_above.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\n\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]

crates/vim/test_data/test_j.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,5],"end":[1,5]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,8],"end":[1,8]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_jump_to_end.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[3,4],"end":[3,4]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[3,4],"end":[3,4]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[3,16],"end":[3,16]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Normal"},{"Text":"The quick\n\n"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_jump_to_first_non_whitespace.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"The quick"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":" The quick"},{"Mode":"Normal"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Normal"},{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"\nThe quick"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"    \nThe quick"},{"Mode":"Normal"},{"Selection":{"start":[0,3],"end":[0,3]}},{"Mode":"Normal"}]

crates/vim/test_data/test_k.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,7],"end":[0,7]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"}]

crates/vim/test_data/test_l.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[1,1],"end":[1,1]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[1,4],"end":[1,4]}},{"Mode":"Normal"}]

crates/vim/test_data/test_neovim.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"test"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_o.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\njumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[3,0],"end":[3,0]}},{"Mode":"Insert"},{"Text":"The quick\n\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"fn test() {\n    println!();\n    \n}\n"},{"Mode":"Insert"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Insert"},{"Text":"fn test() {\n\n    println!();\n}"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]

crates/vim/test_data/test_p.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"The quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick brown\nthe lazy dog\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps overjumps o\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,20],"end":[1,20]}},{"Mode":"Normal"}]

crates/vim/test_data/test_visual_change.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"The quick "},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps he lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[1,10],"end":[1,10]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps over\nthe og"},{"Mode":"Insert"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Insert"},{"Text":"uick brown\nfox jumps over\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Insert"},{"Text":"The quick brown\nazy dog"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]

crates/vim/test_data/test_visual_delete.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"The quick "},{"Mode":"Normal"},{"Selection":{"start":[0,9],"end":[0,9]}},{"Mode":"Normal"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The ver\nthe lquick brown\nfox jumps oazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,5],"end":[1,5]}},{"Mode":"Normal"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps over\nthe og"},{"Mode":"Normal"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Normal"},{"Text":"uick brown\nfox jumps over\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick brown\nazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_visual_line_change.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"\nfox jumps over\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nfox jumps over\nThe quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick brown\n\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nthe lazy dog\nThe quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick brown\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"}]

crates/vim/test_data/test_visual_line_delete.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"fox jumps over\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"fox jumps over\nThe quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"the lazy dog\nThe quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick brown"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"}]

crates/vim/test_data/test_x.json ๐Ÿ”—

@@ -0,0 +1 @@
+[{"Text":"est"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Tet"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"},{"Text":"Tes"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"},{"Text":"Tes\ntest"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"}]

script/amplitude_release/main.py ๐Ÿ”—

@@ -0,0 +1,30 @@
+import datetime
+import sys
+
+from amplitude_python_sdk.v2.clients.releases_client import ReleasesAPIClient
+from amplitude_python_sdk.v2.models.releases import Release
+
+
+def main():
+    version = sys.argv[1]
+    version = version.removeprefix("v")
+    
+    api_key = sys.argv[2]
+    secret_key = sys.argv[3]
+    
+    current_datetime = datetime.datetime.now(datetime.timezone.utc) 
+    current_datetime = current_datetime.strftime("%Y-%m-%d %H:%M:%S")
+    
+    release = Release(
+        title=version,
+        version=version,
+        release_start=current_datetime,
+        created_by="GitHub Release Workflow",
+        chart_visibility=True
+    )
+    
+    ReleasesAPIClient(api_key=api_key, secret_key=secret_key).create(release)
+    
+    
+if __name__ == "__main__":
+    main()

styles/src/styleTree/editor.ts ๐Ÿ”—

@@ -1,4 +1,5 @@
 import Theme from "../themes/common/theme";
+import { withOpacity } from "../utils/color";
 import {
   backgroundColor,
   border,
@@ -170,6 +171,24 @@ export default function editor(theme: Theme) {
         background: backgroundColor(theme, "on500"),
       },
     },
+    scrollbar: {
+      width: 12,
+      minHeightFactor: 1.0,
+      track: {
+        border: {
+          left: true,
+          width: 1,
+          color: borderColor(theme, "secondary"),
+        },
+      },
+      thumb: {
+        background: withOpacity(borderColor(theme, "secondary"), 0.5),
+        border: {
+          width: 1,
+          color: withOpacity(borderColor(theme, 'muted'), 0.5),
+        }
+      }
+    },
     compositionMark: {
       underline: {
         thickness: 1.0,

styles/src/themes/common/base16.ts ๐Ÿ”—

@@ -123,7 +123,7 @@ export function createTheme(
   const borderColor = {
     primary: sample(ramps.neutral, isLight ? 1.5 : 0),
     secondary: sample(ramps.neutral, isLight ? 1.25 : 1),
-    muted: sample(ramps.neutral, isLight ? 1 : 3),
+    muted: sample(ramps.neutral, isLight ? 1.25 : 3),
     active: sample(ramps.neutral, isLight ? 4 : 3),
     onMedia: withOpacity(darkest, 0.1),
     ok: sample(ramps.green, 0.3),