From bad3df6e532c7fae4c67d86ff9b3d325e5b60155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Lev=C3=A1k?= <114246119+ixacik@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:54:41 +0100 Subject: [PATCH] vim: Apply linked edits for delete/change/substitute (#48458) 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 --- 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(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 36647fca631f4349a3bfc3c45c1f406e2c5c2c82..387ab0278b9678ddba066f205cf9df67ede9134f 100644 --- a/crates/editor/src/editor.rs +++ b/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, window: &mut Window, cx: &mut Context, + apply_linked_edits: bool, ) { if self.read_only(cx) { return; @@ -5455,6 +5441,12 @@ impl Editor { let text: Arc = 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, 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.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, Vec>)>, + cx: &mut Context, + ) -> 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::(&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 = 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::(&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::(&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 = 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::>(); - 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); }); } diff --git a/crates/editor/src/linked_editing_ranges.rs b/crates/editor/src/linked_editing_ranges.rs index 17c942dfe40fd9459c4de14ec618804952abf25e..34fc1e97df2b01cb3e35b95ec90d0c8d31f5790a 100644 --- a/crates/editor/src/linked_editing_ranges.rs +++ b/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, Vec>)>>, + pub HashMap, Vec>)>>, ); impl LinkedEditingRanges { pub(super) fn get( &self, id: BufferId, - anchor: Range, + anchor: Range, snapshot: &text::BufferSnapshot, - ) -> Option<&(Range, Vec>)> { + ) -> Option<&(Range, Vec>)> { 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, Vec<_>> = Default::default(); + let mut siblings: HashMap, Vec<_>> = Default::default(); let mut insert_sorted_anchor = - |key: &Range, value: &Range| { + |key: &Range, value: &Range| { 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, Vec<(Range, Arc)>>); + +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, + text: Arc, + 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) { + 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) { + self.apply_inner(true, cx); + } + + fn apply_inner(self, expand_empty_ranges_left: bool, cx: &mut Context) { + 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(""); + 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(""); + } + + #[gpui::test] + async fn test_linked_edits_backspace(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(""); + 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(""); + } + + #[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>"); + 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>"); + } + + #[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ˇ»>"); + 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(""); + } +} diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 663ddb8b670796b546cbf8672dc06cf0f92759ce..ceb0c1d51b3e77a5dd4a2f7397b052905a7f2406 100644 --- a/crates/vim/src/normal/change.rs +++ b/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); } }); diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index dd0c33443a61bd6c6e2420b2c8f14242da98f8eb..1d2945012b00fa74cf50b2022bed398efa3db1de 100644 --- a/crates/vim/src/normal/delete.rs +++ b/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); diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index e8b36b73c1de641889d4d5fb229a87432db1a4fb..35cd1c5316fa6a074bf62c0e6f0f13c795f2e97b 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/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::(&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); diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 0e191fc3cc70f8b17407885b1a8a504299a259cb..2d0ec4f69a0aaa93b191933565b9db27d8fb3198 100644 --- a/crates/vim/src/test.rs +++ b/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("", 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(""); +} + +#[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("", 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(""); +} + +#[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("", 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(""); +} + +#[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("", 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(""); +} + +#[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("", 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(""); +} + #[perf] #[gpui::test] async fn test_cancel_selection(cx: &mut gpui::TestAppContext) { diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index d25b1a65d651416eef516a4b3c61546716460dc1..889e3468f2ef6eaa290b6e0aec1971cd2e9ad813 100644 --- a/crates/vim/src/visual.rs +++ b/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);