From 5e7a2cb37fcb8f71fb1a9dca9b706e3c684fd64a Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 19 Sep 2025 13:49:24 +0800 Subject: [PATCH] editor: Add DefinitionProvider. (#1261) https://github.com/user-attachments/assets/f8234a3f-3075-48ce-ae46-8ea869b6a13d - Hold `cmd` to hover on a word to trigger definition check. --- Cargo.toml | 1 + crates/story/examples/code-editor.rs | 82 ++++- crates/story/examples/fixtures/test.rs | 3 + crates/ui/src/input/element.rs | 20 +- crates/ui/src/input/lsp.rs | 401 ------------------------ crates/ui/src/input/lsp/code_actions.rs | 131 ++++++++ crates/ui/src/input/lsp/completions.rs | 148 +++++++++ crates/ui/src/input/lsp/definitions.rs | 186 +++++++++++ crates/ui/src/input/lsp/hover.rs | 66 ++++ crates/ui/src/input/lsp/mod.rs | 121 +++++++ crates/ui/src/input/state.rs | 169 +++++----- 11 files changed, 841 insertions(+), 487 deletions(-) delete mode 100644 crates/ui/src/input/lsp.rs create mode 100644 crates/ui/src/input/lsp/code_actions.rs create mode 100644 crates/ui/src/input/lsp/completions.rs create mode 100644 crates/ui/src/input/lsp/definitions.rs create mode 100644 crates/ui/src/input/lsp/hover.rs create mode 100644 crates/ui/src/input/lsp/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 4bfce1e2..471d637f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ single_range_in_vec_init = "allow" style = { level = "allow", priority = -1 } todo = "deny" type_complexity = "allow" +manual_is_multiple_of = "allow" [profile.dev] codegen-units = 16 diff --git a/crates/story/examples/code-editor.rs b/crates/story/examples/code-editor.rs index 77e375d3..174baedc 100644 --- a/crates/story/examples/code-editor.rs +++ b/crates/story/examples/code-editor.rs @@ -14,8 +14,8 @@ use gpui_component::{ h_flex, highlighter::{Diagnostic, DiagnosticSeverity, Language, LanguageConfig, LanguageRegistry}, input::{ - self, CodeActionProvider, CompletionProvider, HoverProvider, InputEvent, InputState, - Position, Rope, RopeExt, TabSize, TextInput, + self, CodeActionProvider, CompletionProvider, DefinitionProvider, HoverProvider, + InputEvent, InputState, Position, Rope, RopeExt, TabSize, TextInput, }, v_flex, ActiveTheme, ContextModal, IconName, IndexPath, Selectable, Sizable, }; @@ -301,6 +301,83 @@ impl HoverProvider for ExampleLspStore { } } +const RUST_DOC_URLS: &[(&str, &str)] = &[ + ("String", "string/struct.String"), + ("Debug", "fmt/trait.Debug"), + ("Clone", "clone/trait.Clone"), + ("Option", "option/enum.Option"), + ("Result", "result/enum.Result"), + ("Vec", "vec/struct.Vec"), + ("HashMap", "collections/hash_map/struct.HashMap"), + ("HashSet", "collections/hash_set/struct.HashSet"), + ("Arc", "sync/struct.Arc"), + ("RwLock", "sync/struct.RwLock"), + ("Duration", "time/struct.Duration"), +]; + +impl DefinitionProvider for ExampleLspStore { + fn definitions( + &self, + text: &Rope, + offset: usize, + _window: &mut Window, + _cx: &mut App, + ) -> Task>> { + let Some(word_range) = text.word_range(offset) else { + return Task::ready(Ok(vec![])); + }; + let word = text.slice(word_range.clone()).to_string(); + + let document_uri = lsp_types::Uri::from_str("file://example").unwrap(); + let start = text.offset_to_position(word_range.start); + let end = text.offset_to_position(word_range.end); + let symbol_range = lsp_types::Range { start, end }; + + if word == "Duration" { + let target_range = lsp_types::Range { + start: lsp_types::Position { + line: 2, + character: 4, + }, + end: lsp_types::Position { + line: 2, + character: 23, + }, + }; + return Task::ready(Ok(vec![lsp_types::LocationLink { + target_uri: document_uri, + target_range: target_range, + target_selection_range: target_range, + origin_selection_range: Some(symbol_range), + }])); + } + + let names = RUST_DOC_URLS + .iter() + .map(|(name, _)| *name) + .collect::>(); + for (ix, t) in names.iter().enumerate() { + if *t == word { + let url = RUST_DOC_URLS[ix].1; + let location = lsp_types::LocationLink { + target_uri: lsp_types::Uri::from_str(&format!( + "https://doc.rust-lang.org/std/{}.html", + url + )) + .unwrap(), + target_selection_range: lsp_types::Range::default(), + target_range: lsp_types::Range::default(), + origin_selection_range: Some(symbol_range), + }; + + return Task::ready(Ok(vec![location])); + } + } + + Task::ready(Ok(vec![])) + } +} + struct TextConvertor; impl CodeActionProvider for TextConvertor { @@ -528,6 +605,7 @@ impl Example { editor.lsp.completion_provider = Some(lsp_store.clone()); editor.lsp.code_action_providers = vec![lsp_store.clone(), Rc::new(TextConvertor)]; editor.lsp.hover_provider = Some(lsp_store.clone()); + editor.lsp.definition_provider = Some(lsp_store.clone()); editor }); diff --git a/crates/story/examples/fixtures/test.rs b/crates/story/examples/fixtures/test.rs index 6b4ecae7..63b34e89 100644 --- a/crates/story/examples/fixtures/test.rs +++ b/crates/story/examples/fixtures/test.rs @@ -42,6 +42,9 @@ impl HelloWorld { } // Greets multiple people asynchronously with configurable delay + // + // Use `Command-click` on `Duration` will jump to its definition. + // Use `Control-click` on `String`, `HashMap` or `Result` will open its documentation page. pub async fn greet>(&self, names: &[T]) -> Result<()> { for name in names { time::sleep(Duration::from_millis(100)).await; diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 6fa6d87e..b75dced6 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -2,7 +2,7 @@ use std::{ops::Range, rc::Rc}; use gpui::{ fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler, - Entity, GlobalElementId, Half, HighlightStyle, IntoElement, LayoutId, MouseButton, + Entity, GlobalElementId, Half, HighlightStyle, Hitbox, IntoElement, LayoutId, MouseButton, MouseMoveEvent, Path, Pixels, Point, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle, Window, WrappedLine, }; @@ -21,7 +21,7 @@ pub(super) const RIGHT_MARGIN: Pixels = px(10.); pub(super) const LINE_NUMBER_RIGHT_MARGIN: Pixels = px(10.); pub(super) struct TextElement { - state: Entity, + pub(crate) state: Entity, placeholder: SharedString, } @@ -230,7 +230,8 @@ impl TextElement { (cursor_bounds, scroll_offset, current_row) } - fn layout_match_range( + /// Layout the match range to a Path. + pub(crate) fn layout_match_range( range: Range, last_layout: &LastLayout, bounds: &mut Bounds, @@ -516,6 +517,11 @@ impl TextElement { let diagnostic_styles = diagnostics.styles_for_range(&visible_byte_range, cx); + // hover definition style + if let Some(hover_style) = self.layout_hover_definition(cx) { + styles.push(hover_style); + } + // Combine marker styles styles = gpui::combine_highlights(diagnostic_styles, styles).collect(); @@ -537,6 +543,7 @@ pub(super) struct PrepaintState { selection_path: Option>, hover_highlight_path: Option>, search_match_paths: Vec<(Path, bool)>, + hover_definition_hitbox: Option, bounds: Bounds, } @@ -914,6 +921,8 @@ impl Element for TextElement { None }; + let hover_definition_hitbox = self.layout_hover_definition_hitbox(state, window, cx); + PrepaintState { bounds, last_layout, @@ -925,6 +934,7 @@ impl Element for TextElement { selection_path, search_match_paths, hover_highlight_path, + hover_definition_hitbox, } } @@ -1117,6 +1127,10 @@ impl Element for TextElement { cx.notify(); }); + if let Some(hitbox) = prepaint.hover_definition_hitbox.as_ref() { + window.set_cursor_style(gpui::CursorStyle::PointingHand, &hitbox); + } + self.paint_mouse_listeners(window, cx); } } diff --git a/crates/ui/src/input/lsp.rs b/crates/ui/src/input/lsp.rs deleted file mode 100644 index d2b84074..00000000 --- a/crates/ui/src/input/lsp.rs +++ /dev/null @@ -1,401 +0,0 @@ -use std::{cell::RefCell, ops::Range, rc::Rc}; - -use anyhow::Result; -use gpui::{App, Context, Entity, EntityInputHandler, SharedString, Task, Window}; -use lsp_types::{ - request::Completion, CodeAction, CompletionContext, CompletionItem, CompletionResponse, -}; -use rope::Rope; - -use crate::input::{ - popovers::{CodeActionItem, CodeActionMenu, CompletionMenu, ContextMenu, HoverPopover}, - InputState, RopeExt, -}; - -/// LSP ServerCapabilities -/// -/// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#serverCapabilities -pub struct Lsp { - /// The completion provider. - pub completion_provider: Option>, - /// The code action providers. - pub code_action_providers: Vec>, - /// The hover provider. - pub hover_provider: Option>, - _hover_task: Task>, -} - -impl Default for Lsp { - fn default() -> Self { - Self { - completion_provider: None, - code_action_providers: vec![], - hover_provider: None, - _hover_task: Task::ready(Ok(())), - } - } -} - -/// A trait for providing code completions based on the current input state and context. -pub trait CompletionProvider { - /// Fetches completions based on the given byte offset. - fn completions( - &self, - text: &Rope, - offset: usize, - trigger: CompletionContext, - window: &mut Window, - cx: &mut Context, - ) -> Task>>; - - fn resolve_completions( - &self, - _completion_indices: Vec, - _completions: Rc>>, - _: &mut Context, - ) -> Task> { - Task::ready(Ok(false)) - } - - /// Determines if the completion should be triggered based on the given byte offset. - /// - /// This is called on the main thread. - fn is_completion_trigger( - &self, - offset: usize, - new_text: &str, - cx: &mut Context, - ) -> bool; -} - -pub trait CodeActionProvider { - /// The id for this CodeAction. - fn id(&self) -> SharedString; - - /// Fetches code actions for the specified range. - fn code_actions( - &self, - state: Entity, - range: Range, - window: &mut Window, - cx: &mut App, - ) -> Task>>; - - /// Performs the specified code action. - fn perform_code_action( - &self, - state: Entity, - action: CodeAction, - push_to_history: bool, - window: &mut Window, - cx: &mut App, - ) -> Task>; -} - -pub trait HoverProvider { - /// Hover provider - /// - /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover - fn hover( - &self, - _text: &Rope, - _offset: usize, - _window: &mut Window, - _cx: &mut App, - ) -> Task>> { - Task::ready(Ok(None)) - } -} - -impl InputState { - pub(crate) fn hide_context_menu(&mut self, cx: &mut Context) { - self.context_menu = None; - self._context_menu_task = Task::ready(Ok(())); - cx.notify(); - } - - pub(crate) fn is_context_menu_open(&self, cx: &App) -> bool { - let Some(menu) = self.context_menu.as_ref() else { - return false; - }; - - menu.is_open(cx) - } - - /// Handles an action for the completion menu, if it exists. - /// - /// Return true if the action was handled, otherwise false. - pub fn handle_action_for_context_menu( - &mut self, - action: Box, - window: &mut Window, - cx: &mut Context, - ) -> bool { - let Some(menu) = self.context_menu.as_ref() else { - return false; - }; - - let mut handled = false; - - match menu { - ContextMenu::Completion(menu) => { - _ = menu.update(cx, |menu, cx| { - handled = menu.handle_action(action, window, cx) - }); - } - ContextMenu::CodeAction(menu) => { - _ = menu.update(cx, |menu, cx| { - handled = menu.handle_action(action, window, cx) - }); - } - }; - - handled - } - - pub fn handle_completion_trigger( - &mut self, - range: &Range, - new_text: &str, - window: &mut Window, - cx: &mut Context, - ) { - if self.completion_inserting { - return; - } - - let Some(provider) = self.lsp.completion_provider.clone() else { - return; - }; - - let start = range.end; - let new_offset = self.cursor(); - - if !provider.is_completion_trigger(start, new_text, cx) { - return; - } - - let menu = match self.context_menu.as_ref() { - Some(ContextMenu::Completion(menu)) => Some(menu), - _ => None, - }; - - // To create or get the existing completion menu. - let menu = match menu { - Some(menu) => menu.clone(), - None => { - let menu = CompletionMenu::new(cx.entity(), window, cx); - self.context_menu = Some(ContextMenu::Completion(menu.clone())); - menu - } - }; - - let start_offset = menu.read(cx).trigger_start_offset.unwrap_or(start); - if new_offset < start_offset { - return; - } - - let query = self - .text_for_range( - self.range_to_utf16(&(start_offset..new_offset)), - &mut None, - window, - cx, - ) - .map(|s| s.trim().to_string()) - .unwrap_or_default(); - _ = menu.update(cx, |menu, _| { - menu.update_query(start_offset, query.clone()); - }); - - let completion_context = CompletionContext { - trigger_kind: lsp_types::CompletionTriggerKind::TRIGGER_CHARACTER, - trigger_character: Some(query), - }; - - let provider_responses = - provider.completions(&self.text, start_offset, completion_context, window, cx); - self._context_menu_task = cx.spawn_in(window, async move |editor, cx| { - let mut completions: Vec = vec![]; - if let Some(provider_responses) = provider_responses.await.ok() { - for resp in provider_responses { - match resp { - CompletionResponse::Array(items) => completions.extend(items), - CompletionResponse::List(list) => completions.extend(list.items), - } - } - } - - if completions.is_empty() { - _ = menu.update(cx, |menu, cx| { - menu.hide(cx); - cx.notify(); - }); - - return Ok(()); - } - - editor - .update_in(cx, |editor, window, cx| { - if !editor.focus_handle.is_focused(window) { - return; - } - - _ = menu.update(cx, |menu, cx| { - menu.show(new_offset, completions, window, cx); - }); - - cx.notify(); - }) - .ok(); - - Ok(()) - }); - } - - /// Show code actions for the cursor. - pub(super) fn handle_code_action_trigger( - &mut self, - window: &mut Window, - cx: &mut Context, - ) { - let providers = self.lsp.code_action_providers.clone(); - let menu = match self.context_menu.as_ref() { - Some(ContextMenu::CodeAction(menu)) => Some(menu), - _ => None, - }; - - let menu = match menu { - Some(menu) => menu.clone(), - None => { - let menu = CodeActionMenu::new(cx.entity(), window, cx); - self.context_menu = Some(ContextMenu::CodeAction(menu.clone())); - menu - } - }; - - let range = self.selected_range.start..self.selected_range.end; - - let state = cx.entity(); - self._context_menu_task = cx.spawn_in(window, async move |editor, cx| { - let mut provider_responses = vec![]; - _ = cx.update(|window, cx| { - for provider in providers { - let task = provider.code_actions(state.clone(), range.clone(), window, cx); - provider_responses.push((provider.id(), task)); - } - }); - - let mut code_actions: Vec = vec![]; - for (provider_id, provider_responses) in provider_responses { - if let Some(responses) = provider_responses.await.ok() { - code_actions.extend(responses.into_iter().map(|action| CodeActionItem { - provider_id: provider_id.clone(), - action, - })) - } - } - - if code_actions.is_empty() { - _ = menu.update(cx, |menu, cx| { - menu.hide(cx); - cx.notify(); - }); - - return Ok(()); - } - editor - .update_in(cx, |editor, window, cx| { - if !editor.focus_handle.is_focused(window) { - return; - } - - _ = menu.update(cx, |menu, cx| { - menu.show(editor.cursor(), code_actions, window, cx); - }); - - cx.notify(); - }) - .ok(); - - Ok(()) - }); - } - - pub(crate) fn perform_code_action( - &mut self, - item: &CodeActionItem, - window: &mut Window, - cx: &mut Context, - ) { - let providers = self.lsp.code_action_providers.clone(); - let Some(provider) = providers - .iter() - .find(|provider| provider.id() == item.provider_id) - else { - return; - }; - - let state = cx.entity(); - let task = provider.perform_code_action(state, item.action.clone(), true, window, cx); - - cx.spawn_in(window, async move |_, _| { - let _ = task.await; - }) - .detach(); - } - - /// Apply a list of [`lsp_types::TextEdit`] to mutate the text. - pub fn apply_lsp_edits( - &mut self, - text_edits: &Vec, - window: &mut Window, - cx: &mut Context, - ) { - for edit in text_edits { - let start = self.text.position_to_offset(&edit.range.start); - let end = self.text.position_to_offset(&edit.range.end); - - let range_utf16 = self.range_to_utf16(&(start..end)); - self.replace_text_in_range(Some(range_utf16), &edit.new_text, window, cx); - } - } - - /// Handle hover trigger LSP request. - pub(super) fn handle_hover( - &mut self, - offset: usize, - window: &mut Window, - cx: &mut Context, - ) { - let Some(provider) = self.lsp.hover_provider.clone() else { - return; - }; - - if let Some(hover_popover) = self.hover_popover.as_ref() { - if hover_popover.read(cx).is_same(offset) { - return; - } - } - - // Currently not implemented. - let task = provider.hover(&self.text, offset, window, cx); - let word_range = self.text.word_range(offset).unwrap_or(offset..offset); - let editor = cx.entity(); - self.lsp._hover_task = cx.spawn_in(window, async move |_, cx| { - let result = task.await?; - - _ = editor.update(cx, |editor, cx| match result { - Some(hover) => { - let hover_popover = HoverPopover::new(cx.entity(), word_range, &hover, cx); - editor.hover_popover = Some(hover_popover); - } - None => { - editor.hover_popover = None; - } - }); - - Ok(()) - }); - } -} diff --git a/crates/ui/src/input/lsp/code_actions.rs b/crates/ui/src/input/lsp/code_actions.rs new file mode 100644 index 00000000..993f7023 --- /dev/null +++ b/crates/ui/src/input/lsp/code_actions.rs @@ -0,0 +1,131 @@ +use anyhow::Result; +use gpui::{App, Context, Entity, SharedString, Task, Window}; +use lsp_types::CodeAction; +use std::ops::Range; + +use crate::input::{ + popovers::{CodeActionItem, CodeActionMenu, ContextMenu}, + InputState, +}; + +pub trait CodeActionProvider { + /// The id for this CodeAction. + fn id(&self) -> SharedString; + + /// Fetches code actions for the specified range. + /// + /// textDocument/codeAction + /// + /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_codeAction + fn code_actions( + &self, + state: Entity, + range: Range, + window: &mut Window, + cx: &mut App, + ) -> Task>>; + + /// Performs the specified code action. + fn perform_code_action( + &self, + state: Entity, + action: CodeAction, + push_to_history: bool, + window: &mut Window, + cx: &mut App, + ) -> Task>; +} + +impl InputState { + /// Show code actions for the cursor. + pub(crate) fn handle_code_action_trigger( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let providers = self.lsp.code_action_providers.clone(); + let menu = match self.context_menu.as_ref() { + Some(ContextMenu::CodeAction(menu)) => Some(menu), + _ => None, + }; + + let menu = match menu { + Some(menu) => menu.clone(), + None => { + let menu = CodeActionMenu::new(cx.entity(), window, cx); + self.context_menu = Some(ContextMenu::CodeAction(menu.clone())); + menu + } + }; + + let range = self.selected_range.start..self.selected_range.end; + + let state = cx.entity(); + self._context_menu_task = cx.spawn_in(window, async move |editor, cx| { + let mut provider_responses = vec![]; + _ = cx.update(|window, cx| { + for provider in providers { + let task = provider.code_actions(state.clone(), range.clone(), window, cx); + provider_responses.push((provider.id(), task)); + } + }); + + let mut code_actions: Vec = vec![]; + for (provider_id, provider_responses) in provider_responses { + if let Some(responses) = provider_responses.await.ok() { + code_actions.extend(responses.into_iter().map(|action| CodeActionItem { + provider_id: provider_id.clone(), + action, + })) + } + } + + if code_actions.is_empty() { + _ = menu.update(cx, |menu, cx| { + menu.hide(cx); + cx.notify(); + }); + + return Ok(()); + } + editor + .update_in(cx, |editor, window, cx| { + if !editor.focus_handle.is_focused(window) { + return; + } + + _ = menu.update(cx, |menu, cx| { + menu.show(editor.cursor(), code_actions, window, cx); + }); + + cx.notify(); + }) + .ok(); + + Ok(()) + }); + } + + pub(crate) fn perform_code_action( + &mut self, + item: &CodeActionItem, + window: &mut Window, + cx: &mut Context, + ) { + let providers = self.lsp.code_action_providers.clone(); + let Some(provider) = providers + .iter() + .find(|provider| provider.id() == item.provider_id) + else { + return; + }; + + let state = cx.entity(); + let task = provider.perform_code_action(state, item.action.clone(), true, window, cx); + + cx.spawn_in(window, async move |_, _| { + let _ = task.await; + }) + .detach(); + } +} diff --git a/crates/ui/src/input/lsp/completions.rs b/crates/ui/src/input/lsp/completions.rs new file mode 100644 index 00000000..55b7d677 --- /dev/null +++ b/crates/ui/src/input/lsp/completions.rs @@ -0,0 +1,148 @@ +use anyhow::Result; +use gpui::{Context, EntityInputHandler, Task, Window}; +use lsp_types::{request::Completion, CompletionContext, CompletionItem, CompletionResponse}; +use rope::Rope; +use std::{cell::RefCell, ops::Range, rc::Rc}; + +use crate::input::{ + popovers::{CompletionMenu, ContextMenu}, + InputState, +}; + +/// A trait for providing code completions based on the current input state and context. +pub trait CompletionProvider { + /// Fetches completions based on the given byte offset. + /// + /// textDocument/completion + /// + /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion + fn completions( + &self, + text: &Rope, + offset: usize, + trigger: CompletionContext, + window: &mut Window, + cx: &mut Context, + ) -> Task>>; + + fn resolve_completions( + &self, + _completion_indices: Vec, + _completions: Rc>>, + _: &mut Context, + ) -> Task> { + Task::ready(Ok(false)) + } + + /// Determines if the completion should be triggered based on the given byte offset. + /// + /// This is called on the main thread. + fn is_completion_trigger( + &self, + offset: usize, + new_text: &str, + cx: &mut Context, + ) -> bool; +} + +impl InputState { + pub(crate) fn handle_completion_trigger( + &mut self, + range: &Range, + new_text: &str, + window: &mut Window, + cx: &mut Context, + ) { + if self.completion_inserting { + return; + } + + let Some(provider) = self.lsp.completion_provider.clone() else { + return; + }; + + let start = range.end; + let new_offset = self.cursor(); + + if !provider.is_completion_trigger(start, new_text, cx) { + return; + } + + let menu = match self.context_menu.as_ref() { + Some(ContextMenu::Completion(menu)) => Some(menu), + _ => None, + }; + + // To create or get the existing completion menu. + let menu = match menu { + Some(menu) => menu.clone(), + None => { + let menu = CompletionMenu::new(cx.entity(), window, cx); + self.context_menu = Some(ContextMenu::Completion(menu.clone())); + menu + } + }; + + let start_offset = menu.read(cx).trigger_start_offset.unwrap_or(start); + if new_offset < start_offset { + return; + } + + let query = self + .text_for_range( + self.range_to_utf16(&(start_offset..new_offset)), + &mut None, + window, + cx, + ) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + _ = menu.update(cx, |menu, _| { + menu.update_query(start_offset, query.clone()); + }); + + let completion_context = CompletionContext { + trigger_kind: lsp_types::CompletionTriggerKind::TRIGGER_CHARACTER, + trigger_character: Some(query), + }; + + let provider_responses = + provider.completions(&self.text, start_offset, completion_context, window, cx); + self._context_menu_task = cx.spawn_in(window, async move |editor, cx| { + let mut completions: Vec = vec![]; + if let Some(provider_responses) = provider_responses.await.ok() { + for resp in provider_responses { + match resp { + CompletionResponse::Array(items) => completions.extend(items), + CompletionResponse::List(list) => completions.extend(list.items), + } + } + } + + if completions.is_empty() { + _ = menu.update(cx, |menu, cx| { + menu.hide(cx); + cx.notify(); + }); + + return Ok(()); + } + + editor + .update_in(cx, |editor, window, cx| { + if !editor.focus_handle.is_focused(window) { + return; + } + + _ = menu.update(cx, |menu, cx| { + menu.show(new_offset, completions, window, cx); + }); + + cx.notify(); + }) + .ok(); + + Ok(()) + }); + } +} diff --git a/crates/ui/src/input/lsp/definitions.rs b/crates/ui/src/input/lsp/definitions.rs new file mode 100644 index 00000000..faf514d4 --- /dev/null +++ b/crates/ui/src/input/lsp/definitions.rs @@ -0,0 +1,186 @@ +use anyhow::Result; +use gpui::{ + px, App, Context, HighlightStyle, Hitbox, MouseDownEvent, Task, UnderlineStyle, Window, +}; +use rope::Rope; +use std::{ops::Range, rc::Rc}; + +use crate::{ + input::{element::TextElement, InputState, RopeExt}, + ActiveTheme, +}; + +/// Definition provider +/// +/// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition +pub trait DefinitionProvider { + /// textDocument/definition + /// + /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition + fn definitions( + &self, + _text: &Rope, + _offset: usize, + _window: &mut Window, + _cx: &mut App, + ) -> Task>>; +} + +#[derive(Clone, Default)] +pub(crate) struct HoverDefinition { + /// The range of the symbol that triggered the hover. + symbol_range: Range, + pub(crate) locations: Rc>, +} + +impl HoverDefinition { + pub(crate) fn new(symbol_range: Range, locations: Vec) -> Self { + Self { + symbol_range, + locations: Rc::new(locations), + } + } + + pub(crate) fn is_same(&self, offset: usize) -> bool { + self.symbol_range.contains(&offset) + } +} + +impl InputState { + pub(super) fn handle_hover_definition( + &mut self, + offset: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(provider) = self.lsp.definition_provider.clone() else { + return; + }; + if let Some(hover_definition) = self.hover_definition.as_ref() { + if hover_definition.is_same(offset) { + return; + } + } + + // Currently not implemented. + let task = provider.definitions(&self.text, offset, window, cx); + let mut symbol_range = self.text.word_range(offset).unwrap_or(offset..offset); + let editor = cx.entity(); + self.lsp._hover_task = cx.spawn_in(window, async move |_, cx| { + let locations = task.await?; + + _ = editor.update(cx, |editor, cx| { + if locations.is_empty() { + editor.hover_definition = None; + } else { + if let Some(location) = locations.first() { + if let Some(range) = location.origin_selection_range { + let start = editor.text.position_to_offset(&range.start); + let end = editor.text.position_to_offset(&range.end); + symbol_range = start..end; + } + } + + editor.hover_definition = Some(HoverDefinition::new(symbol_range, locations)); + } + cx.notify(); + }); + + Ok(()) + }); + } + + /// Return true if handled. + pub(crate) fn handle_click_hover_definition( + &mut self, + event: &MouseDownEvent, + offset: usize, + _: &mut Window, + cx: &mut Context, + ) -> bool { + if !event.modifiers.secondary() { + return false; + } + + let Some(hover_definition) = self.hover_definition.as_ref() else { + return false; + }; + if !hover_definition.is_same(offset) { + return false; + } + + let Some(location) = hover_definition.locations.first().cloned() else { + return false; + }; + + if location + .target_uri + .scheme() + .map(|s| s.as_str() == "https" || s.as_str() == "http") + == Some(true) + { + cx.open_url(&location.target_uri.to_string()); + } else { + // Move to the location. + let target_range = location.target_range; + let start = self.text.position_to_offset(&target_range.start); + let end = self.text.position_to_offset(&target_range.end); + + self.move_to(start, cx); + self.select_to(end, cx); + } + + true + } +} + +impl TextElement { + pub(crate) fn layout_hover_definition( + &self, + cx: &App, + ) -> Option<(Range, HighlightStyle)> { + let editor = self.state.read(cx); + if !editor.mode.is_code_editor() { + return None; + } + + let Some(hover_definition) = editor.hover_definition.as_ref() else { + return None; + }; + + let mut highlight_style: HighlightStyle = cx + .theme() + .highlight_theme + .link_text + .map(|style| style.into()) + .unwrap_or_default(); + + highlight_style.underline = Some(UnderlineStyle { + thickness: px(1.), + ..UnderlineStyle::default() + }); + + Some((hover_definition.symbol_range.clone(), highlight_style)) + } + + pub(crate) fn layout_hover_definition_hitbox( + &self, + editor: &InputState, + window: &mut Window, + _cx: &App, + ) -> Option { + if !editor.mode.is_code_editor() { + return None; + } + + let Some(hover_definition) = editor.hover_definition.as_ref() else { + return None; + }; + + let Some(bounds) = editor.range_to_bounds(&hover_definition.symbol_range) else { + return None; + }; + + Some(window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)) + } +} diff --git a/crates/ui/src/input/lsp/hover.rs b/crates/ui/src/input/lsp/hover.rs new file mode 100644 index 00000000..ffead6d0 --- /dev/null +++ b/crates/ui/src/input/lsp/hover.rs @@ -0,0 +1,66 @@ +use anyhow::Result; +use gpui::{App, Context, Task, Window}; +use rope::Rope; + +use crate::input::{popovers::HoverPopover, InputState, RopeExt}; + +/// Hover provider +/// +/// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover +pub trait HoverProvider { + /// textDocument/hover + /// + /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover + fn hover( + &self, + _text: &Rope, + _offset: usize, + _window: &mut Window, + _cx: &mut App, + ) -> Task>>; +} + +impl InputState { + /// Handle hover trigger LSP request. + pub(super) fn handle_hover_popover( + &mut self, + offset: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(provider) = self.lsp.hover_provider.clone() else { + return; + }; + + if let Some(hover_popover) = self.hover_popover.as_ref() { + if hover_popover.read(cx).is_same(offset) { + return; + } + } + + // Currently not implemented. + let task = provider.hover(&self.text, offset, window, cx); + let mut symbol_range = self.text.word_range(offset).unwrap_or(offset..offset); + let editor = cx.entity(); + self.lsp._hover_task = cx.spawn_in(window, async move |_, cx| { + let result = task.await?; + + _ = editor.update(cx, |editor, cx| match result { + Some(hover) => { + if let Some(range) = hover.range { + let start = editor.text.position_to_offset(&range.start); + let end = editor.text.position_to_offset(&range.end); + symbol_range = start..end; + } + let hover_popover = HoverPopover::new(cx.entity(), symbol_range, &hover, cx); + editor.hover_popover = Some(hover_popover); + } + None => { + editor.hover_popover = None; + } + }); + + Ok(()) + }); + } +} diff --git a/crates/ui/src/input/lsp/mod.rs b/crates/ui/src/input/lsp/mod.rs new file mode 100644 index 00000000..8715f55d --- /dev/null +++ b/crates/ui/src/input/lsp/mod.rs @@ -0,0 +1,121 @@ +use anyhow::Result; +use gpui::{App, Context, EntityInputHandler, MouseMoveEvent, Task, Window}; +use std::rc::Rc; + +use crate::input::{popovers::ContextMenu, InputState, RopeExt}; + +mod code_actions; +mod completions; +mod definitions; +mod hover; + +pub use code_actions::*; +pub use completions::*; +pub use definitions::*; +pub use hover::*; + +/// LSP ServerCapabilities +/// +/// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#serverCapabilities +pub struct Lsp { + /// The completion provider. + pub completion_provider: Option>, + /// The code action providers. + pub code_action_providers: Vec>, + /// The hover provider. + pub hover_provider: Option>, + /// The definition provider. + pub definition_provider: Option>, + _hover_task: Task>, +} + +impl Default for Lsp { + fn default() -> Self { + Self { + completion_provider: None, + code_action_providers: vec![], + hover_provider: None, + definition_provider: None, + _hover_task: Task::ready(Ok(())), + } + } +} + +impl InputState { + pub(crate) fn hide_context_menu(&mut self, cx: &mut Context) { + self.context_menu = None; + self._context_menu_task = Task::ready(Ok(())); + cx.notify(); + } + + pub(crate) fn is_context_menu_open(&self, cx: &App) -> bool { + let Some(menu) = self.context_menu.as_ref() else { + return false; + }; + + menu.is_open(cx) + } + + /// Handles an action for the completion menu, if it exists. + /// + /// Return true if the action was handled, otherwise false. + pub fn handle_action_for_context_menu( + &mut self, + action: Box, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(menu) = self.context_menu.as_ref() else { + return false; + }; + + let mut handled = false; + + match menu { + ContextMenu::Completion(menu) => { + _ = menu.update(cx, |menu, cx| { + handled = menu.handle_action(action, window, cx) + }); + } + ContextMenu::CodeAction(menu) => { + _ = menu.update(cx, |menu, cx| { + handled = menu.handle_action(action, window, cx) + }); + } + }; + + handled + } + + /// Apply a list of [`lsp_types::TextEdit`] to mutate the text. + pub fn apply_lsp_edits( + &mut self, + text_edits: &Vec, + window: &mut Window, + cx: &mut Context, + ) { + for edit in text_edits { + let start = self.text.position_to_offset(&edit.range.start); + let end = self.text.position_to_offset(&edit.range.end); + + let range_utf16 = self.range_to_utf16(&(start..end)); + self.replace_text_in_range(Some(range_utf16), &edit.new_text, window, cx); + } + } + + pub(super) fn handle_mouse_move( + &mut self, + offset: usize, + event: &MouseMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + if event.modifiers.secondary() { + self.hover_popover = None; + self.handle_hover_definition(offset, window, cx); + } else { + self.hover_definition = None; + self.handle_hover_popover(offset, window, cx); + } + } +} diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index f6383cba..08131038 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -32,7 +32,7 @@ use super::{ use crate::input::{ popovers::{ContextMenu, DiagnosticPopover, HoverPopover}, search::{self, SearchPanel}, - Lsp, Position, + HoverDefinition, Lsp, Position, }; use crate::input::{RopeExt as _, Selection}; use crate::{highlighter::DiagnosticSet, input::text_wrapper::LineItem}; @@ -299,6 +299,8 @@ pub struct InputState { /// A flag to indicate if we are currently inserting a completion item. pub(super) completion_inserting: bool, pub(super) hover_popover: Option>, + /// The LSP definitions locations for "Go to Definition" feature. + pub(super) hover_definition: Option, pub lsp: Lsp, @@ -384,6 +386,7 @@ impl InputState { context_menu: None, completion_inserting: false, hover_popover: None, + hover_definition: None, _subscriptions, _context_menu_task: Task::ready(Ok(())), } @@ -614,7 +617,7 @@ impl InputState { /// Move the cursor vertically by one line (up or down) while preserving the column if possible. /// /// move_lines: Number of lines to move vertically (positive for down, negative for up). - fn move_vertical(&mut self, move_lines: isize, window: &mut Window, cx: &mut Context) { + fn move_vertical(&mut self, move_lines: isize, _: &mut Window, cx: &mut Context) { if self.mode.is_single_line() { return; } @@ -653,7 +656,7 @@ impl InputState { } } self.pause_blink_cursor(cx); - self.move_to(new_offset, window, cx); + self.move_to(new_offset, cx); // Set back the preferred_column self.preferred_column = was_preferred_column; cx.notify(); @@ -861,7 +864,7 @@ impl InputState { // TODO: Scroll to make the row in center of viewport. - self.move_to(offset, window, cx); + self.move_to(offset, cx); self.update_preferred_column(); self.focus(window, cx); } @@ -874,21 +877,21 @@ impl InputState { }); } - pub(super) fn left(&mut self, _: &MoveLeft, window: &mut Window, cx: &mut Context) { + pub(super) fn left(&mut self, _: &MoveLeft, _: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); if self.selected_range.is_empty() { - self.move_to(self.previous_boundary(self.cursor()), window, cx); + self.move_to(self.previous_boundary(self.cursor()), cx); } else { - self.move_to(self.selected_range.start, window, cx) + self.move_to(self.selected_range.start, cx) } } - pub(super) fn right(&mut self, _: &MoveRight, window: &mut Window, cx: &mut Context) { + pub(super) fn right(&mut self, _: &MoveRight, _: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); if self.selected_range.is_empty() { - self.move_to(self.next_boundary(self.selected_range.end), window, cx); + self.move_to(self.next_boundary(self.selected_range.end), cx); } else { - self.move_to(self.selected_range.end, window, cx) + self.move_to(self.selected_range.end, cx) } } @@ -904,7 +907,6 @@ impl InputState { if !self.selected_range.is_empty() { self.move_to( self.previous_boundary(self.selected_range.start.saturating_sub(1)), - window, cx, ); } @@ -924,7 +926,6 @@ impl InputState { if !self.selected_range.is_empty() { self.move_to( self.next_boundary(self.selected_range.end.saturating_sub(1)), - window, cx, ); } @@ -964,162 +965,137 @@ impl InputState { self.move_vertical(display_lines, window, cx); } - pub(super) fn select_left( - &mut self, - _: &SelectLeft, - window: &mut Window, - cx: &mut Context, - ) { - self.select_to(self.previous_boundary(self.cursor()), window, cx); + pub(super) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { + self.select_to(self.previous_boundary(self.cursor()), cx); } - pub(super) fn select_right( - &mut self, - _: &SelectRight, - window: &mut Window, - cx: &mut Context, - ) { - self.select_to(self.next_boundary(self.cursor()), window, cx); + pub(super) fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { + self.select_to(self.next_boundary(self.cursor()), cx); } - pub(super) fn select_up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { + pub(super) fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { if self.mode.is_single_line() { return; } let offset = self.start_of_line().saturating_sub(1); - self.select_to(self.previous_boundary(offset), window, cx); + self.select_to(self.previous_boundary(offset), cx); } - pub(super) fn select_down( - &mut self, - _: &SelectDown, - window: &mut Window, - cx: &mut Context, - ) { + pub(super) fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { if self.mode.is_single_line() { return; } let offset = (self.end_of_line() + 1).min(self.text.len()); - self.select_to(self.next_boundary(offset), window, cx); + self.select_to(self.next_boundary(offset), cx); } - pub(super) fn select_all( - &mut self, - _: &SelectAll, - window: &mut Window, - cx: &mut Context, - ) { - self.move_to(0, window, cx); - self.select_to(self.text.len(), window, cx); + pub(super) fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.move_to(0, cx); + self.select_to(self.text.len(), cx); } - pub(super) fn home(&mut self, _: &MoveHome, window: &mut Window, cx: &mut Context) { + pub(super) fn home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); let offset = self.start_of_line(); - self.move_to(offset, window, cx); + self.move_to(offset, cx); } - pub(super) fn end(&mut self, _: &MoveEnd, window: &mut Window, cx: &mut Context) { + pub(super) fn end(&mut self, _: &MoveEnd, _: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); let offset = self.end_of_line(); - self.move_to(offset, window, cx); + self.move_to(offset, cx); } pub(super) fn move_to_start( &mut self, _: &MoveToStart, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { - self.move_to(0, window, cx); + self.move_to(0, cx); } - pub(super) fn move_to_end( - &mut self, - _: &MoveToEnd, - window: &mut Window, - cx: &mut Context, - ) { - self.move_to(self.text.len(), window, cx); + pub(super) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context) { + self.move_to(self.text.len(), cx); } pub(super) fn move_to_previous_word( &mut self, _: &MoveToPreviousWord, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let offset = self.previous_start_of_word(); - self.move_to(offset, window, cx); + self.move_to(offset, cx); } pub(super) fn move_to_next_word( &mut self, _: &MoveToNextWord, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let offset = self.next_end_of_word(); - self.move_to(offset, window, cx); + self.move_to(offset, cx); } pub(super) fn select_to_start( &mut self, _: &SelectToStart, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { - self.select_to(0, window, cx); + self.select_to(0, cx); } pub(super) fn select_to_end( &mut self, _: &SelectToEnd, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let end = self.text.len(); - self.select_to(end, window, cx); + self.select_to(end, cx); } pub(super) fn select_to_start_of_line( &mut self, _: &SelectToStartOfLine, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let offset = self.start_of_line(); - self.select_to(offset, window, cx); + self.select_to(offset, cx); } pub(super) fn select_to_end_of_line( &mut self, _: &SelectToEndOfLine, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let offset = self.end_of_line(); - self.select_to(offset, window, cx); + self.select_to(offset, cx); } pub(super) fn select_to_previous_word( &mut self, _: &SelectToPreviousWordStart, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let offset = self.previous_start_of_word(); - self.select_to(offset, window, cx); + self.select_to(offset, cx); } pub(super) fn select_to_next_word( &mut self, _: &SelectToNextWordEnd, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let offset = self.next_end_of_word(); - self.select_to(offset, window, cx); + self.select_to(offset, cx); } /// Return the start offset of the previous word. @@ -1230,7 +1206,7 @@ impl InputState { pub(super) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { if self.selected_range.is_empty() { - self.select_to(self.previous_boundary(self.cursor()), window, cx) + self.select_to(self.previous_boundary(self.cursor()), cx) } self.replace_text_in_range(None, "", window, cx); self.pause_blink_cursor(cx); @@ -1238,7 +1214,7 @@ impl InputState { pub(super) fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { if self.selected_range.is_empty() { - self.select_to(self.next_boundary(self.cursor()), window, cx) + self.select_to(self.next_boundary(self.cursor()), cx) } self.replace_text_in_range(None, "", window, cx); self.pause_blink_cursor(cx); @@ -1546,6 +1522,11 @@ impl InputState { self.selecting = true; let offset = self.index_for_mouse_position(event.position, window, cx); + + if self.handle_click_hover_definition(event, offset, window, cx) { + return; + } + // Double click to select word if event.button == MouseButton::Left && event.click_count == 2 { self.select_word(offset, window, cx); @@ -1553,9 +1534,9 @@ impl InputState { } if event.modifiers.shift { - self.select_to(offset, window, cx); + self.select_to(offset, cx); } else { - self.move_to(offset, window, cx) + self.move_to(offset, cx) } } @@ -1577,7 +1558,7 @@ impl InputState { ) { // Show diagnostic popover on mouse move let offset = self.index_for_mouse_position(event.position, window, cx); - self.handle_hover(offset, window, cx); + self.handle_mouse_move(offset, event, window, cx); if self.mode.is_code_editor() { if let Some(diagnostic) = self @@ -1669,6 +1650,7 @@ impl InputState { // Scroll down scroll_offset.y = -(row_offset_y - bounds.size.height.half()); } + self.update_scroll_offset(Some(scroll_offset), cx); } @@ -1751,7 +1733,7 @@ impl InputState { /// The offset is the UTF-8 offset. /// /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. - fn move_to(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { + pub(crate) fn move_to(&mut self, offset: usize, cx: &mut Context) { let offset = offset.clamp(0, self.text.len()); self.selected_range = (offset..offset).into(); self.scroll_to(offset, cx); @@ -1776,7 +1758,7 @@ impl InputState { } } - fn index_for_mouse_position( + pub(crate) fn index_for_mouse_position( &self, position: Point, _window: &Window, @@ -1895,7 +1877,7 @@ impl InputState { /// The offset is the UTF-8 offset. /// /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. - fn select_to(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { + pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context) { let offset = offset.clamp(0, self.text.len()); if self.selection_reversed { self.selected_range.start = offset @@ -2107,7 +2089,7 @@ impl InputState { } let offset = self.index_for_mouse_position(event.position, window, cx); - self.select_to(offset, window, cx); + self.select_to(offset, cx); } fn is_valid_input(&self, new_text: &str, cx: &mut Context) -> bool { @@ -2186,6 +2168,31 @@ impl InputState { pub(super) fn selected_text(&self) -> Rope { self.text.slice(self.selected_range.into()) } + + pub(crate) fn range_to_bounds(&self, range: &Range) -> Option> { + let Some(last_layout) = self.last_layout.as_ref() else { + return None; + }; + + let Some(last_bounds) = self.last_bounds else { + return None; + }; + + let (_, _, start_pos) = self.line_and_position_for_offset(range.start); + let (_, _, end_pos) = self.line_and_position_for_offset(range.end); + + let Some(start_pos) = start_pos else { + return None; + }; + let Some(end_pos) = end_pos else { + return None; + }; + + Some(Bounds::from_corners( + last_bounds.origin + start_pos, + last_bounds.origin + end_pos + point(px(0.), last_layout.line_height), + )) + } } impl EntityInputHandler for InputState {