vim: Apply linked edits for delete/change/substitute (#48458)

Patrik Levák and dino created

Ensure that editing one tag in a linked pair correctly mirrors the change 
to the other tag for Vim delete/change/substitute commands, visual 
mode operations, and the standard editor delete action.

Extract a `LinkedEdits` struct to deduplicate linked edit collection and
application across `handle_input`, `replace_selections`, `do_completion`,
`backspace`, and `delete`. Introduce `linked_edits_for_selections` as a
shared helper for building linked edits from the current selections.

Closes #35941

Release Notes:
- Fixed linked edits for delete/change/substitute commands so tag pairs
stay in sync.

---------

Co-authored-by: dino <dinojoaocosta@gmail.com>

Change summary

crates/editor/src/editor.rs                | 197 +++++++++++------------
crates/editor/src/linked_editing_ranges.rs | 185 +++++++++++++++++++++
crates/vim/src/normal/change.rs            |   4 
crates/vim/src/normal/delete.rs            |   4 
crates/vim/src/normal/substitute.rs        |   9 
crates/vim/src/test.rs                     | 111 +++++++++++++
crates/vim/src/visual.rs                   |   2 
7 files changed, 392 insertions(+), 120 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -72,6 +72,7 @@ pub use git::blame::BlameRenderer;
 pub use hover_popover::hover_markdown_style;
 pub use inlays::Inlay;
 pub use items::MAX_TAB_TITLE_LEN;
+pub use linked_editing_ranges::LinkedEdits;
 pub use lsp::CompletionContext;
 pub use lsp_ext::lsp_tasks;
 pub use multi_buffer::{
@@ -4535,6 +4536,7 @@ impl Editor {
         let start_difference = start_offset - start_byte_offset;
         let end_offset = TO::to_offset(&selection.end, &buffer_snapshot);
         let end_difference = end_offset - start_byte_offset;
+
         // Current range has associated linked ranges.
         let mut linked_edits = HashMap::<_, Vec<_>>::default();
         for range in linked_ranges.iter() {
@@ -4579,7 +4581,7 @@ impl Editor {
         let selections = self.selections.all_adjusted(&self.display_snapshot(cx));
         let mut bracket_inserted = false;
         let mut edits = Vec::new();
-        let mut linked_edits = HashMap::<_, Vec<_>>::default();
+        let mut linked_edits = LinkedEdits::new();
         let mut new_selections = Vec::with_capacity(selections.len());
         let mut new_autoclose_regions = Vec::new();
         let snapshot = self.buffer.read(cx).read(cx);
@@ -4879,16 +4881,8 @@ impl Editor {
                 });
 
                 if is_word_char {
-                    if let Some(ranges) = self
-                        .linked_editing_ranges_for(start_anchor.text_anchor..anchor.text_anchor, cx)
-                    {
-                        for (buffer, edits) in ranges {
-                            linked_edits
-                                .entry(buffer.clone())
-                                .or_default()
-                                .extend(edits.into_iter().map(|range| (range, text.clone())));
-                        }
-                    }
+                    let anchor_range = start_anchor.text_anchor..anchor.text_anchor;
+                    linked_edits.push(&self, anchor_range, text.clone(), cx);
                 } else {
                     clear_linked_edit_ranges = true;
                 }
@@ -4922,21 +4916,7 @@ impl Editor {
                     buffer.edit(edits, this.autoindent_mode.clone(), cx);
                 }
             });
-            for (buffer, edits) in linked_edits {
-                buffer.update(cx, |buffer, cx| {
-                    let snapshot = buffer.snapshot();
-                    let edits = edits
-                        .into_iter()
-                        .map(|(range, text)| {
-                            use text::ToPoint as TP;
-                            let end_point = TP::to_point(&range.end, &snapshot);
-                            let start_point = TP::to_point(&range.start, &snapshot);
-                            (start_point..end_point, text)
-                        })
-                        .sorted_by_key(|(range, _)| range.start);
-                    buffer.edit(edits, None, cx);
-                })
-            }
+            linked_edits.apply(cx);
             let new_anchor_selections = new_selections.iter().map(|e| &e.0);
             let new_selection_deltas = new_selections.iter().map(|e| e.1);
             let map = this.display_map.update(cx, |map, cx| map.snapshot(cx));
@@ -5438,15 +5418,21 @@ impl Editor {
         let autoindent = text.is_empty().not().then(|| AutoindentMode::Block {
             original_indent_columns: Vec::new(),
         });
-        self.insert_with_autoindent_mode(text, autoindent, window, cx);
+        self.replace_selections(text, autoindent, window, cx, false);
     }
 
-    fn insert_with_autoindent_mode(
+    /// Replaces the editor's selections with the provided `text`, applying the
+    /// given `autoindent_mode` (`None` will skip autoindentation).
+    ///
+    /// Early returns if the editor is in read-only mode, without applying any
+    /// edits.
+    fn replace_selections(
         &mut self,
         text: &str,
         autoindent_mode: Option<AutoindentMode>,
         window: &mut Window,
         cx: &mut Context<Self>,
+        apply_linked_edits: bool,
     ) {
         if self.read_only(cx) {
             return;
@@ -5455,6 +5441,12 @@ impl Editor {
         let text: Arc<str> = text.into();
         self.transact(window, cx, |this, window, cx| {
             let old_selections = this.selections.all_adjusted(&this.display_snapshot(cx));
+            let linked_edits = if apply_linked_edits {
+                this.linked_edits_for_selections(text.clone(), cx)
+            } else {
+                LinkedEdits::new()
+            };
+
             let selection_anchors = this.buffer.update(cx, |buffer, cx| {
                 let anchors = {
                     let snapshot = buffer.read(cx);
@@ -5476,14 +5468,75 @@ impl Editor {
                 anchors
             });
 
+            linked_edits.apply(cx);
+
             this.change_selections(Default::default(), window, cx, |s| {
                 s.select_anchors(selection_anchors);
             });
 
+            if apply_linked_edits {
+                refresh_linked_ranges(this, window, cx);
+            }
+
             cx.notify();
         });
     }
 
+    /// Collects linked edits for the current selections, pairing each linked
+    /// range with `text`.
+    pub fn linked_edits_for_selections(&self, text: Arc<str>, cx: &App) -> LinkedEdits {
+        let mut linked_edits = LinkedEdits::new();
+        if !self.linked_edit_ranges.is_empty() {
+            for selection in self.selections.disjoint_anchors() {
+                let start = selection.start.text_anchor;
+                let end = selection.end.text_anchor;
+                linked_edits.push(self, start..end, text.clone(), cx);
+            }
+        }
+        linked_edits
+    }
+
+    /// Deletes the content covered by the current selections and applies
+    /// linked edits.
+    pub fn delete_selections_with_linked_edits(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.replace_selections("", None, window, cx, true);
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn set_linked_edit_ranges_for_testing(
+        &mut self,
+        ranges: Vec<(Range<Point>, Vec<Range<Point>>)>,
+        cx: &mut Context<Self>,
+    ) -> Option<()> {
+        let Some((buffer, _)) = self
+            .buffer
+            .read(cx)
+            .text_anchor_for_position(self.selections.newest_anchor().start, cx)
+        else {
+            return None;
+        };
+        let buffer = buffer.read(cx);
+        let buffer_id = buffer.remote_id();
+        let mut linked_ranges = Vec::with_capacity(ranges.len());
+        for (base_range, linked_ranges_points) in ranges {
+            let base_anchor =
+                buffer.anchor_before(base_range.start)..buffer.anchor_after(base_range.end);
+            let linked_anchors = linked_ranges_points
+                .into_iter()
+                .map(|range| buffer.anchor_before(range.start)..buffer.anchor_after(range.end))
+                .collect();
+            linked_ranges.push((base_anchor, linked_anchors));
+        }
+        let mut map = HashMap::default();
+        map.insert(buffer_id, linked_ranges);
+        self.linked_edit_ranges = linked_editing_ranges::LinkedEditingRanges(map);
+        Some(())
+    }
+
     fn trigger_completion_on_input(
         &mut self,
         text: &str,
@@ -6421,8 +6474,9 @@ impl Editor {
             .selections
             .all::<MultiBufferOffset>(&self.display_snapshot(cx));
         let mut ranges = Vec::new();
-        let mut linked_edits = HashMap::<_, Vec<_>>::default();
+        let mut linked_edits = LinkedEdits::new();
 
+        let text: Arc<str> = new_text.clone().into();
         for selection in &selections {
             let range = if selection.id == newest_anchor.id {
                 replace_range_multibuffer.clone()
@@ -6448,16 +6502,8 @@ impl Editor {
             if !self.linked_edit_ranges.is_empty() {
                 let start_anchor = snapshot.anchor_before(range.start);
                 let end_anchor = snapshot.anchor_after(range.end);
-                if let Some(ranges) = self
-                    .linked_editing_ranges_for(start_anchor.text_anchor..end_anchor.text_anchor, cx)
-                {
-                    for (buffer, edits) in ranges {
-                        linked_edits
-                            .entry(buffer.clone())
-                            .or_default()
-                            .extend(edits.into_iter().map(|range| (range, new_text.to_owned())));
-                    }
-                }
+                let anchor_range = start_anchor.text_anchor..end_anchor.text_anchor;
+                linked_edits.push(&self, anchor_range, text.clone(), cx);
             }
         }
 
@@ -6489,22 +6535,7 @@ impl Editor {
                     multi_buffer.edit(edits, auto_indent, cx);
                 });
             }
-            for (buffer, edits) in linked_edits {
-                buffer.update(cx, |buffer, cx| {
-                    let snapshot = buffer.snapshot();
-                    let edits = edits
-                        .into_iter()
-                        .map(|(range, text)| {
-                            use text::ToPoint as TP;
-                            let end_point = TP::to_point(&range.end, &snapshot);
-                            let start_point = TP::to_point(&range.start, &snapshot);
-                            (start_point..end_point, text)
-                        })
-                        .sorted_by_key(|(range, _)| range.start);
-                    buffer.edit(edits, None, cx);
-                })
-            }
-
+            linked_edits.apply(cx);
             editor.refresh_edit_prediction(true, false, window, cx);
         });
         self.invalidate_autoclose_regions(&self.selections.disjoint_anchors_arc(), &snapshot);
@@ -8142,7 +8173,7 @@ impl Editor {
                                 text: text_to_insert.clone().into(),
                             });
 
-                            self.insert_with_autoindent_mode(&text_to_insert, None, window, cx);
+                            self.replace_selections(&text_to_insert, None, window, cx, false);
                             self.refresh_edit_prediction(true, true, window, cx);
                             cx.notify();
                         } else {
@@ -10691,29 +10722,9 @@ impl Editor {
         self.transact(window, cx, |this, window, cx| {
             this.select_autoclose_pair(window, cx);
 
-            let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx));
-
-            let mut linked_ranges = HashMap::<_, Vec<_>>::default();
-            if !this.linked_edit_ranges.is_empty() {
-                let selections = this.selections.all::<MultiBufferPoint>(&display_map);
-                let snapshot = this.buffer.read(cx).snapshot(cx);
-
-                for selection in selections.iter() {
-                    let selection_start = snapshot.anchor_before(selection.start).text_anchor;
-                    let selection_end = snapshot.anchor_after(selection.end).text_anchor;
-                    if selection_start.buffer_id != selection_end.buffer_id {
-                        continue;
-                    }
-                    if let Some(ranges) =
-                        this.linked_editing_ranges_for(selection_start..selection_end, cx)
-                    {
-                        for (buffer, entries) in ranges {
-                            linked_ranges.entry(buffer).or_default().extend(entries);
-                        }
-                    }
-                }
-            }
+            let linked_edits = this.linked_edits_for_selections(Arc::from(""), cx);
 
+            let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx));
             let mut selections = this.selections.all::<MultiBufferPoint>(&display_map);
             for selection in &mut selections {
                 if selection.is_empty() {
@@ -10750,32 +10761,7 @@ impl Editor {
 
             this.change_selections(Default::default(), window, cx, |s| s.select(selections));
             this.insert("", window, cx);
-            let empty_str: Arc<str> = Arc::from("");
-            for (buffer, edits) in linked_ranges {
-                let snapshot = buffer.read(cx).snapshot();
-                use text::ToPoint as TP;
-
-                let edits = edits
-                    .into_iter()
-                    .map(|range| {
-                        let end_point = TP::to_point(&range.end, &snapshot);
-                        let mut start_point = TP::to_point(&range.start, &snapshot);
-
-                        if end_point == start_point {
-                            let offset = text::ToOffset::to_offset(&range.start, &snapshot)
-                                .saturating_sub(1);
-                            start_point =
-                                snapshot.clip_point(TP::to_point(&offset, &snapshot), Bias::Left);
-                        };
-
-                        (start_point..end_point, empty_str.clone())
-                    })
-                    .sorted_by_key(|(range, _)| range.start)
-                    .collect::<Vec<_>>();
-                buffer.update(cx, |this, cx| {
-                    this.edit(edits, None, cx);
-                })
-            }
+            linked_edits.apply_with_left_expansion(cx);
             this.refresh_edit_prediction(true, false, window, cx);
             refresh_linked_ranges(this, window, cx);
         });
@@ -10797,8 +10783,11 @@ impl Editor {
                     }
                 })
             });
+            let linked_edits = this.linked_edits_for_selections(Arc::from(""), cx);
             this.insert("", window, cx);
+            linked_edits.apply(cx);
             this.refresh_edit_prediction(true, false, window, cx);
+            refresh_linked_ranges(this, window, cx);
         });
     }
 

crates/editor/src/linked_editing_ranges.rs 🔗

@@ -1,9 +1,10 @@
 use collections::HashMap;
-use gpui::{AppContext, Context, Window};
+use gpui::{AppContext, Context, Entity, Window};
 use itertools::Itertools;
+use language::Buffer;
 use multi_buffer::MultiBufferOffset;
-use std::{ops::Range, time::Duration};
-use text::{AnchorRangeExt, BufferId, ToPoint};
+use std::{ops::Range, sync::Arc, time::Duration};
+use text::{Anchor, AnchorRangeExt, Bias, BufferId, ToOffset, ToPoint};
 use util::ResultExt;
 
 use crate::Editor;
@@ -11,16 +12,16 @@ use crate::Editor;
 #[derive(Clone, Default)]
 pub(super) struct LinkedEditingRanges(
     /// Ranges are non-overlapping and sorted by .0 (thus, [x + 1].start > [x].end must hold)
-    pub HashMap<BufferId, Vec<(Range<text::Anchor>, Vec<Range<text::Anchor>>)>>,
+    pub HashMap<BufferId, Vec<(Range<Anchor>, Vec<Range<Anchor>>)>>,
 );
 
 impl LinkedEditingRanges {
     pub(super) fn get(
         &self,
         id: BufferId,
-        anchor: Range<text::Anchor>,
+        anchor: Range<Anchor>,
         snapshot: &text::BufferSnapshot,
-    ) -> Option<&(Range<text::Anchor>, Vec<Range<text::Anchor>>)> {
+    ) -> Option<&(Range<Anchor>, Vec<Range<Anchor>>)> {
         let ranges_for_buffer = self.0.get(&id)?;
         let lower_bound = ranges_for_buffer
             .partition_point(|(range, _)| range.start.cmp(&anchor.start, snapshot).is_le());
@@ -115,9 +116,9 @@ pub(super) fn refresh_linked_ranges(
                         });
                         _current_selection_contains_range?;
                         // Now link every range as each-others sibling.
-                        let mut siblings: HashMap<Range<text::Anchor>, Vec<_>> = Default::default();
+                        let mut siblings: HashMap<Range<Anchor>, Vec<_>> = Default::default();
                         let mut insert_sorted_anchor =
-                            |key: &Range<text::Anchor>, value: &Range<text::Anchor>| {
+                            |key: &Range<Anchor>, value: &Range<Anchor>| {
                                 siblings.entry(key.clone()).or_default().push(value.clone());
                             };
                         for items in edits.into_iter().combinations(2) {
@@ -173,3 +174,171 @@ pub(super) fn refresh_linked_ranges(
     }));
     None
 }
+
+/// Accumulates edits destined for linked editing ranges, for example, matching
+/// HTML/JSX tags, across one or more buffers. Edits are stored as anchor ranges
+/// so they track buffer changes and are only resolved to concrete points at
+/// apply time.
+pub struct LinkedEdits(HashMap<Entity<Buffer>, Vec<(Range<Anchor>, Arc<str>)>>);
+
+impl LinkedEdits {
+    pub fn new() -> Self {
+        Self(HashMap::default())
+    }
+
+    /// Queries the editor's linked editing ranges for the given anchor range and, if any
+    /// are found, records them paired with `text` for later application.
+    pub(crate) fn push(
+        &mut self,
+        editor: &Editor,
+        anchor_range: Range<Anchor>,
+        text: Arc<str>,
+        cx: &gpui::App,
+    ) {
+        if let Some(editing_ranges) = editor.linked_editing_ranges_for(anchor_range, cx) {
+            for (buffer, ranges) in editing_ranges {
+                self.0
+                    .entry(buffer)
+                    .or_default()
+                    .extend(ranges.into_iter().map(|range| (range, text.clone())));
+            }
+        }
+    }
+
+    /// Resolves all stored anchor ranges to points using the current buffer snapshot,
+    /// sorts them, and applies the edits.
+    pub fn apply(self, cx: &mut Context<Editor>) {
+        self.apply_inner(false, cx);
+    }
+
+    /// Like [`apply`](Self::apply), but empty ranges (where start == end) are
+    /// expanded one character to the left before applying. For context, this
+    /// was introduced in order to be available to `backspace` so as to delete a
+    /// character in each linked range even when the selection was a cursor.
+    pub fn apply_with_left_expansion(self, cx: &mut Context<Editor>) {
+        self.apply_inner(true, cx);
+    }
+
+    fn apply_inner(self, expand_empty_ranges_left: bool, cx: &mut Context<Editor>) {
+        for (buffer, ranges_edits) in self.0 {
+            buffer.update(cx, |buffer, cx| {
+                let snapshot = buffer.snapshot();
+                let edits = ranges_edits
+                    .into_iter()
+                    .map(|(range, text)| {
+                        let mut start = range.start.to_point(&snapshot);
+                        let end = range.end.to_point(&snapshot);
+
+                        if expand_empty_ranges_left && start == end {
+                            let offset = range.start.to_offset(&snapshot).saturating_sub(1);
+                            start = snapshot.clip_point(offset.to_point(&snapshot), Bias::Left);
+                        }
+
+                        (start..end, text)
+                    })
+                    .sorted_by_key(|(range, _)| range.start);
+
+                buffer.edit(edits, None, cx);
+            });
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::{editor_tests::init_test, test::editor_test_context::EditorTestContext};
+    use gpui::TestAppContext;
+    use text::Point;
+
+    #[gpui::test]
+    async fn test_linked_edits_push_and_apply(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+        let mut cx = EditorTestContext::new(cx).await;
+
+        cx.set_state("<diˇv></div>");
+        cx.update_editor(|editor, _window, cx| {
+            editor
+                .set_linked_edit_ranges_for_testing(
+                    vec![(
+                        Point::new(0, 1)..Point::new(0, 4),
+                        vec![Point::new(0, 7)..Point::new(0, 10)],
+                    )],
+                    cx,
+                )
+                .unwrap();
+        });
+
+        cx.simulate_keystroke("x");
+        cx.assert_editor_state("<dixˇv></dixv>");
+    }
+
+    #[gpui::test]
+    async fn test_linked_edits_backspace(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+        let mut cx = EditorTestContext::new(cx).await;
+
+        cx.set_state("<divˇ></div>");
+        cx.update_editor(|editor, _window, cx| {
+            editor
+                .set_linked_edit_ranges_for_testing(
+                    vec![(
+                        Point::new(0, 1)..Point::new(0, 4),
+                        vec![Point::new(0, 7)..Point::new(0, 10)],
+                    )],
+                    cx,
+                )
+                .unwrap();
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.backspace(&Default::default(), window, cx);
+        });
+        cx.assert_editor_state("<diˇ></di>");
+    }
+
+    #[gpui::test]
+    async fn test_linked_edits_delete(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+        let mut cx = EditorTestContext::new(cx).await;
+
+        cx.set_state("<ˇdiv></div>");
+        cx.update_editor(|editor, _window, cx| {
+            editor
+                .set_linked_edit_ranges_for_testing(
+                    vec![(
+                        Point::new(0, 1)..Point::new(0, 4),
+                        vec![Point::new(0, 7)..Point::new(0, 10)],
+                    )],
+                    cx,
+                )
+                .unwrap();
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.delete(&Default::default(), window, cx);
+        });
+        cx.assert_editor_state("<ˇiv></iv>");
+    }
+
+    #[gpui::test]
+    async fn test_linked_edits_selection(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+        let mut cx = EditorTestContext::new(cx).await;
+
+        cx.set_state("<«divˇ»></div>");
+        cx.update_editor(|editor, _window, cx| {
+            editor
+                .set_linked_edit_ranges_for_testing(
+                    vec![(
+                        Point::new(0, 1)..Point::new(0, 4),
+                        vec![Point::new(0, 7)..Point::new(0, 10)],
+                    )],
+                    cx,
+                )
+                .unwrap();
+        });
+
+        cx.simulate_keystrokes("s p a n");
+        cx.assert_editor_state("<spanˇ></span>");
+    }
+}

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

@@ -89,7 +89,7 @@ impl Vim {
                 });
                 if let Some(kind) = motion_kind {
                     vim.copy_selections_content(editor, kind, window, cx);
-                    editor.insert("", window, cx);
+                    editor.delete_selections_with_linked_edits(window, cx);
                     editor.refresh_edit_prediction(true, false, window, cx);
                 }
             });
@@ -126,7 +126,7 @@ impl Vim {
                         _ => MotionKind::Exclusive,
                     };
                     vim.copy_selections_content(editor, kind, window, cx);
-                    editor.insert("", window, cx);
+                    editor.delete_selections_with_linked_edits(window, cx);
                     editor.refresh_edit_prediction(true, false, window, cx);
                 }
             });

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

@@ -68,7 +68,7 @@ impl Vim {
                 });
                 let Some(kind) = motion_kind else { return };
                 vim.copy_ranges(editor, kind, false, ranges_to_copy, window, cx);
-                editor.insert("", window, cx);
+                editor.delete_selections_with_linked_edits(window, cx);
 
                 // Fixup cursor position after the deletion
                 editor.set_clip_at_line_ends(true, cx);
@@ -169,7 +169,7 @@ impl Vim {
                     });
                 });
                 vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx);
-                editor.insert("", window, cx);
+                editor.delete_selections_with_linked_edits(window, cx);
 
                 // Fixup cursor position after the deletion
                 editor.set_clip_at_line_ends(true, cx);

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

@@ -1,11 +1,12 @@
-use editor::{Editor, SelectionEffects, movement};
-use gpui::{Context, Window, actions};
-use language::Point;
+use std::sync::Arc;
 
 use crate::{
     Mode, Vim,
     motion::{Motion, MotionKind},
 };
+use editor::{Editor, SelectionEffects, movement};
+use gpui::{Context, Window, actions};
+use language::Point;
 
 actions!(
     vim,
@@ -94,12 +95,14 @@ impl Vim {
                     MotionKind::Exclusive
                 };
                 vim.copy_selections_content(editor, kind, window, cx);
+                let linked_edits = editor.linked_edits_for_selections(Arc::from(""), cx);
                 let selections = editor
                     .selections
                     .all::<Point>(&editor.display_snapshot(cx))
                     .into_iter();
                 let edits = selections.map(|selection| (selection.start..selection.end, ""));
                 editor.edit(edits, cx);
+                linked_edits.apply(cx);
             });
         });
         self.switch_mode(Mode::Insert, true, window, cx);

crates/vim/src/test.rs 🔗

@@ -93,6 +93,117 @@ async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) {
     assert_eq!(cx.mode(), Mode::Normal);
 }
 
+#[perf]
+#[gpui::test]
+async fn test_vim_linked_edits_delete_x(app_cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new_html(app_cx).await;
+
+    cx.set_state("<diˇv></div>", Mode::Normal);
+    cx.update_editor(|editor, _window, cx| {
+        editor
+            .set_linked_edit_ranges_for_testing(
+                vec![(
+                    Point::new(0, 1)..Point::new(0, 4),
+                    vec![Point::new(0, 7)..Point::new(0, 10)],
+                )],
+                cx,
+            )
+            .expect("linked edit ranges should be set");
+    });
+
+    cx.simulate_keystrokes("x");
+    cx.assert_editor_state("<diˇ></di>");
+}
+
+#[perf]
+#[gpui::test]
+async fn test_vim_linked_edits_change_iw(app_cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new_html(app_cx).await;
+
+    cx.set_state("<diˇv></div>", Mode::Normal);
+    cx.update_editor(|editor, _window, cx| {
+        editor
+            .set_linked_edit_ranges_for_testing(
+                vec![(
+                    Point::new(0, 1)..Point::new(0, 4),
+                    vec![Point::new(0, 7)..Point::new(0, 10)],
+                )],
+                cx,
+            )
+            .expect("linked edit ranges should be set");
+    });
+
+    cx.simulate_keystrokes("c i w s p a n escape");
+    cx.assert_editor_state("<spaˇn></span>");
+}
+
+#[perf]
+#[gpui::test]
+async fn test_vim_linked_edits_substitute_s(app_cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new_html(app_cx).await;
+
+    cx.set_state("<diˇv></div>", Mode::Normal);
+    cx.update_editor(|editor, _window, cx| {
+        editor
+            .set_linked_edit_ranges_for_testing(
+                vec![(
+                    Point::new(0, 1)..Point::new(0, 4),
+                    vec![Point::new(0, 7)..Point::new(0, 10)],
+                )],
+                cx,
+            )
+            .expect("linked edit ranges should be set");
+    });
+
+    cx.simulate_keystrokes("s s p a n escape");
+    cx.assert_editor_state("<dispaˇn></dispan>");
+}
+
+#[perf]
+#[gpui::test]
+async fn test_vim_linked_edits_visual_change(app_cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new_html(app_cx).await;
+
+    cx.set_state("<diˇv></div>", Mode::Normal);
+    cx.update_editor(|editor, _window, cx| {
+        editor
+            .set_linked_edit_ranges_for_testing(
+                vec![(
+                    Point::new(0, 1)..Point::new(0, 4),
+                    vec![Point::new(0, 7)..Point::new(0, 10)],
+                )],
+                cx,
+            )
+            .expect("linked edit ranges should be set");
+    });
+
+    // Visual change routes through substitute; visual `s` shares this path.
+    cx.simulate_keystrokes("v i w c s p a n escape");
+    cx.assert_editor_state("<spaˇn></span>");
+}
+
+#[perf]
+#[gpui::test]
+async fn test_vim_linked_edits_visual_substitute_s(app_cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new_html(app_cx).await;
+
+    cx.set_state("<diˇv></div>", Mode::Normal);
+    cx.update_editor(|editor, _window, cx| {
+        editor
+            .set_linked_edit_ranges_for_testing(
+                vec![(
+                    Point::new(0, 1)..Point::new(0, 4),
+                    vec![Point::new(0, 7)..Point::new(0, 10)],
+                )],
+                cx,
+            )
+            .expect("linked edit ranges should be set");
+    });
+
+    cx.simulate_keystrokes("v i w s s p a n escape");
+    cx.assert_editor_state("<spaˇn></span>");
+}
+
 #[perf]
 #[gpui::test]
 async fn test_cancel_selection(cx: &mut gpui::TestAppContext) {

crates/vim/src/visual.rs 🔗

@@ -687,7 +687,7 @@ impl Vim {
                         });
                     });
                 }
-                editor.insert("", window, cx);
+                editor.delete_selections_with_linked_edits(window, cx);
 
                 // Fixup cursor position after the deletion
                 editor.set_clip_at_line_ends(true, cx);