diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs new file mode 100644 index 0000000000000000000000000000000000000000..1817190e426b4eb954bfc707a01da15af5f290f6 --- /dev/null +++ b/crates/editor/src/code_context_menus.rs @@ -0,0 +1,895 @@ +use std::{cell::Cell, cmp::Reverse, ops::Range, sync::Arc}; + +use fuzzy::{StringMatch, StringMatchCandidate}; +use gpui::{ + div, px, uniform_list, AnyElement, BackgroundExecutor, Div, FontWeight, ListSizingBehavior, + Model, MouseButton, Pixels, ScrollStrategy, SharedString, StrikethroughStyle, StyledText, + UniformListScrollHandle, ViewContext, WeakView, +}; +use language::Buffer; +use language::{CodeLabel, Documentation}; +use lsp::LanguageServerId; +use multi_buffer::{Anchor, ExcerptId}; +use ordered_float::OrderedFloat; +use parking_lot::RwLock; +use project::{CodeAction, Completion, TaskSourceKind}; +use task::ResolvedTask; +use ui::{ + h_flex, ActiveTheme as _, Color, FluentBuilder as _, InteractiveElement as _, IntoElement, + Label, LabelCommon as _, LabelSize, ListItem, ParentElement as _, Popover, Selectable as _, + StatefulInteractiveElement as _, Styled, StyledExt as _, +}; +use util::ResultExt as _; +use workspace::Workspace; + +use crate::{ + actions::{ConfirmCodeAction, ConfirmCompletion}, + display_map::DisplayPoint, + render_parsed_markdown, split_words, styled_runs_for_code_label, CodeActionProvider, + CompletionId, CompletionProvider, DisplayRow, Editor, EditorStyle, ResolvedTasks, +}; + +pub enum CodeContextMenu { + Completions(CompletionsMenu), + CodeActions(CodeActionsMenu), +} + +impl CodeContextMenu { + pub fn select_first( + &mut self, + provider: Option<&dyn CompletionProvider>, + cx: &mut ViewContext, + ) -> bool { + if self.visible() { + match self { + CodeContextMenu::Completions(menu) => menu.select_first(provider, cx), + CodeContextMenu::CodeActions(menu) => menu.select_first(cx), + } + true + } else { + false + } + } + + pub fn select_prev( + &mut self, + provider: Option<&dyn CompletionProvider>, + cx: &mut ViewContext, + ) -> bool { + if self.visible() { + match self { + CodeContextMenu::Completions(menu) => menu.select_prev(provider, cx), + CodeContextMenu::CodeActions(menu) => menu.select_prev(cx), + } + true + } else { + false + } + } + + pub fn select_next( + &mut self, + provider: Option<&dyn CompletionProvider>, + cx: &mut ViewContext, + ) -> bool { + if self.visible() { + match self { + CodeContextMenu::Completions(menu) => menu.select_next(provider, cx), + CodeContextMenu::CodeActions(menu) => menu.select_next(cx), + } + true + } else { + false + } + } + + pub fn select_last( + &mut self, + provider: Option<&dyn CompletionProvider>, + cx: &mut ViewContext, + ) -> bool { + if self.visible() { + match self { + CodeContextMenu::Completions(menu) => menu.select_last(provider, cx), + CodeContextMenu::CodeActions(menu) => menu.select_last(cx), + } + true + } else { + false + } + } + + pub fn visible(&self) -> bool { + match self { + CodeContextMenu::Completions(menu) => menu.visible(), + CodeContextMenu::CodeActions(menu) => menu.visible(), + } + } + + pub fn render( + &self, + cursor_position: DisplayPoint, + style: &EditorStyle, + max_height: Pixels, + workspace: Option>, + cx: &mut ViewContext, + ) -> (ContextMenuOrigin, AnyElement) { + match self { + CodeContextMenu::Completions(menu) => ( + ContextMenuOrigin::EditorPoint(cursor_position), + menu.render(style, max_height, workspace, cx), + ), + CodeContextMenu::CodeActions(menu) => { + menu.render(cursor_position, style, max_height, cx) + } + } + } +} + +pub enum ContextMenuOrigin { + EditorPoint(DisplayPoint), + GutterIndicator(DisplayRow), +} + +#[derive(Clone, Debug)] +pub struct CompletionsMenu { + pub id: CompletionId, + sort_completions: bool, + pub initial_position: Anchor, + pub buffer: Model, + pub completions: Arc>>, + match_candidates: Arc<[StringMatchCandidate]>, + pub matches: Arc<[StringMatch]>, + pub selected_item: usize, + scroll_handle: UniformListScrollHandle, + resolve_completions: bool, + pub aside_was_displayed: Cell, + show_completion_documentation: bool, +} + +impl CompletionsMenu { + pub fn new( + id: CompletionId, + sort_completions: bool, + show_completion_documentation: bool, + initial_position: Anchor, + buffer: Model, + completions: Box<[Completion]>, + aside_was_displayed: bool, + ) -> Self { + let match_candidates = completions + .iter() + .enumerate() + .map(|(id, completion)| { + StringMatchCandidate::new( + id, + completion.label.text[completion.label.filter_range.clone()].into(), + ) + }) + .collect(); + + Self { + id, + sort_completions, + initial_position, + buffer, + show_completion_documentation, + completions: Arc::new(RwLock::new(completions)), + match_candidates, + matches: Vec::new().into(), + selected_item: 0, + scroll_handle: UniformListScrollHandle::new(), + resolve_completions: true, + aside_was_displayed: Cell::new(aside_was_displayed), + } + } + + pub fn new_snippet_choices( + id: CompletionId, + sort_completions: bool, + choices: &Vec, + selection: Range, + buffer: Model, + ) -> Self { + let completions = choices + .iter() + .map(|choice| Completion { + old_range: selection.start.text_anchor..selection.end.text_anchor, + new_text: choice.to_string(), + label: CodeLabel { + text: choice.to_string(), + runs: Default::default(), + filter_range: Default::default(), + }, + server_id: LanguageServerId(usize::MAX), + documentation: None, + lsp_completion: Default::default(), + confirm: None, + }) + .collect(); + + let match_candidates = choices + .iter() + .enumerate() + .map(|(id, completion)| StringMatchCandidate::new(id, completion.to_string())) + .collect(); + let matches = choices + .iter() + .enumerate() + .map(|(id, completion)| StringMatch { + candidate_id: id, + score: 1., + positions: vec![], + string: completion.clone(), + }) + .collect(); + Self { + id, + sort_completions, + initial_position: selection.start, + buffer, + completions: Arc::new(RwLock::new(completions)), + match_candidates, + matches, + selected_item: 0, + scroll_handle: UniformListScrollHandle::new(), + resolve_completions: false, + aside_was_displayed: Cell::new(false), + show_completion_documentation: false, + } + } + + fn select_first( + &mut self, + provider: Option<&dyn CompletionProvider>, + cx: &mut ViewContext, + ) { + self.selected_item = 0; + self.scroll_handle + .scroll_to_item(self.selected_item, ScrollStrategy::Top); + self.resolve_selected_completion(provider, cx); + cx.notify(); + } + + fn select_prev( + &mut self, + provider: Option<&dyn CompletionProvider>, + cx: &mut ViewContext, + ) { + if self.selected_item > 0 { + self.selected_item -= 1; + } else { + self.selected_item = self.matches.len() - 1; + } + self.scroll_handle + .scroll_to_item(self.selected_item, ScrollStrategy::Top); + self.resolve_selected_completion(provider, cx); + cx.notify(); + } + + fn select_next( + &mut self, + provider: Option<&dyn CompletionProvider>, + cx: &mut ViewContext, + ) { + if self.selected_item + 1 < self.matches.len() { + self.selected_item += 1; + } else { + self.selected_item = 0; + } + self.scroll_handle + .scroll_to_item(self.selected_item, ScrollStrategy::Top); + self.resolve_selected_completion(provider, cx); + cx.notify(); + } + + fn select_last( + &mut self, + provider: Option<&dyn CompletionProvider>, + cx: &mut ViewContext, + ) { + self.selected_item = self.matches.len() - 1; + self.scroll_handle + .scroll_to_item(self.selected_item, ScrollStrategy::Top); + self.resolve_selected_completion(provider, cx); + cx.notify(); + } + + pub fn resolve_selected_completion( + &mut self, + provider: Option<&dyn CompletionProvider>, + cx: &mut ViewContext, + ) { + if !self.resolve_completions { + return; + } + let Some(provider) = provider else { + return; + }; + + let completion_index = self.matches[self.selected_item].candidate_id; + let resolve_task = provider.resolve_completions( + self.buffer.clone(), + vec![completion_index], + self.completions.clone(), + cx, + ); + + cx.spawn(move |editor, mut cx| async move { + if let Some(true) = resolve_task.await.log_err() { + editor.update(&mut cx, |_, cx| cx.notify()).ok(); + } + }) + .detach(); + } + + fn visible(&self) -> bool { + !self.matches.is_empty() + } + + fn render( + &self, + style: &EditorStyle, + max_height: Pixels, + workspace: Option>, + cx: &mut ViewContext, + ) -> AnyElement { + let show_completion_documentation = self.show_completion_documentation; + let widest_completion_ix = self + .matches + .iter() + .enumerate() + .max_by_key(|(_, mat)| { + let completions = self.completions.read(); + let completion = &completions[mat.candidate_id]; + let documentation = &completion.documentation; + + let mut len = completion.label.text.chars().count(); + if let Some(Documentation::SingleLine(text)) = documentation { + if show_completion_documentation { + len += text.chars().count(); + } + } + + len + }) + .map(|(ix, _)| ix); + + let completions = self.completions.clone(); + let matches = self.matches.clone(); + let selected_item = self.selected_item; + let style = style.clone(); + + let multiline_docs = if show_completion_documentation { + let mat = &self.matches[selected_item]; + match &self.completions.read()[mat.candidate_id].documentation { + Some(Documentation::MultiLinePlainText(text)) => { + Some(div().child(SharedString::from(text.clone()))) + } + Some(Documentation::MultiLineMarkdown(parsed)) if !parsed.text.is_empty() => { + Some(div().child(render_parsed_markdown( + "completions_markdown", + parsed, + &style, + workspace, + cx, + ))) + } + Some(Documentation::Undocumented) if self.aside_was_displayed.get() => { + Some(div().child("No documentation")) + } + _ => None, + } + } else { + None + }; + + let aside_contents = if let Some(multiline_docs) = multiline_docs { + Some(multiline_docs) + } else if self.aside_was_displayed.get() { + Some(div().child("Fetching documentation...")) + } else { + None + }; + self.aside_was_displayed.set(aside_contents.is_some()); + + let aside_contents = aside_contents.map(|div| { + div.id("multiline_docs") + .max_h(max_height) + .flex_1() + .px_1p5() + .py_1() + .min_w(px(260.)) + .max_w(px(640.)) + .w(px(500.)) + .overflow_y_scroll() + .occlude() + }); + + let list = uniform_list( + cx.view().clone(), + "completions", + matches.len(), + move |_editor, range, cx| { + let start_ix = range.start; + let completions_guard = completions.read(); + + matches[range] + .iter() + .enumerate() + .map(|(ix, mat)| { + let item_ix = start_ix + ix; + let candidate_id = mat.candidate_id; + let completion = &completions_guard[candidate_id]; + + let documentation = if show_completion_documentation { + &completion.documentation + } else { + &None + }; + + let highlights = gpui::combine_highlights( + mat.ranges().map(|range| (range, FontWeight::BOLD.into())), + styled_runs_for_code_label(&completion.label, &style.syntax).map( + |(range, mut highlight)| { + // Ignore font weight for syntax highlighting, as we'll use it + // for fuzzy matches. + highlight.font_weight = None; + + if completion.lsp_completion.deprecated.unwrap_or(false) { + highlight.strikethrough = Some(StrikethroughStyle { + thickness: 1.0.into(), + ..Default::default() + }); + highlight.color = Some(cx.theme().colors().text_muted); + } + + (range, highlight) + }, + ), + ); + let completion_label = StyledText::new(completion.label.text.clone()) + .with_highlights(&style.text, highlights); + let documentation_label = + if let Some(Documentation::SingleLine(text)) = documentation { + if text.trim().is_empty() { + None + } else { + Some( + Label::new(text.clone()) + .ml_4() + .size(LabelSize::Small) + .color(Color::Muted), + ) + } + } else { + None + }; + + let color_swatch = completion + .color() + .map(|color| div().size_4().bg(color).rounded_sm()); + + div().min_w(px(220.)).max_w(px(540.)).child( + ListItem::new(mat.candidate_id) + .inset(true) + .selected(item_ix == selected_item) + .on_click(cx.listener(move |editor, _event, cx| { + cx.stop_propagation(); + if let Some(task) = editor.confirm_completion( + &ConfirmCompletion { + item_ix: Some(item_ix), + }, + cx, + ) { + task.detach_and_log_err(cx) + } + })) + .start_slot::
(color_swatch) + .child(h_flex().overflow_hidden().child(completion_label)) + .end_slot::