use std::ops::Range; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::AtomicBool; use agent::context_store::ContextStore; use anyhow::Result; use editor::{CompletionProvider, Editor, ExcerptId, ToOffset as _}; use file_icons::FileIcons; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{App, Entity, Task, WeakEntity}; use http_client::HttpClientWithUrl; use itertools::Itertools; use language::{Buffer, CodeLabel, HighlightId}; use lsp::CompletionContext; use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, Symbol, WorktreeId}; use prompt_store::PromptStore; use rope::Point; use text::{Anchor, OffsetRangeExt, ToPoint}; use ui::prelude::*; use util::ResultExt as _; use workspace::Workspace; use agent::{ Thread, context::{AgentContextHandle, AgentContextKey, RULES_ICON}, thread_store::{TextThreadStore, ThreadStore}, }; use super::fetch_context_picker::fetch_url_content; use super::file_context_picker::{FileMatch, search_files}; use super::rules_context_picker::{RulesContextEntry, search_rules}; use super::symbol_context_picker::SymbolMatch; use super::symbol_context_picker::search_symbols; use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads}; use super::{ ContextPickerAction, ContextPickerEntry, ContextPickerMode, MentionLink, RecentEntry, available_context_picker_entries, recent_context_picker_entries_with_store, selection_ranges, }; use crate::message_editor::ContextCreasesAddon; pub(crate) enum Match { File(FileMatch), Symbol(SymbolMatch), Thread(ThreadMatch), Fetch(SharedString), Rules(RulesContextEntry), Entry(EntryMatch), } pub struct EntryMatch { mat: Option, entry: ContextPickerEntry, } impl Match { pub fn score(&self) -> f64 { match self { Match::File(file) => file.mat.score, Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.), Match::Thread(_) => 1., Match::Symbol(_) => 1., Match::Fetch(_) => 1., Match::Rules(_) => 1., } } } fn search( mode: Option, query: String, cancellation_flag: Arc, recent_entries: Vec, prompt_store: Option>, thread_store: Option>, text_thread_context_store: Option>, workspace: Entity, cx: &mut App, ) -> Task> { match mode { Some(ContextPickerMode::File) => { let search_files_task = search_files(query, cancellation_flag, &workspace, cx); cx.background_spawn(async move { search_files_task .await .into_iter() .map(Match::File) .collect() }) } Some(ContextPickerMode::Symbol) => { let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx); cx.background_spawn(async move { search_symbols_task .await .into_iter() .map(Match::Symbol) .collect() }) } Some(ContextPickerMode::Thread) => { if let Some((thread_store, context_store)) = thread_store .as_ref() .and_then(|t| t.upgrade()) .zip(text_thread_context_store.as_ref().and_then(|t| t.upgrade())) { let search_threads_task = search_threads(query, cancellation_flag, thread_store, context_store, cx); cx.background_spawn(async move { search_threads_task .await .into_iter() .map(Match::Thread) .collect() }) } else { Task::ready(Vec::new()) } } Some(ContextPickerMode::Fetch) => { if !query.is_empty() { Task::ready(vec![Match::Fetch(query.into())]) } else { Task::ready(Vec::new()) } } Some(ContextPickerMode::Rules) => { if let Some(prompt_store) = prompt_store.as_ref() { let search_rules_task = search_rules(query, cancellation_flag, prompt_store, cx); cx.background_spawn(async move { search_rules_task .await .into_iter() .map(Match::Rules) .collect::>() }) } else { Task::ready(Vec::new()) } } None => { if query.is_empty() { let mut matches = recent_entries .into_iter() .map(|entry| match entry { super::RecentEntry::File { project_path, path_prefix, } => Match::File(FileMatch { mat: fuzzy::PathMatch { score: 1., positions: Vec::new(), worktree_id: project_path.worktree_id.to_usize(), path: project_path.path, path_prefix, is_dir: false, distance_to_relative_ancestor: 0, }, is_recent: true, }), super::RecentEntry::Thread(thread_context_entry) => { Match::Thread(ThreadMatch { thread: thread_context_entry, is_recent: true, }) } }) .collect::>(); matches.extend( available_context_picker_entries(&prompt_store, &thread_store, &workspace, cx) .into_iter() .map(|mode| { Match::Entry(EntryMatch { entry: mode, mat: None, }) }), ); Task::ready(matches) } else { let executor = cx.background_executor().clone(); let search_files_task = search_files(query.clone(), cancellation_flag, &workspace, cx); let entries = available_context_picker_entries(&prompt_store, &thread_store, &workspace, cx); let entry_candidates = entries .iter() .enumerate() .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword())) .collect::>(); cx.background_spawn(async move { let mut matches = search_files_task .await .into_iter() .map(Match::File) .collect::>(); let entry_matches = fuzzy::match_strings( &entry_candidates, &query, false, true, 100, &Arc::new(AtomicBool::default()), executor, ) .await; matches.extend(entry_matches.into_iter().map(|mat| { Match::Entry(EntryMatch { entry: entries[mat.candidate_id], mat: Some(mat), }) })); matches.sort_by(|a, b| { b.score() .partial_cmp(&a.score()) .unwrap_or(std::cmp::Ordering::Equal) }); matches }) } } } } pub struct ContextPickerCompletionProvider { workspace: WeakEntity, context_store: WeakEntity, thread_store: Option>, text_thread_store: Option>, editor: WeakEntity, excluded_buffer: Option>, } impl ContextPickerCompletionProvider { pub fn new( workspace: WeakEntity, context_store: WeakEntity, thread_store: Option>, text_thread_store: Option>, editor: WeakEntity, exclude_buffer: Option>, ) -> Self { Self { workspace, context_store, thread_store, text_thread_store, editor, excluded_buffer: exclude_buffer, } } fn completion_for_entry( entry: ContextPickerEntry, excerpt_id: ExcerptId, source_range: Range, editor: Entity, context_store: Entity, workspace: &Entity, cx: &mut App, ) -> Option { match entry { ContextPickerEntry::Mode(mode) => Some(Completion { replace_range: source_range, new_text: format!("@{} ", mode.keyword()), label: CodeLabel::plain(mode.label().to_string(), None), icon_path: Some(mode.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, insert_text_mode: None, // This ensures that when a user accepts this completion, the // completion menu will still be shown after "@category " is // inserted confirm: Some(Arc::new(|_, _, _| true)), }), ContextPickerEntry::Action(action) => { let (new_text, on_action) = match action { ContextPickerAction::AddSelections => { let selections = selection_ranges(workspace, cx); let selection_infos = selections .iter() .map(|(buffer, range)| { let full_path = buffer .read(cx) .file() .map(|file| file.full_path(cx)) .unwrap_or_else(|| PathBuf::from("untitled")); let file_name = full_path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); let line_range = range.to_point(&buffer.read(cx).snapshot()); let link = MentionLink::for_selection( &file_name, &full_path.to_string_lossy(), line_range.start.row as usize..line_range.end.row as usize, ); (file_name, link, line_range) }) .collect::>(); let new_text = format!( "{} ", selection_infos.iter().map(|(_, link, _)| link).join(" ") ); let callback = Arc::new({ move |_, window: &mut Window, cx: &mut App| { context_store.update(cx, |context_store, cx| { for (buffer, range) in &selections { context_store.add_selection( buffer.clone(), range.clone(), cx, ); } }); let editor = editor.clone(); let selection_infos = selection_infos.clone(); window.defer(cx, move |window, cx| { let mut current_offset = 0; for (file_name, link, line_range) in selection_infos.iter() { let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); let Some(start) = snapshot .anchor_in_excerpt(excerpt_id, source_range.start) else { return; }; let offset = start.to_offset(&snapshot) + current_offset; let text_len = link.len(); let range = snapshot.anchor_after(offset) ..snapshot.anchor_after(offset + text_len); let crease = super::crease_for_mention( format!( "{} ({}-{})", file_name, line_range.start.row + 1, line_range.end.row + 1 ) .into(), IconName::Reader.path().into(), range, editor.downgrade(), ); editor.update(cx, |editor, cx| { editor.insert_creases(vec![crease.clone()], cx); editor.fold_creases(vec![crease], false, window, cx); }); current_offset += text_len + 1; } }); false } }); (new_text, callback) } }; Some(Completion { replace_range: source_range.clone(), new_text, label: CodeLabel::plain(action.label().to_string(), None), icon_path: Some(action.icon().path().into()), documentation: None, source: project::CompletionSource::Custom, insert_text_mode: None, // This ensures that when a user accepts this completion, the // completion menu will still be shown after "@category " is // inserted confirm: Some(on_action), }) } } } fn completion_for_thread( thread_entry: ThreadContextEntry, excerpt_id: ExcerptId, source_range: Range, recent: bool, editor: Entity, context_store: Entity, thread_store: Entity, text_thread_store: Entity, ) -> Completion { let icon_for_completion = if recent { IconName::HistoryRerun } else { IconName::Thread }; let new_text = format!("{} ", MentionLink::for_thread(&thread_entry)); let new_text_len = new_text.len(); Completion { replace_range: source_range.clone(), new_text, label: CodeLabel::plain(thread_entry.title().to_string(), None), documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, icon_path: Some(icon_for_completion.path().into()), confirm: Some(confirm_completion_callback( IconName::Thread.path().into(), thread_entry.title().clone(), excerpt_id, source_range.start, new_text_len - 1, editor, context_store.clone(), move |window, cx| match &thread_entry { ThreadContextEntry::Thread { id, .. } => { let thread_id = id.clone(); let context_store = context_store.clone(); let thread_store = thread_store.clone(); window.spawn::<_, Option<_>>(cx, async move |cx| { let thread: Entity = thread_store .update_in(cx, |thread_store, window, cx| { thread_store.open_thread(&thread_id, window, cx) }) .ok()? .await .log_err()?; let context = context_store .update(cx, |context_store, cx| { context_store.add_thread(thread, false, cx) }) .ok()??; Some(context) }) } ThreadContextEntry::Context { path, .. } => { let path = path.clone(); let context_store = context_store.clone(); let text_thread_store = text_thread_store.clone(); cx.spawn::<_, Option<_>>(async move |cx| { let thread = text_thread_store .update(cx, |store, cx| store.open_local_context(path, cx)) .ok()? .await .log_err()?; let context = context_store .update(cx, |context_store, cx| { context_store.add_text_thread(thread, false, cx) }) .ok()??; Some(context) }) } }, )), } } fn completion_for_rules( rules: RulesContextEntry, excerpt_id: ExcerptId, source_range: Range, editor: Entity, context_store: Entity, ) -> Completion { let new_text = format!("{} ", MentionLink::for_rule(&rules)); let new_text_len = new_text.len(); Completion { replace_range: source_range.clone(), new_text, label: CodeLabel::plain(rules.title.to_string(), None), documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, icon_path: Some(RULES_ICON.path().into()), confirm: Some(confirm_completion_callback( RULES_ICON.path().into(), rules.title.clone(), excerpt_id, source_range.start, new_text_len - 1, editor, context_store.clone(), move |_, cx| { let user_prompt_id = rules.prompt_id; let context = context_store.update(cx, |context_store, cx| { context_store.add_rules(user_prompt_id, false, cx) }); Task::ready(context) }, )), } } fn completion_for_fetch( source_range: Range, url_to_fetch: SharedString, excerpt_id: ExcerptId, editor: Entity, context_store: Entity, http_client: Arc, ) -> Completion { let new_text = format!("{} ", MentionLink::for_fetch(&url_to_fetch)); let new_text_len = new_text.len(); Completion { replace_range: source_range.clone(), new_text, label: CodeLabel::plain(url_to_fetch.to_string(), None), documentation: None, source: project::CompletionSource::Custom, icon_path: Some(IconName::ToolWeb.path().into()), insert_text_mode: None, confirm: Some(confirm_completion_callback( IconName::ToolWeb.path().into(), url_to_fetch.clone(), excerpt_id, source_range.start, new_text_len - 1, editor, context_store.clone(), move |_, cx| { let context_store = context_store.clone(); let http_client = http_client.clone(); let url_to_fetch = url_to_fetch.clone(); cx.spawn(async move |cx| { if let Some(context) = context_store .read_with(cx, |context_store, _| { context_store.get_url_context(url_to_fetch.clone()) }) .ok()? { return Some(context); } let content = cx .background_spawn(fetch_url_content( http_client, url_to_fetch.to_string(), )) .await .log_err()?; context_store .update(cx, |context_store, cx| { context_store.add_fetched_url(url_to_fetch.to_string(), content, cx) }) .ok() }) }, )), } } fn completion_for_path( project_path: ProjectPath, path_prefix: &str, is_recent: bool, is_directory: bool, excerpt_id: ExcerptId, source_range: Range, editor: Entity, context_store: Entity, cx: &App, ) -> Completion { let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory( &project_path.path, path_prefix, ); let label = build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx); let full_path = if let Some(directory) = directory { format!("{}{}", directory, file_name) } else { file_name.to_string() }; let crease_icon_path = if is_directory { FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into()) } else { FileIcons::get_icon(Path::new(&full_path), cx) .unwrap_or_else(|| IconName::File.path().into()) }; let completion_icon_path = if is_recent { IconName::HistoryRerun.path().into() } else { crease_icon_path.clone() }; let new_text = format!("{} ", MentionLink::for_file(&file_name, &full_path)); let new_text_len = new_text.len(); Completion { replace_range: source_range.clone(), new_text, label, documentation: None, source: project::CompletionSource::Custom, icon_path: Some(completion_icon_path), insert_text_mode: None, confirm: Some(confirm_completion_callback( crease_icon_path, file_name, excerpt_id, source_range.start, new_text_len - 1, editor, context_store.clone(), move |_, cx| { if is_directory { Task::ready( context_store .update(cx, |context_store, cx| { context_store.add_directory(&project_path, false, cx) }) .log_err() .flatten(), ) } else { let result = context_store.update(cx, |context_store, cx| { context_store.add_file_from_path(project_path.clone(), false, cx) }); cx.spawn(async move |_| result.await.log_err().flatten()) } }, )), } } fn completion_for_symbol( symbol: Symbol, excerpt_id: ExcerptId, source_range: Range, editor: Entity, context_store: Entity, workspace: Entity, cx: &mut App, ) -> Option { let path_prefix = workspace .read(cx) .project() .read(cx) .worktree_for_id(symbol.path.worktree_id, cx)? .read(cx) .root_name(); let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory( &symbol.path.path, path_prefix, ); let full_path = if let Some(directory) = directory { format!("{}{}", directory, file_name) } else { file_name.to_string() }; let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); let mut label = CodeLabel::plain(symbol.name.clone(), None); label.push_str(" ", None); label.push_str(&file_name, comment_id); label.push_str(&format!(" L{}", symbol.range.start.0.row + 1), comment_id); let new_text = format!("{} ", MentionLink::for_symbol(&symbol.name, &full_path)); let new_text_len = new_text.len(); Some(Completion { replace_range: source_range.clone(), new_text, label, documentation: None, source: project::CompletionSource::Custom, icon_path: Some(IconName::Code.path().into()), insert_text_mode: None, confirm: Some(confirm_completion_callback( IconName::Code.path().into(), symbol.name.clone().into(), excerpt_id, source_range.start, new_text_len - 1, editor, context_store.clone(), move |_, cx| { let symbol = symbol.clone(); let context_store = context_store.clone(); let workspace = workspace.clone(); let result = super::symbol_context_picker::add_symbol( symbol, false, workspace, context_store.downgrade(), cx, ); cx.spawn(async move |_| result.await.log_err()?.0) }, )), }) } } fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel { let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); let mut label = CodeLabel::default(); label.push_str(file_name, None); label.push_str(" ", None); if let Some(directory) = directory { label.push_str(directory, comment_id); } label.filter_range = 0..label.text().len(); label } impl CompletionProvider for ContextPickerCompletionProvider { fn completions( &self, excerpt_id: ExcerptId, buffer: &Entity, buffer_position: Anchor, _trigger: CompletionContext, _window: &mut Window, cx: &mut Context, ) -> Task>> { let state = buffer.update(cx, |buffer, _cx| { let position = buffer_position.to_point(buffer); let line_start = Point::new(position.row, 0); let offset_to_line = buffer.point_to_offset(line_start); let mut lines = buffer.text_for_range(line_start..position).lines(); let line = lines.next()?; MentionCompletion::try_parse(line, offset_to_line) }); let Some(state) = state else { return Task::ready(Ok(Vec::new())); }; let Some((workspace, context_store)) = self.workspace.upgrade().zip(self.context_store.upgrade()) else { return Task::ready(Ok(Vec::new())); }; let snapshot = buffer.read(cx).snapshot(); let source_range = snapshot.anchor_before(state.source_range.start) ..snapshot.anchor_after(state.source_range.end); let thread_store = self.thread_store.clone(); let text_thread_store = self.text_thread_store.clone(); let editor = self.editor.clone(); let http_client = workspace.read(cx).client().http_client(); let MentionCompletion { mode, argument, .. } = state; let query = argument.unwrap_or_else(|| "".to_string()); let excluded_path = self .excluded_buffer .as_ref() .and_then(WeakEntity::upgrade) .and_then(|b| b.read(cx).file()) .map(|file| ProjectPath::from_file(file.as_ref(), cx)); let recent_entries = recent_context_picker_entries_with_store( context_store.clone(), thread_store.clone(), text_thread_store.clone(), workspace.clone(), excluded_path.clone(), cx, ); let prompt_store = thread_store.as_ref().and_then(|thread_store| { thread_store .read_with(cx, |thread_store, _cx| thread_store.prompt_store().clone()) .ok() .flatten() }); let search_task = search( mode, query, Arc::::default(), recent_entries, prompt_store, thread_store.clone(), text_thread_store.clone(), workspace.clone(), cx, ); cx.spawn(async move |_, cx| { let matches = search_task.await; let Some(editor) = editor.upgrade() else { return Ok(Vec::new()); }; let completions = cx.update(|cx| { matches .into_iter() .filter_map(|mat| match mat { Match::File(FileMatch { mat, is_recent }) => { let project_path = ProjectPath { worktree_id: WorktreeId::from_usize(mat.worktree_id), path: mat.path.clone(), }; if excluded_path.as_ref() == Some(&project_path) { return None; } Some(Self::completion_for_path( project_path, &mat.path_prefix, is_recent, mat.is_dir, excerpt_id, source_range.clone(), editor.clone(), context_store.clone(), cx, )) } Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol( symbol, excerpt_id, source_range.clone(), editor.clone(), context_store.clone(), workspace.clone(), cx, ), Match::Thread(ThreadMatch { thread, is_recent, .. }) => { let thread_store = thread_store.as_ref().and_then(|t| t.upgrade())?; let text_thread_store = text_thread_store.as_ref().and_then(|t| t.upgrade())?; Some(Self::completion_for_thread( thread, excerpt_id, source_range.clone(), is_recent, editor.clone(), context_store.clone(), thread_store, text_thread_store, )) } Match::Rules(user_rules) => Some(Self::completion_for_rules( user_rules, excerpt_id, source_range.clone(), editor.clone(), context_store.clone(), )), Match::Fetch(url) => Some(Self::completion_for_fetch( source_range.clone(), url, excerpt_id, editor.clone(), context_store.clone(), http_client.clone(), )), Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry( entry, excerpt_id, source_range.clone(), editor.clone(), context_store.clone(), &workspace, cx, ), }) .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, }]) }) } fn is_completion_trigger( &self, buffer: &Entity, position: language::Anchor, _text: &str, _trigger_in_words: bool, _menu_is_open: bool, cx: &mut Context, ) -> bool { let buffer = buffer.read(cx); let position = position.to_point(buffer); let line_start = Point::new(position.row, 0); let offset_to_line = buffer.point_to_offset(line_start); let mut lines = buffer.text_for_range(line_start..position).lines(); if let Some(line) = lines.next() { MentionCompletion::try_parse(line, offset_to_line) .map(|completion| { completion.source_range.start <= offset_to_line + position.column as usize && completion.source_range.end >= offset_to_line + position.column as usize }) .unwrap_or(false) } else { false } } fn sort_completions(&self) -> bool { false } fn filter_completions(&self) -> bool { false } } fn confirm_completion_callback( crease_icon_path: SharedString, crease_text: SharedString, excerpt_id: ExcerptId, start: Anchor, content_len: usize, editor: Entity, context_store: Entity, add_context_fn: impl Fn(&mut Window, &mut App) -> Task> + Send + Sync + 'static, ) -> Arc bool + Send + Sync> { Arc::new(move |_, window, cx| { let context = add_context_fn(window, cx); let crease_text = crease_text.clone(); let crease_icon_path = crease_icon_path.clone(); let editor = editor.clone(); let context_store = context_store.clone(); window.defer(cx, move |window, cx| { let crease_id = crate::context_picker::insert_crease_for_mention( excerpt_id, start, content_len, crease_text.clone(), crease_icon_path, editor.clone(), window, cx, ); cx.spawn(async move |cx| { let crease_id = crease_id?; let context = context.await?; editor .update(cx, |editor, cx| { if let Some(addon) = editor.addon_mut::() { addon.add_creases( &context_store, AgentContextKey(context), [(crease_id, crease_text)], cx, ); } }) .ok() }) .detach(); }); false }) } #[derive(Debug, Default, PartialEq)] struct MentionCompletion { source_range: Range, mode: Option, argument: Option, } impl MentionCompletion { fn try_parse(line: &str, offset_to_line: usize) -> Option { let last_mention_start = line.rfind('@')?; if last_mention_start >= line.len() { return Some(Self::default()); } if last_mention_start > 0 && line .chars() .nth(last_mention_start - 1) .is_some_and(|c| !c.is_whitespace()) { return None; } let rest_of_line = &line[last_mention_start + 1..]; let mut mode = None; let mut argument = None; let mut parts = rest_of_line.split_whitespace(); let mut end = last_mention_start + 1; if let Some(mode_text) = parts.next() { end += mode_text.len(); if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok() { mode = Some(parsed_mode); } else { argument = Some(mode_text.to_string()); } match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) { Some(whitespace_count) => { if let Some(argument_text) = parts.next() { argument = Some(argument_text.to_string()); end += whitespace_count + argument_text.len(); } } None => { // Rest of line is entirely whitespace end += rest_of_line.len() - mode_text.len(); } } } Some(Self { source_range: last_mention_start + offset_to_line..end + offset_to_line, mode, argument, }) } } #[cfg(test)] mod tests { use super::*; use editor::AnchorRangeExt; use gpui::{EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext}; use project::{Project, ProjectPath}; use serde_json::json; use settings::SettingsStore; use std::{ops::Deref, rc::Rc}; use util::path; use workspace::{AppState, Item}; #[test] fn test_mention_completion_parse() { assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None); assert_eq!( MentionCompletion::try_parse("Lorem @", 0), Some(MentionCompletion { source_range: 6..7, mode: None, argument: None, }) ); assert_eq!( MentionCompletion::try_parse("Lorem @file", 0), Some(MentionCompletion { source_range: 6..11, mode: Some(ContextPickerMode::File), argument: None, }) ); assert_eq!( MentionCompletion::try_parse("Lorem @file ", 0), Some(MentionCompletion { source_range: 6..12, mode: Some(ContextPickerMode::File), argument: None, }) ); assert_eq!( MentionCompletion::try_parse("Lorem @file main.rs", 0), Some(MentionCompletion { source_range: 6..19, mode: Some(ContextPickerMode::File), argument: Some("main.rs".to_string()), }) ); assert_eq!( MentionCompletion::try_parse("Lorem @file main.rs ", 0), Some(MentionCompletion { source_range: 6..19, mode: Some(ContextPickerMode::File), argument: Some("main.rs".to_string()), }) ); assert_eq!( MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0), Some(MentionCompletion { source_range: 6..19, mode: Some(ContextPickerMode::File), argument: Some("main.rs".to_string()), }) ); assert_eq!( MentionCompletion::try_parse("Lorem @main", 0), Some(MentionCompletion { source_range: 6..11, mode: None, argument: Some("main".to_string()), }) ); assert_eq!(MentionCompletion::try_parse("test@", 0), None); } struct AtMentionEditor(Entity); impl Item for AtMentionEditor { type Event = (); fn include_in_nav_history() -> bool { false } fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { "Test".into() } } impl EventEmitter<()> for AtMentionEditor {} impl Focusable for AtMentionEditor { fn focus_handle(&self, cx: &App) -> FocusHandle { self.0.read(cx).focus_handle(cx) } } impl Render for AtMentionEditor { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { self.0.clone().into_any_element() } } #[gpui::test] async fn test_context_completion_provider(cx: &mut TestAppContext) { init_test(cx); let app_state = cx.update(AppState::test); cx.update(|cx| { language::init(cx); editor::init(cx); workspace::init(app_state.clone(), cx); Project::init_settings(cx); }); app_state .fs .as_fake() .insert_tree( path!("/dir"), json!({ "editor": "", "a": { "one.txt": "", "two.txt": "", "three.txt": "", "four.txt": "" }, "b": { "five.txt": "", "six.txt": "", "seven.txt": "", "eight.txt": "", } }), ) .await; let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let workspace = window.root(cx).unwrap(); let worktree = project.update(cx, |project, cx| { let mut worktrees = project.worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 1); worktrees.pop().unwrap() }); let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); let mut cx = VisualTestContext::from_window(*window.deref(), cx); let paths = vec![ path!("a/one.txt"), path!("a/two.txt"), path!("a/three.txt"), path!("a/four.txt"), path!("b/five.txt"), path!("b/six.txt"), path!("b/seven.txt"), path!("b/eight.txt"), ]; let mut opened_editors = Vec::new(); for path in paths { let buffer = workspace .update_in(&mut cx, |workspace, window, cx| { workspace.open_path( ProjectPath { worktree_id, path: Path::new(path).into(), }, None, false, window, cx, ) }) .await .unwrap(); opened_editors.push(buffer); } let editor = workspace.update_in(&mut cx, |workspace, window, cx| { let editor = cx.new(|cx| { Editor::new( editor::EditorMode::full(), multi_buffer::MultiBuffer::build_simple("", cx), None, window, cx, ) }); workspace.active_pane().update(cx, |pane, cx| { pane.add_item( Box::new(cx.new(|_| AtMentionEditor(editor.clone()))), true, true, None, window, cx, ); }); editor }); let context_store = cx.new(|_| ContextStore::new(project.downgrade(), None)); let editor_entity = editor.downgrade(); editor.update_in(&mut cx, |editor, window, cx| { let last_opened_buffer = opened_editors.last().and_then(|editor| { editor .downcast::()? .read(cx) .buffer() .read(cx) .as_singleton() .as_ref() .map(Entity::downgrade) }); window.focus(&editor.focus_handle(cx)); editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( workspace.downgrade(), context_store.downgrade(), None, None, editor_entity, last_opened_buffer, )))); }); cx.simulate_input("Lorem "); editor.update(&mut cx, |editor, cx| { assert_eq!(editor.text(cx), "Lorem "); assert!(!editor.has_visible_completions_menu()); }); cx.simulate_input("@"); editor.update(&mut cx, |editor, cx| { assert_eq!(editor.text(cx), "Lorem @"); assert!(editor.has_visible_completions_menu()); assert_eq!( current_completion_labels(editor), &[ "seven.txt dir/b/", "six.txt dir/b/", "five.txt dir/b/", "four.txt dir/a/", "Files & Directories", "Symbols", "Fetch" ] ); }); // Select and confirm "File" editor.update_in(&mut cx, |editor, window, cx| { assert!(editor.has_visible_completions_menu()); editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); }); cx.run_until_parked(); editor.update(&mut cx, |editor, cx| { assert_eq!(editor.text(cx), "Lorem @file "); assert!(editor.has_visible_completions_menu()); }); cx.simulate_input("one"); editor.update(&mut cx, |editor, cx| { assert_eq!(editor.text(cx), "Lorem @file one"); assert!(editor.has_visible_completions_menu()); assert_eq!(current_completion_labels(editor), vec!["one.txt dir/a/"]); }); editor.update_in(&mut cx, |editor, window, cx| { assert!(editor.has_visible_completions_menu()); editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); }); editor.update(&mut cx, |editor, cx| { assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) "); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), vec![Point::new(0, 6)..Point::new(0, 37)] ); }); cx.simulate_input(" "); editor.update(&mut cx, |editor, cx| { assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) "); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), vec![Point::new(0, 6)..Point::new(0, 37)] ); }); cx.simulate_input("Ipsum "); editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum ", ); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), vec![Point::new(0, 6)..Point::new(0, 37)] ); }); cx.simulate_input("@file "); editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum @file ", ); assert!(editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), vec![Point::new(0, 6)..Point::new(0, 37)] ); }); editor.update_in(&mut cx, |editor, window, cx| { editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); }); cx.run_until_parked(); editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt) " ); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), vec![ Point::new(0, 6)..Point::new(0, 37), Point::new(0, 45)..Point::new(0, 80) ] ); }); cx.simulate_input("\n@"); editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt) \n@" ); assert!(editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), vec![ Point::new(0, 6)..Point::new(0, 37), Point::new(0, 45)..Point::new(0, 80) ] ); }); editor.update_in(&mut cx, |editor, window, cx| { editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); }); cx.run_until_parked(); editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt) \n[@six.txt](@file:dir/b/six.txt) " ); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), vec![ Point::new(0, 6)..Point::new(0, 37), Point::new(0, 45)..Point::new(0, 80), Point::new(1, 0)..Point::new(1, 31) ] ); }); } fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec> { let snapshot = editor.buffer().read(cx).snapshot(cx); editor.display_map.update(cx, |display_map, cx| { display_map .snapshot(cx) .folds_in_range(0..snapshot.len()) .map(|fold| fold.range.to_point(&snapshot)) .collect() }) } fn current_completion_labels(editor: &Editor) -> Vec { let completions = editor.current_completions().expect("Missing completions"); completions .into_iter() .map(|completion| completion.label.text) .collect::>() } pub(crate) fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); theme::init(theme::LoadThemes::JustBase, cx); client::init_settings(cx); language::init(cx); Project::init_settings(cx); workspace::init_settings(cx); editor::init_settings(cx); }); } }