Resolves https://github.com/zed-industries/zed/issues/56518 This diff fixes code lens decorations continuing to paint into the gutter when the editor is horizontally scrolled. Code lens entries were rendered as flex editor blocks with explicit gutter padding, which allowed them to bypass the text viewport clipping used by content-only blocks. With this change, code lens entries render as spacer-style custom blocks instead. Spacer blocks already scroll with buffer content and paint through the text-side mask, so the code lens padding can be relative to the editor text area and decorations clip at the left edge of the viewport. | Before | After | | --- | --- | | <video src="https://github.com/user-attachments/assets/dc567ce5-ad04-4ba2-9156-ec4ec087bd41" controls width="320"></video> | <video src="https://github.com/user-attachments/assets/abb5ba2c-981f-4399-903b-0f8fa9a560e4" controls width="320"></video> | Release Notes: - Fixed code lens decorations painting outside the editor viewport when horizontally scrolling.
1596 lines
57 KiB
Rust
1596 lines
57 KiB
Rust
use std::sync::Arc;
|
|
|
|
use collections::{HashMap, HashSet};
|
|
use futures::{StreamExt as _, future::join_all, stream::FuturesUnordered};
|
|
use gpui::{MouseButton, SharedString, Task, TaskExt, WeakEntity};
|
|
use itertools::Itertools;
|
|
use language::{BufferId, ClientCommand};
|
|
use multi_buffer::{Anchor, MultiBufferRow, MultiBufferSnapshot, ToPoint as _};
|
|
use project::{CodeAction, TaskSourceKind, lsp_store::code_lens::CodeLensActions};
|
|
use task::TaskContext;
|
|
use text::ToOffset as _;
|
|
|
|
use ui::{Context, Window, div, prelude::*};
|
|
|
|
use crate::{
|
|
Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT, SelectionEffects,
|
|
actions::ToggleCodeLens,
|
|
display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId, RenderBlock},
|
|
hover_links::HoverLink,
|
|
};
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct CodeLensLine {
|
|
position: Anchor,
|
|
indent_column: u32,
|
|
items: Vec<CodeLensItem>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct CodeLensItem {
|
|
title: Option<SharedString>,
|
|
action: CodeAction,
|
|
}
|
|
|
|
pub(super) struct CodeLensBlock {
|
|
block_id: CustomBlockId,
|
|
anchor: Anchor,
|
|
line: CodeLensLine,
|
|
}
|
|
|
|
pub(super) struct CodeLensState {
|
|
pub(super) blocks: HashMap<BufferId, Vec<CodeLensBlock>>,
|
|
actions: HashMap<BufferId, CodeLensActions>,
|
|
resolve_task: Task<()>,
|
|
}
|
|
|
|
impl Default for CodeLensState {
|
|
fn default() -> Self {
|
|
Self {
|
|
blocks: HashMap::default(),
|
|
actions: HashMap::default(),
|
|
resolve_task: Task::ready(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn try_handle_client_command(
|
|
action: &CodeAction,
|
|
editor: &mut Editor,
|
|
workspace: &gpui::Entity<workspace::Workspace>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
) -> bool {
|
|
let Some(command) = action.lsp_action.command() else {
|
|
return false;
|
|
};
|
|
|
|
let arguments = command.arguments.as_deref().unwrap_or_default();
|
|
let project = workspace.read(cx).project().clone();
|
|
let client_command = project
|
|
.read(cx)
|
|
.lsp_store()
|
|
.read(cx)
|
|
.language_server_adapter_for_id(action.server_id)
|
|
.and_then(|adapter| adapter.adapter.client_command(&command.command, arguments))
|
|
.or_else(|| match command.command.as_str() {
|
|
"editor.action.showReferences"
|
|
| "editor.action.goToLocations"
|
|
| "editor.action.peekLocations" => Some(ClientCommand::ShowLocations),
|
|
_ => None,
|
|
});
|
|
|
|
match client_command {
|
|
Some(ClientCommand::ScheduleTask(task_template)) => {
|
|
schedule_task(task_template, action, editor, workspace, window, cx)
|
|
}
|
|
Some(ClientCommand::ShowLocations) => {
|
|
try_show_references(arguments, action, editor, window, cx)
|
|
}
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
fn schedule_task(
|
|
task_template: task::TaskTemplate,
|
|
action: &CodeAction,
|
|
editor: &Editor,
|
|
workspace: &gpui::Entity<workspace::Workspace>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
) -> bool {
|
|
let task_context = TaskContext {
|
|
cwd: task_template.cwd.as_ref().map(std::path::PathBuf::from),
|
|
..TaskContext::default()
|
|
};
|
|
let language_name = editor
|
|
.buffer()
|
|
.read(cx)
|
|
.buffer(action.range.start.buffer_id)
|
|
.and_then(|buffer| buffer.read(cx).language())
|
|
.map(|language| language.name());
|
|
let task_source_kind = match language_name {
|
|
Some(language_name) => TaskSourceKind::Lsp {
|
|
server: action.server_id,
|
|
language_name: SharedString::from(language_name),
|
|
},
|
|
None => TaskSourceKind::AbsPath {
|
|
id_base: "code-lens".into(),
|
|
abs_path: task_template
|
|
.cwd
|
|
.as_ref()
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or_default(),
|
|
},
|
|
};
|
|
|
|
workspace.update(cx, |workspace, cx| {
|
|
workspace.schedule_task(
|
|
task_source_kind,
|
|
&task_template,
|
|
&task_context,
|
|
false,
|
|
window,
|
|
cx,
|
|
);
|
|
});
|
|
true
|
|
}
|
|
|
|
fn try_show_references(
|
|
arguments: &[serde_json::Value],
|
|
action: &CodeAction,
|
|
editor: &mut Editor,
|
|
window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
) -> bool {
|
|
if arguments.len() < 3 {
|
|
return false;
|
|
}
|
|
let Ok(locations) = serde_json::from_value::<Vec<lsp::Location>>(arguments[2].clone()) else {
|
|
return false;
|
|
};
|
|
if locations.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let server_id = action.server_id;
|
|
let nav_entry = editor.navigation_entry(editor.selections.newest_anchor().head(), cx);
|
|
let links = locations
|
|
.into_iter()
|
|
.map(|location| HoverLink::InlayHint(location, server_id))
|
|
.collect();
|
|
editor
|
|
.navigate_to_hover_links(None, links, nav_entry, false, window, cx)
|
|
.detach_and_log_err(cx);
|
|
|
|
true
|
|
}
|
|
|
|
impl Editor {
|
|
pub(super) fn refresh_code_lenses(
|
|
&mut self,
|
|
for_buffer: Option<BufferId>,
|
|
_window: &Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if !self.lsp_data_enabled() || self.code_lens.is_none() {
|
|
return;
|
|
}
|
|
let Some(project) = self.project.clone() else {
|
|
return;
|
|
};
|
|
|
|
let buffers_to_query = self
|
|
.visible_buffers(cx)
|
|
.into_iter()
|
|
.filter(|buffer| self.is_lsp_relevant(buffer.read(cx).file(), cx))
|
|
.chain(for_buffer.and_then(|buffer_id| self.buffer.read(cx).buffer(buffer_id)))
|
|
.filter(|editor_buffer| {
|
|
let editor_buffer_id = editor_buffer.read(cx).remote_id();
|
|
for_buffer.is_none_or(|buffer_id| buffer_id == editor_buffer_id)
|
|
&& self.registered_buffers.contains_key(&editor_buffer_id)
|
|
})
|
|
.unique_by(|buffer| buffer.read(cx).remote_id())
|
|
.collect::<Vec<_>>();
|
|
|
|
if buffers_to_query.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let project = project.downgrade();
|
|
self.refresh_code_lens_task = cx.spawn(async move |editor, cx| {
|
|
cx.background_executor()
|
|
.timer(LSP_REQUEST_DEBOUNCE_TIMEOUT)
|
|
.await;
|
|
|
|
let Some(tasks_per_buffer) = project
|
|
.update(cx, |project, cx| {
|
|
project.lsp_store().update(cx, |lsp_store, cx| {
|
|
buffers_to_query
|
|
.into_iter()
|
|
.map(|buffer| {
|
|
let buffer_id = buffer.read(cx).remote_id();
|
|
let task = lsp_store.code_lens_actions(&buffer, cx);
|
|
async move { (buffer_id, task.await) }
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})
|
|
})
|
|
.ok()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let code_lens_per_buffer = join_all(tasks_per_buffer).await;
|
|
if code_lens_per_buffer.is_empty() {
|
|
return;
|
|
}
|
|
|
|
editor
|
|
.update(cx, |editor, cx| {
|
|
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
|
for (buffer_id, result) in code_lens_per_buffer {
|
|
let actions = match result {
|
|
Ok(Some(actions)) => actions,
|
|
Ok(None) => continue,
|
|
Err(e) => {
|
|
log::error!(
|
|
"Failed to fetch code lenses for buffer {buffer_id:?}: {e:#}"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
editor.apply_lens_actions_for_buffer(buffer_id, actions, &snapshot, cx);
|
|
}
|
|
editor.resolve_visible_code_lenses(cx);
|
|
})
|
|
.ok();
|
|
});
|
|
}
|
|
|
|
/// Reconcile blocks for `buffer_id` against the latest `actions`.
|
|
///
|
|
/// Lenses without a `command` keep a placeholder block so the line
|
|
/// stays reserved while the resolve is in flight — this is what avoids
|
|
/// the post-edit flicker on `rust-analyzer`-style servers. Lenses
|
|
/// whose resolve already came back without a usable title are dropped
|
|
/// (`resolve_visible_code_lenses` won't retry them), otherwise they'd
|
|
/// leave a permanent blank line.
|
|
///
|
|
/// When the new fetch has only placeholders for a row but the old
|
|
/// block was already resolved we keep the old block, so the line
|
|
/// doesn't blank out until the fresh resolve lands.
|
|
fn apply_lens_actions_for_buffer(
|
|
&mut self,
|
|
buffer_id: BufferId,
|
|
actions: CodeLensActions,
|
|
snapshot: &MultiBufferSnapshot,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let mut all_lenses = Vec::new();
|
|
for (_, action) in actions.iter().sorted_by_key(|(id, _)| **id) {
|
|
let Some(position) = snapshot.anchor_in_excerpt(action.range.start) else {
|
|
continue;
|
|
};
|
|
if let project::LspAction::CodeLens(lens) = &action.lsp_action {
|
|
let title = lens
|
|
.command
|
|
.as_ref()
|
|
.filter(|cmd| !cmd.title.is_empty())
|
|
.map(|cmd| SharedString::from(&cmd.title));
|
|
if title.is_none() && action.resolved {
|
|
continue;
|
|
}
|
|
all_lenses.push((
|
|
position,
|
|
CodeLensItem {
|
|
title,
|
|
action: action.clone(),
|
|
},
|
|
));
|
|
}
|
|
}
|
|
|
|
let mut new_lines_by_row = group_lenses_by_row(all_lenses, snapshot)
|
|
.map(|line| (MultiBufferRow(line.position.to_point(snapshot).row), line))
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
let editor_handle = cx.entity().downgrade();
|
|
let code_lens = self.code_lens.get_or_insert_with(CodeLensState::default);
|
|
let old_blocks = code_lens.blocks.remove(&buffer_id).unwrap_or_default();
|
|
|
|
let mut kept_blocks = Vec::new();
|
|
let mut renderers_to_replace = HashMap::default();
|
|
let mut blocks_to_remove = HashSet::default();
|
|
let mut covered_rows = HashSet::default();
|
|
|
|
for old in old_blocks {
|
|
let row = MultiBufferRow(old.anchor.to_point(snapshot).row);
|
|
let Some(new_line) = new_lines_by_row.remove(&row) else {
|
|
blocks_to_remove.insert(old.block_id);
|
|
continue;
|
|
};
|
|
covered_rows.insert(row);
|
|
let new_all_unresolved = new_line.items.iter().all(|item| item.title.is_none());
|
|
let old_has_resolved = old.line.items.iter().any(|item| item.title.is_some());
|
|
if new_all_unresolved && old_has_resolved {
|
|
kept_blocks.push(old);
|
|
continue;
|
|
}
|
|
if rendered_text_matches(&old.line, &new_line) {
|
|
kept_blocks.push(old);
|
|
} else {
|
|
let mut updated = old;
|
|
updated.line = new_line.clone();
|
|
renderers_to_replace.insert(
|
|
updated.block_id,
|
|
build_code_lens_renderer(new_line, editor_handle.clone()),
|
|
);
|
|
kept_blocks.push(updated);
|
|
}
|
|
}
|
|
|
|
let mut to_insert = Vec::new();
|
|
for (row, new_line) in new_lines_by_row {
|
|
if covered_rows.contains(&row) {
|
|
continue;
|
|
}
|
|
let anchor = new_line.position;
|
|
let props = BlockProperties {
|
|
placement: BlockPlacement::Above(anchor),
|
|
height: Some(1),
|
|
style: BlockStyle::Spacer,
|
|
render: build_code_lens_renderer(new_line.clone(), editor_handle.clone()),
|
|
priority: 0,
|
|
};
|
|
to_insert.push((props, anchor, new_line));
|
|
}
|
|
|
|
if !blocks_to_remove.is_empty() {
|
|
self.remove_blocks(blocks_to_remove, None, cx);
|
|
}
|
|
if !renderers_to_replace.is_empty() {
|
|
self.replace_blocks(renderers_to_replace, None, cx);
|
|
}
|
|
if !to_insert.is_empty() {
|
|
let mut props = Vec::with_capacity(to_insert.len());
|
|
let mut metadata = Vec::with_capacity(to_insert.len());
|
|
for (p, anchor, line) in to_insert {
|
|
props.push(p);
|
|
metadata.push((anchor, line));
|
|
}
|
|
let block_ids = self.insert_blocks(props, None, cx);
|
|
for (block_id, (anchor, line)) in block_ids.into_iter().zip(metadata) {
|
|
kept_blocks.push(CodeLensBlock {
|
|
block_id,
|
|
anchor,
|
|
line,
|
|
});
|
|
}
|
|
}
|
|
|
|
let code_lens = self.code_lens.get_or_insert_with(CodeLensState::default);
|
|
if actions.is_empty() {
|
|
code_lens.actions.remove(&buffer_id);
|
|
} else {
|
|
code_lens.actions.insert(buffer_id, actions);
|
|
}
|
|
if kept_blocks.is_empty() {
|
|
code_lens.blocks.remove(&buffer_id);
|
|
} else {
|
|
code_lens.blocks.insert(buffer_id, kept_blocks);
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn supports_code_lens(&self, cx: &ui::App) -> bool {
|
|
let Some(project) = self.project.as_ref() else {
|
|
return false;
|
|
};
|
|
let lsp_store = project.read(cx).lsp_store().read(cx);
|
|
lsp_store
|
|
.lsp_server_capabilities
|
|
.values()
|
|
.any(|caps| caps.code_lens_provider.is_some())
|
|
}
|
|
|
|
pub fn code_lens_enabled(&self) -> bool {
|
|
self.code_lens.is_some()
|
|
}
|
|
|
|
pub fn toggle_code_lens_action(
|
|
&mut self,
|
|
_: &ToggleCodeLens,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let currently_enabled = self.code_lens.is_some();
|
|
self.toggle_code_lens(!currently_enabled, window, cx);
|
|
}
|
|
|
|
pub(super) fn toggle_code_lens(
|
|
&mut self,
|
|
enabled: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if enabled {
|
|
self.code_lens.get_or_insert_with(CodeLensState::default);
|
|
self.refresh_code_lenses(None, window, cx);
|
|
} else {
|
|
self.clear_code_lenses(cx);
|
|
}
|
|
}
|
|
|
|
pub(super) fn resolve_visible_code_lenses(&mut self, cx: &mut Context<Self>) {
|
|
if !self.lsp_data_enabled() || self.code_lens.is_none() {
|
|
return;
|
|
}
|
|
let Some(project) = self.project.clone() else {
|
|
return;
|
|
};
|
|
|
|
let lsp_store = project.read(cx).lsp_store();
|
|
|
|
let mut pending_resolves = Vec::new();
|
|
for (buffer_snapshot, visible_range, _) in self.visible_buffer_ranges(cx) {
|
|
let buffer_id = buffer_snapshot.remote_id();
|
|
let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) else {
|
|
continue;
|
|
};
|
|
let Some(actions) = self
|
|
.code_lens
|
|
.as_ref()
|
|
.and_then(|state| state.actions.get(&buffer_id))
|
|
else {
|
|
continue;
|
|
};
|
|
for (lens_id, action) in actions {
|
|
if action.resolved {
|
|
continue;
|
|
}
|
|
if let project::LspAction::CodeLens(lens) = &action.lsp_action {
|
|
if lens.command.is_some() {
|
|
continue;
|
|
}
|
|
}
|
|
let action_offset = action.range.start.to_offset(&buffer_snapshot);
|
|
if action_offset < visible_range.start.0 || action_offset > visible_range.end.0 {
|
|
continue;
|
|
}
|
|
let resolve_task = lsp_store.update(cx, |lsp_store, cx| {
|
|
lsp_store.resolve_code_lens(&buffer, action.server_id, *lens_id, cx)
|
|
});
|
|
pending_resolves.push((buffer_id, resolve_task));
|
|
}
|
|
}
|
|
if pending_resolves.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let code_lens = self.code_lens.get_or_insert_with(CodeLensState::default);
|
|
code_lens.resolve_task = cx.spawn(async move |editor, cx| {
|
|
let mut resolves_in_progress = pending_resolves
|
|
.into_iter()
|
|
.map(|(buffer_id, task)| async move { (buffer_id, task.await) })
|
|
.collect::<FuturesUnordered<_>>();
|
|
while let Some((buffer_id, resolve_result)) = resolves_in_progress.next().await {
|
|
let Some((resolved_id, resolved)) = resolve_result else {
|
|
continue;
|
|
};
|
|
editor
|
|
.update(cx, |editor, cx| {
|
|
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
|
let Some(mut actions) = editor
|
|
.code_lens
|
|
.as_ref()
|
|
.and_then(|state| state.actions.get(&buffer_id))
|
|
.cloned()
|
|
else {
|
|
return;
|
|
};
|
|
if let Some(slot) = actions.get_mut(&resolved_id) {
|
|
*slot = resolved;
|
|
}
|
|
editor.apply_lens_actions_for_buffer(buffer_id, actions, &snapshot, cx);
|
|
})
|
|
.ok();
|
|
}
|
|
});
|
|
}
|
|
|
|
pub(super) fn clear_code_lenses(&mut self, cx: &mut Context<Self>) {
|
|
if let Some(code_lens) = self.code_lens.take() {
|
|
let all_blocks = code_lens
|
|
.blocks
|
|
.into_values()
|
|
.flatten()
|
|
.map(|block| block.block_id)
|
|
.collect::<HashSet<_>>();
|
|
if !all_blocks.is_empty() {
|
|
self.remove_blocks(all_blocks, None, cx);
|
|
}
|
|
cx.notify();
|
|
}
|
|
self.refresh_code_lens_task = Task::ready(());
|
|
}
|
|
}
|
|
|
|
/// Whether two lens lines would render the same on screen — same indent
|
|
/// and same titles in the same order. Used to skip recreating a renderer
|
|
/// (and thus a click handler) when nothing about the displayed line
|
|
/// changed; the captured [`CodeAction`] inside the existing renderer keeps
|
|
/// pointing at the right spot because its anchors track buffer edits.
|
|
fn rendered_text_matches(a: &CodeLensLine, b: &CodeLensLine) -> bool {
|
|
a.indent_column == b.indent_column
|
|
&& a.items.len() == b.items.len()
|
|
&& a.items
|
|
.iter()
|
|
.zip(&b.items)
|
|
.all(|(x, y)| x.title == y.title)
|
|
}
|
|
|
|
fn group_lenses_by_row(
|
|
lenses: Vec<(Anchor, CodeLensItem)>,
|
|
snapshot: &MultiBufferSnapshot,
|
|
) -> impl Iterator<Item = CodeLensLine> {
|
|
lenses
|
|
.into_iter()
|
|
.into_group_map_by(|(position, _)| {
|
|
let row = position.to_point(snapshot).row;
|
|
MultiBufferRow(row)
|
|
})
|
|
.into_iter()
|
|
.sorted_by_key(|(row, _)| *row)
|
|
.filter_map(|(row, entries)| {
|
|
let position = entries.first()?.0;
|
|
let items = entries.into_iter().map(|(_, item)| item).collect();
|
|
let indent_column = snapshot.indent_size_for_line(row).len;
|
|
Some(CodeLensLine {
|
|
position,
|
|
indent_column,
|
|
items,
|
|
})
|
|
})
|
|
}
|
|
|
|
fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity<Editor>) -> RenderBlock {
|
|
Arc::new(move |cx| {
|
|
let resolved_items = line
|
|
.items
|
|
.iter()
|
|
.filter_map(|item| item.title.as_ref().map(|title| (title, &item.action)))
|
|
.collect::<Vec<_>>();
|
|
let mut children = Vec::with_capacity((2 * resolved_items.len()).saturating_sub(1));
|
|
let text_style = &cx.editor_style.text;
|
|
let font = text_style.font();
|
|
let font_size = text_style.font_size.to_pixels(cx.window.rem_size()) * 0.9;
|
|
|
|
for (i, (title, action)) in resolved_items.iter().enumerate() {
|
|
if i > 0 {
|
|
children.push(
|
|
div()
|
|
.font(font.clone())
|
|
.text_size(font_size)
|
|
.text_color(cx.app.theme().colors().text_muted)
|
|
.child(" | ")
|
|
.into_any_element(),
|
|
);
|
|
}
|
|
|
|
let title = (*title).clone();
|
|
let action = (*action).clone();
|
|
let position = line.position;
|
|
let editor_handle = editor.clone();
|
|
|
|
children.push(
|
|
div()
|
|
.id(ElementId::from(i))
|
|
.font(font.clone())
|
|
.text_size(font_size)
|
|
.text_color(cx.app.theme().colors().text_muted)
|
|
.cursor_pointer()
|
|
.hover(|style| style.text_color(cx.app.theme().colors().text))
|
|
.child(title)
|
|
.on_mouse_down(MouseButton::Left, |_, _, cx| {
|
|
cx.stop_propagation();
|
|
})
|
|
.on_mouse_down(MouseButton::Right, |_, _, cx| {
|
|
cx.stop_propagation();
|
|
})
|
|
.on_click({
|
|
move |_event, window, cx| {
|
|
if let Some(editor) = editor_handle.upgrade() {
|
|
editor.update(cx, |editor, cx| {
|
|
editor.change_selections(
|
|
SelectionEffects::default(),
|
|
window,
|
|
cx,
|
|
|s| {
|
|
s.select_anchor_ranges([position..position]);
|
|
},
|
|
);
|
|
|
|
let action = action.clone();
|
|
if let Some(workspace) = editor.workspace() {
|
|
if try_handle_client_command(
|
|
&action, editor, &workspace, window, cx,
|
|
) {
|
|
return;
|
|
}
|
|
|
|
let project = workspace.read(cx).project().clone();
|
|
if let Some(buffer) = editor
|
|
.buffer()
|
|
.read(cx)
|
|
.buffer(action.range.start.buffer_id)
|
|
{
|
|
project
|
|
.update(cx, |project, cx| {
|
|
project
|
|
.apply_code_action(buffer, action, true, cx)
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
})
|
|
.into_any_element(),
|
|
);
|
|
}
|
|
|
|
div()
|
|
.id(cx.block_id)
|
|
.pl(cx.em_width * (line.indent_column as f32 + 0.5))
|
|
.h_full()
|
|
.flex()
|
|
.flex_row()
|
|
.items_end()
|
|
.children(children)
|
|
.into_any_element()
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
sync::{Arc, Mutex},
|
|
time::Duration,
|
|
};
|
|
|
|
use collections::HashSet;
|
|
use futures::StreamExt;
|
|
use gpui::TestAppContext;
|
|
use settings::CodeLens;
|
|
use util::path;
|
|
|
|
use crate::{
|
|
Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT,
|
|
editor_tests::{init_test, update_test_editor_settings},
|
|
test::editor_lsp_test_context::EditorLspTestContext,
|
|
};
|
|
|
|
#[gpui::test]
|
|
async fn test_code_lens_blocks(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
update_test_editor_settings(cx, &|settings| {
|
|
settings.code_lens = Some(CodeLens::On);
|
|
});
|
|
|
|
let mut cx = EditorLspTestContext::new_typescript(
|
|
lsp::ServerCapabilities {
|
|
code_lens_provider: Some(lsp::CodeLensOptions {
|
|
resolve_provider: None,
|
|
}),
|
|
execute_command_provider: Some(lsp::ExecuteCommandOptions {
|
|
commands: vec!["lens_cmd".to_string()],
|
|
..lsp::ExecuteCommandOptions::default()
|
|
}),
|
|
..lsp::ServerCapabilities::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
let mut code_lens_request =
|
|
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
|
|
Ok(Some(vec![
|
|
lsp::CodeLens {
|
|
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
|
|
command: Some(lsp::Command {
|
|
title: "2 references".to_owned(),
|
|
command: "lens_cmd".to_owned(),
|
|
arguments: None,
|
|
}),
|
|
data: None,
|
|
},
|
|
lsp::CodeLens {
|
|
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 19)),
|
|
command: Some(lsp::Command {
|
|
title: "0 references".to_owned(),
|
|
command: "lens_cmd".to_owned(),
|
|
arguments: None,
|
|
}),
|
|
data: None,
|
|
},
|
|
]))
|
|
});
|
|
|
|
cx.set_state("ˇfunction hello() {}\nfunction world() {}");
|
|
|
|
assert!(
|
|
code_lens_request.next().await.is_some(),
|
|
"should have received a code lens request"
|
|
);
|
|
cx.run_until_parked();
|
|
|
|
cx.editor.read_with(&cx.cx.cx, |editor, _cx| {
|
|
assert_eq!(
|
|
editor.code_lens_enabled(),
|
|
true,
|
|
"code lens should be enabled"
|
|
);
|
|
let total_blocks: usize = editor
|
|
.code_lens
|
|
.as_ref()
|
|
.map(|s| s.blocks.values().map(|v| v.len()).sum())
|
|
.unwrap_or(0);
|
|
assert_eq!(total_blocks, 2, "Should have inserted two code lens blocks");
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_code_lens_blocks_kept_across_refresh(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
update_test_editor_settings(cx, &|settings| {
|
|
settings.code_lens = Some(CodeLens::On);
|
|
});
|
|
|
|
let mut cx = EditorLspTestContext::new_typescript(
|
|
lsp::ServerCapabilities {
|
|
code_lens_provider: Some(lsp::CodeLensOptions {
|
|
resolve_provider: None,
|
|
}),
|
|
execute_command_provider: Some(lsp::ExecuteCommandOptions {
|
|
commands: vec!["lens_cmd".to_string()],
|
|
..lsp::ExecuteCommandOptions::default()
|
|
}),
|
|
..lsp::ServerCapabilities::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
let mut code_lens_request =
|
|
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
|
|
Ok(Some(vec![lsp::CodeLens {
|
|
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
|
|
command: Some(lsp::Command {
|
|
title: "1 reference".to_owned(),
|
|
command: "lens_cmd".to_owned(),
|
|
arguments: None,
|
|
}),
|
|
data: None,
|
|
}]))
|
|
});
|
|
|
|
cx.set_state("ˇfunction hello() {}\nfunction world() {}");
|
|
|
|
assert!(
|
|
code_lens_request.next().await.is_some(),
|
|
"should have received the initial code lens request"
|
|
);
|
|
cx.run_until_parked();
|
|
|
|
let initial_block_ids = cx.editor.read_with(&cx.cx.cx, |editor, _| {
|
|
editor
|
|
.code_lens
|
|
.as_ref()
|
|
.map(|s| {
|
|
s.blocks
|
|
.values()
|
|
.flatten()
|
|
.map(|b| b.block_id)
|
|
.collect::<HashSet<_>>()
|
|
})
|
|
.unwrap_or_default()
|
|
});
|
|
assert_eq!(
|
|
initial_block_ids.len(),
|
|
1,
|
|
"Should have one initial code lens block"
|
|
);
|
|
|
|
cx.update_editor(|editor, window, cx| {
|
|
editor.move_to_end(&crate::actions::MoveToEnd, window, cx);
|
|
editor.handle_input("\n// trailing comment", window, cx);
|
|
});
|
|
cx.executor()
|
|
.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(50));
|
|
assert!(
|
|
code_lens_request.next().await.is_some(),
|
|
"should have received another code lens request after edit"
|
|
);
|
|
cx.run_until_parked();
|
|
|
|
let refreshed_block_ids = cx.editor.read_with(&cx.cx.cx, |editor, _| {
|
|
editor
|
|
.code_lens
|
|
.as_ref()
|
|
.map(|s| {
|
|
s.blocks
|
|
.values()
|
|
.flatten()
|
|
.map(|b| b.block_id)
|
|
.collect::<HashSet<_>>()
|
|
})
|
|
.unwrap_or_default()
|
|
});
|
|
assert_eq!(
|
|
refreshed_block_ids, initial_block_ids,
|
|
"Code lens blocks should be preserved across refreshes when their content is unchanged"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_code_lens_blocks_kept_when_only_resolve_fills_titles(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
update_test_editor_settings(cx, &|settings| {
|
|
settings.code_lens = Some(CodeLens::On);
|
|
});
|
|
|
|
let mut cx = EditorLspTestContext::new_typescript(
|
|
lsp::ServerCapabilities {
|
|
code_lens_provider: Some(lsp::CodeLensOptions {
|
|
resolve_provider: Some(true),
|
|
}),
|
|
..lsp::ServerCapabilities::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
// The LSP returns shallow code lenses on every fetch; only `resolve`
|
|
// populates the command/title. This is the realistic flow with
|
|
// servers like rust-analyzer and exercises the path where each
|
|
// post-edit refresh comes back unresolved before the resolve catches
|
|
// up.
|
|
let mut code_lens_request =
|
|
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
|
|
Ok(Some(vec![lsp::CodeLens {
|
|
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
|
|
command: None,
|
|
data: Some(serde_json::json!({"id": "lens_1"})),
|
|
}]))
|
|
});
|
|
|
|
cx.lsp
|
|
.set_request_handler::<lsp::request::CodeLensResolve, _, _>(|lens, _| async move {
|
|
Ok(lsp::CodeLens {
|
|
command: Some(lsp::Command {
|
|
title: "1 reference".to_owned(),
|
|
command: "resolved_cmd".to_owned(),
|
|
arguments: None,
|
|
}),
|
|
..lens
|
|
})
|
|
});
|
|
|
|
cx.set_state("ˇfunction hello() {}\nfunction world() {}");
|
|
|
|
assert!(
|
|
code_lens_request.next().await.is_some(),
|
|
"should have received the initial code lens request"
|
|
);
|
|
cx.run_until_parked();
|
|
|
|
let initial = cx.editor.read_with(&cx.cx.cx, |editor, _| {
|
|
editor
|
|
.code_lens
|
|
.as_ref()
|
|
.map(|s| {
|
|
s.blocks
|
|
.values()
|
|
.flatten()
|
|
.map(|b| b.block_id)
|
|
.collect::<HashSet<_>>()
|
|
})
|
|
.unwrap_or_default()
|
|
});
|
|
assert_eq!(
|
|
initial.len(),
|
|
1,
|
|
"resolve should have inserted exactly one block from the shallow lens"
|
|
);
|
|
|
|
for keystroke in [" ", "x", "y"] {
|
|
cx.update_editor(|editor, window, cx| {
|
|
editor.move_to_end(&crate::actions::MoveToEnd, window, cx);
|
|
editor.handle_input(keystroke, window, cx);
|
|
});
|
|
cx.executor()
|
|
.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(50));
|
|
assert!(
|
|
code_lens_request.next().await.is_some(),
|
|
"should have received another (shallow) code lens request after edit"
|
|
);
|
|
cx.run_until_parked();
|
|
|
|
let after = cx.editor.read_with(&cx.cx.cx, |editor, _| {
|
|
editor
|
|
.code_lens
|
|
.as_ref()
|
|
.map(|s| {
|
|
s.blocks
|
|
.values()
|
|
.flatten()
|
|
.map(|b| b.block_id)
|
|
.collect::<HashSet<_>>()
|
|
})
|
|
.unwrap_or_default()
|
|
});
|
|
assert_eq!(
|
|
after, initial,
|
|
"Block IDs must survive the unresolved-fetch → resolve cycle without churn"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_code_lens_placeholder_block_before_resolve(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
update_test_editor_settings(cx, &|settings| {
|
|
settings.code_lens = Some(CodeLens::On);
|
|
});
|
|
|
|
let mut cx = EditorLspTestContext::new_typescript(
|
|
lsp::ServerCapabilities {
|
|
code_lens_provider: Some(lsp::CodeLensOptions {
|
|
resolve_provider: Some(true),
|
|
}),
|
|
..lsp::ServerCapabilities::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
let mut code_lens_request =
|
|
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
|
|
let mut lenses = Vec::new();
|
|
lenses.push(lsp::CodeLens {
|
|
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
|
|
command: None,
|
|
data: Some(serde_json::json!({"id": "lens_1"})),
|
|
});
|
|
Ok(Some(lenses))
|
|
});
|
|
|
|
let (resolve_tx, resolve_rx) = futures::channel::oneshot::channel::<()>();
|
|
let resolve_rx = std::sync::Mutex::new(Some(resolve_rx));
|
|
cx.lsp
|
|
.set_request_handler::<lsp::request::CodeLensResolve, _, _>(move |lens, _| {
|
|
let rx = resolve_rx.lock().unwrap().take();
|
|
async move {
|
|
if let Some(rx) = rx {
|
|
rx.await.ok();
|
|
}
|
|
Ok(lsp::CodeLens {
|
|
command: Some(lsp::Command {
|
|
title: "1 reference".to_owned(),
|
|
command: "resolved_cmd".to_owned(),
|
|
arguments: None,
|
|
}),
|
|
..lens
|
|
})
|
|
}
|
|
});
|
|
|
|
cx.set_state("ˇfunction hello() {}");
|
|
|
|
assert!(
|
|
code_lens_request.next().await.is_some(),
|
|
"should have received the initial code lens request"
|
|
);
|
|
cx.run_until_parked();
|
|
|
|
cx.editor.read_with(&cx.cx.cx, |editor, _| {
|
|
let total_blocks: usize = editor
|
|
.code_lens
|
|
.as_ref()
|
|
.map(|s| s.blocks.values().map(|v| v.len()).sum())
|
|
.unwrap_or(0);
|
|
assert_eq!(
|
|
total_blocks, 1,
|
|
"a placeholder block should be reserved before the resolve completes"
|
|
);
|
|
});
|
|
|
|
resolve_tx.send(()).ok();
|
|
cx.run_until_parked();
|
|
|
|
cx.editor.read_with(&cx.cx.cx, |editor, _| {
|
|
let total_blocks: usize = editor
|
|
.code_lens
|
|
.as_ref()
|
|
.map(|s| s.blocks.values().map(|v| v.len()).sum())
|
|
.unwrap_or(0);
|
|
assert_eq!(
|
|
total_blocks, 1,
|
|
"the placeholder block should still be present after resolution"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_code_lens_block_removed_when_resolve_yields_empty_title(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
update_test_editor_settings(cx, &|settings| {
|
|
settings.code_lens = Some(CodeLens::On);
|
|
});
|
|
|
|
let mut cx = EditorLspTestContext::new_typescript(
|
|
lsp::ServerCapabilities {
|
|
code_lens_provider: Some(lsp::CodeLensOptions {
|
|
resolve_provider: Some(true),
|
|
}),
|
|
..lsp::ServerCapabilities::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
let mut code_lens_request =
|
|
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
|
|
let mut lenses = Vec::new();
|
|
lenses.push(lsp::CodeLens {
|
|
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
|
|
command: None,
|
|
data: Some(serde_json::json!({"id": "lens_1"})),
|
|
});
|
|
Ok(Some(lenses))
|
|
});
|
|
|
|
cx.lsp
|
|
.set_request_handler::<lsp::request::CodeLensResolve, _, _>(|lens, _| async move {
|
|
Ok(lsp::CodeLens {
|
|
command: Some(lsp::Command {
|
|
title: String::new(),
|
|
command: "noop".to_owned(),
|
|
arguments: None,
|
|
}),
|
|
..lens
|
|
})
|
|
});
|
|
|
|
cx.set_state("ˇfunction hello() {}");
|
|
|
|
assert!(
|
|
code_lens_request.next().await.is_some(),
|
|
"should have received the initial code lens request"
|
|
);
|
|
cx.run_until_parked();
|
|
|
|
cx.editor.read_with(&cx.cx.cx, |editor, _| {
|
|
let total_blocks: usize = editor
|
|
.code_lens
|
|
.as_ref()
|
|
.map(|s| s.blocks.values().map(|v| v.len()).sum())
|
|
.unwrap_or(0);
|
|
assert_eq!(
|
|
total_blocks, 0,
|
|
"placeholder block should be cleaned up when its lens resolves to a blank title"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_code_lens_same_range_lenses_resolve_independently(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
update_test_editor_settings(cx, &|settings| {
|
|
settings.code_lens = Some(CodeLens::On);
|
|
});
|
|
|
|
let mut cx = EditorLspTestContext::new_typescript(
|
|
lsp::ServerCapabilities {
|
|
code_lens_provider: Some(lsp::CodeLensOptions {
|
|
resolve_provider: Some(true),
|
|
}),
|
|
..lsp::ServerCapabilities::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
// Two shallow lenses on the same range, distinguished only by `data`
|
|
// — exactly the shape vtsls/TypeScript-LS uses for the
|
|
// "references" + "implementations" pair on the same line.
|
|
let mut code_lens_request =
|
|
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
|
|
Ok(Some(vec![
|
|
lsp::CodeLens {
|
|
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
|
|
command: None,
|
|
data: Some(serde_json::json!({"kind": "references"})),
|
|
},
|
|
lsp::CodeLens {
|
|
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
|
|
command: None,
|
|
data: Some(serde_json::json!({"kind": "implementations"})),
|
|
},
|
|
]))
|
|
});
|
|
|
|
let resolve_calls = Arc::new(Mutex::new(Vec::<serde_json::Value>::new()));
|
|
cx.lsp
|
|
.set_request_handler::<lsp::request::CodeLensResolve, _, _>({
|
|
let resolve_calls = resolve_calls.clone();
|
|
move |lens, _| {
|
|
let resolve_calls = resolve_calls.clone();
|
|
async move {
|
|
let kind = lens
|
|
.data
|
|
.as_ref()
|
|
.and_then(|d| d.get("kind"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null);
|
|
resolve_calls.lock().unwrap().push(kind.clone());
|
|
let title = match kind.as_str() {
|
|
Some("references") => "2 references",
|
|
Some("implementations") => "1 implementation",
|
|
_ => "",
|
|
};
|
|
Ok(lsp::CodeLens {
|
|
command: Some(lsp::Command {
|
|
title: title.to_owned(),
|
|
command: "noop".to_owned(),
|
|
arguments: None,
|
|
}),
|
|
..lens
|
|
})
|
|
}
|
|
}
|
|
});
|
|
|
|
cx.set_state("ˇfunction hello() {}");
|
|
|
|
assert!(
|
|
code_lens_request.next().await.is_some(),
|
|
"should have received the initial code lens request"
|
|
);
|
|
cx.run_until_parked();
|
|
|
|
let calls = resolve_calls.lock().unwrap().clone();
|
|
assert_eq!(
|
|
calls.len(),
|
|
2,
|
|
"both same-range lenses should be resolved independently, got {calls:?}"
|
|
);
|
|
let kinds: Vec<&str> = calls.iter().filter_map(|v| v.as_str()).collect();
|
|
assert_eq!(kinds.contains(&"references"), true);
|
|
assert_eq!(kinds.contains(&"implementations"), true);
|
|
|
|
cx.editor.read_with(&cx.cx.cx, |editor, _| {
|
|
let blocks = editor
|
|
.code_lens
|
|
.as_ref()
|
|
.map(|s| s.blocks.values().flatten().collect::<Vec<_>>())
|
|
.unwrap_or_default();
|
|
assert_eq!(
|
|
blocks.len(),
|
|
1,
|
|
"a single block should host both lens items"
|
|
);
|
|
let titles: Vec<String> = blocks[0]
|
|
.line
|
|
.items
|
|
.iter()
|
|
.filter_map(|item| item.title.as_ref().map(|t| t.to_string()))
|
|
.collect();
|
|
assert_eq!(titles.len(), 2, "both lens titles should be resolved");
|
|
assert_eq!(titles.contains(&"2 references".to_string()), true);
|
|
assert_eq!(titles.contains(&"1 implementation".to_string()), true);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_code_lens_block_removed_when_resolve_yields_no_command(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
update_test_editor_settings(cx, &|settings| {
|
|
settings.code_lens = Some(CodeLens::On);
|
|
});
|
|
|
|
let mut cx = EditorLspTestContext::new_typescript(
|
|
lsp::ServerCapabilities {
|
|
code_lens_provider: Some(lsp::CodeLensOptions {
|
|
resolve_provider: Some(true),
|
|
}),
|
|
..lsp::ServerCapabilities::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
let mut code_lens_request =
|
|
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
|
|
Ok(Some(vec![lsp::CodeLens {
|
|
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
|
|
command: None,
|
|
data: Some(serde_json::json!({"id": "lens_1"})),
|
|
}]))
|
|
});
|
|
|
|
// Server acknowledges the resolve but still returns no `command` —
|
|
// a real-world scenario for buggy/incomplete servers. Without
|
|
// cleanup the placeholder line would be reserved forever because
|
|
// `resolve_visible_code_lenses` skips actions with `resolved=true`.
|
|
cx.lsp
|
|
.set_request_handler::<lsp::request::CodeLensResolve, _, _>(|lens, _| async move {
|
|
Ok(lsp::CodeLens {
|
|
command: None,
|
|
..lens
|
|
})
|
|
});
|
|
|
|
cx.set_state("ˇfunction hello() {}");
|
|
|
|
assert!(
|
|
code_lens_request.next().await.is_some(),
|
|
"should have received the initial code lens request"
|
|
);
|
|
cx.run_until_parked();
|
|
|
|
cx.editor.read_with(&cx.cx.cx, |editor, _| {
|
|
let total_blocks: usize = editor
|
|
.code_lens
|
|
.as_ref()
|
|
.map(|s| s.blocks.values().map(|v| v.len()).sum())
|
|
.unwrap_or(0);
|
|
assert_eq!(
|
|
total_blocks, 0,
|
|
"placeholder block should be cleaned up when resolve yields no command"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_code_lens_disabled_by_default(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
|
|
let mut cx = EditorLspTestContext::new_typescript(
|
|
lsp::ServerCapabilities {
|
|
code_lens_provider: Some(lsp::CodeLensOptions {
|
|
resolve_provider: None,
|
|
}),
|
|
execute_command_provider: Some(lsp::ExecuteCommandOptions {
|
|
commands: vec!["lens_cmd".to_string()],
|
|
..lsp::ExecuteCommandOptions::default()
|
|
}),
|
|
..lsp::ServerCapabilities::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
cx.lsp
|
|
.set_request_handler::<lsp::request::CodeLensRequest, _, _>(|_, _| async move {
|
|
panic!("Should not request code lenses when disabled");
|
|
});
|
|
|
|
cx.set_state("ˇfunction hello() {}");
|
|
cx.run_until_parked();
|
|
|
|
cx.editor.read_with(&cx.cx.cx, |editor, _cx| {
|
|
assert_eq!(
|
|
editor.code_lens_enabled(),
|
|
false,
|
|
"code lens should not be enabled when setting is off"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_code_lens_toggling(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
update_test_editor_settings(cx, &|settings| {
|
|
settings.code_lens = Some(CodeLens::On);
|
|
});
|
|
|
|
let mut cx = EditorLspTestContext::new_typescript(
|
|
lsp::ServerCapabilities {
|
|
code_lens_provider: Some(lsp::CodeLensOptions {
|
|
resolve_provider: None,
|
|
}),
|
|
execute_command_provider: Some(lsp::ExecuteCommandOptions {
|
|
commands: vec!["lens_cmd".to_string()],
|
|
..lsp::ExecuteCommandOptions::default()
|
|
}),
|
|
..lsp::ServerCapabilities::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
let mut code_lens_request =
|
|
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
|
|
Ok(Some(vec![lsp::CodeLens {
|
|
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
|
|
command: Some(lsp::Command {
|
|
title: "1 reference".to_owned(),
|
|
command: "lens_cmd".to_owned(),
|
|
arguments: None,
|
|
}),
|
|
data: None,
|
|
}]))
|
|
});
|
|
|
|
cx.set_state("ˇfunction hello() {}");
|
|
|
|
assert!(
|
|
code_lens_request.next().await.is_some(),
|
|
"should have received a code lens request"
|
|
);
|
|
cx.run_until_parked();
|
|
|
|
cx.editor.read_with(&cx.cx.cx, |editor, _cx| {
|
|
assert_eq!(
|
|
editor.code_lens_enabled(),
|
|
true,
|
|
"code lens should be enabled"
|
|
);
|
|
let total_blocks: usize = editor
|
|
.code_lens
|
|
.as_ref()
|
|
.map(|s| s.blocks.values().map(|v| v.len()).sum())
|
|
.unwrap_or(0);
|
|
assert_eq!(total_blocks, 1, "Should have one code lens block");
|
|
});
|
|
|
|
cx.update_editor(|editor, _window, cx| {
|
|
editor.clear_code_lenses(cx);
|
|
});
|
|
|
|
cx.editor.read_with(&cx.cx.cx, |editor, _cx| {
|
|
assert_eq!(
|
|
editor.code_lens_enabled(),
|
|
false,
|
|
"code lens should be disabled after clearing"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_code_lens_resolve(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
update_test_editor_settings(cx, &|settings| {
|
|
settings.code_lens = Some(CodeLens::On);
|
|
});
|
|
|
|
let mut cx = EditorLspTestContext::new_typescript(
|
|
lsp::ServerCapabilities {
|
|
code_lens_provider: Some(lsp::CodeLensOptions {
|
|
resolve_provider: Some(true),
|
|
}),
|
|
..lsp::ServerCapabilities::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
let mut code_lens_request =
|
|
cx.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _, _| async {
|
|
Ok(Some(vec![
|
|
lsp::CodeLens {
|
|
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)),
|
|
command: None,
|
|
data: Some(serde_json::json!({"id": "lens_1"})),
|
|
},
|
|
lsp::CodeLens {
|
|
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 19)),
|
|
command: None,
|
|
data: Some(serde_json::json!({"id": "lens_2"})),
|
|
},
|
|
]))
|
|
});
|
|
|
|
cx.lsp
|
|
.set_request_handler::<lsp::request::CodeLensResolve, _, _>(|lens, _| async move {
|
|
let id = lens
|
|
.data
|
|
.as_ref()
|
|
.and_then(|d| d.get("id"))
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("unknown");
|
|
let title = match id {
|
|
"lens_1" => "3 references",
|
|
"lens_2" => "1 implementation",
|
|
_ => "unknown",
|
|
};
|
|
Ok(lsp::CodeLens {
|
|
command: Some(lsp::Command {
|
|
title: title.to_owned(),
|
|
command: format!("resolved_{id}"),
|
|
arguments: None,
|
|
}),
|
|
..lens
|
|
})
|
|
});
|
|
|
|
cx.set_state("ˇfunction hello() {}\nfunction world() {}");
|
|
|
|
assert!(
|
|
code_lens_request.next().await.is_some(),
|
|
"should have received a code lens request"
|
|
);
|
|
cx.run_until_parked();
|
|
|
|
cx.editor.read_with(&cx.cx.cx, |editor, _cx| {
|
|
let total_blocks: usize = editor
|
|
.code_lens
|
|
.as_ref()
|
|
.map(|s| s.blocks.values().map(|v| v.len()).sum())
|
|
.unwrap_or(0);
|
|
assert_eq!(
|
|
total_blocks, 2,
|
|
"Unresolved lenses should have been resolved and displayed"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_code_lens_resolve_only_visible(cx: &mut TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
update_test_editor_settings(cx, &|settings| {
|
|
settings.code_lens = Some(CodeLens::On);
|
|
});
|
|
|
|
let line_count: u32 = 100;
|
|
let lens_every: u32 = 10;
|
|
let lines = (0..line_count)
|
|
.map(|i| format!("function func_{i}() {{}}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
let lens_lines = (0..line_count)
|
|
.filter(|i| i % lens_every == 0)
|
|
.collect::<Vec<_>>();
|
|
|
|
let resolved_lines = Arc::new(Mutex::new(Vec::<u32>::new()));
|
|
|
|
let fs = project::FakeFs::new(cx.executor());
|
|
fs.insert_tree(path!("/dir"), serde_json::json!({ "main.ts": lines }))
|
|
.await;
|
|
|
|
let project = project::Project::test(fs, [path!("/dir").as_ref()], cx).await;
|
|
let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
|
|
workspace::MultiWorkspace::test_new(project.clone(), window, cx)
|
|
});
|
|
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
|
|
|
|
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
|
|
language_registry.add(Arc::new(language::Language::new(
|
|
language::LanguageConfig {
|
|
name: "TypeScript".into(),
|
|
matcher: language::LanguageMatcher {
|
|
path_suffixes: vec!["ts".to_string()],
|
|
..language::LanguageMatcher::default()
|
|
},
|
|
..language::LanguageConfig::default()
|
|
},
|
|
Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
|
|
)));
|
|
|
|
let mut fake_servers = language_registry.register_fake_lsp(
|
|
"TypeScript",
|
|
language::FakeLspAdapter {
|
|
capabilities: lsp::ServerCapabilities {
|
|
code_lens_provider: Some(lsp::CodeLensOptions {
|
|
resolve_provider: Some(true),
|
|
}),
|
|
..lsp::ServerCapabilities::default()
|
|
},
|
|
..language::FakeLspAdapter::default()
|
|
},
|
|
);
|
|
|
|
let editor = workspace
|
|
.update_in(cx, |workspace, window, cx| {
|
|
workspace.open_abs_path(
|
|
std::path::PathBuf::from(path!("/dir/main.ts")),
|
|
workspace::OpenOptions::default(),
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.downcast::<Editor>()
|
|
.unwrap();
|
|
let fake_server = fake_servers.next().await.unwrap();
|
|
|
|
let lens_lines_for_handler = lens_lines.clone();
|
|
fake_server.set_request_handler::<lsp::request::CodeLensRequest, _, _>(move |_, _| {
|
|
let lens_lines = lens_lines_for_handler.clone();
|
|
async move {
|
|
Ok(Some(
|
|
lens_lines
|
|
.iter()
|
|
.map(|&line| lsp::CodeLens {
|
|
range: lsp::Range::new(
|
|
lsp::Position::new(line, 0),
|
|
lsp::Position::new(line, 10),
|
|
),
|
|
command: None,
|
|
data: Some(serde_json::json!({ "line": line })),
|
|
})
|
|
.collect(),
|
|
))
|
|
}
|
|
});
|
|
|
|
{
|
|
let resolved_lines = resolved_lines.clone();
|
|
fake_server.set_request_handler::<lsp::request::CodeLensResolve, _, _>(
|
|
move |lens, _| {
|
|
let resolved_lines = resolved_lines.clone();
|
|
async move {
|
|
let line = lens
|
|
.data
|
|
.as_ref()
|
|
.and_then(|d| d.get("line"))
|
|
.and_then(|v| v.as_u64())
|
|
.unwrap() as u32;
|
|
resolved_lines.lock().unwrap().push(line);
|
|
Ok(lsp::CodeLens {
|
|
command: Some(lsp::Command {
|
|
title: format!("{line} references"),
|
|
command: format!("show_refs_{line}"),
|
|
arguments: None,
|
|
}),
|
|
..lens
|
|
})
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
cx.executor().advance_clock(Duration::from_millis(500));
|
|
cx.run_until_parked();
|
|
|
|
let initial_resolved = resolved_lines
|
|
.lock()
|
|
.unwrap()
|
|
.drain(..)
|
|
.collect::<HashSet<_>>();
|
|
assert_eq!(
|
|
initial_resolved,
|
|
HashSet::from_iter([0, 10, 20, 30, 40]),
|
|
"Only lenses visible at the top should be resolved"
|
|
);
|
|
|
|
editor.update_in(cx, |editor, window, cx| {
|
|
editor.move_to_end(&crate::actions::MoveToEnd, window, cx);
|
|
});
|
|
cx.executor().advance_clock(Duration::from_millis(500));
|
|
cx.run_until_parked();
|
|
|
|
let after_scroll_resolved = resolved_lines
|
|
.lock()
|
|
.unwrap()
|
|
.drain(..)
|
|
.collect::<HashSet<_>>();
|
|
// Once the lenses are first applied we insert a placeholder block per
|
|
// lens row so the line is reserved while the resolve is in flight.
|
|
// Those placeholder blocks add display height, so after scrolling to
|
|
// the end the visible buffer-row range is slightly smaller than it
|
|
// would be without them, and lens row 60 is just outside it.
|
|
assert_eq!(
|
|
after_scroll_resolved,
|
|
HashSet::from_iter([70, 80, 90]),
|
|
"Only newly visible lenses at the bottom should be resolved, not middle ones"
|
|
);
|
|
}
|
|
}
|