diff --git a/crates/agent/src/context_picker/completion_provider.rs b/crates/agent/src/context_picker/completion_provider.rs index 245ddbc717..ffc395f888 100644 --- a/crates/agent/src/context_picker/completion_provider.rs +++ b/crates/agent/src/context_picker/completion_provider.rs @@ -14,7 +14,7 @@ use http_client::HttpClientWithUrl; use itertools::Itertools; use language::{Buffer, CodeLabel, HighlightId}; use lsp::CompletionContext; -use project::{Completion, CompletionIntent, ProjectPath, Symbol, WorktreeId}; +use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, Symbol, WorktreeId}; use prompt_store::PromptStore; use rope::Point; use text::{Anchor, OffsetRangeExt, ToPoint}; @@ -746,7 +746,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { _trigger: CompletionContext, _window: &mut Window, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { let state = buffer.update(cx, |buffer, _cx| { let position = buffer_position.to_point(buffer); let line_start = Point::new(position.row, 0); @@ -756,13 +756,13 @@ impl CompletionProvider for ContextPickerCompletionProvider { MentionCompletion::try_parse(line, offset_to_line) }); let Some(state) = state else { - return Task::ready(Ok(None)); + return Task::ready(Ok(Vec::new())); }; let Some((workspace, context_store)) = self.workspace.upgrade().zip(self.context_store.upgrade()) else { - return Task::ready(Ok(None)); + return Task::ready(Ok(Vec::new())); }; let snapshot = buffer.read(cx).snapshot(); @@ -815,10 +815,10 @@ impl CompletionProvider for ContextPickerCompletionProvider { cx.spawn(async move |_, cx| { let matches = search_task.await; let Some(editor) = editor.upgrade() else { - return Ok(None); + return Ok(Vec::new()); }; - Ok(Some(cx.update(|cx| { + let completions = cx.update(|cx| { matches .into_iter() .filter_map(|mat| match mat { @@ -901,7 +901,14 @@ impl CompletionProvider for ContextPickerCompletionProvider { ), }) .collect() - })?)) + })?; + + Ok(vec![CompletionResponse { + completions, + // Since this does its own filtering (see `filter_completions()` returns false), + // there is no benefit to computing whether this set of completions is incomplete. + is_incomplete: true, + }]) }) } diff --git a/crates/assistant_context_editor/src/slash_command.rs b/crates/assistant_context_editor/src/slash_command.rs index b0f16e53a7..fb34d29cca 100644 --- a/crates/assistant_context_editor/src/slash_command.rs +++ b/crates/assistant_context_editor/src/slash_command.rs @@ -48,7 +48,7 @@ impl SlashCommandCompletionProvider { name_range: Range, window: &mut Window, cx: &mut App, - ) -> Task>>> { + ) -> Task>> { let slash_commands = self.slash_commands.clone(); let candidates = slash_commands .command_names(cx) @@ -71,28 +71,27 @@ impl SlashCommandCompletionProvider { .await; cx.update(|_, cx| { - Some( - matches - .into_iter() - .filter_map(|mat| { - let command = slash_commands.command(&mat.string, cx)?; - let mut new_text = mat.string.clone(); - let requires_argument = command.requires_argument(); - let accepts_arguments = command.accepts_arguments(); - if requires_argument || accepts_arguments { - new_text.push(' '); - } + let completions = matches + .into_iter() + .filter_map(|mat| { + let command = slash_commands.command(&mat.string, cx)?; + let mut new_text = mat.string.clone(); + let requires_argument = command.requires_argument(); + let accepts_arguments = command.accepts_arguments(); + if requires_argument || accepts_arguments { + new_text.push(' '); + } - let confirm = - editor - .clone() - .zip(workspace.clone()) - .map(|(editor, workspace)| { - let command_name = mat.string.clone(); - let command_range = command_range.clone(); - let editor = editor.clone(); - let workspace = workspace.clone(); - Arc::new( + let confirm = + editor + .clone() + .zip(workspace.clone()) + .map(|(editor, workspace)| { + let command_name = mat.string.clone(); + let command_range = command_range.clone(); + let editor = editor.clone(); + let workspace = workspace.clone(); + Arc::new( move |intent: CompletionIntent, window: &mut Window, cx: &mut App| { @@ -118,22 +117,27 @@ impl SlashCommandCompletionProvider { } }, ) as Arc<_> - }); - Some(project::Completion { - replace_range: name_range.clone(), - documentation: Some(CompletionDocumentation::SingleLine( - command.description().into(), - )), - new_text, - label: command.label(cx), - icon_path: None, - insert_text_mode: None, - confirm, - source: CompletionSource::Custom, - }) + }); + + Some(project::Completion { + replace_range: name_range.clone(), + documentation: Some(CompletionDocumentation::SingleLine( + command.description().into(), + )), + new_text, + label: command.label(cx), + icon_path: None, + insert_text_mode: None, + confirm, + source: CompletionSource::Custom, }) - .collect(), - ) + }) + .collect(); + + vec![project::CompletionResponse { + completions, + is_incomplete: false, + }] }) }) } @@ -147,7 +151,7 @@ impl SlashCommandCompletionProvider { last_argument_range: Range, window: &mut Window, cx: &mut App, - ) -> Task>>> { + ) -> Task>> { let new_cancel_flag = Arc::new(AtomicBool::new(false)); let mut flag = self.cancel_flag.lock(); flag.store(true, SeqCst); @@ -165,28 +169,27 @@ impl SlashCommandCompletionProvider { let workspace = self.workspace.clone(); let arguments = arguments.to_vec(); cx.background_spawn(async move { - Ok(Some( - completions - .await? - .into_iter() - .map(|new_argument| { - let confirm = - editor - .clone() - .zip(workspace.clone()) - .map(|(editor, workspace)| { - Arc::new({ - let mut completed_arguments = arguments.clone(); - if new_argument.replace_previous_arguments { - completed_arguments.clear(); - } else { - completed_arguments.pop(); - } - completed_arguments.push(new_argument.new_text.clone()); + let completions = completions + .await? + .into_iter() + .map(|new_argument| { + let confirm = + editor + .clone() + .zip(workspace.clone()) + .map(|(editor, workspace)| { + Arc::new({ + let mut completed_arguments = arguments.clone(); + if new_argument.replace_previous_arguments { + completed_arguments.clear(); + } else { + completed_arguments.pop(); + } + completed_arguments.push(new_argument.new_text.clone()); - let command_range = command_range.clone(); - let command_name = command_name.clone(); - move |intent: CompletionIntent, + let command_range = command_range.clone(); + let command_name = command_name.clone(); + move |intent: CompletionIntent, window: &mut Window, cx: &mut App| { if new_argument.after_completion.run() @@ -210,34 +213,41 @@ impl SlashCommandCompletionProvider { !new_argument.after_completion.run() } } - }) as Arc<_> - }); + }) as Arc<_> + }); - let mut new_text = new_argument.new_text.clone(); - if new_argument.after_completion == AfterCompletion::Continue { - new_text.push(' '); - } + let mut new_text = new_argument.new_text.clone(); + if new_argument.after_completion == AfterCompletion::Continue { + new_text.push(' '); + } - project::Completion { - replace_range: if new_argument.replace_previous_arguments { - argument_range.clone() - } else { - last_argument_range.clone() - }, - label: new_argument.label, - icon_path: None, - new_text, - documentation: None, - confirm, - insert_text_mode: None, - source: CompletionSource::Custom, - } - }) - .collect(), - )) + project::Completion { + replace_range: if new_argument.replace_previous_arguments { + argument_range.clone() + } else { + last_argument_range.clone() + }, + label: new_argument.label, + icon_path: None, + new_text, + documentation: None, + confirm, + insert_text_mode: None, + source: CompletionSource::Custom, + } + }) + .collect(); + + Ok(vec![project::CompletionResponse { + completions, + is_incomplete: false, + }]) }) } else { - Task::ready(Ok(Some(Vec::new()))) + Task::ready(Ok(vec![project::CompletionResponse { + completions: Vec::new(), + is_incomplete: false, + }])) } } } @@ -251,7 +261,7 @@ impl CompletionProvider for SlashCommandCompletionProvider { _: editor::CompletionContext, window: &mut Window, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { let Some((name, arguments, command_range, last_argument_range)) = buffer.update(cx, |buffer, _cx| { let position = buffer_position.to_point(buffer); @@ -295,7 +305,10 @@ impl CompletionProvider for SlashCommandCompletionProvider { Some((name, arguments, command_range, last_argument_range)) }) else { - return Task::ready(Ok(Some(Vec::new()))); + return Task::ready(Ok(vec![project::CompletionResponse { + completions: Vec::new(), + is_incomplete: false, + }])); }; if let Some((arguments, argument_range)) = arguments { diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs index 5979617674..7a580896a6 100644 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ b/crates/collab_ui/src/chat_panel/message_editor.rs @@ -12,7 +12,7 @@ use language::{ Anchor, Buffer, BufferSnapshot, CodeLabel, LanguageRegistry, ToOffset, language_settings::SoftWrap, }; -use project::{Completion, CompletionSource, search::SearchQuery}; +use project::{Completion, CompletionResponse, CompletionSource, search::SearchQuery}; use settings::Settings; use std::{ cell::RefCell, @@ -64,9 +64,9 @@ impl CompletionProvider for MessageEditorCompletionProvider { _: editor::CompletionContext, _window: &mut Window, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { let Some(handle) = self.0.upgrade() else { - return Task::ready(Ok(None)); + return Task::ready(Ok(Vec::new())); }; handle.update(cx, |message_editor, cx| { message_editor.completions(buffer, buffer_position, cx) @@ -248,22 +248,21 @@ impl MessageEditor { buffer: &Entity, end_anchor: Anchor, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { if let Some((start_anchor, query, candidates)) = self.collect_mention_candidates(buffer, end_anchor, cx) { if !candidates.is_empty() { return cx.spawn(async move |_, cx| { - Ok(Some( - Self::resolve_completions_for_candidates( - &cx, - query.as_str(), - &candidates, - start_anchor..end_anchor, - Self::completion_for_mention, - ) - .await, - )) + let completion_response = Self::resolve_completions_for_candidates( + &cx, + query.as_str(), + &candidates, + start_anchor..end_anchor, + Self::completion_for_mention, + ) + .await; + Ok(vec![completion_response]) }); } } @@ -273,21 +272,23 @@ impl MessageEditor { { if !candidates.is_empty() { return cx.spawn(async move |_, cx| { - Ok(Some( - Self::resolve_completions_for_candidates( - &cx, - query.as_str(), - candidates, - start_anchor..end_anchor, - Self::completion_for_emoji, - ) - .await, - )) + let completion_response = Self::resolve_completions_for_candidates( + &cx, + query.as_str(), + candidates, + start_anchor..end_anchor, + Self::completion_for_emoji, + ) + .await; + Ok(vec![completion_response]) }); } } - Task::ready(Ok(Some(Vec::new()))) + Task::ready(Ok(vec![CompletionResponse { + completions: Vec::new(), + is_incomplete: false, + }])) } async fn resolve_completions_for_candidates( @@ -296,18 +297,19 @@ impl MessageEditor { candidates: &[StringMatchCandidate], range: Range, completion_fn: impl Fn(&StringMatch) -> (String, CodeLabel), - ) -> Vec { + ) -> CompletionResponse { + const LIMIT: usize = 10; let matches = fuzzy::match_strings( candidates, query, true, - 10, + LIMIT, &Default::default(), cx.background_executor().clone(), ) .await; - matches + let completions = matches .into_iter() .map(|mat| { let (new_text, label) = completion_fn(&mat); @@ -322,7 +324,12 @@ impl MessageEditor { source: CompletionSource::Custom, } }) - .collect() + .collect::>(); + + CompletionResponse { + is_incomplete: completions.len() >= LIMIT, + completions, + } } fn completion_for_mention(mat: &StringMatch) -> (String, CodeLabel) { diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index d149beb461..389fa24587 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -13,7 +13,7 @@ use gpui::{ use language::{Buffer, CodeLabel, ToOffset}; use menu::Confirm; use project::{ - Completion, + Completion, CompletionResponse, debugger::session::{CompletionsQuery, OutputToken, Session, SessionEvent}, }; use settings::Settings; @@ -262,9 +262,9 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider { _trigger: editor::CompletionContext, _window: &mut Window, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { let Some(console) = self.0.upgrade() else { - return Task::ready(Ok(None)); + return Task::ready(Ok(Vec::new())); }; let support_completions = console @@ -322,7 +322,7 @@ impl ConsoleQueryBarCompletionProvider { buffer: &Entity, buffer_position: language::Anchor, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { let (variables, string_matches) = console.update(cx, |console, cx| { let mut variables = HashMap::default(); let mut string_matches = Vec::default(); @@ -354,39 +354,43 @@ impl ConsoleQueryBarCompletionProvider { let query = buffer.read(cx).text(); cx.spawn(async move |_, cx| { + const LIMIT: usize = 10; let matches = fuzzy::match_strings( &string_matches, &query, true, - 10, + LIMIT, &Default::default(), cx.background_executor().clone(), ) .await; - Ok(Some( - matches - .iter() - .filter_map(|string_match| { - let variable_value = variables.get(&string_match.string)?; + let completions = matches + .iter() + .filter_map(|string_match| { + let variable_value = variables.get(&string_match.string)?; - Some(project::Completion { - replace_range: buffer_position..buffer_position, - new_text: string_match.string.clone(), - label: CodeLabel { - filter_range: 0..string_match.string.len(), - text: format!("{} {}", string_match.string, variable_value), - runs: Vec::new(), - }, - icon_path: None, - documentation: None, - confirm: None, - source: project::CompletionSource::Custom, - insert_text_mode: None, - }) + Some(project::Completion { + replace_range: buffer_position..buffer_position, + new_text: string_match.string.clone(), + label: CodeLabel { + filter_range: 0..string_match.string.len(), + text: format!("{} {}", string_match.string, variable_value), + runs: Vec::new(), + }, + icon_path: None, + documentation: None, + confirm: None, + source: project::CompletionSource::Custom, + insert_text_mode: None, }) - .collect(), - )) + }) + .collect::>(); + + Ok(vec![project::CompletionResponse { + is_incomplete: completions.len() >= LIMIT, + completions, + }]) }) } @@ -396,7 +400,7 @@ impl ConsoleQueryBarCompletionProvider { buffer: &Entity, buffer_position: language::Anchor, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { let completion_task = console.update(cx, |console, cx| { console.session.update(cx, |state, cx| { let frame_id = console.stack_frame_list.read(cx).opened_stack_frame_id(); @@ -411,53 +415,56 @@ impl ConsoleQueryBarCompletionProvider { cx.background_executor().spawn(async move { let completions = completion_task.await?; - Ok(Some( - completions - .into_iter() - .map(|completion| { - let new_text = completion - .text - .as_ref() - .unwrap_or(&completion.label) - .to_owned(); - let buffer_text = snapshot.text(); - let buffer_bytes = buffer_text.as_bytes(); - let new_bytes = new_text.as_bytes(); + let completions = completions + .into_iter() + .map(|completion| { + let new_text = completion + .text + .as_ref() + .unwrap_or(&completion.label) + .to_owned(); + let buffer_text = snapshot.text(); + let buffer_bytes = buffer_text.as_bytes(); + let new_bytes = new_text.as_bytes(); - let mut prefix_len = 0; - for i in (0..new_bytes.len()).rev() { - if buffer_bytes.ends_with(&new_bytes[0..i]) { - prefix_len = i; - break; - } + let mut prefix_len = 0; + for i in (0..new_bytes.len()).rev() { + if buffer_bytes.ends_with(&new_bytes[0..i]) { + prefix_len = i; + break; } + } - let buffer_offset = buffer_position.to_offset(&snapshot); - let start = buffer_offset - prefix_len; - let start = snapshot.clip_offset(start, Bias::Left); - let start = snapshot.anchor_before(start); - let replace_range = start..buffer_position; + let buffer_offset = buffer_position.to_offset(&snapshot); + let start = buffer_offset - prefix_len; + let start = snapshot.clip_offset(start, Bias::Left); + let start = snapshot.anchor_before(start); + let replace_range = start..buffer_position; - project::Completion { - replace_range, - new_text, - label: CodeLabel { - filter_range: 0..completion.label.len(), - text: completion.label, - runs: Vec::new(), - }, - icon_path: None, - documentation: None, - confirm: None, - source: project::CompletionSource::BufferWord { - word_range: buffer_position..language::Anchor::MAX, - resolved: false, - }, - insert_text_mode: None, - } - }) - .collect(), - )) + project::Completion { + replace_range, + new_text, + label: CodeLabel { + filter_range: 0..completion.label.len(), + text: completion.label, + runs: Vec::new(), + }, + icon_path: None, + documentation: None, + confirm: None, + source: project::CompletionSource::BufferWord { + word_range: buffer_position..language::Anchor::MAX, + resolved: false, + }, + insert_text_mode: None, + } + }) + .collect(); + + Ok(vec![project::CompletionResponse { + completions, + is_incomplete: false, + }]) }) } } diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 4ec90a204e..3d61bfb6a4 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -1,9 +1,8 @@ use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ AnyElement, Entity, Focusable, FontWeight, ListSizingBehavior, ScrollStrategy, SharedString, - Size, StrikethroughStyle, StyledText, UniformListScrollHandle, div, px, uniform_list, + Size, StrikethroughStyle, StyledText, Task, UniformListScrollHandle, div, px, uniform_list, }; -use gpui::{AsyncWindowContext, WeakEntity}; use itertools::Itertools; use language::CodeLabel; use language::{Buffer, LanguageName, LanguageRegistry}; @@ -18,6 +17,7 @@ use task::TaskContext; use std::collections::VecDeque; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::{ cell::RefCell, cmp::{Reverse, min}, @@ -47,15 +47,10 @@ pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.); // Constants for the markdown cache. The purpose of this cache is to reduce flickering due to // documentation not yet being parsed. // -// The size of the cache is set to the number of items fetched around the current selection plus one -// for the current selection and another to avoid cases where and adjacent selection exits the -// cache. The only current benefit of a larger cache would be doing less markdown parsing when the -// selection revisits items. -// -// One future benefit of a larger cache would be reducing flicker on backspace. This would require -// not recreating the menu on every change, by not re-querying the language server when -// `is_incomplete = false`. -const MARKDOWN_CACHE_MAX_SIZE: usize = MARKDOWN_CACHE_BEFORE_ITEMS + MARKDOWN_CACHE_AFTER_ITEMS + 2; +// The size of the cache is set to 16, which is roughly 3 times more than the number of items +// fetched around the current selection. This way documentation is more often ready for render when +// revisiting previous entries, such as when pressing backspace. +const MARKDOWN_CACHE_MAX_SIZE: usize = 16; const MARKDOWN_CACHE_BEFORE_ITEMS: usize = 2; const MARKDOWN_CACHE_AFTER_ITEMS: usize = 2; @@ -197,27 +192,48 @@ pub enum ContextMenuOrigin { QuickActionBar, } -#[derive(Clone)] pub struct CompletionsMenu { pub id: CompletionId, sort_completions: bool, pub initial_position: Anchor, + pub initial_query: Option>, + pub is_incomplete: bool, pub buffer: Entity, pub completions: Rc>>, - match_candidates: Rc<[StringMatchCandidate]>, - pub entries: Rc>>, + match_candidates: Arc<[StringMatchCandidate]>, + pub entries: Rc>>, pub selected_item: usize, + filter_task: Task<()>, + cancel_filter: Arc, scroll_handle: UniformListScrollHandle, resolve_completions: bool, show_completion_documentation: bool, pub(super) ignore_completion_provider: bool, last_rendered_range: Rc>>>, - markdown_cache: Rc)>>>, + markdown_cache: Rc)>>>, language_registry: Option>, language: Option, snippet_sort_order: SnippetSortOrder, } +#[derive(Clone, Debug, PartialEq)] +enum MarkdownCacheKey { + ForCandidate { + candidate_id: usize, + }, + ForCompletionMatch { + new_text: String, + markdown_source: SharedString, + }, +} + +// TODO: There should really be a wrapper around fuzzy match tasks that does this. +impl Drop for CompletionsMenu { + fn drop(&mut self) { + self.cancel_filter.store(true, Ordering::Relaxed); + } +} + impl CompletionsMenu { pub fn new( id: CompletionId, @@ -225,6 +241,8 @@ impl CompletionsMenu { show_completion_documentation: bool, ignore_completion_provider: bool, initial_position: Anchor, + initial_query: Option>, + is_incomplete: bool, buffer: Entity, completions: Box<[Completion]>, snippet_sort_order: SnippetSortOrder, @@ -242,17 +260,21 @@ impl CompletionsMenu { id, sort_completions, initial_position, + initial_query, + is_incomplete, buffer, show_completion_documentation, ignore_completion_provider, completions: RefCell::new(completions).into(), match_candidates, - entries: RefCell::new(Vec::new()).into(), + entries: Rc::new(RefCell::new(Box::new([]))), selected_item: 0, + filter_task: Task::ready(()), + cancel_filter: Arc::new(AtomicBool::new(false)), scroll_handle: UniformListScrollHandle::new(), resolve_completions: true, last_rendered_range: RefCell::new(None).into(), - markdown_cache: RefCell::new(VecDeque::with_capacity(MARKDOWN_CACHE_MAX_SIZE)).into(), + markdown_cache: RefCell::new(VecDeque::new()).into(), language_registry, language, snippet_sort_order, @@ -303,16 +325,20 @@ impl CompletionsMenu { positions: vec![], string: completion.clone(), }) - .collect::>(); + .collect(); Self { id, sort_completions, initial_position: selection.start, + initial_query: None, + is_incomplete: false, buffer, completions: RefCell::new(completions).into(), match_candidates, entries: RefCell::new(entries).into(), selected_item: 0, + filter_task: Task::ready(()), + cancel_filter: Arc::new(AtomicBool::new(false)), scroll_handle: UniformListScrollHandle::new(), resolve_completions: false, show_completion_documentation: false, @@ -390,14 +416,7 @@ impl CompletionsMenu { ) { 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); - self.start_markdown_parse_for_nearby_entries(cx); - if let Some(provider) = provider { - self.handle_selection_changed(provider, window, cx); - } - cx.notify(); + self.handle_selection_changed(provider, window, cx); } } @@ -418,18 +437,25 @@ impl CompletionsMenu { } fn handle_selection_changed( - &self, - provider: &dyn CompletionProvider, + &mut self, + provider: Option<&dyn CompletionProvider>, window: &mut Window, - cx: &mut App, + cx: &mut Context, ) { - let entries = self.entries.borrow(); - let entry = if self.selected_item < entries.len() { - Some(&entries[self.selected_item]) - } else { - None - }; - provider.selection_changed(entry, window, cx); + self.scroll_handle + .scroll_to_item(self.selected_item, ScrollStrategy::Top); + if let Some(provider) = provider { + let entries = self.entries.borrow(); + let entry = if self.selected_item < entries.len() { + Some(&entries[self.selected_item]) + } else { + None + }; + provider.selection_changed(entry, window, cx); + } + self.resolve_visible_completions(provider, cx); + self.start_markdown_parse_for_nearby_entries(cx); + cx.notify(); } pub fn resolve_visible_completions( @@ -444,6 +470,19 @@ impl CompletionsMenu { return; }; + let entries = self.entries.borrow(); + if entries.is_empty() { + return; + } + if self.selected_item >= entries.len() { + log::error!( + "bug: completion selected_item >= entries.len(): {} >= {}", + self.selected_item, + entries.len() + ); + self.selected_item = entries.len() - 1; + } + // Attempt to resolve completions for every item that will be displayed. This matters // because single line documentation may be displayed inline with the completion. // @@ -455,7 +494,6 @@ impl CompletionsMenu { 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 { @@ -508,11 +546,11 @@ impl CompletionsMenu { .update(cx, |editor, cx| { // `resolve_completions` modified state affecting display. cx.notify(); - editor.with_completions_menu_matching_id( - completion_id, - || (), - |this| this.start_markdown_parse_for_nearby_entries(cx), - ); + editor.with_completions_menu_matching_id(completion_id, |menu| { + if let Some(menu) = menu { + menu.start_markdown_parse_for_nearby_entries(cx) + } + }); }) .ok(); } @@ -548,11 +586,11 @@ impl CompletionsMenu { return None; } let candidate_id = entries[index].candidate_id; - match &self.completions.borrow()[candidate_id].documentation { - Some(CompletionDocumentation::MultiLineMarkdown(source)) if !source.is_empty() => Some( - self.get_or_create_markdown(candidate_id, source.clone(), false, cx) - .1, - ), + let completions = self.completions.borrow(); + match &completions[candidate_id].documentation { + Some(CompletionDocumentation::MultiLineMarkdown(source)) if !source.is_empty() => self + .get_or_create_markdown(candidate_id, Some(source), false, &completions, cx) + .map(|(_, markdown)| markdown), Some(_) => None, _ => None, } @@ -561,38 +599,75 @@ impl CompletionsMenu { fn get_or_create_markdown( &self, candidate_id: usize, - source: SharedString, + source: Option<&SharedString>, is_render: bool, + completions: &[Completion], cx: &mut Context, - ) -> (bool, Entity) { + ) -> Option<(bool, Entity)> { let mut markdown_cache = self.markdown_cache.borrow_mut(); - if let Some((cache_index, (_, markdown))) = markdown_cache - .iter() - .find_position(|(id, _)| *id == candidate_id) - { - let markdown = if is_render && cache_index != 0 { + + let mut has_completion_match_cache_entry = false; + let mut matching_entry = markdown_cache.iter().find_position(|(key, _)| match key { + MarkdownCacheKey::ForCandidate { candidate_id: id } => *id == candidate_id, + MarkdownCacheKey::ForCompletionMatch { .. } => { + has_completion_match_cache_entry = true; + false + } + }); + + if has_completion_match_cache_entry && matching_entry.is_none() { + if let Some(source) = source { + matching_entry = markdown_cache.iter().find_position(|(key, _)| { + matches!(key, MarkdownCacheKey::ForCompletionMatch { markdown_source, .. } + if markdown_source == source) + }); + } else { + // Heuristic guess that documentation can be reused when new_text matches. This is + // to mitigate documentation flicker while typing. If this is wrong, then resolution + // should cause the correct documentation to be displayed soon. + let completion = &completions[candidate_id]; + matching_entry = markdown_cache.iter().find_position(|(key, _)| { + matches!(key, MarkdownCacheKey::ForCompletionMatch { new_text, .. } + if new_text == &completion.new_text) + }); + } + } + + if let Some((cache_index, (key, markdown))) = matching_entry { + let markdown = markdown.clone(); + + // Since the markdown source matches, the key can now be ForCandidate. + if source.is_some() && matches!(key, MarkdownCacheKey::ForCompletionMatch { .. }) { + markdown_cache[cache_index].0 = MarkdownCacheKey::ForCandidate { candidate_id }; + } + + if is_render && cache_index != 0 { // Move the current selection's cache entry to the front. markdown_cache.rotate_right(1); let cache_len = markdown_cache.len(); markdown_cache.swap(0, (cache_index + 1) % cache_len); - &markdown_cache[0].1 - } else { - markdown - }; + } let is_parsing = markdown.update(cx, |markdown, cx| { - // `reset` is called as it's possible for documentation to change due to resolve - // requests. It does nothing if `source` is unchanged. - markdown.reset(source, cx); + if let Some(source) = source { + // `reset` is called as it's possible for documentation to change due to resolve + // requests. It does nothing if `source` is unchanged. + markdown.reset(source.clone(), cx); + } markdown.is_parsing() }); - return (is_parsing, markdown.clone()); + return Some((is_parsing, markdown)); } + let Some(source) = source else { + // Can't create markdown as there is no source. + return None; + }; + if markdown_cache.len() < MARKDOWN_CACHE_MAX_SIZE { let markdown = cx.new(|cx| { Markdown::new( - source, + source.clone(), self.language_registry.clone(), self.language.clone(), cx, @@ -601,17 +676,20 @@ impl CompletionsMenu { // Handles redraw when the markdown is done parsing. The current render is for a // deferred draw, and so without this did not redraw when `markdown` notified. cx.observe(&markdown, |_, _, cx| cx.notify()).detach(); - markdown_cache.push_front((candidate_id, markdown.clone())); - (true, markdown) + markdown_cache.push_front(( + MarkdownCacheKey::ForCandidate { candidate_id }, + markdown.clone(), + )); + Some((true, markdown)) } else { debug_assert_eq!(markdown_cache.capacity(), MARKDOWN_CACHE_MAX_SIZE); // Moves the last cache entry to the start. The ring buffer is full, so this does no // copying and just shifts indexes. markdown_cache.rotate_right(1); - markdown_cache[0].0 = candidate_id; + markdown_cache[0].0 = MarkdownCacheKey::ForCandidate { candidate_id }; let markdown = &markdown_cache[0].1; - markdown.update(cx, |markdown, cx| markdown.reset(source, cx)); - (true, markdown.clone()) + markdown.update(cx, |markdown, cx| markdown.reset(source.clone(), cx)); + Some((true, markdown.clone())) } } @@ -774,37 +852,46 @@ impl CompletionsMenu { } let mat = &self.entries.borrow()[self.selected_item]; - let multiline_docs = match self.completions.borrow_mut()[mat.candidate_id] - .documentation - .as_ref()? - { - CompletionDocumentation::MultiLinePlainText(text) => div().child(text.clone()), - CompletionDocumentation::SingleLineAndMultiLinePlainText { + let completions = self.completions.borrow_mut(); + let multiline_docs = match completions[mat.candidate_id].documentation.as_ref() { + Some(CompletionDocumentation::MultiLinePlainText(text)) => div().child(text.clone()), + Some(CompletionDocumentation::SingleLineAndMultiLinePlainText { plain_text: Some(text), .. - } => div().child(text.clone()), - CompletionDocumentation::MultiLineMarkdown(source) if !source.is_empty() => { - let (is_parsing, markdown) = - self.get_or_create_markdown(mat.candidate_id, source.clone(), true, cx); - if is_parsing { + }) => div().child(text.clone()), + Some(CompletionDocumentation::MultiLineMarkdown(source)) if !source.is_empty() => { + let Some((false, markdown)) = self.get_or_create_markdown( + mat.candidate_id, + Some(source), + true, + &completions, + cx, + ) else { return None; - } - div().child( - MarkdownElement::new(markdown, hover_markdown_style(window, cx)) - .code_block_renderer(markdown::CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, - border: false, - }) - .on_url_click(open_markdown_url), - ) + }; + Self::render_markdown(markdown, window, cx) } - CompletionDocumentation::MultiLineMarkdown(_) => return None, - CompletionDocumentation::SingleLine(_) => return None, - CompletionDocumentation::Undocumented => return None, - CompletionDocumentation::SingleLineAndMultiLinePlainText { - plain_text: None, .. - } => { + None => { + // Handle the case where documentation hasn't yet been resolved but there's a + // `new_text` match in the cache. + // + // TODO: It's inconsistent that documentation caching based on matching `new_text` + // only works for markdown. Consider generally caching the results of resolving + // completions. + let Some((false, markdown)) = + self.get_or_create_markdown(mat.candidate_id, None, true, &completions, cx) + else { + return None; + }; + Self::render_markdown(markdown, window, cx) + } + Some(CompletionDocumentation::MultiLineMarkdown(_)) => return None, + Some(CompletionDocumentation::SingleLine(_)) => return None, + Some(CompletionDocumentation::Undocumented) => return None, + Some(CompletionDocumentation::SingleLineAndMultiLinePlainText { + plain_text: None, + .. + }) => { return None; } }; @@ -824,6 +911,177 @@ impl CompletionsMenu { ) } + fn render_markdown( + markdown: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Div { + div().child( + MarkdownElement::new(markdown, hover_markdown_style(window, cx)) + .code_block_renderer(markdown::CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }) + .on_url_click(open_markdown_url), + ) + } + + pub fn filter( + &mut self, + query: Option>, + provider: Option>, + window: &mut Window, + cx: &mut Context, + ) { + self.cancel_filter.store(true, Ordering::Relaxed); + if let Some(query) = query { + self.cancel_filter = Arc::new(AtomicBool::new(false)); + let matches = self.do_async_filtering(query, cx); + let id = self.id; + self.filter_task = cx.spawn_in(window, async move |editor, cx| { + let matches = matches.await; + editor + .update_in(cx, |editor, window, cx| { + editor.with_completions_menu_matching_id(id, |this| { + if let Some(this) = this { + this.set_filter_results(matches, provider, window, cx); + } + }); + }) + .ok(); + }); + } else { + self.filter_task = Task::ready(()); + let matches = self.unfiltered_matches(); + self.set_filter_results(matches, provider, window, cx); + } + } + + pub fn do_async_filtering( + &self, + query: Arc, + cx: &Context, + ) -> Task> { + let matches_task = cx.background_spawn({ + let query = query.clone(); + let match_candidates = self.match_candidates.clone(); + let cancel_filter = self.cancel_filter.clone(); + let background_executor = cx.background_executor().clone(); + async move { + fuzzy::match_strings( + &match_candidates, + &query, + query.chars().any(|c| c.is_uppercase()), + 100, + &cancel_filter, + background_executor, + ) + .await + } + }); + + let completions = self.completions.clone(); + let sort_completions = self.sort_completions; + let snippet_sort_order = self.snippet_sort_order; + cx.foreground_executor().spawn(async move { + let mut matches = matches_task.await; + + if sort_completions { + matches = Self::sort_string_matches( + matches, + Some(&query), + snippet_sort_order, + completions.borrow().as_ref(), + ); + } + + matches + }) + } + + /// Like `do_async_filtering` but there is no filter query, so no need to spawn tasks. + pub fn unfiltered_matches(&self) -> Vec { + let mut matches = self + .match_candidates + .iter() + .enumerate() + .map(|(candidate_id, candidate)| StringMatch { + candidate_id, + score: Default::default(), + positions: Default::default(), + string: candidate.string.clone(), + }) + .collect(); + + if self.sort_completions { + matches = Self::sort_string_matches( + matches, + None, + self.snippet_sort_order, + self.completions.borrow().as_ref(), + ); + } + + matches + } + + pub fn set_filter_results( + &mut self, + matches: Vec, + provider: Option>, + window: &mut Window, + cx: &mut Context, + ) { + *self.entries.borrow_mut() = matches.into_boxed_slice(); + self.selected_item = 0; + self.handle_selection_changed(provider.as_deref(), window, cx); + } + + fn sort_string_matches( + matches: Vec, + query: Option<&str>, + snippet_sort_order: SnippetSortOrder, + completions: &[Completion], + ) -> Vec { + let mut sortable_items: Vec> = matches + .into_iter() + .map(|string_match| { + let completion = &completions[string_match.candidate_id]; + + let is_snippet = matches!( + &completion.source, + CompletionSource::Lsp { lsp_completion, .. } + if lsp_completion.kind == Some(CompletionItemKind::SNIPPET) + ); + + let sort_text = + if let CompletionSource::Lsp { lsp_completion, .. } = &completion.source { + lsp_completion.sort_text.as_deref() + } else { + None + }; + + let (sort_kind, sort_label) = completion.sort_key(); + + SortableMatch { + string_match, + is_snippet, + sort_text, + sort_kind, + sort_label, + } + }) + .collect(); + + Self::sort_matches(&mut sortable_items, query, snippet_sort_order); + + sortable_items + .into_iter() + .map(|sortable| sortable.string_match) + .collect() + } + pub fn sort_matches( matches: &mut Vec>, query: Option<&str>, @@ -857,6 +1115,7 @@ impl CompletionsMenu { let fuzzy_bracket_threshold = max_score * (3.0 / 5.0); let query_start_lower = query + .as_ref() .and_then(|q| q.chars().next()) .and_then(|c| c.to_lowercase().next()); @@ -890,6 +1149,7 @@ impl CompletionsMenu { }; let sort_mixed_case_prefix_length = Reverse( query + .as_ref() .map(|q| { q.chars() .zip(mat.string_match.string.chars()) @@ -920,97 +1180,32 @@ impl CompletionsMenu { }); } - pub async fn filter( - &mut self, - query: Option<&str>, - provider: Option>, - editor: WeakEntity, - cx: &mut AsyncWindowContext, - ) { - let mut matches = if let Some(query) = query { - fuzzy::match_strings( - &self.match_candidates, - query, - query.chars().any(|c| c.is_uppercase()), - 100, - &Default::default(), - cx.background_executor().clone(), - ) - .await - } else { - self.match_candidates - .iter() - .enumerate() - .map(|(candidate_id, candidate)| StringMatch { - candidate_id, - score: Default::default(), - positions: Default::default(), - string: candidate.string.clone(), - }) - .collect() - }; + pub fn preserve_markdown_cache(&mut self, prev_menu: CompletionsMenu) { + self.markdown_cache = prev_menu.markdown_cache.clone(); - if self.sort_completions { - let completions = self.completions.borrow(); - - let mut sortable_items: Vec> = matches - .into_iter() - .map(|string_match| { - let completion = &completions[string_match.candidate_id]; - - let is_snippet = matches!( - &completion.source, - CompletionSource::Lsp { lsp_completion, .. } - if lsp_completion.kind == Some(CompletionItemKind::SNIPPET) - ); - - let sort_text = - if let CompletionSource::Lsp { lsp_completion, .. } = &completion.source { - lsp_completion.sort_text.as_deref() - } else { - None - }; - - let (sort_kind, sort_label) = completion.sort_key(); - - SortableMatch { - string_match, - is_snippet, - sort_text, - sort_kind, - sort_label, + // Convert ForCandidate cache keys to ForCompletionMatch keys. + let prev_completions = prev_menu.completions.borrow(); + self.markdown_cache + .borrow_mut() + .retain_mut(|(key, _markdown)| match key { + MarkdownCacheKey::ForCompletionMatch { .. } => true, + MarkdownCacheKey::ForCandidate { candidate_id } => { + if let Some(completion) = prev_completions.get(*candidate_id) { + match &completion.documentation { + Some(CompletionDocumentation::MultiLineMarkdown(source)) => { + *key = MarkdownCacheKey::ForCompletionMatch { + new_text: completion.new_text.clone(), + markdown_source: source.clone(), + }; + true + } + _ => false, + } + } else { + false } - }) - .collect(); - - Self::sort_matches(&mut sortable_items, query, self.snippet_sort_order); - - matches = sortable_items - .into_iter() - .map(|sortable| sortable.string_match) - .collect(); - } - - *self.entries.borrow_mut() = matches; - self.selected_item = 0; - // This keeps the display consistent when y_flipped. - self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); - - if let Some(provider) = provider { - cx.update(|window, cx| { - // Since this is async, it's possible the menu has been closed and possibly even - // another opened. `provider.selection_changed` should not be called in this case. - let this_menu_still_active = editor - .read_with(cx, |editor, _cx| { - editor.with_completions_menu_matching_id(self.id, || false, |_| true) - }) - .unwrap_or(false); - if this_menu_still_active { - self.handle_selection_changed(&*provider, window, cx); } - }) - .ok(); - } + }); } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 463e3c5d54..ea8ec7096f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -123,7 +123,7 @@ use markdown::Markdown; use mouse_context_menu::MouseContextMenu; use persistence::DB; use project::{ - BreakpointWithPosition, ProjectPath, + BreakpointWithPosition, CompletionResponse, ProjectPath, debugger::{ breakpoint_store::{ BreakpointEditAction, BreakpointSessionState, BreakpointState, BreakpointStore, @@ -987,7 +987,7 @@ pub struct Editor { context_menu: RefCell>, context_menu_options: Option, mouse_context_menu: Option, - completion_tasks: Vec<(CompletionId, Task>)>, + completion_tasks: Vec<(CompletionId, Task<()>)>, inline_blame_popover: Option, signature_help_state: SignatureHelpState, auto_signature_help: Option, @@ -1200,7 +1200,7 @@ impl Default for SelectionHistoryMode { struct DeferredSelectionEffectsState { changed: bool, - show_completions: bool, + should_update_completions: bool, autoscroll: Option, old_cursor_position: Anchor, history_entry: SelectionHistoryEntry, @@ -2657,7 +2657,7 @@ impl Editor { &mut self, local: bool, old_cursor_position: &Anchor, - show_completions: bool, + should_update_completions: bool, window: &mut Window, cx: &mut Context, ) { @@ -2720,14 +2720,7 @@ impl Editor { if local { let new_cursor_position = self.selections.newest_anchor().head(); - let mut context_menu = self.context_menu.borrow_mut(); - let completion_menu = match context_menu.as_ref() { - Some(CodeContextMenu::Completions(menu)) => Some(menu), - _ => { - *context_menu = None; - None - } - }; + if let Some(buffer_id) = new_cursor_position.buffer_id { if !self.registered_buffers.contains_key(&buffer_id) { if let Some(project) = self.project.as_ref() { @@ -2744,50 +2737,40 @@ impl Editor { } } - if let Some(completion_menu) = completion_menu { - let cursor_position = new_cursor_position.to_offset(buffer); - let (word_range, kind) = - buffer.surrounding_word(completion_menu.initial_position, true); - if kind == Some(CharKind::Word) - && word_range.to_inclusive().contains(&cursor_position) - { - let mut completion_menu = completion_menu.clone(); - drop(context_menu); - - let query = Self::completion_query(buffer, cursor_position); - let completion_provider = self.completion_provider.clone(); - cx.spawn_in(window, async move |this, cx| { - completion_menu - .filter(query.as_deref(), completion_provider, this.clone(), cx) - .await; - - this.update(cx, |this, cx| { - let mut context_menu = this.context_menu.borrow_mut(); - let Some(CodeContextMenu::Completions(menu)) = context_menu.as_ref() - else { - return; - }; - - if menu.id > completion_menu.id { - return; - } - - *context_menu = Some(CodeContextMenu::Completions(completion_menu)); - drop(context_menu); - cx.notify(); - }) - }) - .detach(); - - if show_completions { - self.show_completions(&ShowCompletions { trigger: None }, window, cx); - } - } else { - drop(context_menu); - self.hide_context_menu(window, cx); + let mut context_menu = self.context_menu.borrow_mut(); + let completion_menu = match context_menu.as_ref() { + Some(CodeContextMenu::Completions(menu)) => Some(menu), + Some(CodeContextMenu::CodeActions(_)) => { + *context_menu = None; + None + } + None => None, + }; + let completion_position = completion_menu.map(|menu| menu.initial_position); + drop(context_menu); + + if should_update_completions { + if let Some(completion_position) = completion_position { + let new_cursor_offset = new_cursor_position.to_offset(buffer); + let position_matches = + new_cursor_offset == completion_position.to_offset(buffer); + let continue_showing = if position_matches { + let (word_range, kind) = buffer.surrounding_word(new_cursor_offset, true); + if let Some(CharKind::Word) = kind { + word_range.start < new_cursor_offset + } else { + false + } + } else { + false + }; + + if continue_showing { + self.show_completions(&ShowCompletions { trigger: None }, window, cx); + } else { + self.hide_context_menu(window, cx); + } } - } else { - drop(context_menu); } hide_hover(self, cx); @@ -2981,7 +2964,7 @@ impl Editor { self.change_selections_inner(true, autoscroll, window, cx, change) } - pub(crate) fn change_selections_without_showing_completions( + pub(crate) fn change_selections_without_updating_completions( &mut self, autoscroll: Option, window: &mut Window, @@ -2993,7 +2976,7 @@ impl Editor { fn change_selections_inner( &mut self, - show_completions: bool, + should_update_completions: bool, autoscroll: Option, window: &mut Window, cx: &mut Context, @@ -3001,14 +2984,14 @@ impl Editor { ) -> R { if let Some(state) = &mut self.deferred_selection_effects_state { state.autoscroll = autoscroll.or(state.autoscroll); - state.show_completions = show_completions; + state.should_update_completions = should_update_completions; let (changed, result) = self.selections.change_with(cx, change); state.changed |= changed; return result; } let mut state = DeferredSelectionEffectsState { changed: false, - show_completions, + should_update_completions, autoscroll, old_cursor_position: self.selections.newest_anchor().head(), history_entry: SelectionHistoryEntry { @@ -3068,7 +3051,7 @@ impl Editor { self.selections_did_change( true, &old_cursor_position, - state.show_completions, + state.should_update_completions, window, cx, ); @@ -3979,7 +3962,7 @@ impl Editor { } let had_active_inline_completion = this.has_active_inline_completion(); - this.change_selections_without_showing_completions( + this.change_selections_without_updating_completions( Some(Autoscroll::fit()), window, cx, @@ -5025,7 +5008,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.open_completions_menu(true, None, window, cx); + self.open_or_update_completions_menu(true, None, window, cx); } pub fn show_completions( @@ -5034,10 +5017,10 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.open_completions_menu(false, options.trigger.as_deref(), window, cx); + self.open_or_update_completions_menu(false, options.trigger.as_deref(), window, cx); } - fn open_completions_menu( + fn open_or_update_completions_menu( &mut self, ignore_completion_provider: bool, trigger: Option<&str>, @@ -5047,9 +5030,6 @@ impl Editor { if self.pending_rename.is_some() { return; } - if !self.snippet_stack.is_empty() && self.context_menu.borrow().as_ref().is_some() { - return; - } let position = self.selections.newest_anchor().head(); if position.diff_base_anchor.is_some() { @@ -5062,11 +5042,52 @@ impl Editor { return; }; let buffer_snapshot = buffer.read(cx).snapshot(); - let show_completion_documentation = buffer_snapshot - .settings_at(buffer_position, cx) - .show_completion_documentation; - let query = Self::completion_query(&self.buffer.read(cx).read(cx), position); + let query: Option> = + Self::completion_query(&self.buffer.read(cx).read(cx), position) + .map(|query| query.into()); + + let provider = if ignore_completion_provider { + None + } else { + self.completion_provider.clone() + }; + + let sort_completions = provider + .as_ref() + .map_or(false, |provider| provider.sort_completions()); + + let filter_completions = provider + .as_ref() + .map_or(true, |provider| provider.filter_completions()); + + // When `is_incomplete` is false, can filter completions instead of re-querying when the + // current query is a suffix of the initial query. + if let Some(CodeContextMenu::Completions(menu)) = self.context_menu.borrow_mut().as_mut() { + if !menu.is_incomplete && filter_completions { + // If the new query is a suffix of the old query (typing more characters) and + // the previous result was complete, the existing completions can be filtered. + // + // Note that this is always true for snippet completions. + let query_matches = match (&menu.initial_query, &query) { + (Some(initial_query), Some(query)) => query.starts_with(initial_query.as_ref()), + (None, _) => true, + _ => false, + }; + if query_matches { + let position_matches = if menu.initial_position == position { + true + } else { + let snapshot = self.buffer.read(cx).read(cx); + menu.initial_position.to_offset(&snapshot) == position.to_offset(&snapshot) + }; + if position_matches { + menu.filter(query.clone(), provider.clone(), window, cx); + return; + } + } + } + }; let trigger_kind = match trigger { Some(trigger) if buffer.read(cx).completion_triggers().contains(trigger) => { @@ -5085,14 +5106,14 @@ impl Editor { trigger_kind, }; - let (old_range, word_kind) = buffer_snapshot.surrounding_word(buffer_position); - let (old_range, word_to_exclude) = if word_kind == Some(CharKind::Word) { + let (replace_range, word_kind) = buffer_snapshot.surrounding_word(buffer_position); + let (replace_range, word_to_exclude) = if word_kind == Some(CharKind::Word) { let word_to_exclude = buffer_snapshot - .text_for_range(old_range.clone()) + .text_for_range(replace_range.clone()) .collect::(); ( - buffer_snapshot.anchor_before(old_range.start) - ..buffer_snapshot.anchor_after(old_range.end), + buffer_snapshot.anchor_before(replace_range.start) + ..buffer_snapshot.anchor_after(replace_range.end), Some(word_to_exclude), ) } else { @@ -5106,6 +5127,10 @@ impl Editor { let completion_settings = language_settings(language.clone(), buffer_snapshot.file(), cx).completions; + let show_completion_documentation = buffer_snapshot + .settings_at(buffer_position, cx) + .show_completion_documentation; + // The document can be large, so stay in reasonable bounds when searching for words, // otherwise completion pop-up might be slow to appear. const WORD_LOOKUP_ROWS: u32 = 5_000; @@ -5121,18 +5146,13 @@ impl Editor { let word_search_range = buffer_snapshot.point_to_offset(min_word_search) ..buffer_snapshot.point_to_offset(max_word_search); - let provider = if ignore_completion_provider { - None - } else { - self.completion_provider.clone() - }; let skip_digits = query .as_ref() .map_or(true, |query| !query.chars().any(|c| c.is_digit(10))); - let (mut words, provided_completions) = match &provider { + let (mut words, provider_responses) = match &provider { Some(provider) => { - let completions = provider.completions( + let provider_responses = provider.completions( position.excerpt_id, &buffer, buffer_position, @@ -5153,7 +5173,7 @@ impl Editor { }), }; - (words, completions) + (words, provider_responses) } None => ( cx.background_spawn(async move { @@ -5163,137 +5183,165 @@ impl Editor { skip_digits, }) }), - Task::ready(Ok(None)), + Task::ready(Ok(Vec::new())), ), }; - let sort_completions = provider - .as_ref() - .map_or(false, |provider| provider.sort_completions()); - - let filter_completions = provider - .as_ref() - .map_or(true, |provider| provider.filter_completions()); - let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; let id = post_inc(&mut self.next_completion_id); let task = cx.spawn_in(window, async move |editor, cx| { - async move { - editor.update(cx, |this, _| { - this.completion_tasks.retain(|(task_id, _)| *task_id >= id); - })?; + let Ok(()) = editor.update(cx, |this, _| { + this.completion_tasks.retain(|(task_id, _)| *task_id >= id); + }) else { + return; + }; - let mut completions = Vec::new(); - if let Some(provided_completions) = provided_completions.await.log_err().flatten() { - completions.extend(provided_completions); + // TODO: Ideally completions from different sources would be selectively re-queried, so + // that having one source with `is_incomplete: true` doesn't cause all to be re-queried. + let mut completions = Vec::new(); + let mut is_incomplete = false; + if let Some(provider_responses) = provider_responses.await.log_err() { + if !provider_responses.is_empty() { + for response in provider_responses { + completions.extend(response.completions); + is_incomplete = is_incomplete || response.is_incomplete; + } if completion_settings.words == WordsCompletionMode::Fallback { words = Task::ready(BTreeMap::default()); } } + } - let mut words = words.await; - if let Some(word_to_exclude) = &word_to_exclude { - words.remove(word_to_exclude); - } - for lsp_completion in &completions { - words.remove(&lsp_completion.new_text); - } - completions.extend(words.into_iter().map(|(word, word_range)| Completion { - replace_range: old_range.clone(), - new_text: word.clone(), - label: CodeLabel::plain(word, None), - icon_path: None, - documentation: None, - source: CompletionSource::BufferWord { - word_range, - resolved: false, - }, - insert_text_mode: Some(InsertTextMode::AS_IS), - confirm: None, - })); + let mut words = words.await; + if let Some(word_to_exclude) = &word_to_exclude { + words.remove(word_to_exclude); + } + for lsp_completion in &completions { + words.remove(&lsp_completion.new_text); + } + completions.extend(words.into_iter().map(|(word, word_range)| Completion { + replace_range: replace_range.clone(), + new_text: word.clone(), + label: CodeLabel::plain(word, None), + icon_path: None, + documentation: None, + source: CompletionSource::BufferWord { + word_range, + resolved: false, + }, + insert_text_mode: Some(InsertTextMode::AS_IS), + confirm: None, + })); - let menu = if completions.is_empty() { - None - } else { - let mut menu = editor.update(cx, |editor, cx| { - let languages = editor - .workspace - .as_ref() - .and_then(|(workspace, _)| workspace.upgrade()) - .map(|workspace| workspace.read(cx).app_state().languages.clone()); - CompletionsMenu::new( - id, - sort_completions, - show_completion_documentation, - ignore_completion_provider, - position, - buffer.clone(), - completions.into(), - snippet_sort_order, - languages, - language, - cx, - ) - })?; - - menu.filter( - if filter_completions { - query.as_deref() - } else { - None - }, - provider, - editor.clone(), + let menu = if completions.is_empty() { + None + } else { + let Ok((mut menu, matches_task)) = editor.update(cx, |editor, cx| { + let languages = editor + .workspace + .as_ref() + .and_then(|(workspace, _)| workspace.upgrade()) + .map(|workspace| workspace.read(cx).app_state().languages.clone()); + let menu = CompletionsMenu::new( + id, + sort_completions, + show_completion_documentation, + ignore_completion_provider, + position, + query.clone(), + is_incomplete, + buffer.clone(), + completions.into(), + snippet_sort_order, + languages, + language, cx, - ) - .await; + ); - menu.visible().then_some(menu) + let query = if filter_completions { query } else { None }; + let matches_task = if let Some(query) = query { + menu.do_async_filtering(query, cx) + } else { + Task::ready(menu.unfiltered_matches()) + }; + (menu, matches_task) + }) else { + return; }; - editor.update_in(cx, |editor, window, cx| { + let matches = matches_task.await; + + let Ok(()) = editor.update_in(cx, |editor, window, cx| { + // Newer menu already set, so exit. match editor.context_menu.borrow().as_ref() { - None => {} Some(CodeContextMenu::Completions(prev_menu)) => { if prev_menu.id > id { return; } } - _ => return, + _ => {} + }; + + // Only valid to take prev_menu because it the new menu is immediately set + // below, or the menu is hidden. + match editor.context_menu.borrow_mut().take() { + Some(CodeContextMenu::Completions(prev_menu)) => { + let position_matches = + if prev_menu.initial_position == menu.initial_position { + true + } else { + let snapshot = editor.buffer.read(cx).read(cx); + prev_menu.initial_position.to_offset(&snapshot) + == menu.initial_position.to_offset(&snapshot) + }; + if position_matches { + // Preserve markdown cache before `set_filter_results` because it will + // try to populate the documentation cache. + menu.preserve_markdown_cache(prev_menu); + } + } + _ => {} + }; + + menu.set_filter_results(matches, provider, window, cx); + }) else { + return; + }; + + menu.visible().then_some(menu) + }; + + editor + .update_in(cx, |editor, window, cx| { + if editor.focus_handle.is_focused(window) { + if let Some(menu) = menu { + *editor.context_menu.borrow_mut() = + Some(CodeContextMenu::Completions(menu)); + + crate::hover_popover::hide_hover(editor, cx); + if editor.show_edit_predictions_in_menu() { + editor.update_visible_inline_completion(window, cx); + } else { + editor.discard_inline_completion(false, cx); + } + + cx.notify(); + return; + } } - if editor.focus_handle.is_focused(window) && menu.is_some() { - let mut menu = menu.unwrap(); - menu.resolve_visible_completions(editor.completion_provider.as_deref(), cx); - crate::hover_popover::hide_hover(editor, cx); - *editor.context_menu.borrow_mut() = - Some(CodeContextMenu::Completions(menu)); - - if editor.show_edit_predictions_in_menu() { - editor.update_visible_inline_completion(window, cx); - } else { - editor.discard_inline_completion(false, cx); - } - - cx.notify(); - } else if editor.completion_tasks.len() <= 1 { - // If there are no more completion tasks and the last menu was - // empty, we should hide it. + if editor.completion_tasks.len() <= 1 { + // If there are no more completion tasks and the last menu was empty, we should hide it. let was_hidden = editor.hide_context_menu(window, cx).is_none(); - // If it was already hidden and we don't show inline - // completions in the menu, we should also show the - // inline-completion when available. + // If it was already hidden and we don't show inline completions in the menu, we should + // also show the inline-completion when available. if was_hidden && editor.show_edit_predictions_in_menu() { editor.update_visible_inline_completion(window, cx); } } - })?; - - anyhow::Ok(()) - } - .log_err() - .await + }) + .ok(); }); self.completion_tasks.push((id, task)); @@ -5313,17 +5361,16 @@ impl Editor { pub fn with_completions_menu_matching_id( &self, id: CompletionId, - on_absent: impl FnOnce() -> R, - on_match: impl FnOnce(&mut CompletionsMenu) -> R, + f: impl FnOnce(Option<&mut CompletionsMenu>) -> R, ) -> R { let mut context_menu = self.context_menu.borrow_mut(); let Some(CodeContextMenu::Completions(completions_menu)) = &mut *context_menu else { - return on_absent(); + return f(None); }; if completions_menu.id != id { - return on_absent(); + return f(None); } - on_match(completions_menu) + f(Some(completions_menu)) } pub fn confirm_completion( @@ -5396,7 +5443,7 @@ impl Editor { .clone(); cx.stop_propagation(); - let buffer_handle = completions_menu.buffer; + let buffer_handle = completions_menu.buffer.clone(); let CompletionEdit { new_text, @@ -20206,7 +20253,7 @@ pub trait CompletionProvider { trigger: CompletionContext, window: &mut Window, cx: &mut Context, - ) -> Task>>>; + ) -> Task>>; fn resolve_completions( &self, @@ -20315,7 +20362,7 @@ fn snippet_completions( buffer: &Entity, buffer_position: text::Anchor, cx: &mut App, -) -> Task>> { +) -> Task> { let languages = buffer.read(cx).languages_at(buffer_position); let snippet_store = project.snippets().read(cx); @@ -20334,7 +20381,10 @@ fn snippet_completions( .collect(); if scopes.is_empty() { - return Task::ready(Ok(vec![])); + return Task::ready(Ok(CompletionResponse { + completions: vec![], + is_incomplete: false, + })); } let snapshot = buffer.read(cx).text_snapshot(); @@ -20344,7 +20394,8 @@ fn snippet_completions( let executor = cx.background_executor().clone(); cx.background_spawn(async move { - let mut all_results: Vec = Vec::new(); + let mut is_incomplete = false; + let mut completions: Vec = Vec::new(); for (scope, snippets) in scopes.into_iter() { let classifier = CharClassifier::new(Some(scope)).for_completion(true); let mut last_word = chars @@ -20354,7 +20405,10 @@ fn snippet_completions( last_word = last_word.chars().rev().collect(); if last_word.is_empty() { - return Ok(vec![]); + return Ok(CompletionResponse { + completions: vec![], + is_incomplete: true, + }); } let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); @@ -20375,16 +20429,21 @@ fn snippet_completions( }) .collect::>(); + const MAX_RESULTS: usize = 100; let mut matches = fuzzy::match_strings( &candidates, &last_word, last_word.chars().any(|c| c.is_uppercase()), - 100, + MAX_RESULTS, &Default::default(), executor.clone(), ) .await; + if matches.len() >= MAX_RESULTS { + is_incomplete = true; + } + // Remove all candidates where the query's start does not match the start of any word in the candidate if let Some(query_start) = last_word.chars().next() { matches.retain(|string_match| { @@ -20404,76 +20463,72 @@ fn snippet_completions( .map(|m| m.string) .collect::>(); - let mut result: Vec = snippets - .iter() - .filter_map(|snippet| { - let matching_prefix = snippet - .prefix - .iter() - .find(|prefix| matched_strings.contains(*prefix))?; - let start = as_offset - last_word.len(); - let start = snapshot.anchor_before(start); - let range = start..buffer_position; - let lsp_start = to_lsp(&start); - let lsp_range = lsp::Range { - start: lsp_start, - end: lsp_end, - }; - Some(Completion { - replace_range: range, - new_text: snippet.body.clone(), - source: CompletionSource::Lsp { - insert_range: None, - server_id: LanguageServerId(usize::MAX), - resolved: true, - lsp_completion: Box::new(lsp::CompletionItem { - label: snippet.prefix.first().unwrap().clone(), - kind: Some(CompletionItemKind::SNIPPET), - label_details: snippet.description.as_ref().map(|description| { - lsp::CompletionItemLabelDetails { - detail: Some(description.clone()), - description: None, - } - }), - insert_text_format: Some(InsertTextFormat::SNIPPET), - text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( - lsp::InsertReplaceEdit { - new_text: snippet.body.clone(), - insert: lsp_range, - replace: lsp_range, - }, - )), - filter_text: Some(snippet.body.clone()), - sort_text: Some(char::MAX.to_string()), - ..lsp::CompletionItem::default() + completions.extend(snippets.iter().filter_map(|snippet| { + let matching_prefix = snippet + .prefix + .iter() + .find(|prefix| matched_strings.contains(*prefix))?; + let start = as_offset - last_word.len(); + let start = snapshot.anchor_before(start); + let range = start..buffer_position; + let lsp_start = to_lsp(&start); + let lsp_range = lsp::Range { + start: lsp_start, + end: lsp_end, + }; + Some(Completion { + replace_range: range, + new_text: snippet.body.clone(), + source: CompletionSource::Lsp { + insert_range: None, + server_id: LanguageServerId(usize::MAX), + resolved: true, + lsp_completion: Box::new(lsp::CompletionItem { + label: snippet.prefix.first().unwrap().clone(), + kind: Some(CompletionItemKind::SNIPPET), + label_details: snippet.description.as_ref().map(|description| { + lsp::CompletionItemLabelDetails { + detail: Some(description.clone()), + description: None, + } }), - lsp_defaults: None, - }, - label: CodeLabel { - text: matching_prefix.clone(), - runs: Vec::new(), - filter_range: 0..matching_prefix.len(), - }, - icon_path: None, - documentation: Some( - CompletionDocumentation::SingleLineAndMultiLinePlainText { - single_line: snippet.name.clone().into(), - plain_text: snippet - .description - .clone() - .map(|description| description.into()), - }, - ), - insert_text_mode: None, - confirm: None, - }) + insert_text_format: Some(InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: snippet.body.clone(), + insert: lsp_range, + replace: lsp_range, + }, + )), + filter_text: Some(snippet.body.clone()), + sort_text: Some(char::MAX.to_string()), + ..lsp::CompletionItem::default() + }), + lsp_defaults: None, + }, + label: CodeLabel { + text: matching_prefix.clone(), + runs: Vec::new(), + filter_range: 0..matching_prefix.len(), + }, + icon_path: None, + documentation: Some(CompletionDocumentation::SingleLineAndMultiLinePlainText { + single_line: snippet.name.clone().into(), + plain_text: snippet + .description + .clone() + .map(|description| description.into()), + }), + insert_text_mode: None, + confirm: None, }) - .collect(); - - all_results.append(&mut result); + })) } - Ok(all_results) + Ok(CompletionResponse { + completions, + is_incomplete, + }) }) } @@ -20486,25 +20541,17 @@ impl CompletionProvider for Entity { options: CompletionContext, _window: &mut Window, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { self.update(cx, |project, cx| { let snippets = snippet_completions(project, buffer, buffer_position, cx); let project_completions = project.completions(buffer, buffer_position, options, cx); cx.background_spawn(async move { - let snippets_completions = snippets.await?; - match project_completions.await? { - Some(mut completions) => { - completions.extend(snippets_completions); - Ok(Some(completions)) - } - None => { - if snippets_completions.is_empty() { - Ok(None) - } else { - Ok(Some(snippets_completions)) - } - } + let mut responses = project_completions.await?; + let snippets = snippets.await?; + if !snippets.completions.is_empty() { + responses.push(snippets); } + Ok(responses) }) }) } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 58bc064690..af62a8af72 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::{ JoinLines, + code_context_menus::CodeContextMenu, inline_completion_tests::FakeInlineCompletionProvider, linked_editing_ranges::LinkedEditingRanges, scroll::scroll_amount::ScrollAmount, @@ -11184,14 +11185,15 @@ async fn test_completion(cx: &mut TestAppContext) { "}); cx.simulate_keystroke("."); handle_completion_request( - &mut cx, indoc! {" one.|<> two three "}, vec!["first_completion", "second_completion"], + true, counter.clone(), + &mut cx, ) .await; cx.condition(|editor, _| editor.context_menu_visible()) @@ -11291,7 +11293,6 @@ async fn test_completion(cx: &mut TestAppContext) { additional edit "}); handle_completion_request( - &mut cx, indoc! {" one.second_completion two s @@ -11299,7 +11300,9 @@ async fn test_completion(cx: &mut TestAppContext) { additional edit "}, vec!["fourth_completion", "fifth_completion", "sixth_completion"], + true, counter.clone(), + &mut cx, ) .await; cx.condition(|editor, _| editor.context_menu_visible()) @@ -11309,7 +11312,6 @@ async fn test_completion(cx: &mut TestAppContext) { cx.simulate_keystroke("i"); handle_completion_request( - &mut cx, indoc! {" one.second_completion two si @@ -11317,7 +11319,9 @@ async fn test_completion(cx: &mut TestAppContext) { additional edit "}, vec!["fourth_completion", "fifth_completion", "sixth_completion"], + true, counter.clone(), + &mut cx, ) .await; cx.condition(|editor, _| editor.context_menu_visible()) @@ -11351,10 +11355,11 @@ async fn test_completion(cx: &mut TestAppContext) { editor.show_completions(&ShowCompletions { trigger: None }, window, cx); }); handle_completion_request( - &mut cx, "editor.", vec!["close", "clobber"], + true, counter.clone(), + &mut cx, ) .await; cx.condition(|editor, _| editor.context_menu_visible()) @@ -11371,6 +11376,128 @@ async fn test_completion(cx: &mut TestAppContext) { apply_additional_edits.await.unwrap(); } +#[gpui::test] +async fn test_completion_reuse(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + let counter = Arc::new(AtomicUsize::new(0)); + cx.set_state("objˇ"); + cx.simulate_keystroke("."); + + // Initial completion request returns complete results + let is_incomplete = false; + handle_completion_request( + "obj.|<>", + vec!["a", "ab", "abc"], + is_incomplete, + counter.clone(), + &mut cx, + ) + .await; + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("obj.ˇ"); + check_displayed_completions(vec!["a", "ab", "abc"], &mut cx); + + // Type "a" - filters existing completions + cx.simulate_keystroke("a"); + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("obj.aˇ"); + check_displayed_completions(vec!["a", "ab", "abc"], &mut cx); + + // Type "b" - filters existing completions + cx.simulate_keystroke("b"); + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("obj.abˇ"); + check_displayed_completions(vec!["ab", "abc"], &mut cx); + + // Type "c" - filters existing completions + cx.simulate_keystroke("c"); + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("obj.abcˇ"); + check_displayed_completions(vec!["abc"], &mut cx); + + // Backspace to delete "c" - filters existing completions + cx.update_editor(|editor, window, cx| { + editor.backspace(&Backspace, window, cx); + }); + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("obj.abˇ"); + check_displayed_completions(vec!["ab", "abc"], &mut cx); + + // Moving cursor to the left dismisses menu. + cx.update_editor(|editor, window, cx| { + editor.move_left(&MoveLeft, window, cx); + }); + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 1); + cx.assert_editor_state("obj.aˇb"); + cx.update_editor(|editor, _, _| { + assert_eq!(editor.context_menu_visible(), false); + }); + + // Type "b" - new request + cx.simulate_keystroke("b"); + let is_incomplete = false; + handle_completion_request( + "obj.a", + vec!["ab", "abc"], + is_incomplete, + counter.clone(), + &mut cx, + ) + .await; + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 2); + cx.assert_editor_state("obj.abˇb"); + check_displayed_completions(vec!["ab", "abc"], &mut cx); + + // Backspace to delete "b" - since query was "ab" and is now "a", new request is made. + cx.update_editor(|editor, window, cx| { + editor.backspace(&Backspace, window, cx); + }); + let is_incomplete = false; + handle_completion_request( + "obj.b", + vec!["a", "ab", "abc"], + is_incomplete, + counter.clone(), + &mut cx, + ) + .await; + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 3); + cx.assert_editor_state("obj.aˇb"); + check_displayed_completions(vec!["a", "ab", "abc"], &mut cx); + + // Backspace to delete "a" - dismisses menu. + cx.update_editor(|editor, window, cx| { + editor.backspace(&Backspace, window, cx); + }); + cx.run_until_parked(); + assert_eq!(counter.load(atomic::Ordering::Acquire), 3); + cx.assert_editor_state("obj.ˇb"); + cx.update_editor(|editor, _, _| { + assert_eq!(editor.context_menu_visible(), false); + }); +} + #[gpui::test] async fn test_word_completion(cx: &mut TestAppContext) { let lsp_fetch_timeout_ms = 10; @@ -12051,9 +12178,11 @@ async fn test_no_duplicated_completion_requests(cx: &mut TestAppContext) { let task_completion_item = closure_completion_item.clone(); counter_clone.fetch_add(1, atomic::Ordering::Release); async move { - Ok(Some(lsp::CompletionResponse::Array(vec![ - task_completion_item, - ]))) + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: true, + item_defaults: None, + items: vec![task_completion_item], + }))) } }); @@ -21109,6 +21238,22 @@ pub fn handle_signature_help_request( } } +#[track_caller] +pub fn check_displayed_completions(expected: Vec<&'static str>, cx: &mut EditorLspTestContext) { + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow().as_ref() { + let entries = menu.entries.borrow(); + let entries = entries + .iter() + .map(|entry| entry.string.as_str()) + .collect::>(); + assert_eq!(entries, expected); + } else { + panic!("Expected completions menu"); + } + }); +} + /// Handle completion request passing a marked string specifying where the completion /// should be triggered from using '|' character, what range should be replaced, and what completions /// should be returned using '<' and '>' to delimit the range. @@ -21116,10 +21261,11 @@ pub fn handle_signature_help_request( /// Also see `handle_completion_request_with_insert_and_replace`. #[track_caller] pub fn handle_completion_request( - cx: &mut EditorLspTestContext, marked_string: &str, completions: Vec<&'static str>, + is_incomplete: bool, counter: Arc, + cx: &mut EditorLspTestContext, ) -> impl Future { let complete_from_marker: TextRangeMarker = '|'.into(); let replace_range_marker: TextRangeMarker = ('<', '>').into(); @@ -21143,8 +21289,10 @@ pub fn handle_completion_request( params.text_document_position.position, complete_from_position ); - Ok(Some(lsp::CompletionResponse::Array( - completions + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: is_incomplete, + item_defaults: None, + items: completions .iter() .map(|completion_text| lsp::CompletionItem { label: completion_text.to_string(), @@ -21155,7 +21303,7 @@ pub fn handle_completion_request( ..Default::default() }) .collect(), - ))) + }))) } }); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 974870bf2c..d962ee9f56 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -1095,14 +1095,15 @@ mod tests { //prompt autocompletion menu cx.simulate_keystroke("."); handle_completion_request( - &mut cx, indoc! {" one.|<> two three "}, vec!["first_completion", "second_completion"], + true, counter.clone(), + &mut cx, ) .await; cx.condition(|editor, _| editor.context_menu_visible()) // wait until completion menu is visible diff --git a/crates/editor/src/jsx_tag_auto_close.rs b/crates/editor/src/jsx_tag_auto_close.rs index df50ab9b2f..c365b285c7 100644 --- a/crates/editor/src/jsx_tag_auto_close.rs +++ b/crates/editor/src/jsx_tag_auto_close.rs @@ -600,7 +600,7 @@ pub(crate) fn handle_from( }) .collect::>(); this.update_in(cx, |this, window, cx| { - this.change_selections_without_showing_completions(None, window, cx, |s| { + this.change_selections_without_updating_completions(None, window, cx, |s| { s.select(base_selections); }); }) diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index fdd6bf2332..43dd130fe2 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -759,8 +759,8 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { }) .await .unwrap() - .unwrap() .into_iter() + .flat_map(|response| response.completions) .map(|c| c.label.text) .collect::>(); assert_eq!( diff --git a/crates/inspector_ui/src/div_inspector.rs b/crates/inspector_ui/src/div_inspector.rs index 16396fc586..664c904d39 100644 --- a/crates/inspector_ui/src/div_inspector.rs +++ b/crates/inspector_ui/src/div_inspector.rs @@ -11,7 +11,7 @@ use language::{ DiagnosticSeverity, LanguageServerId, Point, ToOffset as _, ToPoint as _, }; use project::lsp_store::CompletionDocumentation; -use project::{Completion, CompletionSource, Project, ProjectPath}; +use project::{Completion, CompletionResponse, CompletionSource, Project, ProjectPath}; use std::cell::RefCell; use std::fmt::Write as _; use std::ops::Range; @@ -641,18 +641,18 @@ impl CompletionProvider for RustStyleCompletionProvider { _: editor::CompletionContext, _window: &mut Window, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { let Some(replace_range) = completion_replace_range(&buffer.read(cx).snapshot(), &position) else { - return Task::ready(Ok(Some(Vec::new()))); + return Task::ready(Ok(Vec::new())); }; self.div_inspector.update(cx, |div_inspector, _cx| { div_inspector.rust_completion_replace_range = Some(replace_range.clone()); }); - Task::ready(Ok(Some( - STYLE_METHODS + Task::ready(Ok(vec![CompletionResponse { + completions: STYLE_METHODS .iter() .map(|(_, method)| Completion { replace_range: replace_range.clone(), @@ -667,7 +667,8 @@ impl CompletionProvider for RustStyleCompletionProvider { confirm: None, }) .collect(), - ))) + is_incomplete: false, + }])) } fn resolve_completions( diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 00ab0cc94b..1bbabe1724 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -1,10 +1,10 @@ mod signature_help; use crate::{ - CodeAction, CompletionSource, CoreCompletion, DocumentHighlight, DocumentSymbol, Hover, - HoverBlock, HoverBlockKind, InlayHint, InlayHintLabel, InlayHintLabelPart, - InlayHintLabelPartTooltip, InlayHintTooltip, Location, LocationLink, LspAction, MarkupContent, - PrepareRenameResponse, ProjectTransaction, ResolveState, + CodeAction, CompletionSource, CoreCompletion, CoreCompletionResponse, DocumentHighlight, + DocumentSymbol, Hover, HoverBlock, HoverBlockKind, InlayHint, InlayHintLabel, + InlayHintLabelPart, InlayHintLabelPartTooltip, InlayHintTooltip, Location, LocationLink, + LspAction, MarkupContent, PrepareRenameResponse, ProjectTransaction, ResolveState, lsp_store::{LocalLspStore, LspStore}, }; use anyhow::{Context as _, Result}; @@ -2095,7 +2095,7 @@ impl LspCommand for GetHover { #[async_trait(?Send)] impl LspCommand for GetCompletions { - type Response = Vec; + type Response = CoreCompletionResponse; type LspRequest = lsp::request::Completion; type ProtoRequest = proto::GetCompletions; @@ -2127,19 +2127,22 @@ impl LspCommand for GetCompletions { mut cx: AsyncApp, ) -> Result { let mut response_list = None; - let mut completions = if let Some(completions) = completions { + let (mut completions, mut is_incomplete) = if let Some(completions) = completions { match completions { - lsp::CompletionResponse::Array(completions) => completions, + lsp::CompletionResponse::Array(completions) => (completions, false), lsp::CompletionResponse::List(mut list) => { + let is_incomplete = list.is_incomplete; let items = std::mem::take(&mut list.items); response_list = Some(list); - items + (items, is_incomplete) } } } else { - Vec::new() + (Vec::new(), false) }; + let unfiltered_completions_count = completions.len(); + let language_server_adapter = lsp_store .read_with(&mut cx, |lsp_store, _| { lsp_store.language_server_adapter_for_id(server_id) @@ -2259,11 +2262,17 @@ impl LspCommand for GetCompletions { }); })?; + // If completions were filtered out due to errors that may be transient, mark the result + // incomplete so that it is re-queried. + if unfiltered_completions_count != completions.len() { + is_incomplete = true; + } + language_server_adapter .process_completions(&mut completions) .await; - Ok(completions + let completions = completions .into_iter() .zip(completion_edits) .map(|(mut lsp_completion, mut edit)| { @@ -2290,7 +2299,12 @@ impl LspCommand for GetCompletions { }, } }) - .collect()) + .collect(); + + Ok(CoreCompletionResponse { + completions, + is_incomplete, + }) } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetCompletions { @@ -2332,18 +2346,20 @@ impl LspCommand for GetCompletions { } fn response_to_proto( - completions: Vec, + response: CoreCompletionResponse, _: &mut LspStore, _: PeerId, buffer_version: &clock::Global, _: &mut App, ) -> proto::GetCompletionsResponse { proto::GetCompletionsResponse { - completions: completions + completions: response + .completions .iter() .map(LspStore::serialize_completion) .collect(), version: serialize_version(buffer_version), + can_reuse: !response.is_incomplete, } } @@ -2360,11 +2376,16 @@ impl LspCommand for GetCompletions { })? .await?; - message + let completions = message .completions .into_iter() .map(LspStore::deserialize_completion) - .collect() + .collect::>>()?; + + Ok(CoreCompletionResponse { + completions, + is_incomplete: !message.can_reuse, + }) } fn buffer_id_from_proto(message: &proto::GetCompletions) -> Result { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 2d5da1c5b3..d4ec3f35b4 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -3,8 +3,8 @@ pub mod lsp_ext_command; pub mod rust_analyzer_ext; use crate::{ - CodeAction, Completion, CompletionSource, CoreCompletion, Hover, InlayHint, LspAction, - ProjectItem, ProjectPath, ProjectTransaction, ResolveState, Symbol, ToolchainStore, + CodeAction, Completion, CompletionResponse, CompletionSource, CoreCompletion, Hover, InlayHint, + LspAction, ProjectItem, ProjectPath, ProjectTransaction, ResolveState, Symbol, ToolchainStore, buffer_store::{BufferStore, BufferStoreEvent}, environment::ProjectEnvironment, lsp_command::{self, *}, @@ -998,7 +998,7 @@ impl LocalLspStore { .collect::>(); async move { - futures::future::join_all(shutdown_futures).await; + join_all(shutdown_futures).await; } } @@ -5081,7 +5081,7 @@ impl LspStore { position: PointUtf16, context: CompletionContext, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { let language_registry = self.languages.clone(); if let Some((upstream_client, project_id)) = self.upstream_client() { @@ -5105,11 +5105,17 @@ impl LspStore { }); cx.foreground_executor().spawn(async move { - let completions = task.await?; - let mut result = Vec::new(); - populate_labels_for_completions(completions, language, lsp_adapter, &mut result) - .await; - Ok(Some(result)) + let completion_response = task.await?; + let completions = populate_labels_for_completions( + completion_response.completions, + language, + lsp_adapter, + ) + .await; + Ok(vec![CompletionResponse { + completions, + is_incomplete: completion_response.is_incomplete, + }]) }) } else if let Some(local) = self.as_local() { let snapshot = buffer.read(cx).snapshot(); @@ -5123,7 +5129,7 @@ impl LspStore { ) .completions; if !completion_settings.lsp { - return Task::ready(Ok(None)); + return Task::ready(Ok(Vec::new())); } let server_ids: Vec<_> = buffer.update(cx, |buffer, cx| { @@ -5190,25 +5196,23 @@ impl LspStore { } })?; - let mut has_completions_returned = false; - let mut completions = Vec::new(); - for (lsp_adapter, task) in tasks { - if let Ok(Some(new_completions)) = task.await { - has_completions_returned = true; - populate_labels_for_completions( - new_completions, + let futures = tasks.into_iter().map(async |(lsp_adapter, task)| { + let completion_response = task.await.ok()??; + let completions = populate_labels_for_completions( + completion_response.completions, language.clone(), lsp_adapter, - &mut completions, ) .await; - } - } - if has_completions_returned { - Ok(Some(completions)) - } else { - Ok(None) - } + Some(CompletionResponse { + completions, + is_incomplete: completion_response.is_incomplete, + }) + }); + + let responses: Vec> = join_all(futures).await; + + Ok(responses.into_iter().flatten().collect()) }) } else { Task::ready(Err(anyhow!("No upstream client or local language server"))) @@ -9547,8 +9551,7 @@ async fn populate_labels_for_completions( new_completions: Vec, language: Option>, lsp_adapter: Option>, - completions: &mut Vec, -) { +) -> Vec { let lsp_completions = new_completions .iter() .filter_map(|new_completion| { @@ -9572,6 +9575,7 @@ async fn populate_labels_for_completions( .into_iter() .fuse(); + let mut completions = Vec::new(); for completion in new_completions { match completion.source.lsp_completion(true) { Some(lsp_completion) => { @@ -9612,6 +9616,7 @@ async fn populate_labels_for_completions( } } } + completions } #[derive(Debug)] diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 99ffb2055b..5f45846227 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -555,6 +555,23 @@ impl std::fmt::Debug for Completion { } } +/// Response from a source of completions. +pub struct CompletionResponse { + pub completions: Vec, + /// When false, indicates that the list is complete and so does not need to be re-queried if it + /// can be filtered instead. + pub is_incomplete: bool, +} + +/// Response from language server completion request. +#[derive(Clone, Debug, Default)] +pub(crate) struct CoreCompletionResponse { + pub completions: Vec, + /// When false, indicates that the list is complete and so does not need to be re-queried if it + /// can be filtered instead. + pub is_incomplete: bool, +} + /// A generic completion that can come from different sources. #[derive(Clone, Debug)] pub(crate) struct CoreCompletion { @@ -3430,7 +3447,7 @@ impl Project { position: T, context: CompletionContext, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { let position = position.to_point_utf16(buffer.read(cx)); self.lsp_store.update(cx, |lsp_store, cx| { lsp_store.completions(buffer, position, context, cx) diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 5ba121ec08..d4ee2aff24 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -3014,7 +3014,12 @@ async fn test_completions_with_text_edit(cx: &mut gpui::TestAppContext) { .next() .await; - let completions = completions.await.unwrap().unwrap(); + let completions = completions + .await + .unwrap() + .into_iter() + .flat_map(|response| response.completions) + .collect::>(); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); assert_eq!(completions.len(), 1); @@ -3097,7 +3102,12 @@ async fn test_completions_with_edit_ranges(cx: &mut gpui::TestAppContext) { .next() .await; - let completions = completions.await.unwrap().unwrap(); + let completions = completions + .await + .unwrap() + .into_iter() + .flat_map(|response| response.completions) + .collect::>(); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); assert_eq!(completions.len(), 1); @@ -3139,7 +3149,12 @@ async fn test_completions_with_edit_ranges(cx: &mut gpui::TestAppContext) { .next() .await; - let completions = completions.await.unwrap().unwrap(); + let completions = completions + .await + .unwrap() + .into_iter() + .flat_map(|response| response.completions) + .collect::>(); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); assert_eq!(completions.len(), 1); @@ -3210,7 +3225,12 @@ async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) { }) .next() .await; - let completions = completions.await.unwrap().unwrap(); + let completions = completions + .await + .unwrap() + .into_iter() + .flat_map(|response| response.completions) + .collect::>(); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); assert_eq!(completions.len(), 1); assert_eq!(completions[0].new_text, "fullyQualifiedName"); @@ -3237,7 +3257,12 @@ async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) { }) .next() .await; - let completions = completions.await.unwrap().unwrap(); + let completions = completions + .await + .unwrap() + .into_iter() + .flat_map(|response| response.completions) + .collect::>(); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); assert_eq!(completions.len(), 1); assert_eq!(completions[0].new_text, "component"); @@ -3305,7 +3330,12 @@ async fn test_completions_with_carriage_returns(cx: &mut gpui::TestAppContext) { }) .next() .await; - let completions = completions.await.unwrap().unwrap(); + let completions = completions + .await + .unwrap() + .into_iter() + .flat_map(|response| response.completions) + .collect::>(); assert_eq!(completions.len(), 1); assert_eq!(completions[0].new_text, "fully\nQualified\nName"); } diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index 9bc17978dd..47eb6fa3d3 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -195,6 +195,8 @@ message LspExtGoToParentModuleResponse { message GetCompletionsResponse { repeated Completion completions = 1; repeated VectorClockEntry version = 2; + // `!is_complete`, inverted for a default of `is_complete = true` + bool can_reuse = 3; } message ApplyCompletionAdditionalEdits { diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index def14d12f5..5988b525b7 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -513,8 +513,8 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext assert_eq!( result - .unwrap() .into_iter() + .flat_map(|response| response.completions) .map(|c| c.label.text) .collect::>(), vec!["boop".to_string()]