Detailed changes
@@ -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);
});
}
@@ -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>");
+ }
+}
@@ -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);
}
});
@@ -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);
@@ -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);
@@ -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) {
@@ -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);