editor: Fix edit range for linked edits on do completion (#29650)

Closes #29544

Fixes an issue where accepting an HTML completion would correctly edit
the start tag but incorrectly update the end tag due to incorrect linked
edit ranges.

I want to handle multi cursor case (as it barely works now), but seems
like this should go first. As, it might need whole `do_completions`
overhaul.

Todo:
- [x] Tests for completion aceept on linked edits

Before:


https://github.com/user-attachments/assets/917f8d2a-4a0f-46e8-a004-675fde55fe3d

After:


https://github.com/user-attachments/assets/84b760b6-a5b9-45c4-85d8-b5dccf97775f

Release Notes:

- Fixes an issue where accepting an HTML completion would correctly edit
the start tag but incorrectly update the end tag.
This commit is contained in:
Smit Barmase 2025-04-30 21:44:20 +05:30 committed by GitHub
parent e4e455ae6b
commit e697cf9747
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 144 additions and 3 deletions

View file

@ -5005,11 +5005,11 @@ impl Editor {
range
};
ranges.push(range);
ranges.push(range.clone());
if !self.linked_edit_ranges.is_empty() {
let start_anchor = snapshot.anchor_before(selection.head());
let end_anchor = snapshot.anchor_after(selection.tail());
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)
{

View file

@ -1,6 +1,7 @@
use super::*;
use crate::{
JoinLines,
linked_editing_ranges::LinkedEditingRanges,
scroll::scroll_amount::ScrollAmount,
test::{
assert_text_with_selections, build_editor,
@ -19559,6 +19560,146 @@ async fn test_hide_mouse_context_menu_on_modal_opened(cx: &mut TestAppContext) {
});
}
#[gpui::test]
async fn test_html_linked_edits_on_completion(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
fs.insert_file(path!("/file.html"), Default::default())
.await;
let project = Project::test(fs, [path!("/").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
let html_language = Arc::new(Language::new(
LanguageConfig {
name: "HTML".into(),
matcher: LanguageMatcher {
path_suffixes: vec!["html".to_string()],
..LanguageMatcher::default()
},
brackets: BracketPairConfig {
pairs: vec![BracketPair {
start: "<".into(),
end: ">".into(),
close: true,
..Default::default()
}],
..Default::default()
},
..Default::default()
},
Some(tree_sitter_html::LANGUAGE.into()),
));
language_registry.add(html_language);
let mut fake_servers = language_registry.register_fake_lsp(
"HTML",
FakeLspAdapter {
capabilities: lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
resolve_provider: Some(true),
..Default::default()
}),
..Default::default()
},
..Default::default()
},
);
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let worktree_id = workspace
.update(cx, |workspace, _window, cx| {
workspace.project().update(cx, |project, cx| {
project.worktrees(cx).next().unwrap().read(cx).id()
})
})
.unwrap();
project
.update(cx, |project, cx| {
project.open_local_buffer_with_lsp(path!("/file.html"), cx)
})
.await
.unwrap();
let editor = workspace
.update(cx, |workspace, window, cx| {
workspace.open_path((worktree_id, "file.html"), None, true, window, cx)
})
.unwrap()
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let fake_server = fake_servers.next().await.unwrap();
editor.update_in(cx, |editor, window, cx| {
editor.set_text("<ad></ad>", window, cx);
editor.change_selections(None, window, cx, |selections| {
selections.select_ranges([Point::new(0, 3)..Point::new(0, 3)]);
});
let Some((buffer, _)) = editor
.buffer
.read(cx)
.text_anchor_for_position(editor.selections.newest_anchor().start, cx)
else {
panic!("Failed to get buffer for selection position");
};
let buffer = buffer.read(cx);
let buffer_id = buffer.remote_id();
let opening_range =
buffer.anchor_before(Point::new(0, 1))..buffer.anchor_after(Point::new(0, 3));
let closing_range =
buffer.anchor_before(Point::new(0, 6))..buffer.anchor_after(Point::new(0, 8));
let mut linked_ranges = HashMap::default();
linked_ranges.insert(
buffer_id,
vec![(opening_range.clone(), vec![closing_range.clone()])],
);
editor.linked_edit_ranges = LinkedEditingRanges(linked_ranges);
});
let mut completion_handle =
fake_server.set_request_handler::<lsp::request::Completion, _, _>(move |_, _| async move {
Ok(Some(lsp::CompletionResponse::Array(vec![
lsp::CompletionItem {
label: "head".to_string(),
text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace(
lsp::InsertReplaceEdit {
new_text: "head".to_string(),
insert: lsp::Range::new(
lsp::Position::new(0, 1),
lsp::Position::new(0, 3),
),
replace: lsp::Range::new(
lsp::Position::new(0, 1),
lsp::Position::new(0, 3),
),
},
)),
..Default::default()
},
])))
});
editor.update_in(cx, |editor, window, cx| {
editor.show_completions(&ShowCompletions { trigger: None }, window, cx);
});
cx.run_until_parked();
completion_handle.next().await.unwrap();
editor.update(cx, |editor, _| {
assert!(
editor.context_menu_visible(),
"Completion menu should be visible"
);
});
editor.update_in(cx, |editor, window, cx| {
editor.confirm_completion(&ConfirmCompletion::default(), window, cx)
});
cx.executor().run_until_parked();
editor.update(cx, |editor, cx| {
assert_eq!(editor.text(cx), "<head></head>");
});
}
fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
let point = DisplayPoint::new(DisplayRow(row as u32), column as u32);
point..point