feat: preserve diff context during LSP navigation

This commit is contained in:
Valdemar Lorenzen
2026-10-12 14:03:19 +02:00
parent 6eb99a792a
commit 1b8b669ca0
10 changed files with 2559 additions and 126 deletions
+132 -6
View File
@@ -166,6 +166,7 @@ pub struct EditorSession {
lsp_request_id: u64,
lsp_source_cursor: Option<Cursor>,
lsp_source_mode: Mode,
lsp_source_view: Option<(usize, Option<u64>)>,
pending_hover: bool,
pending_completion: bool,
hover_owner: Option<String>,
@@ -264,6 +265,7 @@ impl EditorSession {
lsp_request_id: 0,
lsp_source_cursor: None,
lsp_source_mode: Mode::Normal,
lsp_source_view: None,
pending_hover: false,
pending_completion: false,
hover_owner: None,
@@ -299,10 +301,12 @@ impl EditorSession {
pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) {
self.editor.scroll_diff(focus, down);
self.synchronize_documents();
}
pub(crate) fn scroll_diff_horizontal(&mut self, focus: Focus, right: bool, width: usize) {
self.editor.scroll_diff_horizontal(focus, right, width);
self.synchronize_documents();
}
pub fn handle(&mut self, input: Input) {
@@ -401,11 +405,16 @@ impl EditorSession {
fn dispatch(&mut self, invocation: Invocation) {
if invocation.plugin == "core" {
if !self.editor.document_actions_available()
|| (invocation.command == "lsp.completion" && self.editor.mutation_blocked())
{
return;
}
let result = match invocation.command.as_str() {
"lsp.definition" | "lsp.hover" | "lsp.completion" => {
if let Some(id) = self.lsp_request_id.checked_add(1) {
self.lsp_request_id = id;
let context = self.editor.context();
let context = self.editor.lsp_context();
let source = InvocationSource {
buffer_id: context.buffer_id,
pane_id: self.editor.pane_id(),
@@ -414,6 +423,10 @@ impl EditorSession {
self.requests.entry("core".into()).or_default().latest = Some((id, source));
self.lsp_source_cursor = Some(self.editor.cursor());
self.lsp_source_mode = self.editor.mode();
self.lsp_source_view = Some((
self.editor.tab_id(),
self.editor.diff().and_then(|diff| diff.comparison_id),
));
self.pending_hover = invocation.command == "lsp.hover";
self.pending_completion = invocation.command == "lsp.completion";
match invocation.command.as_str() {
@@ -502,10 +515,17 @@ impl EditorSession {
|| source.revision != self.editor.text_revision()
|| self.lsp_source_cursor != Some(self.editor.cursor())
|| self.editor.mode() != self.lsp_source_mode
|| self.lsp_source_view
!= Some((
self.editor.tab_id(),
self.editor.diff().and_then(|diff| diff.comparison_id),
))
|| !self.editor.document_actions_available()
})
{
requests.latest = None;
self.lsp_source_cursor = None;
self.lsp_source_view = None;
self.pending_hover = false;
self.pending_completion = false;
}
@@ -517,9 +537,15 @@ impl EditorSession {
if !self.editor.should_quit() {
for document in self.editor.interaction_documents() {
let path = document.path.map(Path::to_string_lossy);
let root = self
.editor
.interaction_document_root(document.id)
.to_string_lossy();
let previous = self.documents.get(&document.id);
let renamed = previous
.is_some_and(|previous| previous.snapshot.path.as_deref() != path.as_deref());
let renamed = previous.is_some_and(|previous| {
previous.snapshot.path.as_deref() != path.as_deref()
|| previous.snapshot.root.as_str() != root.as_ref()
});
if renamed {
events.push(DocumentEvent {
event: DocumentEventKind::Close,
@@ -534,7 +560,7 @@ impl EditorSession {
id: document.id,
revision: document.revision,
path: path.map(|path| path.into_owned()),
root: self.editor.project_root().to_string_lossy().into_owned(),
root: root.into_owned(),
text: document.lines.join("\n"),
};
events.push(DocumentEvent {
@@ -636,14 +662,19 @@ impl EditorSession {
request_id: Option<u64>,
source: InvocationSource,
) -> bool {
if self.editor.diff().is_some() && !self.editor.diff_interacting() {
if !self.editor.document_actions_available() {
return false;
}
self.editor.buffer_id() == source.buffer_id
&& self.source_is_current(source)
&& (owner != "core"
|| (self.lsp_source_cursor == Some(self.editor.cursor())
&& self.editor.mode() == self.lsp_source_mode))
&& self.editor.mode() == self.lsp_source_mode
&& self.lsp_source_view
== Some((
self.editor.tab_id(),
self.editor.diff().and_then(|diff| diff.comparison_id),
))))
&& self
.requests
.get(owner)
@@ -755,7 +786,16 @@ impl EditorSession {
revision,
path,
..
}
| UpdateEffect::NavigateUtf16 {
buffer_id,
revision,
path,
..
} => {
if owner != "core" && matches!(effect, UpdateEffect::NavigateUtf16 { .. }) {
return Err("UTF-16 navigation is a native LSP update only".into());
}
let source = InvocationSource {
buffer_id: *buffer_id,
pane_id: self.editor.pane_id(),
@@ -827,6 +867,26 @@ impl EditorSession {
.editor
.replace_validated_semantic_tokens(buffer_id, revision, tokens);
}
UpdateEffect::NavigateUtf16 {
buffer_id,
revision,
path,
range,
target_range,
} => {
let source = InvocationSource {
buffer_id,
pane_id: self.editor.pane_id(),
revision,
};
if self.request_is_current(owner, update.request_id, source) {
if let Err(error) = self.editor.navigate_lsp_to(path, range, target_range) {
self.editor
.set_message(format!("LSP: definition failed: {error}"));
}
changed = true;
}
}
UpdateEffect::Navigate {
buffer_id,
revision,
@@ -1050,6 +1110,68 @@ mod tests {
sync::atomic::{AtomicU64, Ordering},
};
#[test]
fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() {
use crate::plugin::Utf16Range;
let fixture = Fixture::new();
let source = fixture.file("buffer.txt", "source");
let target = fixture.file("target.txt", "A😀e\u{301}Z\n");
let mut session = EditorSession::open(Some(source.clone()), vec![], None).unwrap();
session.dispatch(Invocation {
plugin: "core".into(),
command: "lsp.definition".into(),
args: String::new(),
});
let buffer_id = session.editor.buffer_id();
let revision = session.editor.text_revision();
let request_id = session.lsp_request_id;
let span = Utf16Range {
start: (0, 5),
end: (0, 6),
};
let invalid = Utf16Range {
end: (9, 0),
..span
};
let update = |range, target_range| PluginUpdate {
request_id: Some(request_id),
effects: vec![UpdateEffect::NavigateUtf16 {
buffer_id,
revision,
path: target.clone(),
range,
target_range,
}],
};
for (range, target_range) in [(invalid, None), (span, Some(invalid))] {
assert!(
session
.apply_update("core", update(range, target_range))
.unwrap()
);
assert_eq!(session.editor.buffer_id(), buffer_id);
assert_eq!(session.editor.path(), Some(source.as_path()));
assert_eq!(session.editor.buffer_count(), 1);
assert_eq!(session.editor.tab_count(), 1);
assert_eq!(session.editor.cursor(), Cursor::default());
assert!(session.editor.message().contains("definition failed"));
}
assert!(session.apply_update("fixture", update(span, None)).is_err());
assert_eq!(session.editor.buffer_id(), buffer_id);
assert!(
session
.apply_update("core", update(span, Some(span)))
.unwrap()
);
assert_eq!(session.editor.path(), Some(target.as_path()));
assert_eq!(session.editor.cursor(), Cursor { row: 0, column: 3 });
assert_eq!(session.editor.tab_count(), 1);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor.path(), Some(source.as_path()));
assert_eq!(session.editor.cursor(), Cursor::default());
}
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
#[test]
@@ -1087,6 +1209,10 @@ mod tests {
));
session.lsp_source_cursor = Some(session.editor.cursor());
session.lsp_source_mode = session.editor.mode();
session.lsp_source_view = Some((
session.editor.tab_id(),
session.editor.diff().and_then(|diff| diff.comparison_id),
));
session.editor_mut().with_render_pane(1, |pane| {
assert_eq!(pane.pane_id(), 1);
assert_eq!(pane.lines(), ["source"]);