editor: Honor lsp_results_location on cmd-click (#64039)

# Objective

Fixes #62916.

With `"lsp_results_location": "picker"`, `cmd-b` opens the LSP locations
picker but cmd-clicking the same symbol still opens a `Definitions for
…` multibuffer. #61187 fixed this for the go-to-definition *fallback*;
the definition path itself was left behind.

The setting is only consulted by `handle_nav_action` in `lsp_locations`,
which runs when one of the navigation *actions* is dispatched. Cmd-click
never dispatches them: `cmd_click_reveal_task` either navigates the
cached hover links itself, or calls `go_to_definition_of_kind` /
`go_to_type_definition` directly.

## Solution

Both cmd-click paths now dispatch the matching action (`GoToDefinition`,
`GoToTypeDefinition`, …) when `lsp_results_location` is `picker` and the
click is not a split one, so the picker gets its chance; everything else
is untouched:

- with the default `multi_buffer` setting `picker_action` returns `None`
and the old code runs unchanged;
- alt (split) clicks keep their current behavior, since the split
actions have no picker handler;
- URL and file links keep opening directly — the action is only
dispatched when every resolved link is a `HoverLink::Text`.

The dispatch is deferred to after the current editor update via
`cx.spawn_in`: `LspLocationsPicker::open_for_editor` reads the editor,
so dispatching inline panics with `cannot read editor::Editor while it
is already being updated` (this is also why the existing fallback
dispatch happens from a spawn).

## Testing

`cargo test -p lsp_locations` — two new tests, one per cmd-click path: a
cold cmd-click, and a cmd-click on a link already resolved by hovering
with the modifier held (the path users actually hit). Both fail on
`main` with the fix reverted and pass with it.

Also ran `cargo test -p editor --lib hover` (68 passed) and `cargo
clippy -p editor -p lsp_locations --all-targets`.

Not yet verified by hand in a running Zed build — the automated tests
above are what backs this.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable


## Release Notes:

- Fixed cmd-click on a symbol ignoring the `lsp_results_location`
setting: with `"picker"` it opened a definitions multibuffer instead of
the picker that `cmd-b` opens
([#62916](https://github.com/zed-industries/zed/issues/62916)).

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
Vitalii ShevtsovandKirill Bulatov authored and GitHub committed 2026-09-16 16:04:13 +00:00
1 parent d9abcde22a
commit 2f10cba7c6
4 files changed
+874 -115

No files matched your search

+1 -1
View File
@@ -11285,7 +11285,7 @@ impl Editor {
self.read_scroll_position_from_db(item_id, workspace_id, window, cx);
}
pub(crate) fn lsp_data_enabled(&self) -> bool {
pub fn lsp_data_enabled(&self) -> bool {
self.enable_lsp_data && self.mode().is_full()
}
+3
View File
@@ -417,6 +417,9 @@ impl EditorElement {
register_action(editor, window, Editor::go_to_prev_hunk);
register_action(editor, window, Editor::go_to_next_document_highlight);
register_action(editor, window, Editor::go_to_prev_document_highlight);
if editor.read(cx).lsp_data_enabled() {
register_action(editor, window, Editor::open_definition_locations);
}
register_action(editor, window, |editor, action, window, cx| {
editor
.go_to_definition(action, window, cx)
+427 -106
View File
@@ -1,24 +1,47 @@
use crate::{
Anchor, Editor, EditorSettings, EditorSnapshot, FindAllReferences, GoToDefinitionSplit,
GoToTypeDefinition, GoToTypeDefinitionSplit, GotoDefinitionKind, HighlightKey, Navigated,
PointForPosition, SelectPhase, editor_settings::GoToDefinitionFallback, scroll::ScrollAmount,
Anchor, Editor, EditorSettings, EditorSnapshot, FindAllReferences, GotoDefinitionKind,
HighlightKey, Navigated, PointForPosition, SelectPhase,
editor_settings::GoToDefinitionFallback, scroll::ScrollAmount,
};
use gpui::{
App, AsyncWindowContext, Context, Entity, Focusable, HighlightStyle, Modifiers, Pixels, Task,
UnderlineStyle, Window, px,
Action, App, AsyncWindowContext, Context, Entity, Focusable, HighlightStyle, Modifiers, Pixels,
Task, UnderlineStyle, WeakEntity, Window, px,
};
use language::{Bias, ToOffset};
use linkify::{LinkFinder, LinkKind};
use lsp::LanguageServerId;
use project::{InlayId, LocationLink, Project, ResolvedPath};
use project::{InlayId, Location, LocationLink, Project, ResolvedPath};
use regex::Regex;
use settings::Settings;
use std::{ops::Range, str::FromStr as _, sync::LazyLock};
use settings::{OpenResultsIn, Settings};
use std::{
ops::Range,
str::FromStr as _,
sync::{Arc, LazyLock},
};
use text::OffsetRangeExt;
use theme::ActiveTheme as _;
use util::{
ResultExt, TryFutureExt as _, markdown::source_position_from_fragment, paths::PathWithPosition,
};
use workspace::pane::NavigationEntry;
#[derive(Clone, Action)]
#[action(no_json, no_register)]
pub struct OpenDefinitionLocations(pub Arc<DefinitionLocations>);
pub struct DefinitionLocations {
pub editor: WeakEntity<Editor>,
pub position: Anchor,
pub kind: GotoDefinitionKind,
pub locations: Vec<Location>,
pub origin: Option<NavigationEntry>,
}
impl PartialEq for OpenDefinitionLocations {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
}
#[derive(Debug)]
pub struct HoveredLinkState {
@@ -206,24 +229,127 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Editor>,
) {
let focus_handle = self.focus_handle(cx);
let reveal_task = self.cmd_click_reveal_task(point, modifiers, window, cx);
cx.spawn_in(window, async move |_, cx| {
let definition_revealed = reveal_task.await.log_err().unwrap_or(Navigated::No);
if definition_revealed == Navigated::Yes {
return;
}
cx.update(|window, cx| {
match EditorSettings::get_global(cx).go_to_definition_fallback {
GoToDefinitionFallback::None => {}
GoToDefinitionFallback::FindAllReferences => {
focus_handle.dispatch_action(&FindAllReferences::default(), window, cx);
}
}
let kind = if modifiers.shift {
GotoDefinitionKind::Type
} else {
GotoDefinitionKind::Symbol
};
let split = Self::is_alt_pressed(&modifiers, cx);
let snapshot = self.snapshot(window, cx);
let position = snapshot
.buffer_snapshot()
.anchor_before(point.next_valid.to_point(&snapshot.display_snapshot));
let origin = self.navigation_entry(position, cx);
let cached = self.hovered_link_state.take();
self.hide_hovered_link(cx);
let refresh = cached.as_ref().is_none_or(|state| {
state.links.is_empty()
|| (matches!(state.last_trigger_point, TriggerPoint::Text(_))
&& (state.preferred_kind != kind
|| state.task.as_ref().is_some_and(|task| !task.is_ready())))
});
let mut links = cached.map(|state| state.links).unwrap_or_default();
if refresh || !self.lsp_data_enabled() {
links.retain(|link| {
matches!(link, HoverLink::Url(_) | HoverLink::File(_))
|| (self.lsp_data_enabled() && matches!(link, HoverLink::LspLocation(..)))
});
}
if refresh && point.as_valid().is_some() {
self.select(
SelectPhase::Begin {
position: point.next_valid,
add: false,
click_count: 1,
},
window,
cx,
);
}
self.select(SelectPhase::End, window, cx);
let Some((buffer, anchor)) = self.buffer.read(cx).text_anchor_for_position(position, cx)
else {
return;
};
links.retain(|link| match link {
HoverLink::Text(link) => exclude_link_to_position(&buffer, &anchor, link, cx),
_ => true,
});
let definitions = (refresh && self.lsp_data_enabled() && point.as_valid().is_some())
.then(|| {
self.semantics_provider
.as_ref()?
.definitions(&buffer, anchor, kind, cx)
})
.ok();
})
.detach();
.flatten();
if let Some(definitions) = definitions {
let workspace = self.workspace().map(|workspace| workspace.downgrade());
cx.spawn_in(window, async move |editor, cx| {
let definitions = definitions.await.log_err().flatten().unwrap_or_default();
editor
.update_in(cx, |editor, window, cx| {
if editor.workspace().map(|workspace| workspace.downgrade()) != workspace
|| !editor.buffer.read(cx).snapshot(cx).can_resolve(&position)
{
return;
}
if editor.lsp_data_enabled() {
links.extend(
definitions
.into_iter()
.filter(|link| {
exclude_link_to_position(&buffer, &anchor, link, cx)
})
.map(HoverLink::Text),
);
} else {
links.retain(|link| {
matches!(link, HoverLink::Url(_) | HoverLink::File(_))
});
}
editor
.reveal_clicked_links(kind, links, position, origin, split, window, cx);
})
.ok();
})
.detach();
} else {
self.reveal_clicked_links(kind, links, position, origin, split, window, cx);
}
}
pub(crate) fn open_definition_locations(
&mut self,
action: &OpenDefinitionLocations,
window: &mut Window,
cx: &mut Context<Self>,
) {
let action = &action.0;
if action.editor != cx.weak_entity() {
cx.propagate();
return;
}
if !self.lsp_data_enabled()
|| !self
.buffer
.read(cx)
.snapshot(cx)
.can_resolve(&action.position)
{
return;
}
let links = action
.locations
.iter()
.cloned()
.map(|target| {
HoverLink::Text(LocationLink {
origin: None,
target,
})
})
.collect::<Vec<_>>();
self.open_clicked_links(action.kind, links, action.origin.clone(), false, window, cx);
}
pub fn scroll_hover(
@@ -250,90 +376,86 @@ impl Editor {
}
}
fn cmd_click_reveal_task(
fn reveal_clicked_links(
&mut self,
point: PointForPosition,
modifiers: Modifiers,
kind: GotoDefinitionKind,
links: Vec<HoverLink>,
position: Anchor,
origin: Option<NavigationEntry>,
split: bool,
window: &mut Window,
cx: &mut Context<Editor>,
) -> Task<anyhow::Result<Navigated>> {
if let Some(hovered_link_state) = self.hovered_link_state.take() {
self.hide_hovered_link(cx);
if !hovered_link_state.links.is_empty() {
if !self.focus_handle.is_focused(window) {
window.focus(&self.focus_handle, cx);
}
// exclude links pointing back to the current anchor
let current_position = point
.next_valid
.to_point(&self.snapshot(window, cx).display_snapshot);
let Some((buffer, anchor)) = self
.buffer()
.read(cx)
.text_anchor_for_position(current_position, cx)
else {
return Task::ready(Ok(Navigated::No));
};
let Some(multi_buffer_anchor) = self
.buffer()
.read(cx)
.snapshot(cx)
.anchor_in_excerpt(anchor)
else {
return Task::ready(Ok(Navigated::No));
};
let links = hovered_link_state
.links
cx: &mut Context<Self>,
) {
if !split
&& self.lsp_data_enabled()
&& self.workspace().is_some()
&& EditorSettings::get_global(cx).lsp_results_location == OpenResultsIn::Picker
&& links.iter().all(|link| matches!(link, HoverLink::Text(_)))
{
let action = OpenDefinitionLocations(Arc::new(DefinitionLocations {
editor: cx.weak_entity(),
position,
kind,
locations: links
.into_iter()
.filter(|link| {
if let HoverLink::Text(location) = link {
exclude_link_to_position(&buffer, &anchor, location, cx)
} else {
true
}
.filter_map(|link| match link {
HoverLink::Text(link) => Some(link.target),
_ => None,
})
.collect();
let nav_entry = self.navigation_entry(multi_buffer_anchor, cx);
let split = Self::is_alt_pressed(&modifiers, cx);
let navigate_task =
self.navigate_to_hover_links(None, links, nav_entry, split, window, cx);
self.select(SelectPhase::End, window, cx);
return navigate_task;
}
}
// We don't have the correct kind of link cached, set the selection on
// click and immediately trigger GoToDefinition.
self.select(
SelectPhase::Begin {
position: point.next_valid,
add: false,
click_count: 1,
},
window,
cx,
);
let navigate_task = if point.as_valid().is_some() {
let split = Self::is_alt_pressed(&modifiers, cx);
match (modifiers.shift, split) {
(true, true) => {
self.go_to_type_definition_split(&GoToTypeDefinitionSplit, window, cx)
.collect(),
origin,
}));
let focus_handle = self.focus_handle(cx);
window.defer(cx, move |window, cx| {
if window.is_action_available_in(&action, &focus_handle) {
focus_handle.dispatch_action(&action, window, cx);
} else {
action
.0
.editor
.update(cx, |editor, cx| {
editor.open_definition_locations(&action, window, cx);
})
.ok();
}
(true, false) => {
self.go_to_type_definition(&GoToTypeDefinition::default(), window, cx)
}
(false, true) => self.go_to_definition_split(&GoToDefinitionSplit, window, cx),
(false, false) => {
self.go_to_definition_of_kind(GotoDefinitionKind::Symbol, false, window, cx)
}
}
});
} else {
Task::ready(Ok(Navigated::No))
};
self.select(SelectPhase::End, window, cx);
navigate_task
self.open_clicked_links(kind, links, origin, split, window, cx);
}
}
fn open_clicked_links(
&mut self,
kind: GotoDefinitionKind,
links: Vec<HoverLink>,
origin: Option<NavigationEntry>,
split: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let navigation = self.navigate_to_hover_links(Some(kind), links, origin, split, window, cx);
cx.spawn_in(window, async move |editor, cx| {
if navigation.await.log_err().unwrap_or(Navigated::No) == Navigated::Yes {
return;
}
let focus_handle = editor
.read_with(cx, |editor, cx| {
editor.lsp_data_enabled().then(|| editor.focus_handle(cx))
})
.ok()
.flatten();
if let Some(focus_handle) = focus_handle {
cx.update(|window, cx| {
if EditorSettings::get_global(cx).go_to_definition_fallback
== GoToDefinitionFallback::FindAllReferences
{
focus_handle.dispatch_action(&FindAllReferences::default(), window, cx);
}
})
.ok();
}
})
.detach();
}
}
@@ -1072,7 +1194,8 @@ mod tests {
};
use futures::StreamExt;
use gpui::{
Modifiers, MouseButton, MouseDownEvent, MousePressureEvent, MouseUpEvent, PressureStage,
Modifiers, MouseButton, MouseDownEvent, MousePressureEvent, MouseUpEvent, PlatformInput,
PressureStage,
};
use indoc::indoc;
use language::Point;
@@ -1713,6 +1836,7 @@ mod tests {
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
definition_provider: Some(lsp::OneOf::Left(true)),
inlay_hint_provider: Some(lsp::OneOf::Left(true)),
execute_command_provider: Some(lsp::ExecuteCommandOptions {
commands: vec!["upgrade".to_string()],
@@ -1910,13 +2034,57 @@ mod tests {
cx.background_executor.run_until_parked();
cx.simulate_click(hover_point, Modifiers::secondary_key());
cx.background_executor.run_until_parked();
cx.assert_editor_state(indoc! {"
let destination = indoc! {"
struct «TestStructˇ»;
fn main() {
let variable = TestStruct;
}
"});
"};
cx.assert_editor_state(destination);
let source = cx.buffer_text().replace("= TestStruct", "= TeˇstStruct");
let click_point = cx.pixel_position(&source);
let hover_point = cx.pixel_position_for(midpoint);
let mut definitions =
cx.set_request_handler::<GotoDefinition, _, _>(move |uri, params, _| async move {
let params = params.text_document_position_params;
assert_eq!(params.text_document.uri, uri);
assert_eq!(params.position, lsp::Position::new(3, 21));
Ok(Some(lsp::GotoDefinitionResponse::Scalar(
lsp::Location::new(uri, target_range),
)))
});
let editor = cx.editor.clone();
cx.update(|window, cx| {
window.draw(cx).clear(cx);
window.simulate_mouse_move(hover_point, cx);
let state = editor.read(cx).hovered_link_state.as_ref().expect("hover");
assert!(matches!(
state.last_trigger_point,
TriggerPoint::InlayHint(..)
));
assert!(state.links.is_empty());
assert!(!state.task.as_ref().expect("hover task").is_ready());
let down = MouseDownEvent {
position: click_point,
modifiers: Modifiers::secondary_key(),
button: MouseButton::Left,
click_count: 1,
first_mouse: false,
};
let up = MouseUpEvent {
position: down.position,
modifiers: down.modifiers,
button: down.button,
click_count: down.click_count,
};
window.dispatch_event(PlatformInput::MouseDown(down), cx);
window.dispatch_event(PlatformInput::MouseUp(up), cx);
});
cx.run_until_parked();
assert_eq!(definitions.try_recv().ok(), Some(()));
cx.assert_editor_state(destination);
}
#[gpui::test]
@@ -1953,6 +2121,159 @@ mod tests {
);
}
#[gpui::test]
async fn test_reveal_clicked_links_picker_fallback(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
cx.update(|cx| {
let mut settings = EditorSettings::get_global(cx).clone();
settings.lsp_results_location = OpenResultsIn::Picker;
settings.excerpt_context_lines = 0;
EditorSettings::override_global(settings, cx);
});
for disabled in [false, true] {
let mut cx =
EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
let source = cx.editor.clone();
let (buffer, selections) = cx.update_editor(|editor, window, cx| {
editor.set_text("origin\n\nfirst\n\n\n\n\n\nsecond\n", window, cx);
(
editor.buffer.read(cx).as_singleton().expect("buffer"),
editor.selections.disjoint_anchors_arc(),
)
});
cx.run_until_parked();
cx.update_editor(|editor, window, cx| {
let links = [(2, 5), (8, 6)].map(|(row, end)| {
HoverLink::Text(LocationLink {
origin: None,
target: Location {
buffer: buffer.clone(),
range: buffer.read(cx).anchor_before(Point::new(row, 0))
..buffer.read(cx).anchor_after(Point::new(row, end)),
},
})
});
let position = editor.selections.newest_anchor().head();
let origin = editor.navigation_entry(position, cx);
editor.reveal_clicked_links(
GotoDefinitionKind::Symbol,
links.to_vec(),
position,
origin,
false,
window,
cx,
);
if disabled {
editor.disable_lsp_data();
}
});
cx.run_until_parked();
cx.editor(|editor, _, _| {
assert_eq!(editor.selections.disjoint_anchors_arc(), selections);
});
cx.update_workspace(|workspace, window, cx| {
assert!(!workspace.has_active_modal(window, cx));
assert_eq!(workspace.items(cx).count(), if disabled { 1 } else { 2 });
let active = workspace.active_item_as::<Editor>(cx).expect("editor");
if disabled {
assert_eq!(active, source);
return;
}
assert_ne!(active, source);
let multibuffer = active.read(cx).buffer().read(cx);
assert!(!multibuffer.is_singleton());
assert_eq!(
multibuffer.all_buffers_iter().collect::<Vec<_>>(),
std::slice::from_ref(&buffer)
);
assert_eq!(
multibuffer
.snapshot(cx)
.excerpts()
.map(|excerpt| excerpt.primary.to_offset(buffer.read(cx)))
.collect::<Vec<_>>(),
[8..13, 19..25],
);
});
cx.update(|window, _| window.remove_window());
cx.run_until_parked();
}
}
#[gpui::test]
async fn test_cmd_click_cached_links_survive_lsp_disable(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let capabilities = lsp::ServerCapabilities {
definition_provider: Some(lsp::OneOf::Left(true)),
type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
..lsp::ServerCapabilities::default()
};
let delay = std::time::Duration::from_secs(1);
let mut opened = Vec::new();
for marked in ["hˇttps://zed.dev/", "fˇile2.rs"] {
let mut cx = EditorLspTestContext::new_rust(capabilities.clone(), cx).await;
let fs =
cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
fs.as_fake()
.insert_file(path!("/root/dir/file2.rs"), b"target".to_vec())
.await;
cx.run_until_parked();
let source = format!("{marked}\nfirst\nsecond\n");
cx.set_state(&source);
let mut symbols =
cx.set_request_handler::<GotoDefinition, _, _>(|_, _, _| async { Ok(None) });
let mut types =
cx.set_request_handler::<GotoTypeDefinition, _, _>(move |uri, _, app| async move {
app.background_executor().timer(delay).await;
let locations = [1, 2].map(|line| {
let start = lsp::Position::new(line, 0);
let end = lsp::Position::new(line, 1);
lsp::Location::new(uri.clone(), lsp::Range::new(start, end))
});
Ok(Some(lsp::GotoDefinitionResponse::Array(locations.to_vec())))
});
let position = cx.pixel_position(&source);
cx.simulate_mouse_move(position, None, Modifiers::secondary_key());
assert_eq!(symbols.next().await, Some(()));
cx.run_until_parked();
cx.editor(|editor, _, _| {
let state = editor.hovered_link_state.as_ref().expect("cached link");
assert_eq!(state.preferred_kind, GotoDefinitionKind::Symbol);
assert!(state.task.as_ref().expect("hover task").is_ready());
assert_eq!(state.links.len(), 1, "{marked}");
});
let mut modifiers = Modifiers::secondary_key();
modifiers.shift = true;
cx.simulate_click(position, modifiers);
assert!(types.try_recv().is_err());
cx.update_editor(|editor, _, _| editor.disable_lsp_data());
cx.executor().advance_clock(delay);
assert_eq!(types.next().await, Some(()));
cx.run_until_parked();
cx.assert_editor_state(&source);
let url = cx.opened_url();
opened.push(cx.update_workspace(|workspace, window, cx| {
assert!(!workspace.has_active_modal(window, cx));
if marked.starts_with("h") {
return url;
}
let editor = workspace.active_item_as::<Editor>(cx).expect("editor");
let buffer = editor.read(cx).buffer().read(cx);
let buffer = buffer.as_singleton().expect("buffer");
let file = buffer.read(cx).file().expect("file");
let path = file.as_local().expect("local").abs_path(cx);
Some(path.to_string_lossy().into_owned())
}));
cx.update(|window, _| window.remove_window());
cx.run_until_parked();
}
assert_eq!(
opened,
["https://zed.dev/", path!("/root/dir/file2.rs")].map(|target| Some(target.to_owned()))
);
}
#[gpui::test]
async fn test_hover_preconditions(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
+443 -8
View File
@@ -1,3 +1,4 @@
use std::any::TypeId;
use std::ops::Range;
use std::sync::Arc;
@@ -5,17 +6,18 @@ use collections::HashMap;
use editor::actions::{
FindAllReferences, GoToDeclaration, GoToDefinition, GoToImplementation, GoToTypeDefinition,
};
use editor::hover_links::{HoverLink, OpenDefinitionLocations};
use editor::{Editor, EditorSettings, GotoDefinitionKind, OpenResultsIn};
use file_icons::FileIcons;
use fuzzy::StringMatchCandidate;
use gpui::{
AnyElement, App, AppContext, AsyncWindowContext, Context, DismissEvent, Entity, EventEmitter,
FocusHandle, Focusable, HighlightStyle, StyledText, Subscription, Task, TextStyle, WeakEntity,
prelude::*,
AnyElement, App, AppContext, AsyncWindowContext, Context, DismissEvent, DispatchPhase, Entity,
EventEmitter, FocusHandle, Focusable, HighlightStyle, StyledText, Subscription, Task,
TextStyle, WeakEntity, prelude::*,
};
use language::{Buffer, HighlightId, LanguageAwareStyling};
use picker::{Picker, PickerDelegate};
use project::{Location, Project, ProjectPath};
use project::{Location, LocationLink, Project, ProjectPath};
use settings::{GoToDefinitionFallback, Settings as _};
use text::{Anchor, Point};
use theme_settings::ThemeSettings;
@@ -24,6 +26,7 @@ use ui::{ListItem, ListItemSpacing, prelude::*};
use util::ResultExt as _;
use workspace::item::ItemSettings;
use workspace::notifications::NotificationId;
use workspace::pane::NavigationEntry;
use workspace::{ModalView, Toast, Workspace};
pub fn init(cx: &mut App) {
@@ -38,6 +41,24 @@ fn register(editor: &mut Editor, _window: Option<&mut Window>, cx: &mut Context<
if !editor.mode().is_full() {
return;
}
editor
.register_action_renderer(|editor, window, _| {
if editor.lsp_data_enabled() {
window.on_action(
TypeId::of::<OpenDefinitionLocations>(),
|action, phase, window, cx| {
if phase == DispatchPhase::Bubble {
handle_definition_locations(
action.downcast_ref().expect("definition action"),
window,
cx,
);
}
},
);
}
})
.detach();
let handle = cx.entity().downgrade();
editor
.register_action({
@@ -127,6 +148,65 @@ fn handle_nav_action(
LspLocationsPicker::open_for_editor(kind, editor.clone(), window, cx);
}
fn handle_definition_locations(
action: &OpenDefinitionLocations,
window: &mut Window,
cx: &mut App,
) {
let action = &action.0;
let Some(editor) = action.editor.upgrade() else {
return;
};
if !editor.read(cx).lsp_data_enabled()
|| !editor
.read(cx)
.buffer()
.read(cx)
.snapshot(cx)
.can_resolve(&action.position)
{
return;
}
let Some(workspace) = editor.read(cx).workspace() else {
cx.propagate();
return;
};
if EditorSettings::get_global(cx).lsp_results_location != OpenResultsIn::Picker
|| action
.locations
.iter()
.any(|location| location.buffer.read(cx).file().is_none())
{
cx.propagate();
return;
}
let matches = build_location_matches(&action.locations, cx);
if matches.len() < 2 {
cx.propagate();
return;
}
let kind = match action.kind {
GotoDefinitionKind::Symbol => LspPickerKind::Definition,
GotoDefinitionKind::Type => LspPickerKind::TypeDefinition,
GotoDefinitionKind::Declaration => LspPickerKind::Declaration,
GotoDefinitionKind::Implementation => LspPickerKind::Implementation,
};
workspace.update(cx, |workspace, cx| {
let project = workspace.project().clone();
workspace.toggle_modal(window, cx, |window, cx| {
LspLocationsPicker::new(
kind,
matches,
project,
action.editor.clone(),
action.origin.clone(),
window,
cx,
)
});
});
}
/// Runs the LSP query for `kind` and returns the raw locations. Returns `None`
/// (and reports any error) when there is nothing to query, so the caller stops
/// without an empty-results toast. Deduplication and dropping fileless results
@@ -335,7 +415,7 @@ impl LspLocationsPicker {
workspace
.update_in(cx, |workspace, window, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
Self::new(kind, matches, project, editor, window, cx)
Self::new(kind, matches, project, editor, None, window, cx)
});
})
.log_err();
@@ -348,11 +428,12 @@ impl LspLocationsPicker {
matches: Vec<LocationMatch>,
project: Entity<Project>,
editor: WeakEntity<Editor>,
origin: Option<NavigationEntry>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let preview = picker_preview::editor_preview(project.clone(), window, cx);
let delegate = LspLocationsDelegate::new(kind, matches, project, editor);
let delegate = LspLocationsDelegate::new(kind, matches, project, editor, origin);
let picker = cx.new(|cx| Picker::list_with_preview(delegate, preview, window, cx));
let subscription = cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| {
cx.emit(DismissEvent);
@@ -403,6 +484,7 @@ struct LspLocationsDelegate {
kind: LspPickerKind,
project: Entity<Project>,
editor: WeakEntity<Editor>,
origin: Option<NavigationEntry>,
all_matches: Vec<LocationMatch>,
candidates: Arc<[StringMatchCandidate]>,
matches: Vec<usize>,
@@ -417,6 +499,7 @@ impl LspLocationsDelegate {
all_matches: Vec<LocationMatch>,
project: Entity<Project>,
editor: WeakEntity<Editor>,
origin: Option<NavigationEntry>,
) -> Self {
// Match against the line text and the file path, mirroring the fuzzy
// matching every other Zed picker uses.
@@ -439,6 +522,7 @@ impl LspLocationsDelegate {
kind,
project,
editor,
origin,
all_matches,
candidates,
matches,
@@ -500,7 +584,21 @@ impl LspLocationsDelegate {
};
editor
.update(cx, |editor, cx| {
editor.open_location(location, split, window, cx)
if let Some(origin) = self.origin.take() {
editor.navigate_to_hover_links(
None,
vec![HoverLink::Text(LocationLink {
origin: None,
target: location,
})],
Some(origin),
split,
window,
cx,
)
} else {
editor.open_location(location, split, window, cx)
}
})
.detach_and_log_err(cx);
cx.emit(DismissEvent);
@@ -818,8 +916,11 @@ fn render_matched_line(location_match: &LocationMatch, cx: &App) -> StyledText {
mod tests {
use super::*;
use editor::test::editor_lsp_test_context::EditorLspTestContext;
use gpui::TestAppContext;
use gpui::{Modifiers, TestAppContext};
use indoc::indoc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::{sync::Mutex, time::Duration};
use text::OffsetRangeExt as _;
use workspace::Item as _;
async fn rust_cx(
@@ -1055,6 +1156,111 @@ mod tests {
);
}
#[gpui::test]
async fn test_cmd_click_cold_locations(cx: &mut TestAppContext) {
cx.update(crate::init);
for results in [OpenResultsIn::Picker, OpenResultsIn::MultiBuffer] {
for shift in [false, true] {
for split in [false, true] {
assert_cmd_click(cx, None, shift, false, results, split, false).await;
}
}
assert_cmd_click(cx, None, false, false, results, false, true).await;
}
}
#[gpui::test]
async fn test_cmd_click_cached_locations(cx: &mut TestAppContext) {
cx.update(crate::init);
for results in [OpenResultsIn::Picker, OpenResultsIn::MultiBuffer] {
for shift in [false, true] {
for split in [false, true] {
assert_cmd_click(cx, Some(shift), shift, false, results, split, false).await;
}
for pending in [false, true] {
assert_cmd_click(cx, Some(!shift), shift, pending, results, false, false).await;
}
}
}
}
#[gpui::test]
async fn test_cmd_click_empty_hover_requeries(cx: &mut TestAppContext) {
cx.update(crate::init);
for count in [1, 0, 2] {
let targets = vec![(2, 14, 17); count];
let (mut cx, requests) = cmd_click_request(cx, true, &targets).await;
let target = SOURCE.replace('ˇ', "").replace("= abc", "= «abcˇ»");
cx.assert_editor_state(if count == 0 { SOURCE } else { &target });
assert!(active_picker(&mut cx).is_none());
cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
assert_eq!(requests.load(Ordering::SeqCst), 2);
cx.update(|window, _| window.remove_window());
cx.run_until_parked();
}
}
#[gpui::test]
async fn test_cmd_click_pending_hidden_source(cx: &mut TestAppContext) {
cx.update(crate::init);
let (mut cx, requests) = cmd_click_request(cx, false, &[(2, 14, 17)]).await;
let other = cx
.update_workspace(Editor::new_in_workspace)
.await
.expect("tab");
cx.update(|window, cx| window.draw(cx).clear(cx));
cx.update_workspace(|workspace, _, cx| {
assert_eq!(workspace.active_item_as::<Editor>(cx), Some(other.clone()));
});
cx.executor().advance_clock(Duration::from_secs(1));
cx.run_until_parked();
cx.update_workspace(|workspace, _, cx| {
assert_eq!(workspace.active_item_as::<Editor>(cx), Some(other));
});
cx.assert_editor_state(&SOURCE.replace('ˇ', "").replace("= abc", "= «abcˇ»"));
assert_eq!(requests.load(Ordering::SeqCst), 1);
}
#[gpui::test]
async fn test_cmd_click_pending_preserves_tag_origin(cx: &mut TestAppContext) {
cx.update(crate::init);
let (mut cx, requests) = cmd_click_request(cx, false, &[(0, 3, 7), (2, 14, 17)]).await;
let moved = format!("ˇ{}", SOURCE.replace('ˇ', ""));
cx.set_selections_state(&moved);
assert!(active_picker(&mut cx).is_none());
cx.assert_editor_state(&moved);
cx.executor().advance_clock(Duration::from_secs(1));
cx.run_until_parked();
cx.assert_editor_state(&moved);
let modal = active_picker(&mut cx).expect("locations picker");
cx.update(|window, cx| {
modal.read(cx).picker.clone().update(cx, |picker, cx| {
let matches = &picker.delegate.all_matches;
let ranges = matches.iter().map(|location| location.range.clone());
assert_eq!(ranges.collect::<Vec<_>>(), vec![3..7, 45..48]);
picker.delegate.confirm(false, window, cx);
});
});
cx.run_until_parked();
cx.assert_editor_state(&SOURCE.replace('ˇ', "").replace("main", "«mainˇ»"));
cx.dispatch_action(workspace::ReopenLastPicker);
cx.run_until_parked();
let modal = active_picker(&mut cx).expect("reopened locations picker");
cx.update(|window, cx| {
modal.read(cx).picker.clone().update(cx, |picker, cx| {
picker.set_selected_index(2, None, true, window, cx);
picker.delegate.confirm(false, window, cx);
});
});
cx.run_until_parked();
cx.assert_editor_state(&SOURCE.replace('ˇ', "").replace("= abc", "= «abcˇ»"));
cx.dispatch_action(workspace::pane::GoToOlderTag);
cx.assert_editor_state(&SOURCE.replace('ˇ', "").replace("main", "mainˇ"));
cx.dispatch_action(workspace::pane::GoToOlderTag);
cx.assert_editor_state(SOURCE);
assert_eq!(requests.load(Ordering::SeqCst), 1);
}
#[gpui::test]
async fn test_type_definition_honors_lsp_results_location(cx: &mut TestAppContext) {
cx.update(crate::init);
@@ -1140,4 +1346,233 @@ mod tests {
"declaration should open the picker when lsp_results_location is picker"
);
}
async fn assert_cmd_click(
cx: &mut TestAppContext,
hover: Option<bool>,
shift: bool,
pending: bool,
results: OpenResultsIn,
split: bool,
disabled: bool,
) {
let mut cx = rust_cx(
lsp::ServerCapabilities {
definition_provider: Some(lsp::OneOf::Left(true)),
type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
let scenario = format!(
"hover={hover:?}, shift={shift}, pending={pending}, results={results:?}, split={split}, disabled={disabled}"
);
cx.update_global::<settings::SettingsStore, _>(|settings, cx| {
settings.update_user_settings(cx, |settings| {
settings.editor.lsp_results_location = Some(results);
settings.editor.excerpt_context_lines = Some(0);
});
});
let source = indoc! {r#"
fn main() {
let foo = ();
let foo = ();
let bar = fˇoo;
}
"#}
.replace("\n let bar", "\n\n\n\n\n\n let bar");
cx.set_state(&source);
if disabled {
cx.update_editor(|editor, _, _| {
editor.set_mode(editor::EditorMode::AutoHeight {
min_lines: 1,
max_lines: None,
})
});
}
let position = cx
.editor(|editor, _, cx| editor.pixel_position_of_cursor(cx))
.expect("click pixel");
cx.set_selections_state(&source.replace('ˇ', "").replace("main", "ˇmain"));
cx.assert_editor_selections(vec![3..3]);
let buffer = cx.multibuffer(|buffer, _| buffer.as_singleton().expect("source buffer"));
let requests = Arc::new(Mutex::new(Vec::new()));
let completed = Arc::new(AtomicUsize::new(0));
let delay = Duration::from_secs(1);
for kind in [false, true] {
let requests = requests.clone();
let completed = completed.clone();
let handler = move |params: lsp::GotoDefinitionParams, app: gpui::AsyncApp| {
let requests = requests.clone();
let completed = completed.clone();
async move {
let params = params.text_document_position_params;
requests.lock().expect("lock").push((kind, params.clone()));
if pending && kind == shift {
app.background_executor().timer(delay).await;
}
completed.fetch_add(1, Ordering::SeqCst);
let (start, end) = if kind { (14, 16) } else { (8, 11) };
Ok(Some(lsp::GotoDefinitionResponse::Array(references(
params.text_document.uri,
&[(1, start, end), (3, start, end)],
))))
}
};
if kind {
cx.lsp
.set_request_handler::<lsp::request::GotoTypeDefinition, _, _>(handler);
} else {
cx.lsp
.set_request_handler::<lsp::request::GotoDefinition, _, _>(handler);
}
}
let modifiers = Modifiers {
shift,
alt: split,
..Modifiers::secondary_key()
};
let mut expected_requests = Vec::new();
let query = lsp::TextDocumentPositionParams {
text_document: lsp::TextDocumentIdentifier::new(cx.buffer_lsp_url.clone()),
position: lsp::Position::new(9, 15),
};
if let Some(shift) = hover {
let mut modifiers = modifiers;
modifiers.shift = shift;
cx.simulate_mouse_move(position, None, modifiers);
expected_requests.push((shift, query.clone()));
}
if pending {
cx.simulate_mouse_move(position, None, modifiers);
expected_requests.push((shift, query.clone()));
}
assert_eq!(*requests.lock().expect("requests"), expected_requests);
assert_eq!(
completed.load(Ordering::SeqCst),
usize::from(hover.is_some())
);
cx.simulate_click(position, modifiers);
if pending {
cx.executor().advance_clock(delay);
}
cx.run_until_parked();
if !disabled && hover != Some(shift) {
expected_requests.push((shift, query));
}
assert_eq!(
*requests.lock().expect("requests"),
expected_requests,
"{scenario}"
);
let picker = active_picker(&mut cx);
let source_editor = cx.editor.clone();
let (start, end) = if shift { (14, 16) } else { (8, 11) };
let expected_ranges = vec![
Point::new(1, start)..Point::new(1, end),
Point::new(3, start)..Point::new(3, end),
];
cx.update_workspace(|workspace, _, cx| {
assert_eq!(workspace.panes().len(), 1 + usize::from(split));
let active = workspace.active_item_as::<Editor>(cx).expect("editor");
if disabled {
assert!(picker.is_none());
assert_eq!(active, source_editor);
assert_eq!(workspace.items(cx).count(), 1);
return;
}
let actual_ranges = if results == OpenResultsIn::Picker && !split {
let picker = picker.expect("locations picker");
let delegate = &picker.read(cx).picker.read(cx).delegate;
let kind = match shift {
true => LspPickerKind::TypeDefinition,
false => LspPickerKind::Definition,
};
assert_eq!(delegate.kind, kind);
assert_eq!(workspace.items(cx).count(), 1);
delegate
.all_matches
.iter()
.map(|location| {
assert_eq!(location.buffer, buffer);
location.anchor_range.to_point(buffer.read(cx))
})
.collect::<Vec<_>>()
} else {
assert!(picker.is_none());
let multibuffer = active.read(cx).buffer().read(cx);
assert!(!multibuffer.is_singleton());
assert_eq!(
multibuffer.all_buffers_iter().collect::<Vec<_>>(),
vec![buffer.clone()]
);
assert_eq!(workspace.items(cx).count(), 2);
multibuffer
.snapshot(cx)
.excerpts()
.map(|excerpt| excerpt.primary.to_point(buffer.read(cx)))
.collect::<Vec<_>>()
};
assert_eq!(actual_ranges, expected_ranges);
});
cx.update(|window, _| window.remove_window());
cx.run_until_parked();
}
async fn cmd_click_request(
cx: &mut TestAppContext,
empty_hover: bool,
targets: &[(u32, u32, u32)],
) -> (EditorLspTestContext, Arc<AtomicUsize>) {
let mut cx = rust_cx(
lsp::ServerCapabilities {
definition_provider: Some(lsp::OneOf::Left(true)),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
cx.update_global::<settings::SettingsStore, _>(|settings, cx| {
settings.update_user_settings(cx, |settings| {
settings.editor.lsp_results_location = Some(OpenResultsIn::Picker);
settings.editor.go_to_definition_fallback = Some(GoToDefinitionFallback::None);
});
});
cx.set_state(SOURCE);
let requests = Arc::new(AtomicUsize::new(0));
let locations = references(cx.buffer_lsp_url.clone(), targets);
cx.set_request_handler::<lsp::request::GotoDefinition, _, _>({
let requests = requests.clone();
move |uri, params, app| {
let params = params.text_document_position_params;
assert_eq!(params.text_document.uri, uri);
assert_eq!(params.position, lsp::Position::new(1, 9));
let empty = requests.fetch_add(1, Ordering::SeqCst) == 0 && empty_hover;
let locations = locations.clone();
async move {
if !empty_hover {
app.background_executor()
.timer(Duration::from_secs(1))
.await;
}
Ok(Some(lsp::GotoDefinitionResponse::Array(if empty {
Vec::new()
} else {
locations
})))
}
}
});
let position = cx
.editor(|editor, _, cx| editor.pixel_position_of_cursor(cx))
.expect("click pixel");
if empty_hover {
cx.simulate_mouse_move(position, None, Modifiers::secondary_key());
}
cx.simulate_click(position, Modifiers::secondary_key());
cx.simulate_modifiers_change(Modifiers::none());
(cx, requests)
}
}