diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8534d92032..bb9820f9fc 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -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() } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index a38ceefc16..9d7fde1ba4 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -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) diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 5e07379105..719911d12b 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -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); + +pub struct DefinitionLocations { + pub editor: WeakEntity, + pub position: Anchor, + pub kind: GotoDefinitionKind, + pub locations: Vec, + pub origin: Option, +} + +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, ) { - 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, + ) { + 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::>(); + 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, + position: Anchor, + origin: Option, + split: bool, window: &mut Window, - cx: &mut Context, - ) -> Task> { - 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, + ) { + 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, + origin: Option, + split: bool, + window: &mut Window, + cx: &mut Context, + ) { + 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::(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::(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::>(), + std::slice::from_ref(&buffer) + ); + assert_eq!( + multibuffer + .snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.primary.to_offset(buffer.read(cx))) + .collect::>(), + [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::(|_, _, _| async { Ok(None) }); + let mut types = + cx.set_request_handler::(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::(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, |_| {}); diff --git a/crates/lsp_locations/src/lsp_locations.rs b/crates/lsp_locations/src/lsp_locations.rs index f82a24be57..019a0a8a93 100644 --- a/crates/lsp_locations/src/lsp_locations.rs +++ b/crates/lsp_locations/src/lsp_locations.rs @@ -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::(), + |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, project: Entity, editor: WeakEntity, + origin: Option, window: &mut Window, cx: &mut Context, ) -> 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, editor: WeakEntity, + origin: Option, all_matches: Vec, candidates: Arc<[StringMatchCandidate]>, matches: Vec, @@ -417,6 +499,7 @@ impl LspLocationsDelegate { all_matches: Vec, project: Entity, editor: WeakEntity, + origin: Option, ) -> 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::(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::(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![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, + 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, 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::(handler); + } else { + cx.lsp + .set_request_handler::(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::(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::>() + } 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![buffer.clone()] + ); + assert_eq!(workspace.items(cx).count(), 2); + multibuffer + .snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.primary.to_point(buffer.read(cx))) + .collect::>() + }; + 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) { + let mut cx = rust_cx( + lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + cx.update_global::(|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::({ + 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) + } }