feat: preserve diff context during LSP navigation
This commit is contained in:
@@ -10,8 +10,8 @@
|
||||
- per-tab pane layouts, views, explorers, and comparisons; shared buffers, tab bar, and guarded close/force-close actions.
|
||||
- [x] make diff selector close on escape.
|
||||
- [x] `:` currently overrides the diff selector input (and maybe other input fields) and spawns the command input on top. this is of course not intended behavior and should be fixed.
|
||||
- [ ] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view.
|
||||
- this will require tab support.
|
||||
- [x] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view.
|
||||
- changed symbol ranges stay in the comparison; other targets open ordinary tabs. Historical text stays immutable; source freshness and failure atomicity are preserved.
|
||||
- [ ] diff view: change diff view modes; choose between split and unified diff view (split by default).
|
||||
- [ ] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
|
||||
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||
|
||||
@@ -21,7 +21,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
|
||||
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers and completion edit/item DTOs, and versioned asynchronous update contracts. Semantic token and completion updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types.
|
||||
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs.
|
||||
- `snippet`: owns bounded, frontend-independent template parsing and expansion into plain replacement text and byte-relative tabstops with per-occurrence ancestry. It never edits documents or owns input policy.
|
||||
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. Each tab with a comparison has a cancellable repository worker; inactive tabs keep their workers and receive tagged results without changing visible focus. Closed or superseded comparisons reject stale results. Diff focus suppresses asynchronous editing/navigation effects without retiring healthy plugins.
|
||||
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. Each tab with a comparison has a cancellable repository worker; inactive tabs keep their workers and receive tagged results without changing visible focus. Closed or superseded comparisons reject stale results. Focused diff text supports native LSP navigation, hover, and diagnostics; read-only buffers still reject asynchronous editing effects, and selectors/file lists are not document-action targets.
|
||||
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID or immutable diff snapshot/file identity, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
|
||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
||||
@@ -85,6 +85,8 @@ macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`,
|
||||
|
||||
The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses.
|
||||
|
||||
Diff snapshots retain a separate immutable document identity, full source text, side-specific path (the old path for renamed Before), and UTF-16 positions. Native definition updates preserve their UTF-16 ranges until the editor resolves the target's actual snapshot, dirty buffer, disk text, or lazily loaded comparison file; generic plugin navigation remains grapheme-based. Target ranges overlapping actual changed rows route within the same comparison and reveal the exact selection, including an unchanged declaration above a changed body. Wholly unchanged/context/omitted targets and files outside its changes open an ordinary tab without changing the original comparison or view. Snapshot-URI targets keep their immutable identity. Freshness includes tab, pane, source buffer/revision/caret/mode, and comparison/side; deferred file-load navigation also checks its originating view before moving focus. Invalid targets fail before opening a tab. Completion remains restricted to writable Working After.
|
||||
|
||||
Language configurations can own an ordered server stack. The first matching language wins extension overlap; all its matching servers receive document events, while the first handles hover/definition/completion. The Python preset uses ty then Ruff. LSP merges each worker's latest diagnostic snapshot in assignment order before emitting the unchanged core-owned update. Empty publications/retirement remove only their source; revision/path/root changes and close discard all contributions. Worker snapshots retain identity so queued pre-rename diagnostics cannot attach to a new path with the same text revision. Combined diagnostic message bytes remain bounded to 4 MiB; an overflowing publication retires its worker instead of discarding healthy sibling annotations. Workers are stored in config/root order, avoiding per-poll key cloning/sorting. Shutdown flushes the exit notification and waits for peer termination within the existing bounded grace period before process cleanup.
|
||||
|
||||
Semantic tokens belong only to the first assigned server. Initialization advertises standard token types/modifiers, relative encoding, full/range requests, and server refresh support; deltas, overlapping/multiline tokens, and dynamic registration are not advertised. Each buffer has at most one request in flight; changes/refreshes coalesce into the latest snapshot. Full requests are preferred, with whole-document range fallback. A linear UTF-16 decoder validates raw coordinates and legend indices before outward grapheme rounding; overlong ends truncate at EOL, unknown types and invisible spans fall back to syntax. Both wire frames and decoded spans are bounded to 4 MiB. Snapshot identity rejects stale/renamed/closed replies before decoding. Invalid semantic replies clear the overlay and report an error without retiring diagnostics/navigation; primary retirement clears its overlays, and sibling retirement leaves them intact.
|
||||
|
||||
@@ -55,6 +55,8 @@ Clipboard checks use isolated providers rather than reading or overwriting the d
|
||||
|
||||
Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed.
|
||||
|
||||
Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior.
|
||||
|
||||
Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document.
|
||||
Visibility checks preserve browsing and pane state across Ctrl+e, exclude hidden explorers from focus/wheel routing, and verify full-width review. Status checks preserve catalog associations through sorting, compaction, duplicate inputs, and file/directory collisions, and cover colored A/M/D/R markers, selected/unfocused rows, Tree/List filtering, and sanitized rename paths.
|
||||
Diff styling checks cover independent snapshot language/shebang/injection detection, accepted/stale snapshot identity, real-parser cache freshness/reuse/release, syntax foreground over changed backgrounds, real empty rows versus missing counterparts, scrolling-stable diagonal hatching, and tab/wide/combining/control-character clipping. TestBackend omits wide-glyph continuation cells; terminal smoke verifies their actual styling.
|
||||
|
||||
+6
-2
@@ -307,7 +307,11 @@ The changed-file explorer is **docked on the right**. Modified files show Before
|
||||
|
||||
When a diff occupies the sole workspace pane, its top bar spans the full editor width. The right-docked changed-file explorer starts below that bar rather than covering the header; closing the dock preserves the full-width bar.
|
||||
|
||||
Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes unreferenced snapshots and restores the live or original buffer in its workspace pane; snapshots displayed by other panes remain read-only until closed. Explicit native navigation from the comparison pane to an ordinary buffer leaves the comparison at that destination.
|
||||
Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes unreferenced snapshots and restores the live or original buffer in its workspace pane; snapshots displayed by other panes or tabs remain read-only until closed. Explicit buffer commands and Markdown/plugin navigation retain their ordinary behavior; native LSP definitions use the routing below.
|
||||
|
||||
**LSP definition, hover, and diagnostics work normally in focused Before and After text panes**, including historical snapshots. Each snapshot is served with its immutable revision text and its own document URI, not the current disk contents. Completion and completion edits are available only in writable Working-checkout After; they use the shared buffer's normal undo history. Revision selectors and the changed-file explorer are not document-action targets.
|
||||
|
||||
A definition whose source range **overlaps an actual added, removed, or replaced line** stays in the same comparison and selects the correct file and side, loading file details on demand. For a location link, the symbol's full target range determines overlap and its selection range determines the caret: an unchanged declaration with a changed body still belongs in the comparison. Source coordinates belong to that side, including old paths for renames; navigation reveals the exact selection row, even when previously omitted, never the nearest excerpt. A wholly unchanged target opens a **new ordinary tab**, even when it is visible context or omitted text in a changed file. Files outside the comparison's changes also open in a new tab. The original comparison, pane layout, source caret, excerpts, and dirty buffers remain intact. Loaded targets share their existing text and undo history rather than reloading disk; references to snapshot URIs retain the historical text and read-only identity in the new tab, even after the comparison closes. Unreadable files, invalid UTF-8, invalid source ranges, and unsupported remote URIs do not create a tab or disturb the source.
|
||||
|
||||
Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses its first visible text pane: After for added files, otherwise Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus.
|
||||
|
||||
@@ -319,7 +323,7 @@ In **Normal mode** with a text pane focused, **`[` expands above** and **`]` exp
|
||||
|
||||
Live typing highlights only the displayed source rows while retaining each snapshot's original syntax tree for multiline and injected-language context. Line-width caches and unchanged hunk layouts avoid repeated full-file work; edits that change line alignment or revert changes recompute the diff. Already-queued printable Insert keys share a redraw, but every key still passes through normal bindings, snippet handling, and undo history. Mode changes and other terminal events retain their order.
|
||||
|
||||
Text-pane navigation stays within the shown source lines. At an excerpt boundary, Up/Down or `j`/`k` jumps directly to the next/previous excerpt, and counts count visible lines rather than omitted lines. This also applies to remapped movement keys, Visual-mode movement, and editable After's Insert-mode arrows. Word motions, `gt`/`gb`, search, and occurrence selection use the visible excerpts; `gt`/`gb` target their first/last source line. Hidden short lines do not clamp the caret column. The shared vertical viewport and each pane's horizontal offset stay fixed when movement reverses, scrolling only when the caret crosses a visible edge, as in a normal buffer. Moving or restoring a caret never reveals omitted context: only Normal-mode `[`/`]` expands existing excerpts. Edits still recompute changed regions and retain previously expanded context.
|
||||
Text-pane navigation stays within the shown source lines. At an excerpt boundary, Up/Down or `j`/`k` jumps directly to the next/previous excerpt, and counts count visible lines rather than omitted lines. This also applies to remapped movement keys, Visual-mode movement, and editable After's Insert-mode arrows. Word motions, `gt`/`gb`, search, and occurrence selection use the visible excerpts; `gt`/`gb` target their first/last source line. Hidden short lines do not clamp the caret column. The shared vertical viewport and each pane's horizontal offset stay fixed when movement reverses, scrolling only when the caret crosses a visible edge, as in a normal buffer. Ordinary motions, search, and caret restoration do not reveal omitted context; Normal-mode `[`/`]` expands existing excerpts. LSP definition routing can additionally reveal the exact selection of a symbol whose range overlaps changes. Edits still recompute changed regions and retain previously expanded context.
|
||||
|
||||
Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive).
|
||||
|
||||
|
||||
+96
-6
@@ -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;
|
||||
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"]);
|
||||
|
||||
+1267
-4
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user