Files
oak-gpui/crates/editor/src/link_go_to_definition.rs
T
ForLoveOfCats 40a8c26080 Include empty selections in updating link logic on cmd/shift changed
Prevents an issue where pressing cmd while the mouse button is down
would create a link which would fire on mouse up if the selection was
still empty
2022-08-19 17:25:35 -04:00

880 lines
29 KiB
Rust

use std::ops::Range;
use gpui::{impl_internal_actions, MutableAppContext, Task, ViewContext};
use language::{Bias, ToOffset};
use project::LocationLink;
use settings::Settings;
use util::TryFutureExt;
use workspace::Workspace;
use crate::{
Anchor, DisplayPoint, Editor, EditorSnapshot, GoToDefinition, GoToTypeDefinition, Select,
SelectPhase,
};
#[derive(Clone, PartialEq)]
pub struct UpdateGoToDefinitionLink {
pub point: Option<DisplayPoint>,
pub cmd_held: bool,
pub shift_held: bool,
}
#[derive(Clone, PartialEq)]
pub struct CmdShiftChanged {
pub cmd_down: bool,
pub shift_down: bool,
}
#[derive(Clone, PartialEq)]
pub struct GoToFetchedDefinition {
pub point: DisplayPoint,
}
#[derive(Clone, PartialEq)]
pub struct GoToFetchedTypeDefinition {
pub point: DisplayPoint,
}
impl_internal_actions!(
editor,
[
UpdateGoToDefinitionLink,
CmdShiftChanged,
GoToFetchedDefinition,
GoToFetchedTypeDefinition
]
);
pub fn init(cx: &mut MutableAppContext) {
cx.add_action(update_go_to_definition_link);
cx.add_action(cmd_shift_changed);
cx.add_action(go_to_fetched_definition);
cx.add_action(go_to_fetched_type_definition);
}
#[derive(Debug, Default)]
pub struct LinkGoToDefinitionState {
pub last_mouse_location: Option<Anchor>,
pub symbol_range: Option<Range<Anchor>>,
pub kind: Option<LinkDefinitionKind>,
pub definitions: Vec<LocationLink>,
pub task: Option<Task<Option<()>>>,
}
pub fn update_go_to_definition_link(
editor: &mut Editor,
&UpdateGoToDefinitionLink {
point,
cmd_held,
shift_held,
}: &UpdateGoToDefinitionLink,
cx: &mut ViewContext<Editor>,
) {
let pending_nonempty_selection = editor.has_pending_nonempty_selection();
// Store new mouse point as an anchor
let snapshot = editor.snapshot(cx);
let point = point.map(|point| {
snapshot
.buffer_snapshot
.anchor_before(point.to_offset(&snapshot.display_snapshot, Bias::Left))
});
// If the new point is the same as the previously stored one, return early
if let (Some(a), Some(b)) = (
&point,
&editor.link_go_to_definition_state.last_mouse_location,
) {
if a.cmp(b, &snapshot.buffer_snapshot).is_eq() {
return;
}
}
editor.link_go_to_definition_state.last_mouse_location = point.clone();
if pending_nonempty_selection {
hide_link_definition(editor, cx);
return;
}
if cmd_held {
if let Some(point) = point {
let kind = if shift_held {
LinkDefinitionKind::Type
} else {
LinkDefinitionKind::Symbol
};
show_link_definition(kind, editor, point, snapshot, cx);
return;
}
}
hide_link_definition(editor, cx);
}
pub fn cmd_shift_changed(
editor: &mut Editor,
&CmdShiftChanged {
cmd_down,
shift_down,
}: &CmdShiftChanged,
cx: &mut ViewContext<Editor>,
) {
let pending_selection = editor.has_pending_selection();
if let Some(point) = editor
.link_go_to_definition_state
.last_mouse_location
.clone()
{
if cmd_down && !pending_selection {
let snapshot = editor.snapshot(cx);
let kind = if shift_down {
LinkDefinitionKind::Type
} else {
LinkDefinitionKind::Symbol
};
show_link_definition(kind, editor, point, snapshot, cx);
return;
}
}
hide_link_definition(editor, cx)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LinkDefinitionKind {
Symbol,
Type,
}
pub fn show_link_definition(
definition_kind: LinkDefinitionKind,
editor: &mut Editor,
trigger_point: Anchor,
snapshot: EditorSnapshot,
cx: &mut ViewContext<Editor>,
) {
let same_kind = editor.link_go_to_definition_state.kind == Some(definition_kind);
if !same_kind {
hide_link_definition(editor, cx);
}
if editor.pending_rename.is_some() {
return;
}
let (buffer, buffer_position) = if let Some(output) = editor
.buffer
.read(cx)
.text_anchor_for_position(trigger_point.clone(), cx)
{
output
} else {
return;
};
let excerpt_id = if let Some((excerpt_id, _, _)) = editor
.buffer()
.read(cx)
.excerpt_containing(trigger_point.clone(), cx)
{
excerpt_id
} else {
return;
};
let project = if let Some(project) = editor.project.clone() {
project
} else {
return;
};
// Don't request again if the location is within the symbol region of a previous request with the same kind
if let Some(symbol_range) = &editor.link_go_to_definition_state.symbol_range {
let point_after_start = symbol_range
.start
.cmp(&trigger_point, &snapshot.buffer_snapshot)
.is_le();
let point_before_end = symbol_range
.end
.cmp(&trigger_point, &snapshot.buffer_snapshot)
.is_ge();
let point_within_range = point_after_start && point_before_end;
if point_within_range && same_kind {
return;
}
}
let task = cx.spawn_weak(|this, mut cx| {
async move {
// query the LSP for definition info
let definition_request = cx.update(|cx| {
project.update(cx, |project, cx| match definition_kind {
LinkDefinitionKind::Symbol => project.definition(&buffer, buffer_position, cx),
LinkDefinitionKind::Type => {
project.type_definition(&buffer, buffer_position, cx)
}
})
});
let result = definition_request.await.ok().map(|definition_result| {
(
definition_result.iter().find_map(|link| {
link.origin.as_ref().map(|origin| {
let start = snapshot
.buffer_snapshot
.anchor_in_excerpt(excerpt_id.clone(), origin.range.start);
let end = snapshot
.buffer_snapshot
.anchor_in_excerpt(excerpt_id.clone(), origin.range.end);
start..end
})
}),
definition_result,
)
});
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, cx| {
// Clear any existing highlights
this.clear_text_highlights::<LinkGoToDefinitionState>(cx);
this.link_go_to_definition_state.kind = Some(definition_kind);
this.link_go_to_definition_state.symbol_range = result
.as_ref()
.and_then(|(symbol_range, _)| symbol_range.clone());
if let Some((symbol_range, definitions)) = result {
this.link_go_to_definition_state.definitions = definitions.clone();
let buffer_snapshot = buffer.read(cx).snapshot();
// Only show highlight if there exists a definition to jump to that doesn't contain
// the current location.
let any_definition_does_not_contain_current_location =
definitions.iter().any(|definition| {
let target = &definition.target;
if target.buffer == buffer {
let range = &target.range;
// Expand range by one character as lsp definition ranges include positions adjacent
// but not contained by the symbol range
let start = buffer_snapshot.clip_offset(
range.start.to_offset(&buffer_snapshot).saturating_sub(1),
Bias::Left,
);
let end = buffer_snapshot.clip_offset(
range.end.to_offset(&buffer_snapshot) + 1,
Bias::Right,
);
let offset = buffer_position.to_offset(&buffer_snapshot);
!(start <= offset && end >= offset)
} else {
true
}
});
if any_definition_does_not_contain_current_location {
// If no symbol range returned from language server, use the surrounding word.
let highlight_range = symbol_range.unwrap_or_else(|| {
let snapshot = &snapshot.buffer_snapshot;
let (offset_range, _) = snapshot.surrounding_word(trigger_point);
snapshot.anchor_before(offset_range.start)
..snapshot.anchor_after(offset_range.end)
});
// Highlight symbol using theme link definition highlight style
let style = cx.global::<Settings>().theme.editor.link_definition;
this.highlight_text::<LinkGoToDefinitionState>(
vec![highlight_range],
style,
cx,
);
} else {
hide_link_definition(this, cx);
}
}
})
}
Ok::<_, anyhow::Error>(())
}
.log_err()
});
editor.link_go_to_definition_state.task = Some(task);
}
pub fn hide_link_definition(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
if editor.link_go_to_definition_state.symbol_range.is_some()
|| !editor.link_go_to_definition_state.definitions.is_empty()
{
editor.link_go_to_definition_state.symbol_range.take();
editor.link_go_to_definition_state.definitions.clear();
cx.notify();
}
editor.link_go_to_definition_state.task = None;
editor.clear_text_highlights::<LinkGoToDefinitionState>(cx);
}
pub fn go_to_fetched_definition(
workspace: &mut Workspace,
&GoToFetchedDefinition { point }: &GoToFetchedDefinition,
cx: &mut ViewContext<Workspace>,
) {
go_to_fetched_definition_of_kind(LinkDefinitionKind::Symbol, workspace, point, cx);
}
pub fn go_to_fetched_type_definition(
workspace: &mut Workspace,
&GoToFetchedTypeDefinition { point }: &GoToFetchedTypeDefinition,
cx: &mut ViewContext<Workspace>,
) {
go_to_fetched_definition_of_kind(LinkDefinitionKind::Type, workspace, point, cx);
}
fn go_to_fetched_definition_of_kind(
kind: LinkDefinitionKind,
workspace: &mut Workspace,
point: DisplayPoint,
cx: &mut ViewContext<Workspace>,
) {
let active_item = workspace.active_item(cx);
let editor_handle = if let Some(editor) = active_item
.as_ref()
.and_then(|item| item.act_as::<Editor>(cx))
{
editor
} else {
return;
};
let (cached_definitions, cached_definitions_kind) = editor_handle.update(cx, |editor, cx| {
let definitions = editor.link_go_to_definition_state.definitions.clone();
hide_link_definition(editor, cx);
(definitions, editor.link_go_to_definition_state.kind)
});
let is_correct_kind = cached_definitions_kind == Some(kind);
if !cached_definitions.is_empty() && is_correct_kind {
editor_handle.update(cx, |editor, cx| {
if !editor.focused {
cx.focus_self();
}
});
Editor::navigate_to_definitions(workspace, editor_handle, cached_definitions, cx);
} else {
editor_handle.update(cx, |editor, cx| {
editor.select(
&Select(SelectPhase::Begin {
position: point,
add: false,
click_count: 1,
}),
cx,
);
});
match kind {
LinkDefinitionKind::Symbol => Editor::go_to_definition(workspace, &GoToDefinition, cx),
LinkDefinitionKind::Type => {
Editor::go_to_type_definition(workspace, &GoToTypeDefinition, cx)
}
}
}
}
#[cfg(test)]
mod tests {
use futures::StreamExt;
use indoc::indoc;
use lsp::request::{GotoDefinition, GotoTypeDefinition};
use crate::test::EditorLspTestContext;
use super::*;
#[gpui::test]
async fn test_link_go_to_type_definition(cx: &mut gpui::TestAppContext) {
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
..Default::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
struct A;
let vˇariable = A;
"});
// Basic hold cmd+shift, expect highlight in region if response contains type definition
let hover_point = cx.display_point(indoc! {"
struct A;
let vˇariable = A;
"});
let symbol_range = cx.lsp_range(indoc! {"
struct A;
let «variable» = A;
"});
let target_range = cx.lsp_range(indoc! {"
struct «A»;
let variable = A;
"});
let mut requests =
cx.handle_request::<GotoTypeDefinition, _, _>(move |url, _, _| async move {
Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
lsp::LocationLink {
origin_selection_range: Some(symbol_range),
target_uri: url.clone(),
target_range,
target_selection_range: target_range,
},
])))
});
// Press cmd+shift to trigger highlight
cx.update_editor(|editor, cx| {
update_go_to_definition_link(
editor,
&UpdateGoToDefinitionLink {
point: Some(hover_point),
cmd_held: true,
shift_held: true,
},
cx,
);
});
requests.next().await;
cx.foreground().run_until_parked();
cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
struct A;
let «variable» = A;
"});
// Unpress shift causes highlight to go away (normal goto-definition is not valid here)
cx.update_editor(|editor, cx| {
cmd_shift_changed(
editor,
&CmdShiftChanged {
cmd_down: true,
shift_down: false,
},
cx,
);
});
// Assert no link highlights
cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
struct A;
let variable = A;
"});
// Cmd+shift click without existing definition requests and jumps
let hover_point = cx.display_point(indoc! {"
struct A;
let vˇariable = A;
"});
let target_range = cx.lsp_range(indoc! {"
struct «A»;
let variable = A;
"});
let mut requests =
cx.handle_request::<GotoTypeDefinition, _, _>(move |url, _, _| async move {
Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
lsp::LocationLink {
origin_selection_range: None,
target_uri: url,
target_range,
target_selection_range: target_range,
},
])))
});
cx.update_workspace(|workspace, cx| {
go_to_fetched_type_definition(
workspace,
&GoToFetchedTypeDefinition { point: hover_point },
cx,
);
});
requests.next().await;
cx.foreground().run_until_parked();
cx.assert_editor_state(indoc! {"
struct «Aˇ»;
let variable = A;
"});
}
#[gpui::test]
async fn test_link_go_to_definition(cx: &mut gpui::TestAppContext) {
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
..Default::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
fn ˇtest() { do_work(); }
fn do_work() { test(); }
"});
// Basic hold cmd, expect highlight in region if response contains definition
let hover_point = cx.display_point(indoc! {"
fn test() { do_wˇork(); }
fn do_work() { test(); }
"});
let symbol_range = cx.lsp_range(indoc! {"
fn test() { «do_work»(); }
fn do_work() { test(); }
"});
let target_range = cx.lsp_range(indoc! {"
fn test() { do_work(); }
fn «do_work»() { test(); }
"});
let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
lsp::LocationLink {
origin_selection_range: Some(symbol_range),
target_uri: url.clone(),
target_range,
target_selection_range: target_range,
},
])))
});
cx.update_editor(|editor, cx| {
update_go_to_definition_link(
editor,
&UpdateGoToDefinitionLink {
point: Some(hover_point),
cmd_held: true,
shift_held: false,
},
cx,
);
});
requests.next().await;
cx.foreground().run_until_parked();
cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
fn test() { «do_work»(); }
fn do_work() { test(); }
"});
// Unpress cmd causes highlight to go away
cx.update_editor(|editor, cx| {
cmd_shift_changed(
editor,
&CmdShiftChanged {
cmd_down: false,
shift_down: false,
},
cx,
);
});
// Assert no link highlights
cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
fn test() { do_work(); }
fn do_work() { test(); }
"});
// Response without source range still highlights word
cx.update_editor(|editor, _| editor.link_go_to_definition_state.last_mouse_location = None);
let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
lsp::LocationLink {
// No origin range
origin_selection_range: None,
target_uri: url.clone(),
target_range,
target_selection_range: target_range,
},
])))
});
cx.update_editor(|editor, cx| {
update_go_to_definition_link(
editor,
&UpdateGoToDefinitionLink {
point: Some(hover_point),
cmd_held: true,
shift_held: false,
},
cx,
);
});
requests.next().await;
cx.foreground().run_until_parked();
cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
fn test() { «do_work»(); }
fn do_work() { test(); }
"});
// Moving mouse to location with no response dismisses highlight
let hover_point = cx.display_point(indoc! {"
fˇn test() { do_work(); }
fn do_work() { test(); }
"});
let mut requests = cx
.lsp
.handle_request::<GotoDefinition, _, _>(move |_, _| async move {
// No definitions returned
Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
});
cx.update_editor(|editor, cx| {
update_go_to_definition_link(
editor,
&UpdateGoToDefinitionLink {
point: Some(hover_point),
cmd_held: true,
shift_held: false,
},
cx,
);
});
requests.next().await;
cx.foreground().run_until_parked();
// Assert no link highlights
cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
fn test() { do_work(); }
fn do_work() { test(); }
"});
// Move mouse without cmd and then pressing cmd triggers highlight
let hover_point = cx.display_point(indoc! {"
fn test() { do_work(); }
fn do_work() { teˇst(); }
"});
cx.update_editor(|editor, cx| {
update_go_to_definition_link(
editor,
&UpdateGoToDefinitionLink {
point: Some(hover_point),
cmd_held: false,
shift_held: false,
},
cx,
);
});
cx.foreground().run_until_parked();
// Assert no link highlights
cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
fn test() { do_work(); }
fn do_work() { test(); }
"});
let symbol_range = cx.lsp_range(indoc! {"
fn test() { do_work(); }
fn do_work() { «test»(); }
"});
let target_range = cx.lsp_range(indoc! {"
fn «test»() { do_work(); }
fn do_work() { test(); }
"});
let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
lsp::LocationLink {
origin_selection_range: Some(symbol_range),
target_uri: url,
target_range,
target_selection_range: target_range,
},
])))
});
cx.update_editor(|editor, cx| {
cmd_shift_changed(
editor,
&CmdShiftChanged {
cmd_down: true,
shift_down: false,
},
cx,
);
});
requests.next().await;
cx.foreground().run_until_parked();
cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
fn test() { do_work(); }
fn do_work() { «test»(); }
"});
// Deactivating the window dismisses the highlight
cx.update_workspace(|workspace, cx| {
workspace.on_window_activation_changed(false, cx);
});
cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
fn test() { do_work(); }
fn do_work() { test(); }
"});
// Moving the mouse restores the highlights.
cx.update_editor(|editor, cx| {
update_go_to_definition_link(
editor,
&UpdateGoToDefinitionLink {
point: Some(hover_point),
cmd_held: true,
shift_held: false,
},
cx,
);
});
cx.foreground().run_until_parked();
cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
fn test() { do_work(); }
fn do_work() { «test»(); }
"});
// Moving again within the same symbol range doesn't re-request
let hover_point = cx.display_point(indoc! {"
fn test() { do_work(); }
fn do_work() { tesˇt(); }
"});
cx.update_editor(|editor, cx| {
update_go_to_definition_link(
editor,
&UpdateGoToDefinitionLink {
point: Some(hover_point),
cmd_held: true,
shift_held: false,
},
cx,
);
});
cx.foreground().run_until_parked();
cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
fn test() { do_work(); }
fn do_work() { «test»(); }
"});
// Cmd click with existing definition doesn't re-request and dismisses highlight
cx.update_workspace(|workspace, cx| {
go_to_fetched_definition(workspace, &GoToFetchedDefinition { point: hover_point }, cx);
});
// Assert selection moved to to definition
cx.lsp
.handle_request::<GotoDefinition, _, _>(move |_, _| async move {
// Empty definition response to make sure we aren't hitting the lsp and using
// the cached location instead
Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
});
cx.assert_editor_state(indoc! {"
fn «testˇ»() { do_work(); }
fn do_work() { test(); }
"});
// Assert no link highlights after jump
cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
fn test() { do_work(); }
fn do_work() { test(); }
"});
// Cmd click without existing definition requests and jumps
let hover_point = cx.display_point(indoc! {"
fn test() { do_wˇork(); }
fn do_work() { test(); }
"});
let target_range = cx.lsp_range(indoc! {"
fn test() { do_work(); }
fn «do_work»() { test(); }
"});
let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
lsp::LocationLink {
origin_selection_range: None,
target_uri: url,
target_range,
target_selection_range: target_range,
},
])))
});
cx.update_workspace(|workspace, cx| {
go_to_fetched_definition(workspace, &GoToFetchedDefinition { point: hover_point }, cx);
});
requests.next().await;
cx.foreground().run_until_parked();
cx.assert_editor_state(indoc! {"
fn test() { do_work(); }
fn «do_workˇ»() { test(); }
"});
// 1. We have a pending selection, mouse point is over a symbol that we have a response for, hitting cmd and nothing happens
// 2. Selection is completed, hovering
let hover_point = cx.display_point(indoc! {"
fn test() { do_wˇork(); }
fn do_work() { test(); }
"});
let target_range = cx.lsp_range(indoc! {"
fn test() { do_work(); }
fn «do_work»() { test(); }
"});
let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
lsp::LocationLink {
origin_selection_range: None,
target_uri: url,
target_range,
target_selection_range: target_range,
},
])))
});
// create a pending selection
let selection_range = cx.ranges(indoc! {"
fn «test() { do_w»ork(); }
fn do_work() { test(); }
"})[0]
.clone();
cx.update_editor(|editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let anchor_range = snapshot.anchor_before(selection_range.start)
..snapshot.anchor_after(selection_range.end);
editor.change_selections(Some(crate::Autoscroll::Fit), cx, |s| {
s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character)
});
});
cx.update_editor(|editor, cx| {
update_go_to_definition_link(
editor,
&UpdateGoToDefinitionLink {
point: Some(hover_point),
cmd_held: true,
shift_held: false,
},
cx,
);
});
cx.foreground().run_until_parked();
assert!(requests.try_next().is_err());
cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
fn test() { do_work(); }
fn do_work() { test(); }
"});
cx.foreground().run_until_parked();
}
}