There's still a bit more work to do on this, but this PR is compiling (with warnings) after eliminating the key types. When the tasks below are complete, this will be the new narrative for GPUI: - `Entity<T>` - This replaces `View<T>`/`Model<T>`. It represents a unit of state, and if `T` implements `Render`, then `Entity<T>` implements `Element`. - `&mut App` This replaces `AppContext` and represents the app. - `&mut Context<T>` This replaces `ModelContext` and derefs to `App`. It is provided by the framework when updating an entity. - `&mut Window` Broken out of `&mut WindowContext` which no longer exists. Every method that once took `&mut WindowContext` now takes `&mut Window, &mut App` and every method that took `&mut ViewContext<T>` now takes `&mut Window, &mut Context<T>` Not pictured here are the two other failed attempts. It's been quite a month! Tasks: - [x] Remove `View`, `ViewContext`, `WindowContext` and thread through `Window` - [x] [@cole-miller @mikayla-maki] Redraw window when entities change - [x] [@cole-miller @mikayla-maki] Get examples and Zed running - [x] [@cole-miller @mikayla-maki] Fix Zed rendering - [x] [@mikayla-maki] Fix todo! macros and comments - [x] Fix a bug where the editor would not be redrawn because of view caching - [x] remove publicness window.notify() and replace with `AppContext::notify` - [x] remove `observe_new_window_models`, replace with `observe_new_models` with an optional window - [x] Fix a bug where the project panel would not be redrawn because of the wrong refresh() call being used - [x] Fix the tests - [x] Fix warnings by eliminating `Window` params or using `_` - [x] Fix conflicts - [x] Simplify generic code where possible - [x] Rename types - [ ] Update docs ### issues post merge - [x] Issues switching between normal and insert mode - [x] Assistant re-rendering failure - [x] Vim test failures - [x] Mac build issue Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra <me@as-cii.com> Co-authored-by: Cole Miller <cole@zed.dev> Co-authored-by: Mikayla <mikayla@zed.dev> Co-authored-by: Joseph <joseph@zed.dev> Co-authored-by: max <max@zed.dev> Co-authored-by: Michael Sloan <michael@zed.dev> Co-authored-by: Mikayla Maki <mikaylamaki@Mikaylas-MacBook-Pro.local> Co-authored-by: Mikayla <mikayla.c.maki@gmail.com> Co-authored-by: joão <joao@zed.dev>
1741 lines
64 KiB
Rust
1741 lines
64 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();
|
|
|
|
return self.navigate_to_hover_links(None, links, modifiers.alt, window, cx);
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
);
|
|
|
|
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))
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
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,
|
|
})
|
|
});
|
|
|
|
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("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
|
|
.await;
|
|
|
|
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.ˇ
|
|
"});
|
|
|
|
// File does not exist
|
|
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.
|
|
"});
|
|
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.
|
|
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.
|
|
"});
|
|
|
|
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
|
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.
|
|
"});
|
|
|
|
// Moving the mouse over a relative path that does exist should highlight it
|
|
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.
|
|
"});
|
|
|
|
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
|
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.
|
|
"});
|
|
|
|
// Moving the mouse over an absolute path that does exist should highlight it
|
|
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.
|
|
"});
|
|
|
|
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
|
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.
|
|
"});
|
|
|
|
// Moving the mouse over a path that exists, if we add the language-specific suffix, it should highlight it
|
|
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.
|
|
"});
|
|
|
|
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
|
|
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.
|
|
"});
|
|
|
|
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.to_str().unwrap(), "/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));
|
|
}
|
|
}
|