feat: preserve diff context during LSP navigation
This commit is contained in:
+98
-8
@@ -188,6 +188,7 @@ pub struct DiffState {
|
||||
pub error: Option<String>,
|
||||
pub comparison: Option<Comparison>,
|
||||
pub comparison_id: Option<u64>,
|
||||
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
|
||||
pub files: Option<FilePicker>,
|
||||
pub files_visible: bool,
|
||||
pub file: usize,
|
||||
@@ -229,6 +230,7 @@ impl DiffState {
|
||||
comparison: None,
|
||||
comparison_id: None,
|
||||
files: None,
|
||||
snapshot_roots: [None, None],
|
||||
files_visible: true,
|
||||
file: 0,
|
||||
focus: Focus::Files,
|
||||
@@ -264,6 +266,10 @@ impl DiffState {
|
||||
self.pending.take()
|
||||
}
|
||||
|
||||
pub(crate) fn request_queued(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
|
||||
fn queue(&mut self, operation: Operation) {
|
||||
self.request_kind = Some(match operation {
|
||||
Operation::Compare { .. } => RequestKind::Comparison,
|
||||
@@ -291,7 +297,6 @@ impl DiffState {
|
||||
..
|
||||
}) = &result
|
||||
&& (self.comparison_id != Some(*comparison_id)
|
||||
|| self.file != *file_index
|
||||
|| self.request_kind
|
||||
!= Some(RequestKind::File {
|
||||
comparison_id: *comparison_id,
|
||||
@@ -347,6 +352,7 @@ impl DiffState {
|
||||
self.files = Some(files);
|
||||
self.comparison = Some(comparison);
|
||||
self.comparison_id = Some(id);
|
||||
self.snapshot_roots = [None, None];
|
||||
self.phase = Phase::View;
|
||||
self.file = 0;
|
||||
self.focus = if self.files_visible {
|
||||
@@ -379,8 +385,10 @@ impl DiffState {
|
||||
comparison.editable_paths.remove(&file.path);
|
||||
}
|
||||
*selected = file;
|
||||
self.align_file();
|
||||
self.normalize_focus();
|
||||
if self.file == file_index {
|
||||
self.align_file();
|
||||
self.normalize_focus();
|
||||
}
|
||||
self.error = None;
|
||||
} else {
|
||||
self.error = Some("Unexpected changed-file response".into());
|
||||
@@ -742,9 +750,13 @@ impl DiffState {
|
||||
}
|
||||
|
||||
pub fn after_editable(&self) -> bool {
|
||||
matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
|
||||
&& self.working_after_available()
|
||||
}
|
||||
|
||||
pub(crate) fn working_after_available(&self) -> bool {
|
||||
self.phase == Phase::View
|
||||
&& self.selected_file_loaded()
|
||||
&& matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
|
||||
&& self
|
||||
.after
|
||||
.as_ref()
|
||||
@@ -752,6 +764,7 @@ impl DiffState {
|
||||
&& self.comparison.as_ref().is_some_and(|comparison| {
|
||||
comparison.files.get(self.file).is_some_and(|file| {
|
||||
comparison.editable_paths.contains(&file.path)
|
||||
&& file.status != FileStatus::Removed
|
||||
&& file
|
||||
.notice
|
||||
.as_deref()
|
||||
@@ -770,6 +783,20 @@ impl DiffState {
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
self.select_file_index(index)
|
||||
}
|
||||
|
||||
pub(crate) fn select_file_index(&mut self, index: usize) -> bool {
|
||||
let Some(file) = self
|
||||
.comparison
|
||||
.as_ref()
|
||||
.and_then(|comparison| comparison.files.get(index))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if let Some(files) = &mut self.files {
|
||||
files.reveal_path(&file.path);
|
||||
}
|
||||
self.file = index;
|
||||
self.normalize_focus();
|
||||
self.live_buffer = None;
|
||||
@@ -809,6 +836,69 @@ impl DiffState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_file(&mut self, file_index: usize) {
|
||||
if let Some(comparison_id) = self.comparison_id {
|
||||
self.queue(Operation::LoadFile {
|
||||
comparison_id,
|
||||
file_index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_range_changed(
|
||||
&self,
|
||||
file_index: usize,
|
||||
focus: Focus,
|
||||
first: usize,
|
||||
last: usize,
|
||||
) -> bool {
|
||||
if file_index == self.file {
|
||||
return self.rows.iter().any(|aligned| {
|
||||
aligned.kind != RowKind::Equal
|
||||
&& match focus {
|
||||
Focus::Before => aligned
|
||||
.before
|
||||
.is_some_and(|row| first <= row && row <= last),
|
||||
Focus::After => {
|
||||
aligned.after.is_some_and(|row| first <= row && row <= last)
|
||||
}
|
||||
Focus::Files => false,
|
||||
}
|
||||
});
|
||||
}
|
||||
self.comparison
|
||||
.as_ref()
|
||||
.and_then(|comparison| comparison.files.get(file_index))
|
||||
.is_some_and(|file| {
|
||||
file.hunks.iter().any(|hunk| {
|
||||
let (start, len) = match focus {
|
||||
Focus::Before => (hunk.before_start, hunk.before_len),
|
||||
Focus::After => (hunk.after_start, hunk.after_len),
|
||||
Focus::Files => return false,
|
||||
};
|
||||
len > 0 && start <= last && first < start.saturating_add(len)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_source_row(&mut self, focus: Focus, row: usize) {
|
||||
if self.source_display_row(focus, row).is_none()
|
||||
&& let Some(index) = self.rows.iter().position(|aligned| match focus {
|
||||
Focus::Before => aligned.before == Some(row),
|
||||
Focus::After => aligned.after == Some(row),
|
||||
Focus::Files => false,
|
||||
})
|
||||
{
|
||||
self.excerpt_ranges.push((index, index + 1));
|
||||
self.excerpt_ranges.sort_unstable();
|
||||
self.rebuild_display_rows();
|
||||
}
|
||||
if let Some(display) = self.source_display_row(focus, row) {
|
||||
self.top = display;
|
||||
self.follow_caret = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
|
||||
self.display_rows.iter().position(|display| {
|
||||
matches!(*display, DisplayRow::Line(index) if self.source_row(focus, index) == Some(source_row))
|
||||
@@ -833,14 +923,14 @@ impl DiffState {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot_lines(&self, focus: Focus) -> Vec<String> {
|
||||
let file = &self.comparison.as_ref().expect("snapshot comparison").files[self.file];
|
||||
pub(crate) fn snapshot_lines_at(&self, file_index: usize, focus: Focus) -> Vec<String> {
|
||||
let file = &self.comparison.as_ref().expect("snapshot comparison").files[file_index];
|
||||
let (source, eof, live) = match focus {
|
||||
Focus::Before => (&file.before, file.before_eof, false),
|
||||
Focus::After => (
|
||||
&file.after,
|
||||
file.after_eof,
|
||||
self.live_files.contains(&self.file),
|
||||
self.live_files.contains(&file_index),
|
||||
),
|
||||
Focus::Files => unreachable!("snapshot belongs to a text pane"),
|
||||
};
|
||||
@@ -901,7 +991,7 @@ impl DiffState {
|
||||
}
|
||||
|
||||
pub fn refresh_after(&mut self, lines: &[String], revision: u64) {
|
||||
if !self.after_editable() {
|
||||
if !self.working_after_available() {
|
||||
return;
|
||||
}
|
||||
if self.live_revision == Some(revision) {
|
||||
|
||||
+916
-35
File diff suppressed because it is too large
Load Diff
+117
-68
@@ -20,7 +20,7 @@ use crate::{
|
||||
plugin::{
|
||||
CompletionEdit, CompletionItem, Diagnostic, DiagnosticSeverity, DocumentEvent,
|
||||
DocumentEventKind, DocumentSnapshot, PluginUpdate, SemanticToken, SemanticTokenKind,
|
||||
SemanticTokenModifier, TextPosition, UpdateEffect,
|
||||
SemanticTokenModifier, TextPosition, UpdateEffect, Utf16Range,
|
||||
},
|
||||
snippet,
|
||||
};
|
||||
@@ -624,8 +624,8 @@ struct SyncOptions {
|
||||
|
||||
enum Request {
|
||||
Initialize,
|
||||
Definition(Source),
|
||||
Hover(Source),
|
||||
Definition(Source, Arc<DocumentSnapshot>),
|
||||
Hover(Source, Arc<DocumentSnapshot>),
|
||||
Completion(Source, Arc<DocumentSnapshot>),
|
||||
SemanticTokens(Arc<DocumentSnapshot>),
|
||||
Shutdown,
|
||||
@@ -1001,8 +1001,8 @@ impl Runtime {
|
||||
params["context"] = json!({"triggerKind": 1});
|
||||
}
|
||||
let request = match kind {
|
||||
PositionKind::Definition => Request::Definition(source),
|
||||
PositionKind::Hover => Request::Hover(source),
|
||||
PositionKind::Definition => Request::Definition(source, Arc::clone(&doc.snapshot)),
|
||||
PositionKind::Hover => Request::Hover(source, Arc::clone(&doc.snapshot)),
|
||||
PositionKind::Completion => Request::Completion(source, Arc::clone(&doc.snapshot)),
|
||||
};
|
||||
self.request(method, Some(params), request)
|
||||
@@ -1153,8 +1153,8 @@ impl Runtime {
|
||||
let context = self.requests.remove(&id).ok_or("unexpected LSP response")?;
|
||||
if object.contains_key("result") == object.contains_key("error") {
|
||||
return match context {
|
||||
Request::Hover(source) => {
|
||||
self.hover_result(source, Err("unexpected LSP response".into()))
|
||||
Request::Hover(source, snapshot) => {
|
||||
self.hover_result(source, snapshot, Err("unexpected LSP response".into()))
|
||||
}
|
||||
Request::Completion(source, snapshot) => self.completion_result(
|
||||
source,
|
||||
@@ -1173,9 +1173,11 @@ impl Runtime {
|
||||
.filter(|_| error["code"].as_i64().is_some());
|
||||
let Some(text) = text else {
|
||||
return match context {
|
||||
Request::Hover(source) => {
|
||||
self.hover_result(source, Err("invalid LSP error response".into()))
|
||||
}
|
||||
Request::Hover(source, snapshot) => self.hover_result(
|
||||
source,
|
||||
snapshot,
|
||||
Err("invalid LSP error response".into()),
|
||||
),
|
||||
Request::Completion(source, snapshot) => self.completion_result(
|
||||
source,
|
||||
snapshot,
|
||||
@@ -1189,11 +1191,12 @@ impl Runtime {
|
||||
};
|
||||
match context {
|
||||
Request::Initialize => Err(format!("initialization failed: {text}")),
|
||||
Request::Definition(source) => self.emit(message(
|
||||
&format!("definition failed: {text}"),
|
||||
Some(source.request_id),
|
||||
)),
|
||||
Request::Hover(source) => self.hover_result(source, Err(text.to_owned())),
|
||||
Request::Definition(source, snapshot) => {
|
||||
self.definition_result(source, snapshot, Err(text.to_owned()))
|
||||
}
|
||||
Request::Hover(source, snapshot) => {
|
||||
self.hover_result(source, snapshot, Err(text.to_owned()))
|
||||
}
|
||||
Request::Completion(source, snapshot) => {
|
||||
self.completion_result(source, snapshot, Err(text.to_owned()))
|
||||
}
|
||||
@@ -1205,8 +1208,12 @@ impl Runtime {
|
||||
} else {
|
||||
match context {
|
||||
Request::Initialize => self.initialized(&frame["result"]),
|
||||
Request::Definition(source) => self.definition_result(source, &frame["result"]),
|
||||
Request::Hover(source) => self.hover_result(source, Ok(&frame["result"])),
|
||||
Request::Definition(source, snapshot) => {
|
||||
self.definition_result(source, snapshot, Ok(&frame["result"]))
|
||||
}
|
||||
Request::Hover(source, snapshot) => {
|
||||
self.hover_result(source, snapshot, Ok(&frame["result"]))
|
||||
}
|
||||
Request::Completion(source, snapshot) => {
|
||||
self.completion_result(source, snapshot, Ok(&frame["result"]))
|
||||
}
|
||||
@@ -1394,16 +1401,20 @@ impl Runtime {
|
||||
self.emit_diagnostics(&doc.snapshot, diagnostics)
|
||||
}
|
||||
|
||||
fn definition_result(&self, source: Source, result: &Value) -> Result<(), String> {
|
||||
fn definition_result(
|
||||
&self,
|
||||
source: Source,
|
||||
snapshot: Arc<DocumentSnapshot>,
|
||||
result: Result<&Value, String>,
|
||||
) -> Result<(), String> {
|
||||
if self
|
||||
.documents
|
||||
.get(&source.buffer_id)
|
||||
.is_none_or(|doc| doc.snapshot.revision != source.revision)
|
||||
.is_none_or(|doc| !Arc::ptr_eq(&doc.snapshot, &snapshot))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let result = self.definition_effects(&source, result);
|
||||
match result {
|
||||
match result.and_then(|result| Self::definition_effects(&source, result)) {
|
||||
Ok(effects) => self.emit(PluginUpdate {
|
||||
request_id: Some(source.request_id),
|
||||
effects,
|
||||
@@ -1415,11 +1426,16 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
fn hover_result(&self, source: Source, result: Result<&Value, String>) -> Result<(), String> {
|
||||
fn hover_result(
|
||||
&self,
|
||||
source: Source,
|
||||
snapshot: Arc<DocumentSnapshot>,
|
||||
result: Result<&Value, String>,
|
||||
) -> Result<(), String> {
|
||||
let Some(doc) = self
|
||||
.documents
|
||||
.get(&source.buffer_id)
|
||||
.filter(|doc| doc.snapshot.revision == source.revision)
|
||||
.filter(|doc| Arc::ptr_eq(&doc.snapshot, &snapshot))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -1440,11 +1456,7 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
fn definition_effects(
|
||||
&self,
|
||||
source: &Source,
|
||||
result: &Value,
|
||||
) -> Result<Vec<UpdateEffect>, String> {
|
||||
fn definition_effects(source: &Source, result: &Value) -> Result<Vec<UpdateEffect>, String> {
|
||||
let (location, count) = match result {
|
||||
Value::Null => return Ok(message("no definition found", None).effects),
|
||||
Value::Array(locations) if locations.is_empty() => {
|
||||
@@ -1462,53 +1474,25 @@ impl Runtime {
|
||||
.as_str()
|
||||
.ok_or("invalid definition URI")?,
|
||||
)?;
|
||||
let identity = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||||
let cached = self.snapshots.values().find(|doc| {
|
||||
doc.path.as_ref().is_some_and(|candidate| {
|
||||
let candidate = absolute(Path::new(candidate), &self.root);
|
||||
std::fs::canonicalize(&candidate).unwrap_or(candidate) == identity
|
||||
})
|
||||
});
|
||||
let disk;
|
||||
let text = if let Some(doc) = cached {
|
||||
&doc.text
|
||||
} else {
|
||||
if !std::fs::metadata(&path)
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_file()
|
||||
{
|
||||
return Err("definition target must be a regular file".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(&path)
|
||||
.map_err(|error| error.to_string())?
|
||||
.take((MAX_BODY + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if bytes.len() > MAX_BODY {
|
||||
return Err("definition target exceeds 4 MiB".into());
|
||||
}
|
||||
disk = String::from_utf8(bytes).map_err(|_| "definition target is not UTF-8")?;
|
||||
&disk
|
||||
};
|
||||
let text = normalized(text);
|
||||
if link {
|
||||
range(&text, &location["targetRange"])?;
|
||||
}
|
||||
let (start, _) = range(
|
||||
&text,
|
||||
// Keep the server's document identity and UTF-16 coordinates together.
|
||||
// The editor resolves historical/working text, including lazily loaded
|
||||
// comparison files, before converting the target position.
|
||||
let target_range = link
|
||||
.then(|| range_coordinates(&location["targetRange"]))
|
||||
.transpose()?;
|
||||
let range = range_coordinates(
|
||||
&location[if link {
|
||||
"targetSelectionRange"
|
||||
} else {
|
||||
"range"
|
||||
}],
|
||||
)?;
|
||||
let mut effects = vec![UpdateEffect::Navigate {
|
||||
let mut effects = vec![UpdateEffect::NavigateUtf16 {
|
||||
buffer_id: source.buffer_id,
|
||||
revision: source.revision,
|
||||
path,
|
||||
row: start.row,
|
||||
column: start.column,
|
||||
range,
|
||||
target_range,
|
||||
}];
|
||||
if count > 1 {
|
||||
effects.push(UpdateEffect::Message {
|
||||
@@ -2887,10 +2871,17 @@ fn position(text: &str, value: &Value, round_up: bool) -> Result<TextPosition, S
|
||||
Ok(TextPosition { row, column })
|
||||
}
|
||||
|
||||
fn range(text: &str, value: &Value) -> Result<(TextPosition, TextPosition), String> {
|
||||
if coordinates(&value["start"])? > coordinates(&value["end"])? {
|
||||
fn range_coordinates(value: &Value) -> Result<Utf16Range, String> {
|
||||
let start = coordinates(&value["start"])?;
|
||||
let end = coordinates(&value["end"])?;
|
||||
if start > end {
|
||||
return Err("reversed LSP range".into());
|
||||
}
|
||||
Ok(Utf16Range { start, end })
|
||||
}
|
||||
|
||||
fn range(text: &str, value: &Value) -> Result<(TextPosition, TextPosition), String> {
|
||||
range_coordinates(value)?;
|
||||
Ok((
|
||||
position(text, &value["start"], false)?,
|
||||
position(text, &value["end"], true)?,
|
||||
@@ -3002,6 +2993,64 @@ fn uri_path(uri: &str) -> Result<PathBuf, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_definitions_preserve_snapshot_identity_and_utf16_until_navigation() {
|
||||
let source = Source {
|
||||
buffer_id: 7,
|
||||
revision: 3,
|
||||
row: 0,
|
||||
column: 0,
|
||||
request_id: 11,
|
||||
};
|
||||
let path = Path::new("/vamp-immutable-snapshot/Before/renamed/λ.rs");
|
||||
let span = json!({
|
||||
"start": {"line": 4, "character": 5},
|
||||
"end": {"line": 4, "character": 8}
|
||||
});
|
||||
for location in [
|
||||
json!({"uri": file_uri(path).unwrap(), "range": span}),
|
||||
json!({"targetUri": file_uri(path).unwrap(),
|
||||
"targetRange": span, "targetSelectionRange": span}),
|
||||
] {
|
||||
let effects = Runtime::definition_effects(&source, &location).unwrap();
|
||||
let expected_target_range = location.get("targetUri").map(|_| Utf16Range {
|
||||
start: (4, 5),
|
||||
end: (4, 8),
|
||||
});
|
||||
assert!(matches!(
|
||||
effects.as_slice(),
|
||||
[UpdateEffect::NavigateUtf16 {
|
||||
buffer_id: 7, revision: 3, path: target,
|
||||
range: Utf16Range { start: (4, 5), end: (4, 8) }, target_range,
|
||||
}] if target == path && *target_range == expected_target_range
|
||||
));
|
||||
}
|
||||
for invalid in [
|
||||
json!({"start": {"line": 4, "character": 8},
|
||||
"end": {"line": 4, "character": 5}}),
|
||||
json!({"start": {"line": -1, "character": 0},
|
||||
"end": {"line": 0, "character": 0}}),
|
||||
] {
|
||||
for location in [
|
||||
json!({"uri": file_uri(path).unwrap(), "range": invalid}),
|
||||
json!({"targetUri": file_uri(path).unwrap(),
|
||||
"targetRange": invalid, "targetSelectionRange": span}),
|
||||
json!({"targetUri": file_uri(path).unwrap(),
|
||||
"targetRange": span, "targetSelectionRange": invalid}),
|
||||
] {
|
||||
assert!(Runtime::definition_effects(&source, &location).is_err());
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<UpdateEffect>(json!({
|
||||
"type": "navigate_utf16", "buffer_id": 7, "revision": 3,
|
||||
"path": path, "range": {"start": [4,5], "end": [4,8]},
|
||||
"target_range": null
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_decoder_validates_raw_coordinates_before_grapheme_rounding() {
|
||||
let options = semantic_options(Some(&json!({
|
||||
|
||||
@@ -140,6 +140,13 @@ pub struct CompletionItem {
|
||||
pub snippet: Option<CompletionSnippet>,
|
||||
}
|
||||
|
||||
/// Native LSP range; each coordinate is (zero-based row, UTF-16 column).
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Utf16Range {
|
||||
pub start: (usize, usize),
|
||||
pub end: (usize, usize),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UpdateEffect {
|
||||
@@ -162,6 +169,15 @@ pub enum UpdateEffect {
|
||||
row: usize,
|
||||
column: usize,
|
||||
},
|
||||
/// Native LSP targets retain UTF-16 coordinates until their source text is resolved.
|
||||
#[serde(skip_deserializing)]
|
||||
NavigateUtf16 {
|
||||
buffer_id: usize,
|
||||
revision: u64,
|
||||
path: PathBuf,
|
||||
range: Utf16Range,
|
||||
target_range: Option<Utf16Range>,
|
||||
},
|
||||
Hover {
|
||||
buffer_id: usize,
|
||||
revision: u64,
|
||||
|
||||
+132
-6
@@ -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"]);
|
||||
|
||||
Reference in New Issue
Block a user