Closes #25647 This PR fixes two issues related to cmd + click on URL: 1. Normally cmd + click on URL, it opens browser. Now, alt + tab back to Zed. If you cmd + click on link again it won't work, until you normal click some where else in buffer. It won't even show underline. 2. Again, cmd + click on URL, it opens browser. Now, alt + tab back to Zed. If you cmd + click, some where else in buffer like just normal text, and now try to hover on URL it won't show up underline and cmd + click on it won't work. Unless again, if you plain click somewhere else. Problem: Issue is when clicking we set pending anchor (for selection), and when we mouse up we clear those. This works for normal case without pressing any modifier. But, in case of cmd modifier, we set pending anchor (set when `SelectPhase::Begin`), but we don't clear it once we use that data. Fix: Once we end up using selection, anchor, etc data to figure out where to navigate either URL/defination etc, we clear selection just like how we do it in normal click. This doesn't require to happen after navigate task, so we do it right after our usage of it. Before: https://github.com/user-attachments/assets/b33d93fc-f490-4fa4-ae22-1da1fd6b77a9 After: https://github.com/user-attachments/assets/028f039a-cd13-4651-b461-3ba52f2526de Release Notes: - Fixed an issue where cmd + click on a URL was not working sometimes.
1843 lines
69 KiB
Rust
1843 lines
69 KiB
Rust
use crate::{
|
|
editor_settings::MultiCursorModifier,
|
|
hover_popover::{self, InlayHover},
|
|
scroll::ScrollAmount,
|
|
Anchor, Editor, EditorSettings, EditorSnapshot, FindAllReferences, GoToDefinition,
|
|
GoToTypeDefinition, GotoDefinitionKind, InlayId, Navigated, PointForPosition, SelectPhase,
|
|
};
|
|
use gpui::{px, App, AsyncWindowContext, Context, Entity, Modifiers, Task, Window};
|
|
use language::{Bias, ToOffset};
|
|
use linkify::{LinkFinder, LinkKind};
|
|
use lsp::LanguageServerId;
|
|
use project::{
|
|
HoverBlock, HoverBlockKind, InlayHintLabelPartTooltip, InlayHintTooltip, LocationLink, Project,
|
|
ResolveState, ResolvedPath,
|
|
};
|
|
use settings::Settings;
|
|
use std::ops::Range;
|
|
use theme::ActiveTheme as _;
|
|
use util::{maybe, ResultExt, TryFutureExt as _};
|
|
|
|
#[derive(Debug)]
|
|
pub struct HoveredLinkState {
|
|
pub last_trigger_point: TriggerPoint,
|
|
pub preferred_kind: GotoDefinitionKind,
|
|
pub symbol_range: Option<RangeInEditor>,
|
|
pub links: Vec<HoverLink>,
|
|
pub task: Option<Task<Option<()>>>,
|
|
}
|
|
|
|
#[derive(Debug, Eq, PartialEq, Clone)]
|
|
pub enum RangeInEditor {
|
|
Text(Range<Anchor>),
|
|
Inlay(InlayHighlight),
|
|
}
|
|
|
|
impl RangeInEditor {
|
|
pub fn as_text_range(&self) -> Option<Range<Anchor>> {
|
|
match self {
|
|
Self::Text(range) => Some(range.clone()),
|
|
Self::Inlay(_) => None,
|
|
}
|
|
}
|
|
|
|
pub fn point_within_range(
|
|
&self,
|
|
trigger_point: &TriggerPoint,
|
|
snapshot: &EditorSnapshot,
|
|
) -> bool {
|
|
match (self, trigger_point) {
|
|
(Self::Text(range), TriggerPoint::Text(point)) => {
|
|
let point_after_start = range.start.cmp(point, &snapshot.buffer_snapshot).is_le();
|
|
point_after_start && range.end.cmp(point, &snapshot.buffer_snapshot).is_ge()
|
|
}
|
|
(Self::Inlay(highlight), TriggerPoint::InlayHint(point, _, _)) => {
|
|
highlight.inlay == point.inlay
|
|
&& highlight.range.contains(&point.range.start)
|
|
&& highlight.range.contains(&point.range.end)
|
|
}
|
|
(Self::Inlay(_), TriggerPoint::Text(_))
|
|
| (Self::Text(_), TriggerPoint::InlayHint(_, _, _)) => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum HoverLink {
|
|
Url(String),
|
|
File(ResolvedPath),
|
|
Text(LocationLink),
|
|
InlayHint(lsp::Location, LanguageServerId),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct InlayHighlight {
|
|
pub inlay: InlayId,
|
|
pub inlay_position: Anchor,
|
|
pub range: Range<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum TriggerPoint {
|
|
Text(Anchor),
|
|
InlayHint(InlayHighlight, lsp::Location, LanguageServerId),
|
|
}
|
|
|
|
impl TriggerPoint {
|
|
fn anchor(&self) -> &Anchor {
|
|
match self {
|
|
TriggerPoint::Text(anchor) => anchor,
|
|
TriggerPoint::InlayHint(inlay_range, _, _) => &inlay_range.inlay_position,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn exclude_link_to_position(
|
|
buffer: &Entity<language::Buffer>,
|
|
current_position: &text::Anchor,
|
|
location: &LocationLink,
|
|
cx: &App,
|
|
) -> bool {
|
|
// Exclude definition links that points back to cursor position.
|
|
// (i.e., currently cursor upon definition).
|
|
let snapshot = buffer.read(cx).snapshot();
|
|
!(buffer == &location.target.buffer
|
|
&& current_position
|
|
.bias_right(&snapshot)
|
|
.cmp(&location.target.range.start, &snapshot)
|
|
.is_ge()
|
|
&& current_position
|
|
.cmp(&location.target.range.end, &snapshot)
|
|
.is_le())
|
|
}
|
|
|
|
impl Editor {
|
|
pub(crate) fn update_hovered_link(
|
|
&mut self,
|
|
point_for_position: PointForPosition,
|
|
snapshot: &EditorSnapshot,
|
|
modifiers: Modifiers,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let multi_cursor_setting = EditorSettings::get_global(cx).multi_cursor_modifier;
|
|
let hovered_link_modifier = match multi_cursor_setting {
|
|
MultiCursorModifier::Alt => modifiers.secondary(),
|
|
MultiCursorModifier::CmdOrCtrl => modifiers.alt,
|
|
};
|
|
if !hovered_link_modifier || self.has_pending_selection() {
|
|
self.hide_hovered_link(cx);
|
|
return;
|
|
}
|
|
|
|
match point_for_position.as_valid() {
|
|
Some(point) => {
|
|
let trigger_point = TriggerPoint::Text(
|
|
snapshot
|
|
.buffer_snapshot
|
|
.anchor_before(point.to_offset(&snapshot.display_snapshot, Bias::Left)),
|
|
);
|
|
|
|
show_link_definition(modifiers.shift, self, trigger_point, snapshot, window, cx);
|
|
}
|
|
None => {
|
|
update_inlay_link_and_hover_points(
|
|
snapshot,
|
|
point_for_position,
|
|
self,
|
|
hovered_link_modifier,
|
|
modifiers.shift,
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn hide_hovered_link(&mut self, cx: &mut Context<Self>) {
|
|
self.hovered_link_state.take();
|
|
self.clear_highlights::<HoveredLinkState>(cx);
|
|
}
|
|
|
|
pub(crate) fn handle_click_hovered_link(
|
|
&mut self,
|
|
point: PointForPosition,
|
|
modifiers: Modifiers,
|
|
window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
) {
|
|
let reveal_task = self.cmd_click_reveal_task(point, modifiers, window, cx);
|
|
cx.spawn_in(window, |editor, mut cx| async move {
|
|
let definition_revealed = reveal_task.await.log_err().unwrap_or(Navigated::No);
|
|
let find_references = editor
|
|
.update_in(&mut cx, |editor, window, cx| {
|
|
if definition_revealed == Navigated::Yes {
|
|
return None;
|
|
}
|
|
editor.find_all_references(&FindAllReferences, window, cx)
|
|
})
|
|
.ok()
|
|
.flatten();
|
|
if let Some(find_references) = find_references {
|
|
find_references.await.log_err();
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub fn scroll_hover(
|
|
&mut self,
|
|
amount: &ScrollAmount,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> bool {
|
|
let selection = self.selections.newest_anchor().head();
|
|
let snapshot = self.snapshot(window, cx);
|
|
|
|
let Some(popover) = self.hover_state.info_popovers.iter().find(|popover| {
|
|
popover
|
|
.symbol_range
|
|
.point_within_range(&TriggerPoint::Text(selection), &snapshot)
|
|
}) else {
|
|
return false;
|
|
};
|
|
popover.scroll(amount, window, cx);
|
|
true
|
|
}
|
|
|
|
fn cmd_click_reveal_task(
|
|
&mut self,
|
|
point: PointForPosition,
|
|
modifiers: Modifiers,
|
|
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);
|
|
}
|
|
|
|
// 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 links = hovered_link_state
|
|
.links
|
|
.into_iter()
|
|
.filter(|link| {
|
|
if let HoverLink::Text(location) = link {
|
|
exclude_link_to_position(&buffer, &anchor, location, cx)
|
|
} else {
|
|
true
|
|
}
|
|
})
|
|
.collect();
|
|
let navigate_task =
|
|
self.navigate_to_hover_links(None, links, modifiers.alt, 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() {
|
|
if modifiers.shift {
|
|
self.go_to_type_definition(&GoToTypeDefinition, window, cx)
|
|
} else {
|
|
self.go_to_definition(&GoToDefinition, window, cx)
|
|
}
|
|
} else {
|
|
Task::ready(Ok(Navigated::No))
|
|
};
|
|
self.select(SelectPhase::End, window, cx);
|
|
return navigate_task;
|
|
}
|
|
}
|
|
|
|
pub fn update_inlay_link_and_hover_points(
|
|
snapshot: &EditorSnapshot,
|
|
point_for_position: PointForPosition,
|
|
editor: &mut Editor,
|
|
secondary_held: bool,
|
|
shift_held: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
) {
|
|
let hovered_offset = if point_for_position.column_overshoot_after_line_end == 0 {
|
|
Some(snapshot.display_point_to_inlay_offset(point_for_position.exact_unclipped, Bias::Left))
|
|
} else {
|
|
None
|
|
};
|
|
let mut go_to_definition_updated = false;
|
|
let mut hover_updated = false;
|
|
if let Some(hovered_offset) = hovered_offset {
|
|
let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
|
|
let previous_valid_anchor = buffer_snapshot.anchor_at(
|
|
point_for_position.previous_valid.to_point(snapshot),
|
|
Bias::Left,
|
|
);
|
|
let next_valid_anchor = buffer_snapshot.anchor_at(
|
|
point_for_position.next_valid.to_point(snapshot),
|
|
Bias::Right,
|
|
);
|
|
if let Some(hovered_hint) = editor
|
|
.visible_inlay_hints(cx)
|
|
.into_iter()
|
|
.skip_while(|hint| {
|
|
hint.position
|
|
.cmp(&previous_valid_anchor, &buffer_snapshot)
|
|
.is_lt()
|
|
})
|
|
.take_while(|hint| {
|
|
hint.position
|
|
.cmp(&next_valid_anchor, &buffer_snapshot)
|
|
.is_le()
|
|
})
|
|
.max_by_key(|hint| hint.id)
|
|
{
|
|
let inlay_hint_cache = editor.inlay_hint_cache();
|
|
let excerpt_id = previous_valid_anchor.excerpt_id;
|
|
if let Some(cached_hint) = inlay_hint_cache.hint_by_id(excerpt_id, hovered_hint.id) {
|
|
match cached_hint.resolve_state {
|
|
ResolveState::CanResolve(_, _) => {
|
|
if let Some(buffer_id) = previous_valid_anchor.buffer_id {
|
|
inlay_hint_cache.spawn_hint_resolve(
|
|
buffer_id,
|
|
excerpt_id,
|
|
hovered_hint.id,
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
}
|
|
ResolveState::Resolved => {
|
|
let mut extra_shift_left = 0;
|
|
let mut extra_shift_right = 0;
|
|
if cached_hint.padding_left {
|
|
extra_shift_left += 1;
|
|
extra_shift_right += 1;
|
|
}
|
|
if cached_hint.padding_right {
|
|
extra_shift_right += 1;
|
|
}
|
|
match cached_hint.label {
|
|
project::InlayHintLabel::String(_) => {
|
|
if let Some(tooltip) = cached_hint.tooltip {
|
|
hover_popover::hover_at_inlay(
|
|
editor,
|
|
InlayHover {
|
|
tooltip: match tooltip {
|
|
InlayHintTooltip::String(text) => HoverBlock {
|
|
text,
|
|
kind: HoverBlockKind::PlainText,
|
|
},
|
|
InlayHintTooltip::MarkupContent(content) => {
|
|
HoverBlock {
|
|
text: content.value,
|
|
kind: content.kind,
|
|
}
|
|
}
|
|
},
|
|
range: InlayHighlight {
|
|
inlay: hovered_hint.id,
|
|
inlay_position: hovered_hint.position,
|
|
range: extra_shift_left
|
|
..hovered_hint.text.len() + extra_shift_right,
|
|
},
|
|
},
|
|
window,
|
|
cx,
|
|
);
|
|
hover_updated = true;
|
|
}
|
|
}
|
|
project::InlayHintLabel::LabelParts(label_parts) => {
|
|
let hint_start =
|
|
snapshot.anchor_to_inlay_offset(hovered_hint.position);
|
|
if let Some((hovered_hint_part, part_range)) =
|
|
hover_popover::find_hovered_hint_part(
|
|
label_parts,
|
|
hint_start,
|
|
hovered_offset,
|
|
)
|
|
{
|
|
let highlight_start =
|
|
(part_range.start - hint_start).0 + extra_shift_left;
|
|
let highlight_end =
|
|
(part_range.end - hint_start).0 + extra_shift_right;
|
|
let highlight = InlayHighlight {
|
|
inlay: hovered_hint.id,
|
|
inlay_position: hovered_hint.position,
|
|
range: highlight_start..highlight_end,
|
|
};
|
|
if let Some(tooltip) = hovered_hint_part.tooltip {
|
|
hover_popover::hover_at_inlay(
|
|
editor,
|
|
InlayHover {
|
|
tooltip: match tooltip {
|
|
InlayHintLabelPartTooltip::String(text) => {
|
|
HoverBlock {
|
|
text,
|
|
kind: HoverBlockKind::PlainText,
|
|
}
|
|
}
|
|
InlayHintLabelPartTooltip::MarkupContent(
|
|
content,
|
|
) => HoverBlock {
|
|
text: content.value,
|
|
kind: content.kind,
|
|
},
|
|
},
|
|
range: highlight.clone(),
|
|
},
|
|
window,
|
|
cx,
|
|
);
|
|
hover_updated = true;
|
|
}
|
|
if let Some((language_server_id, location)) =
|
|
hovered_hint_part.location
|
|
{
|
|
if secondary_held
|
|
&& !editor.has_pending_nonempty_selection()
|
|
{
|
|
go_to_definition_updated = true;
|
|
show_link_definition(
|
|
shift_held,
|
|
editor,
|
|
TriggerPoint::InlayHint(
|
|
highlight,
|
|
location,
|
|
language_server_id,
|
|
),
|
|
snapshot,
|
|
window,
|
|
cx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
ResolveState::Resolving => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !go_to_definition_updated {
|
|
editor.hide_hovered_link(cx)
|
|
}
|
|
if !hover_updated {
|
|
hover_popover::hover_at(editor, None, window, cx);
|
|
}
|
|
}
|
|
|
|
pub fn show_link_definition(
|
|
shift_held: bool,
|
|
editor: &mut Editor,
|
|
trigger_point: TriggerPoint,
|
|
snapshot: &EditorSnapshot,
|
|
window: &mut Window,
|
|
cx: &mut Context<Editor>,
|
|
) {
|
|
let preferred_kind = match trigger_point {
|
|
TriggerPoint::Text(_) if !shift_held => GotoDefinitionKind::Symbol,
|
|
_ => GotoDefinitionKind::Type,
|
|
};
|
|
|
|
let (mut hovered_link_state, is_cached) =
|
|
if let Some(existing) = editor.hovered_link_state.take() {
|
|
(existing, true)
|
|
} else {
|
|
(
|
|
HoveredLinkState {
|
|
last_trigger_point: trigger_point.clone(),
|
|
symbol_range: None,
|
|
preferred_kind,
|
|
links: vec![],
|
|
task: None,
|
|
},
|
|
false,
|
|
)
|
|
};
|
|
|
|
if editor.pending_rename.is_some() {
|
|
return;
|
|
}
|
|
|
|
let trigger_anchor = trigger_point.anchor();
|
|
let Some((buffer, buffer_position)) = editor
|
|
.buffer
|
|
.read(cx)
|
|
.text_anchor_for_position(*trigger_anchor, cx)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let Some((excerpt_id, _, _)) = editor
|
|
.buffer()
|
|
.read(cx)
|
|
.excerpt_containing(*trigger_anchor, cx)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let same_kind = hovered_link_state.preferred_kind == preferred_kind
|
|
|| hovered_link_state
|
|
.links
|
|
.first()
|
|
.is_some_and(|d| matches!(d, HoverLink::Url(_)));
|
|
|
|
if same_kind {
|
|
if is_cached && (hovered_link_state.last_trigger_point == trigger_point)
|
|
|| hovered_link_state
|
|
.symbol_range
|
|
.as_ref()
|
|
.is_some_and(|symbol_range| {
|
|
symbol_range.point_within_range(&trigger_point, snapshot)
|
|
})
|
|
{
|
|
editor.hovered_link_state = Some(hovered_link_state);
|
|
return;
|
|
}
|
|
} else {
|
|
editor.hide_hovered_link(cx)
|
|
}
|
|
let project = editor.project.clone();
|
|
let provider = editor.semantics_provider.clone();
|
|
|
|
let snapshot = snapshot.buffer_snapshot.clone();
|
|
hovered_link_state.task = Some(cx.spawn_in(window, |this, mut cx| {
|
|
async move {
|
|
let result = match &trigger_point {
|
|
TriggerPoint::Text(_) => {
|
|
if let Some((url_range, url)) = find_url(&buffer, buffer_position, cx.clone()) {
|
|
this.update(&mut cx, |_, _| {
|
|
let range = maybe!({
|
|
let start =
|
|
snapshot.anchor_in_excerpt(excerpt_id, url_range.start)?;
|
|
let end = snapshot.anchor_in_excerpt(excerpt_id, url_range.end)?;
|
|
Some(RangeInEditor::Text(start..end))
|
|
});
|
|
(range, vec![HoverLink::Url(url)])
|
|
})
|
|
.ok()
|
|
} else if let Some((filename_range, filename)) =
|
|
find_file(&buffer, project.clone(), buffer_position, &mut cx).await
|
|
{
|
|
let range = maybe!({
|
|
let start =
|
|
snapshot.anchor_in_excerpt(excerpt_id, filename_range.start)?;
|
|
let end = snapshot.anchor_in_excerpt(excerpt_id, filename_range.end)?;
|
|
Some(RangeInEditor::Text(start..end))
|
|
});
|
|
|
|
Some((range, vec![HoverLink::File(filename)]))
|
|
} else if let Some(provider) = provider {
|
|
let task = cx.update(|_, cx| {
|
|
provider.definitions(&buffer, buffer_position, preferred_kind, cx)
|
|
})?;
|
|
if let Some(task) = task {
|
|
task.await.ok().map(|definition_result| {
|
|
(
|
|
definition_result.iter().find_map(|link| {
|
|
link.origin.as_ref().and_then(|origin| {
|
|
let start = snapshot.anchor_in_excerpt(
|
|
excerpt_id,
|
|
origin.range.start,
|
|
)?;
|
|
let end = snapshot
|
|
.anchor_in_excerpt(excerpt_id, origin.range.end)?;
|
|
Some(RangeInEditor::Text(start..end))
|
|
})
|
|
}),
|
|
definition_result.into_iter().map(HoverLink::Text).collect(),
|
|
)
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
TriggerPoint::InlayHint(highlight, lsp_location, server_id) => Some((
|
|
Some(RangeInEditor::Inlay(highlight.clone())),
|
|
vec![HoverLink::InlayHint(lsp_location.clone(), *server_id)],
|
|
)),
|
|
};
|
|
|
|
this.update(&mut cx, |editor, cx| {
|
|
// Clear any existing highlights
|
|
editor.clear_highlights::<HoveredLinkState>(cx);
|
|
let Some(hovered_link_state) = editor.hovered_link_state.as_mut() else {
|
|
editor.hide_hovered_link(cx);
|
|
return;
|
|
};
|
|
hovered_link_state.preferred_kind = preferred_kind;
|
|
hovered_link_state.symbol_range = result
|
|
.as_ref()
|
|
.and_then(|(symbol_range, _)| symbol_range.clone());
|
|
|
|
if let Some((symbol_range, definitions)) = result {
|
|
hovered_link_state.links = definitions;
|
|
|
|
let underline_hovered_link = !hovered_link_state.links.is_empty()
|
|
|| hovered_link_state.symbol_range.is_some();
|
|
|
|
if underline_hovered_link {
|
|
let style = gpui::HighlightStyle {
|
|
underline: Some(gpui::UnderlineStyle {
|
|
thickness: px(1.),
|
|
..Default::default()
|
|
}),
|
|
color: Some(cx.theme().colors().link_text_hover),
|
|
..Default::default()
|
|
};
|
|
let highlight_range =
|
|
symbol_range.unwrap_or_else(|| match &trigger_point {
|
|
TriggerPoint::Text(trigger_anchor) => {
|
|
// If no symbol range returned from language server, use the surrounding word.
|
|
let (offset_range, _) =
|
|
snapshot.surrounding_word(*trigger_anchor, false);
|
|
RangeInEditor::Text(
|
|
snapshot.anchor_before(offset_range.start)
|
|
..snapshot.anchor_after(offset_range.end),
|
|
)
|
|
}
|
|
TriggerPoint::InlayHint(highlight, _, _) => {
|
|
RangeInEditor::Inlay(highlight.clone())
|
|
}
|
|
});
|
|
|
|
match highlight_range {
|
|
RangeInEditor::Text(text_range) => editor
|
|
.highlight_text::<HoveredLinkState>(vec![text_range], style, cx),
|
|
RangeInEditor::Inlay(highlight) => editor
|
|
.highlight_inlays::<HoveredLinkState>(vec![highlight], style, cx),
|
|
}
|
|
}
|
|
} else {
|
|
editor.hide_hovered_link(cx);
|
|
}
|
|
})?;
|
|
|
|
Ok::<_, anyhow::Error>(())
|
|
}
|
|
.log_err()
|
|
}));
|
|
|
|
editor.hovered_link_state = Some(hovered_link_state);
|
|
}
|
|
|
|
pub(crate) fn find_url(
|
|
buffer: &Entity<language::Buffer>,
|
|
position: text::Anchor,
|
|
mut cx: AsyncWindowContext,
|
|
) -> Option<(Range<text::Anchor>, String)> {
|
|
const LIMIT: usize = 2048;
|
|
|
|
let Ok(snapshot) = buffer.update(&mut cx, |buffer, _| buffer.snapshot()) else {
|
|
return None;
|
|
};
|
|
|
|
let offset = position.to_offset(&snapshot);
|
|
let mut token_start = offset;
|
|
let mut token_end = offset;
|
|
let mut found_start = false;
|
|
let mut found_end = false;
|
|
|
|
for ch in snapshot.reversed_chars_at(offset).take(LIMIT) {
|
|
if ch.is_whitespace() {
|
|
found_start = true;
|
|
break;
|
|
}
|
|
token_start -= ch.len_utf8();
|
|
}
|
|
// Check if we didn't find the starting whitespace or if we didn't reach the start of the buffer
|
|
if !found_start && token_start != 0 {
|
|
return None;
|
|
}
|
|
|
|
for ch in snapshot
|
|
.chars_at(offset)
|
|
.take(LIMIT - (offset - token_start))
|
|
{
|
|
if ch.is_whitespace() {
|
|
found_end = true;
|
|
break;
|
|
}
|
|
token_end += ch.len_utf8();
|
|
}
|
|
// Check if we didn't find the ending whitespace or if we read more or equal than LIMIT
|
|
// which at this point would happen only if we reached the end of buffer
|
|
if !found_end && (token_end - token_start >= LIMIT) {
|
|
return None;
|
|
}
|
|
|
|
let mut finder = LinkFinder::new();
|
|
finder.kinds(&[LinkKind::Url]);
|
|
let input = snapshot
|
|
.text_for_range(token_start..token_end)
|
|
.collect::<String>();
|
|
|
|
let relative_offset = offset - token_start;
|
|
for link in finder.links(&input) {
|
|
if link.start() <= relative_offset && link.end() >= relative_offset {
|
|
let range = snapshot.anchor_before(token_start + link.start())
|
|
..snapshot.anchor_after(token_start + link.end());
|
|
return Some((range, link.as_str().to_string()));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub(crate) fn find_url_from_range(
|
|
buffer: &Entity<language::Buffer>,
|
|
range: Range<text::Anchor>,
|
|
mut cx: AsyncWindowContext,
|
|
) -> Option<String> {
|
|
const LIMIT: usize = 2048;
|
|
|
|
let Ok(snapshot) = buffer.update(&mut cx, |buffer, _| buffer.snapshot()) else {
|
|
return None;
|
|
};
|
|
|
|
let start_offset = range.start.to_offset(&snapshot);
|
|
let end_offset = range.end.to_offset(&snapshot);
|
|
|
|
let mut token_start = start_offset.min(end_offset);
|
|
let mut token_end = start_offset.max(end_offset);
|
|
|
|
let range_len = token_end - token_start;
|
|
|
|
if range_len >= LIMIT {
|
|
return None;
|
|
}
|
|
|
|
// Skip leading whitespace
|
|
for ch in snapshot.chars_at(token_start).take(range_len) {
|
|
if !ch.is_whitespace() {
|
|
break;
|
|
}
|
|
token_start += ch.len_utf8();
|
|
}
|
|
|
|
// Skip trailing whitespace
|
|
for ch in snapshot.reversed_chars_at(token_end).take(range_len) {
|
|
if !ch.is_whitespace() {
|
|
break;
|
|
}
|
|
token_end -= ch.len_utf8();
|
|
}
|
|
|
|
if token_start >= token_end {
|
|
return None;
|
|
}
|
|
|
|
let text = snapshot
|
|
.text_for_range(token_start..token_end)
|
|
.collect::<String>();
|
|
|
|
let mut finder = LinkFinder::new();
|
|
finder.kinds(&[LinkKind::Url]);
|
|
|
|
if let Some(link) = finder.links(&text).next() {
|
|
if link.start() == 0 && link.end() == text.len() {
|
|
return Some(link.as_str().to_string());
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
pub(crate) async fn find_file(
|
|
buffer: &Entity<language::Buffer>,
|
|
project: Option<Entity<Project>>,
|
|
position: text::Anchor,
|
|
cx: &mut AsyncWindowContext,
|
|
) -> Option<(Range<text::Anchor>, ResolvedPath)> {
|
|
let project = project?;
|
|
let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()).ok()?;
|
|
let scope = snapshot.language_scope_at(position);
|
|
let (range, candidate_file_path) = surrounding_filename(snapshot, position)?;
|
|
|
|
async fn check_path(
|
|
candidate_file_path: &str,
|
|
project: &Entity<Project>,
|
|
buffer: &Entity<language::Buffer>,
|
|
cx: &mut AsyncWindowContext,
|
|
) -> Option<ResolvedPath> {
|
|
project
|
|
.update(cx, |project, cx| {
|
|
project.resolve_path_in_buffer(&candidate_file_path, buffer, cx)
|
|
})
|
|
.ok()?
|
|
.await
|
|
.filter(|s| s.is_file())
|
|
}
|
|
|
|
if let Some(existing_path) = check_path(&candidate_file_path, &project, buffer, cx).await {
|
|
return Some((range, existing_path));
|
|
}
|
|
|
|
if let Some(scope) = scope {
|
|
for suffix in scope.path_suffixes() {
|
|
if candidate_file_path.ends_with(format!(".{suffix}").as_str()) {
|
|
continue;
|
|
}
|
|
|
|
let suffixed_candidate = format!("{candidate_file_path}.{suffix}");
|
|
if let Some(existing_path) = check_path(&suffixed_candidate, &project, buffer, cx).await
|
|
{
|
|
return Some((range, existing_path));
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn surrounding_filename(
|
|
snapshot: language::BufferSnapshot,
|
|
position: text::Anchor,
|
|
) -> Option<(Range<text::Anchor>, String)> {
|
|
const LIMIT: usize = 2048;
|
|
|
|
let offset = position.to_offset(&snapshot);
|
|
let mut token_start = offset;
|
|
let mut token_end = offset;
|
|
let mut found_start = false;
|
|
let mut found_end = false;
|
|
let mut inside_quotes = false;
|
|
|
|
let mut filename = String::new();
|
|
|
|
let mut backwards = snapshot.reversed_chars_at(offset).take(LIMIT).peekable();
|
|
while let Some(ch) = backwards.next() {
|
|
// Escaped whitespace
|
|
if ch.is_whitespace() && backwards.peek() == Some(&'\\') {
|
|
filename.push(ch);
|
|
token_start -= ch.len_utf8();
|
|
backwards.next();
|
|
token_start -= '\\'.len_utf8();
|
|
continue;
|
|
}
|
|
if ch.is_whitespace() {
|
|
found_start = true;
|
|
break;
|
|
}
|
|
if (ch == '"' || ch == '\'') && !inside_quotes {
|
|
found_start = true;
|
|
inside_quotes = true;
|
|
break;
|
|
}
|
|
|
|
filename.push(ch);
|
|
token_start -= ch.len_utf8();
|
|
}
|
|
if !found_start && token_start != 0 {
|
|
return None;
|
|
}
|
|
|
|
filename = filename.chars().rev().collect();
|
|
|
|
let mut forwards = snapshot
|
|
.chars_at(offset)
|
|
.take(LIMIT - (offset - token_start))
|
|
.peekable();
|
|
while let Some(ch) = forwards.next() {
|
|
// Skip escaped whitespace
|
|
if ch == '\\' && forwards.peek().map_or(false, |ch| ch.is_whitespace()) {
|
|
token_end += ch.len_utf8();
|
|
let whitespace = forwards.next().unwrap();
|
|
token_end += whitespace.len_utf8();
|
|
filename.push(whitespace);
|
|
continue;
|
|
}
|
|
|
|
if ch.is_whitespace() {
|
|
found_end = true;
|
|
break;
|
|
}
|
|
if ch == '"' || ch == '\'' {
|
|
// If we're inside quotes, we stop when we come across the next quote
|
|
if inside_quotes {
|
|
found_end = true;
|
|
break;
|
|
} else {
|
|
// Otherwise, we skip the quote
|
|
inside_quotes = true;
|
|
continue;
|
|
}
|
|
}
|
|
filename.push(ch);
|
|
token_end += ch.len_utf8();
|
|
}
|
|
|
|
if !found_end && (token_end - token_start >= LIMIT) {
|
|
return None;
|
|
}
|
|
|
|
if filename.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let range = snapshot.anchor_before(token_start)..snapshot.anchor_after(token_end);
|
|
|
|
Some((range, filename))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{
|
|
display_map::ToDisplayPoint,
|
|
editor_tests::init_test,
|
|
inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels},
|
|
test::editor_lsp_test_context::EditorLspTestContext,
|
|
DisplayPoint,
|
|
};
|
|
use futures::StreamExt;
|
|
use gpui::Modifiers;
|
|
use indoc::indoc;
|
|
use language::language_settings::InlayHintSettings;
|
|
use lsp::request::{GotoDefinition, GotoTypeDefinition};
|
|
use util::{assert_set_eq, path};
|
|
use workspace::item::Item;
|
|
|
|
#[gpui::test]
|
|
async fn test_hover_type_links(cx: &mut gpui::TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
|
|
let mut cx = EditorLspTestContext::new_rust(
|
|
lsp::ServerCapabilities {
|
|
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
|
|
type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
|
|
..Default::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
cx.set_state(indoc! {"
|
|
struct A;
|
|
let vˇariable = A;
|
|
"});
|
|
let screen_coord = cx.editor(|editor, _, cx| editor.pixel_position_of_cursor(cx));
|
|
|
|
// Basic hold cmd+shift, expect highlight in region if response contains type definition
|
|
let symbol_range = cx.lsp_range(indoc! {"
|
|
struct A;
|
|
let «variable» = A;
|
|
"});
|
|
let target_range = cx.lsp_range(indoc! {"
|
|
struct «A»;
|
|
let variable = A;
|
|
"});
|
|
|
|
cx.run_until_parked();
|
|
|
|
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,
|
|
},
|
|
])))
|
|
});
|
|
|
|
let modifiers = if cfg!(target_os = "macos") {
|
|
Modifiers::command_shift()
|
|
} else {
|
|
Modifiers::control_shift()
|
|
};
|
|
|
|
cx.simulate_mouse_move(screen_coord.unwrap(), None, modifiers);
|
|
|
|
requests.next().await;
|
|
cx.run_until_parked();
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
struct A;
|
|
let «variable» = A;
|
|
"});
|
|
|
|
cx.simulate_modifiers_change(Modifiers::secondary_key());
|
|
cx.run_until_parked();
|
|
// Assert no link highlights
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
struct A;
|
|
let variable = A;
|
|
"});
|
|
|
|
cx.simulate_click(screen_coord.unwrap(), modifiers);
|
|
|
|
cx.assert_editor_state(indoc! {"
|
|
struct «Aˇ»;
|
|
let variable = A;
|
|
"});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_hover_links(cx: &mut gpui::TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
|
|
let mut cx = EditorLspTestContext::new_rust(
|
|
lsp::ServerCapabilities {
|
|
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
|
|
definition_provider: Some(lsp::OneOf::Left(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.pixel_position(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.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
|
|
requests.next().await;
|
|
cx.background_executor.run_until_parked();
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
fn test() { «do_work»(); }
|
|
fn do_work() { test(); }
|
|
"});
|
|
|
|
// Unpress cmd causes highlight to go away
|
|
cx.simulate_modifiers_change(Modifiers::none());
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(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.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
|
|
requests.next().await;
|
|
cx.background_executor.run_until_parked();
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
fn test() { «do_work»(); }
|
|
fn do_work() { test(); }
|
|
"});
|
|
|
|
// Moving mouse to location with no response dismisses highlight
|
|
let hover_point = cx.pixel_position(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.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
|
|
|
|
requests.next().await;
|
|
cx.background_executor.run_until_parked();
|
|
|
|
// Assert no link highlights
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
fn test() { do_work(); }
|
|
fn do_work() { test(); }
|
|
"});
|
|
|
|
// // Move mouse without cmd and then pressing cmd triggers highlight
|
|
let hover_point = cx.pixel_position(indoc! {"
|
|
fn test() { do_work(); }
|
|
fn do_work() { teˇst(); }
|
|
"});
|
|
cx.simulate_mouse_move(hover_point, None, Modifiers::none());
|
|
|
|
// Assert no link highlights
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(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.simulate_modifiers_change(Modifiers::secondary_key());
|
|
|
|
requests.next().await;
|
|
cx.background_executor.run_until_parked();
|
|
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
fn test() { do_work(); }
|
|
fn do_work() { «test»(); }
|
|
"});
|
|
|
|
cx.deactivate_window();
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
fn test() { do_work(); }
|
|
fn do_work() { test(); }
|
|
"});
|
|
|
|
cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
|
|
cx.background_executor.run_until_parked();
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
fn test() { do_work(); }
|
|
fn do_work() { «test»(); }
|
|
"});
|
|
|
|
// Moving again within the same symbol range doesn't re-request
|
|
let hover_point = cx.pixel_position(indoc! {"
|
|
fn test() { do_work(); }
|
|
fn do_work() { tesˇt(); }
|
|
"});
|
|
cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
|
|
cx.background_executor.run_until_parked();
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
fn test() { do_work(); }
|
|
fn do_work() { «test»(); }
|
|
"});
|
|
|
|
// Cmd click with existing definition doesn't re-request and dismisses highlight
|
|
cx.simulate_click(hover_point, Modifiers::secondary_key());
|
|
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.background_executor.run_until_parked();
|
|
cx.assert_editor_state(indoc! {"
|
|
fn «testˇ»() { do_work(); }
|
|
fn do_work() { test(); }
|
|
"});
|
|
|
|
// Assert no link highlights after jump
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
fn test() { do_work(); }
|
|
fn do_work() { test(); }
|
|
"});
|
|
|
|
// Cmd click without existing definition requests and jumps
|
|
let hover_point = cx.pixel_position(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.simulate_click(hover_point, Modifiers::secondary_key());
|
|
requests.next().await;
|
|
cx.background_executor.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.pixel_position(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, window, 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()), window, cx, |s| {
|
|
s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character)
|
|
});
|
|
});
|
|
cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
|
|
cx.background_executor.run_until_parked();
|
|
assert!(requests.try_next().is_err());
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
fn test() { do_work(); }
|
|
fn do_work() { test(); }
|
|
"});
|
|
cx.background_executor.run_until_parked();
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_inlay_hover_links(cx: &mut gpui::TestAppContext) {
|
|
init_test(cx, |settings| {
|
|
settings.defaults.inlay_hints = Some(InlayHintSettings {
|
|
enabled: true,
|
|
edit_debounce_ms: 0,
|
|
scroll_debounce_ms: 0,
|
|
show_type_hints: true,
|
|
show_parameter_hints: true,
|
|
show_other_hints: true,
|
|
show_background: false,
|
|
toggle_on_modifiers_press: None,
|
|
})
|
|
});
|
|
|
|
let mut cx = EditorLspTestContext::new_rust(
|
|
lsp::ServerCapabilities {
|
|
inlay_hint_provider: Some(lsp::OneOf::Left(true)),
|
|
..Default::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
cx.set_state(indoc! {"
|
|
struct TestStruct;
|
|
|
|
fn main() {
|
|
let variableˇ = TestStruct;
|
|
}
|
|
"});
|
|
let hint_start_offset = cx.ranges(indoc! {"
|
|
struct TestStruct;
|
|
|
|
fn main() {
|
|
let variableˇ = TestStruct;
|
|
}
|
|
"})[0]
|
|
.start;
|
|
let hint_position = cx.to_lsp(hint_start_offset);
|
|
let target_range = cx.lsp_range(indoc! {"
|
|
struct «TestStruct»;
|
|
|
|
fn main() {
|
|
let variable = TestStruct;
|
|
}
|
|
"});
|
|
|
|
let expected_uri = cx.buffer_lsp_url.clone();
|
|
let hint_label = ": TestStruct";
|
|
cx.lsp
|
|
.handle_request::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
|
|
let expected_uri = expected_uri.clone();
|
|
async move {
|
|
assert_eq!(params.text_document.uri, expected_uri);
|
|
Ok(Some(vec![lsp::InlayHint {
|
|
position: hint_position,
|
|
label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
|
|
value: hint_label.to_string(),
|
|
location: Some(lsp::Location {
|
|
uri: params.text_document.uri,
|
|
range: target_range,
|
|
}),
|
|
..Default::default()
|
|
}]),
|
|
kind: Some(lsp::InlayHintKind::TYPE),
|
|
text_edits: None,
|
|
tooltip: None,
|
|
padding_left: Some(false),
|
|
padding_right: Some(false),
|
|
data: None,
|
|
}]))
|
|
}
|
|
})
|
|
.next()
|
|
.await;
|
|
cx.background_executor.run_until_parked();
|
|
cx.update_editor(|editor, _window, cx| {
|
|
let expected_layers = vec![hint_label.to_string()];
|
|
assert_eq!(expected_layers, cached_hint_labels(editor));
|
|
assert_eq!(expected_layers, visible_hint_labels(editor, cx));
|
|
});
|
|
|
|
let inlay_range = cx
|
|
.ranges(indoc! {"
|
|
struct TestStruct;
|
|
|
|
fn main() {
|
|
let variable« »= TestStruct;
|
|
}
|
|
"})
|
|
.first()
|
|
.cloned()
|
|
.unwrap();
|
|
let midpoint = cx.update_editor(|editor, window, cx| {
|
|
let snapshot = editor.snapshot(window, cx);
|
|
let previous_valid = inlay_range.start.to_display_point(&snapshot);
|
|
let next_valid = inlay_range.end.to_display_point(&snapshot);
|
|
assert_eq!(previous_valid.row(), next_valid.row());
|
|
assert!(previous_valid.column() < next_valid.column());
|
|
DisplayPoint::new(
|
|
previous_valid.row(),
|
|
previous_valid.column() + (hint_label.len() / 2) as u32,
|
|
)
|
|
});
|
|
// Press cmd to trigger highlight
|
|
let hover_point = cx.pixel_position_for(midpoint);
|
|
cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
|
|
cx.background_executor.run_until_parked();
|
|
cx.update_editor(|editor, window, cx| {
|
|
let snapshot = editor.snapshot(window, cx);
|
|
let actual_highlights = snapshot
|
|
.inlay_highlights::<HoveredLinkState>()
|
|
.into_iter()
|
|
.flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight))
|
|
.collect::<Vec<_>>();
|
|
|
|
let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
|
|
let expected_highlight = InlayHighlight {
|
|
inlay: InlayId::Hint(0),
|
|
inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right),
|
|
range: 0..hint_label.len(),
|
|
};
|
|
assert_set_eq!(actual_highlights, vec![&expected_highlight]);
|
|
});
|
|
|
|
cx.simulate_mouse_move(hover_point, None, Modifiers::none());
|
|
// Assert no link highlights
|
|
cx.update_editor(|editor, window, cx| {
|
|
let snapshot = editor.snapshot(window, cx);
|
|
let actual_ranges = snapshot
|
|
.text_highlight_ranges::<HoveredLinkState>()
|
|
.map(|ranges| ranges.as_ref().clone().1)
|
|
.unwrap_or_default();
|
|
|
|
assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}");
|
|
});
|
|
|
|
cx.simulate_modifiers_change(Modifiers::secondary_key());
|
|
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! {"
|
|
struct «TestStructˇ»;
|
|
|
|
fn main() {
|
|
let variable = TestStruct;
|
|
}
|
|
"});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_urls(cx: &mut gpui::TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
let mut cx = EditorLspTestContext::new_rust(
|
|
lsp::ServerCapabilities {
|
|
..Default::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
cx.set_state(indoc! {"
|
|
Let's test a [complex](https://zed.dev/channel/had-(oops)) caseˇ.
|
|
"});
|
|
|
|
let screen_coord = cx.pixel_position(indoc! {"
|
|
Let's test a [complex](https://zed.dev/channel/had-(ˇoops)) case.
|
|
"});
|
|
|
|
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
Let's test a [complex](«https://zed.dev/channel/had-(oops)ˇ») case.
|
|
"});
|
|
|
|
cx.simulate_click(screen_coord, Modifiers::secondary_key());
|
|
assert_eq!(
|
|
cx.opened_url(),
|
|
Some("https://zed.dev/channel/had-(oops)".into())
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_urls_at_beginning_of_buffer(cx: &mut gpui::TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
let mut cx = EditorLspTestContext::new_rust(
|
|
lsp::ServerCapabilities {
|
|
..Default::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
cx.set_state(indoc! {"https://zed.dev/releases is a cool ˇwebpage."});
|
|
|
|
let screen_coord =
|
|
cx.pixel_position(indoc! {"https://zed.dev/relˇeases is a cool webpage."});
|
|
|
|
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(
|
|
indoc! {"«https://zed.dev/releasesˇ» is a cool webpage."},
|
|
);
|
|
|
|
cx.simulate_click(screen_coord, Modifiers::secondary_key());
|
|
assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_urls_at_end_of_buffer(cx: &mut gpui::TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
let mut cx = EditorLspTestContext::new_rust(
|
|
lsp::ServerCapabilities {
|
|
..Default::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
cx.set_state(indoc! {"A cool ˇwebpage is https://zed.dev/releases"});
|
|
|
|
let screen_coord =
|
|
cx.pixel_position(indoc! {"A cool webpage is https://zed.dev/releˇases"});
|
|
|
|
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(
|
|
indoc! {"A cool webpage is «https://zed.dev/releasesˇ»"},
|
|
);
|
|
|
|
cx.simulate_click(screen_coord, Modifiers::secondary_key());
|
|
assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
let mut cx = EditorLspTestContext::new_rust(
|
|
lsp::ServerCapabilities {
|
|
..Default::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
let test_cases = [
|
|
("file ˇ name", None),
|
|
("ˇfile name", Some("file")),
|
|
("file ˇname", Some("name")),
|
|
("fiˇle name", Some("file")),
|
|
("filenˇame", Some("filename")),
|
|
// Absolute path
|
|
("foobar ˇ/home/user/f.txt", Some("/home/user/f.txt")),
|
|
("foobar /home/useˇr/f.txt", Some("/home/user/f.txt")),
|
|
// Windows
|
|
("C:\\Useˇrs\\user\\f.txt", Some("C:\\Users\\user\\f.txt")),
|
|
// Whitespace
|
|
("ˇfile\\ -\\ name.txt", Some("file - name.txt")),
|
|
("file\\ -\\ naˇme.txt", Some("file - name.txt")),
|
|
// Tilde
|
|
("ˇ~/file.txt", Some("~/file.txt")),
|
|
("~/fiˇle.txt", Some("~/file.txt")),
|
|
// Double quotes
|
|
("\"fˇile.txt\"", Some("file.txt")),
|
|
("ˇ\"file.txt\"", Some("file.txt")),
|
|
("ˇ\"fi\\ le.txt\"", Some("fi le.txt")),
|
|
// Single quotes
|
|
("'fˇile.txt'", Some("file.txt")),
|
|
("ˇ'file.txt'", Some("file.txt")),
|
|
("ˇ'fi\\ le.txt'", Some("fi le.txt")),
|
|
];
|
|
|
|
for (input, expected) in test_cases {
|
|
cx.set_state(input);
|
|
|
|
let (position, snapshot) = cx.editor(|editor, _, cx| {
|
|
let positions = editor.selections.newest_anchor().head().text_anchor;
|
|
let snapshot = editor
|
|
.buffer()
|
|
.clone()
|
|
.read(cx)
|
|
.as_singleton()
|
|
.unwrap()
|
|
.read(cx)
|
|
.snapshot();
|
|
(positions, snapshot)
|
|
});
|
|
|
|
let result = surrounding_filename(snapshot, position);
|
|
|
|
if let Some(expected) = expected {
|
|
assert!(result.is_some(), "Failed to find file path: {}", input);
|
|
let (_, path) = result.unwrap();
|
|
assert_eq!(&path, expected, "Incorrect file path for input: {}", input);
|
|
} else {
|
|
assert!(
|
|
result.is_none(),
|
|
"Expected no result, but got one: {:?}",
|
|
result
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_hover_filenames(cx: &mut gpui::TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
let mut cx = EditorLspTestContext::new_rust(
|
|
lsp::ServerCapabilities {
|
|
..Default::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
// Insert a new file
|
|
let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
|
|
fs.as_fake()
|
|
.insert_file(
|
|
path!("/root/dir/file2.rs"),
|
|
"This is file2.rs".as_bytes().to_vec(),
|
|
)
|
|
.await;
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
cx.set_state(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to /root/dir/file2.rs if project is local.
|
|
Or go to /root/dir/file2 if this is a Rust file.ˇ
|
|
"});
|
|
#[cfg(target_os = "windows")]
|
|
cx.set_state(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to C:/root/dir/file2.rs if project is local.
|
|
Or go to C:/root/dir/file2 if this is a Rust file.ˇ
|
|
"});
|
|
|
|
// File does not exist
|
|
#[cfg(not(target_os = "windows"))]
|
|
let screen_coord = cx.pixel_position(indoc! {"
|
|
You can't go to a file that dˇoes_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to /root/dir/file2.rs if project is local.
|
|
Or go to /root/dir/file2 if this is a Rust file.
|
|
"});
|
|
#[cfg(target_os = "windows")]
|
|
let screen_coord = cx.pixel_position(indoc! {"
|
|
You can't go to a file that dˇoes_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to C:/root/dir/file2.rs if project is local.
|
|
Or go to C:/root/dir/file2 if this is a Rust file.
|
|
"});
|
|
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
|
// No highlight
|
|
cx.update_editor(|editor, window, cx| {
|
|
assert!(editor
|
|
.snapshot(window, cx)
|
|
.text_highlight_ranges::<HoveredLinkState>()
|
|
.unwrap_or_default()
|
|
.1
|
|
.is_empty());
|
|
});
|
|
|
|
// Moving the mouse over a file that does exist should highlight it.
|
|
#[cfg(not(target_os = "windows"))]
|
|
let screen_coord = cx.pixel_position(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to fˇile2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to /root/dir/file2.rs if project is local.
|
|
Or go to /root/dir/file2 if this is a Rust file.
|
|
"});
|
|
#[cfg(target_os = "windows")]
|
|
let screen_coord = cx.pixel_position(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to fˇile2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to C:/root/dir/file2.rs if project is local.
|
|
Or go to C:/root/dir/file2 if this is a Rust file.
|
|
"});
|
|
|
|
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
|
#[cfg(not(target_os = "windows"))]
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to «file2.rsˇ» if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to /root/dir/file2.rs if project is local.
|
|
Or go to /root/dir/file2 if this is a Rust file.
|
|
"});
|
|
#[cfg(target_os = "windows")]
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to «file2.rsˇ» if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to C:/root/dir/file2.rs if project is local.
|
|
Or go to C:/root/dir/file2 if this is a Rust file.
|
|
"});
|
|
|
|
// Moving the mouse over a relative path that does exist should highlight it
|
|
#[cfg(not(target_os = "windows"))]
|
|
let screen_coord = cx.pixel_position(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/fˇile2.rs if you want.
|
|
Or go to /root/dir/file2.rs if project is local.
|
|
Or go to /root/dir/file2 if this is a Rust file.
|
|
"});
|
|
#[cfg(target_os = "windows")]
|
|
let screen_coord = cx.pixel_position(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/fˇile2.rs if you want.
|
|
Or go to C:/root/dir/file2.rs if project is local.
|
|
Or go to C:/root/dir/file2 if this is a Rust file.
|
|
"});
|
|
|
|
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
|
#[cfg(not(target_os = "windows"))]
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to «../dir/file2.rsˇ» if you want.
|
|
Or go to /root/dir/file2.rs if project is local.
|
|
Or go to /root/dir/file2 if this is a Rust file.
|
|
"});
|
|
#[cfg(target_os = "windows")]
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to «../dir/file2.rsˇ» if you want.
|
|
Or go to C:/root/dir/file2.rs if project is local.
|
|
Or go to C:/root/dir/file2 if this is a Rust file.
|
|
"});
|
|
|
|
// Moving the mouse over an absolute path that does exist should highlight it
|
|
#[cfg(not(target_os = "windows"))]
|
|
let screen_coord = cx.pixel_position(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to /root/diˇr/file2.rs if project is local.
|
|
Or go to /root/dir/file2 if this is a Rust file.
|
|
"});
|
|
|
|
#[cfg(target_os = "windows")]
|
|
let screen_coord = cx.pixel_position(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to C:/root/diˇr/file2.rs if project is local.
|
|
Or go to C:/root/dir/file2 if this is a Rust file.
|
|
"});
|
|
|
|
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
|
#[cfg(not(target_os = "windows"))]
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to «/root/dir/file2.rsˇ» if project is local.
|
|
Or go to /root/dir/file2 if this is a Rust file.
|
|
"});
|
|
#[cfg(target_os = "windows")]
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to «C:/root/dir/file2.rsˇ» if project is local.
|
|
Or go to C:/root/dir/file2 if this is a Rust file.
|
|
"});
|
|
|
|
// Moving the mouse over a path that exists, if we add the language-specific suffix, it should highlight it
|
|
#[cfg(not(target_os = "windows"))]
|
|
let screen_coord = cx.pixel_position(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to /root/dir/file2.rs if project is local.
|
|
Or go to /root/diˇr/file2 if this is a Rust file.
|
|
"});
|
|
#[cfg(target_os = "windows")]
|
|
let screen_coord = cx.pixel_position(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to C:/root/dir/file2.rs if project is local.
|
|
Or go to C:/root/diˇr/file2 if this is a Rust file.
|
|
"});
|
|
|
|
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
|
#[cfg(not(target_os = "windows"))]
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to /root/dir/file2.rs if project is local.
|
|
Or go to «/root/dir/file2ˇ» if this is a Rust file.
|
|
"});
|
|
#[cfg(target_os = "windows")]
|
|
cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
|
|
You can't go to a file that does_not_exist.txt.
|
|
Go to file2.rs if you want.
|
|
Or go to ../dir/file2.rs if you want.
|
|
Or go to C:/root/dir/file2.rs if project is local.
|
|
Or go to «C:/root/dir/file2ˇ» if this is a Rust file.
|
|
"});
|
|
|
|
cx.simulate_click(screen_coord, Modifiers::secondary_key());
|
|
|
|
cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
|
|
cx.update_workspace(|workspace, _, cx| {
|
|
let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
|
|
|
|
let buffer = active_editor
|
|
.read(cx)
|
|
.buffer()
|
|
.read(cx)
|
|
.as_singleton()
|
|
.unwrap();
|
|
|
|
let file = buffer.read(cx).file().unwrap();
|
|
let file_path = file.as_local().unwrap().abs_path(cx);
|
|
|
|
assert_eq!(
|
|
file_path,
|
|
std::path::PathBuf::from(path!("/root/dir/file2.rs"))
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_hover_directories(cx: &mut gpui::TestAppContext) {
|
|
init_test(cx, |_| {});
|
|
let mut cx = EditorLspTestContext::new_rust(
|
|
lsp::ServerCapabilities {
|
|
..Default::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
|
|
// Insert a new file
|
|
let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
|
|
fs.as_fake()
|
|
.insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
|
|
.await;
|
|
|
|
cx.set_state(indoc! {"
|
|
You can't open ../diˇr because it's a directory.
|
|
"});
|
|
|
|
// File does not exist
|
|
let screen_coord = cx.pixel_position(indoc! {"
|
|
You can't open ../diˇr because it's a directory.
|
|
"});
|
|
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
|
|
|
// No highlight
|
|
cx.update_editor(|editor, window, cx| {
|
|
assert!(editor
|
|
.snapshot(window, cx)
|
|
.text_highlight_ranges::<HoveredLinkState>()
|
|
.unwrap_or_default()
|
|
.1
|
|
.is_empty());
|
|
});
|
|
|
|
// Does not open the directory
|
|
cx.simulate_click(screen_coord, Modifiers::secondary_key());
|
|
cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
|
|
}
|
|
}
|