use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ div, pulsating_between, px, uniform_list, Animation, AnimationExt, AnyElement, BackgroundExecutor, Div, FontWeight, ListSizingBehavior, Model, ScrollStrategy, SharedString, Size, 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 project::{CodeAction, Completion, TaskSourceKind}; use settings::Settings; use std::time::Duration; use std::{ cell::RefCell, cmp::{min, Reverse}, iter, ops::Range, rc::Rc, }; use task::ResolvedTask; use ui::{prelude::*, Color, IntoElement, ListItem, Pixels, Popover, Styled}; use util::ResultExt; 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, }; use crate::{AcceptInlineCompletion, InlineCompletionMenuHint, InlineCompletionText}; pub const MENU_GAP: Pixels = px(4.); pub const MENU_ASIDE_X_PADDING: Pixels = px(16.); pub const MENU_ASIDE_MIN_WIDTH: Pixels = px(260.); pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.); 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 origin(&self, cursor_position: DisplayPoint) -> ContextMenuOrigin { match self { CodeContextMenu::Completions(menu) => menu.origin(cursor_position), CodeContextMenu::CodeActions(menu) => menu.origin(cursor_position), } } pub fn render( &self, style: &EditorStyle, max_height_in_lines: u32, y_flipped: bool, cx: &mut ViewContext, ) -> AnyElement { match self { CodeContextMenu::Completions(menu) => { menu.render(style, max_height_in_lines, y_flipped, cx) } CodeContextMenu::CodeActions(menu) => { menu.render(style, max_height_in_lines, y_flipped, cx) } } } pub fn render_aside( &self, style: &EditorStyle, max_size: Size, workspace: Option>, cx: &mut ViewContext, ) -> Option { match self { CodeContextMenu::Completions(menu) => menu.render_aside(style, max_size, workspace, cx), CodeContextMenu::CodeActions(_) => None, } } } 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: Rc>>, match_candidates: Rc<[StringMatchCandidate]>, pub entries: Rc>>, pub selected_item: usize, scroll_handle: UniformListScrollHandle, resolve_completions: bool, show_completion_documentation: bool, last_rendered_range: Rc>>>, } #[derive(Clone, Debug)] pub(crate) enum CompletionEntry { Match(StringMatch), InlineCompletionHint(InlineCompletionMenuHint), } impl CompletionsMenu { pub fn new( id: CompletionId, sort_completions: bool, show_completion_documentation: bool, initial_position: Anchor, buffer: Model, completions: Box<[Completion]>, ) -> Self { let match_candidates = completions .iter() .enumerate() .map(|(id, completion)| StringMatchCandidate::new(id, &completion.label.filter_text())) .collect(); Self { id, sort_completions, initial_position, buffer, show_completion_documentation, completions: RefCell::new(completions).into(), match_candidates, entries: RefCell::new(Vec::new()).into(), selected_item: 0, scroll_handle: UniformListScrollHandle::new(), resolve_completions: true, last_rendered_range: RefCell::new(None).into(), } } 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, resolved: true, }) .collect(); let match_candidates = choices .iter() .enumerate() .map(|(id, completion)| StringMatchCandidate::new(id, &completion)) .collect(); let entries = choices .iter() .enumerate() .map(|(id, completion)| { CompletionEntry::Match(StringMatch { candidate_id: id, score: 1., positions: vec![], string: completion.clone(), }) }) .collect::>(); Self { id, sort_completions, initial_position: selection.start, buffer, completions: RefCell::new(completions).into(), match_candidates, entries: RefCell::new(entries).into(), selected_item: 0, scroll_handle: UniformListScrollHandle::new(), resolve_completions: false, show_completion_documentation: false, last_rendered_range: RefCell::new(None).into(), } } fn select_first( &mut self, provider: Option<&dyn CompletionProvider>, cx: &mut ViewContext, ) { let index = if self.scroll_handle.y_flipped() { self.entries.borrow().len() - 1 } else { 0 }; self.update_selection_index(index, provider, cx); } fn select_last( &mut self, provider: Option<&dyn CompletionProvider>, cx: &mut ViewContext, ) { let index = if self.scroll_handle.y_flipped() { 0 } else { self.entries.borrow().len() - 1 }; self.update_selection_index(index, provider, cx); } fn select_prev( &mut self, provider: Option<&dyn CompletionProvider>, cx: &mut ViewContext, ) { let index = if self.scroll_handle.y_flipped() { self.next_match_index() } else { self.prev_match_index() }; self.update_selection_index(index, provider, cx); } fn select_next( &mut self, provider: Option<&dyn CompletionProvider>, cx: &mut ViewContext, ) { let index = if self.scroll_handle.y_flipped() { self.prev_match_index() } else { self.next_match_index() }; self.update_selection_index(index, provider, cx); } fn update_selection_index( &mut self, match_index: usize, provider: Option<&dyn CompletionProvider>, cx: &mut ViewContext, ) { if self.selected_item != match_index { self.selected_item = match_index; self.scroll_handle .scroll_to_item(self.selected_item, ScrollStrategy::Top); self.resolve_visible_completions(provider, cx); cx.notify(); } } fn prev_match_index(&self) -> usize { if self.selected_item > 0 { self.selected_item - 1 } else { self.entries.borrow().len() - 1 } } fn next_match_index(&self) -> usize { if self.selected_item + 1 < self.entries.borrow().len() { self.selected_item + 1 } else { 0 } } pub fn show_inline_completion_hint(&mut self, hint: InlineCompletionMenuHint) { let hint = CompletionEntry::InlineCompletionHint(hint); let mut entries = self.entries.borrow_mut(); match entries.first() { Some(CompletionEntry::InlineCompletionHint { .. }) => { entries[0] = hint; } _ => { entries.insert(0, hint); // When `y_flipped`, need to scroll to bring it into view. if self.selected_item == 0 { self.scroll_handle .scroll_to_item(self.selected_item, ScrollStrategy::Top); } } } } pub fn resolve_visible_completions( &mut self, provider: Option<&dyn CompletionProvider>, cx: &mut ViewContext, ) { if !self.resolve_completions { return; } let Some(provider) = provider else { return; }; // Attempt to resolve completions for every item that will be displayed. This matters // because single line documentation may be displayed inline with the completion. // // When navigating to the very beginning or end of completions, `last_rendered_range` may // have no overlap with the completions that will be displayed, so instead use a range based // on the last rendered count. const APPROXIMATE_VISIBLE_COUNT: usize = 12; let last_rendered_range = self.last_rendered_range.borrow().clone(); let visible_count = last_rendered_range .clone() .map_or(APPROXIMATE_VISIBLE_COUNT, |range| range.count()); let entries = self.entries.borrow(); let entry_range = if self.selected_item == 0 { 0..min(visible_count, entries.len()) } else if self.selected_item == entries.len() - 1 { entries.len().saturating_sub(visible_count)..entries.len() } else { last_rendered_range.map_or(0..0, |range| { min(range.start, entries.len())..min(range.end, entries.len()) }) }; // Expand the range to resolve more completions than are predicted to be visible, to reduce // jank on navigation. const EXTRA_TO_RESOLVE: usize = 4; let entry_indices = util::iterate_expanded_and_wrapped_usize_range( entry_range.clone(), EXTRA_TO_RESOLVE, EXTRA_TO_RESOLVE, entries.len(), ); // Avoid work by sometimes filtering out completions that already have documentation. // This filtering doesn't happen if the completions are currently being updated. let completions = self.completions.borrow(); let candidate_ids = entry_indices .flat_map(|i| Self::entry_candidate_id(&entries[i])) .filter(|i| completions[*i].documentation.is_none()); // Current selection is always resolved even if it already has documentation, to handle // out-of-spec language servers that return more results later. let candidate_ids = match Self::entry_candidate_id(&entries[self.selected_item]) { None => candidate_ids.collect::>(), Some(selected_candidate_id) => iter::once(selected_candidate_id) .chain(candidate_ids.filter(|id| *id != selected_candidate_id)) .collect::>(), }; drop(entries); if candidate_ids.is_empty() { return; } let resolve_task = provider.resolve_completions( self.buffer.clone(), candidate_ids, 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 entry_candidate_id(entry: &CompletionEntry) -> Option { match entry { CompletionEntry::Match(entry) => Some(entry.candidate_id), CompletionEntry::InlineCompletionHint { .. } => None, } } pub fn visible(&self) -> bool { !self.entries.borrow().is_empty() } fn origin(&self, cursor_position: DisplayPoint) -> ContextMenuOrigin { ContextMenuOrigin::EditorPoint(cursor_position) } fn render( &self, style: &EditorStyle, max_height_in_lines: u32, y_flipped: bool, cx: &mut ViewContext, ) -> AnyElement { let completions = self.completions.borrow_mut(); let show_completion_documentation = self.show_completion_documentation; let widest_completion_ix = self .entries .borrow() .iter() .enumerate() .max_by_key(|(_, mat)| match mat { CompletionEntry::Match(mat) => { 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 } CompletionEntry::InlineCompletionHint(hint) => { "Zed AI / ".chars().count() + hint.label().chars().count() } }) .map(|(ix, _)| ix); drop(completions); let selected_item = self.selected_item; let completions = self.completions.clone(); let entries = self.entries.clone(); let last_rendered_range = self.last_rendered_range.clone(); let style = style.clone(); let list = uniform_list( cx.view().clone(), "completions", self.entries.borrow().len(), move |_editor, range, cx| { last_rendered_range.borrow_mut().replace(range.clone()); let start_ix = range.start; let completions_guard = completions.borrow_mut(); entries.borrow()[range] .iter() .enumerate() .map(|(ix, mat)| { let item_ix = start_ix + ix; let buffer_font = theme::ThemeSettings::get_global(cx).buffer_font.clone(); let base_label = h_flex() .gap_1() .child(div().font(buffer_font.clone()).child("Zed AI")) .child(div().px_0p5().child("/").opacity(0.2)); match mat { CompletionEntry::Match(mat) => { let candidate_id = mat.candidate_id; let completion = &completions_guard[candidate_id]; let documentation = if show_completion_documentation { &completion.documentation } else { &None }; let filter_start = completion.label.filter_range.start; let highlights = gpui::combine_highlights( mat.ranges().map(|range| { ( filter_start + range.start..filter_start + range.end, 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) .toggle_state(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::