1use std::cmp::Reverse;
2use std::ops::Range;
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::sync::atomic::AtomicBool;
6
7use acp_thread::MentionUri;
8use agent::{HistoryEntry, HistoryStore};
9use anyhow::Result;
10use editor::{
11 CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH,
12};
13use fuzzy::{PathMatch, StringMatch, StringMatchCandidate};
14use gpui::{App, Entity, Task, WeakEntity};
15use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId};
16use lsp::CompletionContext;
17use ordered_float::OrderedFloat;
18use project::lsp_store::{CompletionDocumentation, SymbolLocation};
19use project::{
20 Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse,
21 PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId,
22};
23use prompt_store::{PromptId, PromptStore, UserPromptId};
24use rope::Point;
25use text::{Anchor, ToPoint as _};
26use ui::prelude::*;
27use util::ResultExt as _;
28use util::paths::PathStyle;
29use util::rel_path::RelPath;
30use util::truncate_and_remove_front;
31use workspace::Workspace;
32
33use crate::AgentPanel;
34use crate::mention_set::MentionSet;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub(crate) enum PromptContextEntry {
38 Mode(PromptContextType),
39 Action(PromptContextAction),
40}
41
42impl PromptContextEntry {
43 pub fn keyword(&self) -> &'static str {
44 match self {
45 Self::Mode(mode) => mode.keyword(),
46 Self::Action(action) => action.keyword(),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub(crate) enum PromptContextType {
53 File,
54 Symbol,
55 Fetch,
56 Thread,
57 Rules,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub(crate) enum PromptContextAction {
62 AddSelections,
63}
64
65impl PromptContextAction {
66 pub fn keyword(&self) -> &'static str {
67 match self {
68 Self::AddSelections => "selection",
69 }
70 }
71
72 pub fn label(&self) -> &'static str {
73 match self {
74 Self::AddSelections => "Selection",
75 }
76 }
77
78 pub fn icon(&self) -> IconName {
79 match self {
80 Self::AddSelections => IconName::Reader,
81 }
82 }
83}
84
85impl TryFrom<&str> for PromptContextType {
86 type Error = String;
87
88 fn try_from(value: &str) -> Result<Self, Self::Error> {
89 match value {
90 "file" => Ok(Self::File),
91 "symbol" => Ok(Self::Symbol),
92 "fetch" => Ok(Self::Fetch),
93 "thread" => Ok(Self::Thread),
94 "rule" => Ok(Self::Rules),
95 _ => Err(format!("Invalid context picker mode: {}", value)),
96 }
97 }
98}
99
100impl PromptContextType {
101 pub fn keyword(&self) -> &'static str {
102 match self {
103 Self::File => "file",
104 Self::Symbol => "symbol",
105 Self::Fetch => "fetch",
106 Self::Thread => "thread",
107 Self::Rules => "rule",
108 }
109 }
110
111 pub fn label(&self) -> &'static str {
112 match self {
113 Self::File => "Files & Directories",
114 Self::Symbol => "Symbols",
115 Self::Fetch => "Fetch",
116 Self::Thread => "Threads",
117 Self::Rules => "Rules",
118 }
119 }
120
121 pub fn icon(&self) -> IconName {
122 match self {
123 Self::File => IconName::File,
124 Self::Symbol => IconName::Code,
125 Self::Fetch => IconName::ToolWeb,
126 Self::Thread => IconName::Thread,
127 Self::Rules => IconName::Reader,
128 }
129 }
130}
131
132pub(crate) enum Match {
133 File(FileMatch),
134 Symbol(SymbolMatch),
135 Thread(HistoryEntry),
136 RecentThread(HistoryEntry),
137 Fetch(SharedString),
138 Rules(RulesContextEntry),
139 Entry(EntryMatch),
140}
141
142impl Match {
143 pub fn score(&self) -> f64 {
144 match self {
145 Match::File(file) => file.mat.score,
146 Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
147 Match::Thread(_) => 1.,
148 Match::RecentThread(_) => 1.,
149 Match::Symbol(_) => 1.,
150 Match::Rules(_) => 1.,
151 Match::Fetch(_) => 1.,
152 }
153 }
154}
155
156pub struct EntryMatch {
157 mat: Option<StringMatch>,
158 entry: PromptContextEntry,
159}
160
161#[derive(Debug, Clone)]
162pub struct RulesContextEntry {
163 pub prompt_id: UserPromptId,
164 pub title: SharedString,
165}
166
167#[derive(Debug, Clone)]
168pub struct AvailableCommand {
169 pub name: Arc<str>,
170 pub description: Arc<str>,
171 pub requires_argument: bool,
172}
173
174pub trait PromptCompletionProviderDelegate: Send + Sync + 'static {
175 fn supports_context(&self, mode: PromptContextType, cx: &App) -> bool {
176 self.supported_modes(cx).contains(&mode)
177 }
178 fn supported_modes(&self, cx: &App) -> Vec<PromptContextType>;
179 fn supports_images(&self, cx: &App) -> bool;
180
181 fn available_commands(&self, cx: &App) -> Vec<AvailableCommand>;
182 fn confirm_command(&self, cx: &mut App);
183}
184
185pub struct PromptCompletionProvider<T: PromptCompletionProviderDelegate> {
186 source: Arc<T>,
187 editor: WeakEntity<Editor>,
188 mention_set: Entity<MentionSet>,
189 history_store: Entity<HistoryStore>,
190 prompt_store: Option<Entity<PromptStore>>,
191 workspace: WeakEntity<Workspace>,
192}
193
194impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
195 pub fn new(
196 source: T,
197 editor: WeakEntity<Editor>,
198 mention_set: Entity<MentionSet>,
199 history_store: Entity<HistoryStore>,
200 prompt_store: Option<Entity<PromptStore>>,
201 workspace: WeakEntity<Workspace>,
202 ) -> Self {
203 Self {
204 source: Arc::new(source),
205 editor,
206 mention_set,
207 workspace,
208 history_store,
209 prompt_store,
210 }
211 }
212
213 fn completion_for_entry(
214 entry: PromptContextEntry,
215 source_range: Range<Anchor>,
216 editor: WeakEntity<Editor>,
217 mention_set: WeakEntity<MentionSet>,
218 workspace: &Entity<Workspace>,
219 cx: &mut App,
220 ) -> Option<Completion> {
221 match entry {
222 PromptContextEntry::Mode(mode) => Some(Completion {
223 replace_range: source_range,
224 new_text: format!("@{} ", mode.keyword()),
225 label: CodeLabel::plain(mode.label().to_string(), None),
226 icon_path: Some(mode.icon().path().into()),
227 documentation: None,
228 source: project::CompletionSource::Custom,
229 match_start: None,
230 snippet_deduplication_key: None,
231 insert_text_mode: None,
232 // This ensures that when a user accepts this completion, the
233 // completion menu will still be shown after "@category " is
234 // inserted
235 confirm: Some(Arc::new(|_, _, _| true)),
236 }),
237 PromptContextEntry::Action(action) => Self::completion_for_action(
238 action,
239 source_range,
240 editor,
241 mention_set,
242 workspace,
243 cx,
244 ),
245 }
246 }
247
248 fn completion_for_thread(
249 thread_entry: HistoryEntry,
250 source_range: Range<Anchor>,
251 recent: bool,
252 source: Arc<T>,
253 editor: WeakEntity<Editor>,
254 mention_set: WeakEntity<MentionSet>,
255 workspace: Entity<Workspace>,
256 cx: &mut App,
257 ) -> Completion {
258 let uri = thread_entry.mention_uri();
259
260 let icon_for_completion = if recent {
261 IconName::HistoryRerun.path().into()
262 } else {
263 uri.icon_path(cx)
264 };
265
266 let new_text = format!("{} ", uri.as_link());
267
268 let new_text_len = new_text.len();
269 Completion {
270 replace_range: source_range.clone(),
271 new_text,
272 label: CodeLabel::plain(thread_entry.title().to_string(), None),
273 documentation: None,
274 insert_text_mode: None,
275 source: project::CompletionSource::Custom,
276 match_start: None,
277 snippet_deduplication_key: None,
278 icon_path: Some(icon_for_completion),
279 confirm: Some(confirm_completion_callback(
280 thread_entry.title().clone(),
281 source_range.start,
282 new_text_len - 1,
283 uri,
284 source,
285 editor,
286 mention_set,
287 workspace,
288 )),
289 }
290 }
291
292 fn completion_for_rules(
293 rule: RulesContextEntry,
294 source_range: Range<Anchor>,
295 source: Arc<T>,
296 editor: WeakEntity<Editor>,
297 mention_set: WeakEntity<MentionSet>,
298 workspace: Entity<Workspace>,
299 cx: &mut App,
300 ) -> Completion {
301 let uri = MentionUri::Rule {
302 id: rule.prompt_id.into(),
303 name: rule.title.to_string(),
304 };
305 let new_text = format!("{} ", uri.as_link());
306 let new_text_len = new_text.len();
307 let icon_path = uri.icon_path(cx);
308 Completion {
309 replace_range: source_range.clone(),
310 new_text,
311 label: CodeLabel::plain(rule.title.to_string(), None),
312 documentation: None,
313 insert_text_mode: None,
314 source: project::CompletionSource::Custom,
315 match_start: None,
316 snippet_deduplication_key: None,
317 icon_path: Some(icon_path),
318 confirm: Some(confirm_completion_callback(
319 rule.title,
320 source_range.start,
321 new_text_len - 1,
322 uri,
323 source,
324 editor,
325 mention_set,
326 workspace,
327 )),
328 }
329 }
330
331 pub(crate) fn completion_for_path(
332 project_path: ProjectPath,
333 path_prefix: &RelPath,
334 is_recent: bool,
335 is_directory: bool,
336 source_range: Range<Anchor>,
337 source: Arc<T>,
338 editor: WeakEntity<Editor>,
339 mention_set: WeakEntity<MentionSet>,
340 workspace: Entity<Workspace>,
341 project: Entity<Project>,
342 label_max_chars: usize,
343 cx: &mut App,
344 ) -> Option<Completion> {
345 let path_style = project.read(cx).path_style(cx);
346 let (file_name, directory) =
347 extract_file_name_and_directory(&project_path.path, path_prefix, path_style);
348
349 let label = build_code_label_for_path(
350 &file_name,
351 directory.as_ref().map(|s| s.as_ref()),
352 None,
353 label_max_chars,
354 cx,
355 );
356
357 let abs_path = project.read(cx).absolute_path(&project_path, cx)?;
358
359 let uri = if is_directory {
360 MentionUri::Directory { abs_path }
361 } else {
362 MentionUri::File { abs_path }
363 };
364
365 let crease_icon_path = uri.icon_path(cx);
366 let completion_icon_path = if is_recent {
367 IconName::HistoryRerun.path().into()
368 } else {
369 crease_icon_path
370 };
371
372 let new_text = format!("{} ", uri.as_link());
373 let new_text_len = new_text.len();
374 Some(Completion {
375 replace_range: source_range.clone(),
376 new_text,
377 label,
378 documentation: None,
379 source: project::CompletionSource::Custom,
380 icon_path: Some(completion_icon_path),
381 match_start: None,
382 snippet_deduplication_key: None,
383 insert_text_mode: None,
384 confirm: Some(confirm_completion_callback(
385 file_name,
386 source_range.start,
387 new_text_len - 1,
388 uri,
389 source,
390 editor,
391 mention_set,
392 workspace,
393 )),
394 })
395 }
396
397 fn completion_for_symbol(
398 symbol: Symbol,
399 source_range: Range<Anchor>,
400 source: Arc<T>,
401 editor: WeakEntity<Editor>,
402 mention_set: WeakEntity<MentionSet>,
403 workspace: Entity<Workspace>,
404 label_max_chars: usize,
405 cx: &mut App,
406 ) -> Option<Completion> {
407 let project = workspace.read(cx).project().clone();
408
409 let (abs_path, file_name) = match &symbol.path {
410 SymbolLocation::InProject(project_path) => (
411 project.read(cx).absolute_path(&project_path, cx)?,
412 project_path.path.file_name()?.to_string().into(),
413 ),
414 SymbolLocation::OutsideProject {
415 abs_path,
416 signature: _,
417 } => (
418 PathBuf::from(abs_path.as_ref()),
419 abs_path.file_name().map(|f| f.to_string_lossy())?,
420 ),
421 };
422
423 let label = build_code_label_for_path(
424 &symbol.name,
425 Some(&file_name),
426 Some(symbol.range.start.0.row + 1),
427 label_max_chars,
428 cx,
429 );
430
431 let uri = MentionUri::Symbol {
432 abs_path,
433 name: symbol.name.clone(),
434 line_range: symbol.range.start.0.row..=symbol.range.end.0.row,
435 };
436 let new_text = format!("{} ", uri.as_link());
437 let new_text_len = new_text.len();
438 let icon_path = uri.icon_path(cx);
439 Some(Completion {
440 replace_range: source_range.clone(),
441 new_text,
442 label,
443 documentation: None,
444 source: project::CompletionSource::Custom,
445 icon_path: Some(icon_path),
446 match_start: None,
447 snippet_deduplication_key: None,
448 insert_text_mode: None,
449 confirm: Some(confirm_completion_callback(
450 symbol.name.into(),
451 source_range.start,
452 new_text_len - 1,
453 uri,
454 source,
455 editor,
456 mention_set,
457 workspace,
458 )),
459 })
460 }
461
462 fn completion_for_fetch(
463 source_range: Range<Anchor>,
464 url_to_fetch: SharedString,
465 source: Arc<T>,
466 editor: WeakEntity<Editor>,
467 mention_set: WeakEntity<MentionSet>,
468 workspace: Entity<Workspace>,
469 cx: &mut App,
470 ) -> Option<Completion> {
471 let new_text = format!("@fetch {} ", url_to_fetch);
472 let url_to_fetch = url::Url::parse(url_to_fetch.as_ref())
473 .or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}")))
474 .ok()?;
475 let mention_uri = MentionUri::Fetch {
476 url: url_to_fetch.clone(),
477 };
478 let icon_path = mention_uri.icon_path(cx);
479 Some(Completion {
480 replace_range: source_range.clone(),
481 new_text: new_text.clone(),
482 label: CodeLabel::plain(url_to_fetch.to_string(), None),
483 documentation: None,
484 source: project::CompletionSource::Custom,
485 icon_path: Some(icon_path),
486 match_start: None,
487 snippet_deduplication_key: None,
488 insert_text_mode: None,
489 confirm: Some(confirm_completion_callback(
490 url_to_fetch.to_string().into(),
491 source_range.start,
492 new_text.len() - 1,
493 mention_uri,
494 source,
495 editor,
496 mention_set,
497 workspace,
498 )),
499 })
500 }
501
502 pub(crate) fn completion_for_action(
503 action: PromptContextAction,
504 source_range: Range<Anchor>,
505 editor: WeakEntity<Editor>,
506 mention_set: WeakEntity<MentionSet>,
507 workspace: &Entity<Workspace>,
508 cx: &mut App,
509 ) -> Option<Completion> {
510 let (new_text, on_action) = match action {
511 PromptContextAction::AddSelections => {
512 const PLACEHOLDER: &str = "selection ";
513 let selections = selection_ranges(workspace, cx)
514 .into_iter()
515 .enumerate()
516 .map(|(ix, (buffer, range))| {
517 (
518 buffer,
519 range,
520 (PLACEHOLDER.len() * ix)..(PLACEHOLDER.len() * (ix + 1) - 1),
521 )
522 })
523 .collect::<Vec<_>>();
524
525 let new_text: String = PLACEHOLDER.repeat(selections.len());
526
527 let callback = Arc::new({
528 let source_range = source_range.clone();
529 move |_, window: &mut Window, cx: &mut App| {
530 let editor = editor.clone();
531 let selections = selections.clone();
532 let mention_set = mention_set.clone();
533 let source_range = source_range.clone();
534 window.defer(cx, move |window, cx| {
535 if let Some(editor) = editor.upgrade() {
536 mention_set
537 .update(cx, |store, cx| {
538 store.confirm_mention_for_selection(
539 source_range,
540 selections,
541 editor,
542 window,
543 cx,
544 )
545 })
546 .ok();
547 }
548 });
549 false
550 }
551 });
552
553 (new_text, callback)
554 }
555 };
556
557 Some(Completion {
558 replace_range: source_range,
559 new_text,
560 label: CodeLabel::plain(action.label().to_string(), None),
561 icon_path: Some(action.icon().path().into()),
562 documentation: None,
563 source: project::CompletionSource::Custom,
564 match_start: None,
565 snippet_deduplication_key: None,
566 insert_text_mode: None,
567 // This ensures that when a user accepts this completion, the
568 // completion menu will still be shown after "@category " is
569 // inserted
570 confirm: Some(on_action),
571 })
572 }
573
574 fn search_slash_commands(&self, query: String, cx: &mut App) -> Task<Vec<AvailableCommand>> {
575 let commands = self.source.available_commands(cx);
576 if commands.is_empty() {
577 return Task::ready(Vec::new());
578 }
579
580 cx.spawn(async move |cx| {
581 let candidates = commands
582 .iter()
583 .enumerate()
584 .map(|(id, command)| StringMatchCandidate::new(id, &command.name))
585 .collect::<Vec<_>>();
586
587 let matches = fuzzy::match_strings(
588 &candidates,
589 &query,
590 false,
591 true,
592 100,
593 &Arc::new(AtomicBool::default()),
594 cx.background_executor().clone(),
595 )
596 .await;
597
598 matches
599 .into_iter()
600 .map(|mat| commands[mat.candidate_id].clone())
601 .collect()
602 })
603 }
604
605 fn search_mentions(
606 &self,
607 mode: Option<PromptContextType>,
608 query: String,
609 cancellation_flag: Arc<AtomicBool>,
610 cx: &mut App,
611 ) -> Task<Vec<Match>> {
612 let Some(workspace) = self.workspace.upgrade() else {
613 return Task::ready(Vec::default());
614 };
615 match mode {
616 Some(PromptContextType::File) => {
617 let search_files_task = search_files(query, cancellation_flag, &workspace, cx);
618 cx.background_spawn(async move {
619 search_files_task
620 .await
621 .into_iter()
622 .map(Match::File)
623 .collect()
624 })
625 }
626
627 Some(PromptContextType::Symbol) => {
628 let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx);
629 cx.background_spawn(async move {
630 search_symbols_task
631 .await
632 .into_iter()
633 .map(Match::Symbol)
634 .collect()
635 })
636 }
637
638 Some(PromptContextType::Thread) => {
639 let search_threads_task =
640 search_threads(query, cancellation_flag, &self.history_store, cx);
641 cx.background_spawn(async move {
642 search_threads_task
643 .await
644 .into_iter()
645 .map(Match::Thread)
646 .collect()
647 })
648 }
649
650 Some(PromptContextType::Fetch) => {
651 if !query.is_empty() {
652 Task::ready(vec![Match::Fetch(query.into())])
653 } else {
654 Task::ready(Vec::new())
655 }
656 }
657
658 Some(PromptContextType::Rules) => {
659 if let Some(prompt_store) = self.prompt_store.as_ref() {
660 let search_rules_task =
661 search_rules(query, cancellation_flag, prompt_store, cx);
662 cx.background_spawn(async move {
663 search_rules_task
664 .await
665 .into_iter()
666 .map(Match::Rules)
667 .collect::<Vec<_>>()
668 })
669 } else {
670 Task::ready(Vec::new())
671 }
672 }
673
674 None if query.is_empty() => {
675 let mut matches = self.recent_context_picker_entries(&workspace, cx);
676
677 matches.extend(
678 self.available_context_picker_entries(&workspace, cx)
679 .into_iter()
680 .map(|mode| {
681 Match::Entry(EntryMatch {
682 entry: mode,
683 mat: None,
684 })
685 }),
686 );
687
688 Task::ready(matches)
689 }
690 None => {
691 let executor = cx.background_executor().clone();
692
693 let search_files_task =
694 search_files(query.clone(), cancellation_flag, &workspace, cx);
695
696 let entries = self.available_context_picker_entries(&workspace, cx);
697 let entry_candidates = entries
698 .iter()
699 .enumerate()
700 .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
701 .collect::<Vec<_>>();
702
703 cx.background_spawn(async move {
704 let mut matches = search_files_task
705 .await
706 .into_iter()
707 .map(Match::File)
708 .collect::<Vec<_>>();
709
710 let entry_matches = fuzzy::match_strings(
711 &entry_candidates,
712 &query,
713 false,
714 true,
715 100,
716 &Arc::new(AtomicBool::default()),
717 executor,
718 )
719 .await;
720
721 matches.extend(entry_matches.into_iter().map(|mat| {
722 Match::Entry(EntryMatch {
723 entry: entries[mat.candidate_id],
724 mat: Some(mat),
725 })
726 }));
727
728 matches.sort_by(|a, b| {
729 b.score()
730 .partial_cmp(&a.score())
731 .unwrap_or(std::cmp::Ordering::Equal)
732 });
733
734 matches
735 })
736 }
737 }
738 }
739
740 fn recent_context_picker_entries(
741 &self,
742 workspace: &Entity<Workspace>,
743 cx: &mut App,
744 ) -> Vec<Match> {
745 let mut recent = Vec::with_capacity(6);
746
747 let mut mentions = self
748 .mention_set
749 .read_with(cx, |store, _cx| store.mentions());
750 let workspace = workspace.read(cx);
751 let project = workspace.project().read(cx);
752 let include_root_name = workspace.visible_worktrees(cx).count() > 1;
753
754 if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx)
755 && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx)
756 {
757 let thread = thread.read(cx);
758 mentions.insert(MentionUri::Thread {
759 id: thread.session_id().clone(),
760 name: thread.title().into(),
761 });
762 }
763
764 recent.extend(
765 workspace
766 .recent_navigation_history_iter(cx)
767 .filter(|(_, abs_path)| {
768 abs_path.as_ref().is_none_or(|path| {
769 !mentions.contains(&MentionUri::File {
770 abs_path: path.clone(),
771 })
772 })
773 })
774 .take(4)
775 .filter_map(|(project_path, _)| {
776 project
777 .worktree_for_id(project_path.worktree_id, cx)
778 .map(|worktree| {
779 let path_prefix = if include_root_name {
780 worktree.read(cx).root_name().into()
781 } else {
782 RelPath::empty().into()
783 };
784 Match::File(FileMatch {
785 mat: fuzzy::PathMatch {
786 score: 1.,
787 positions: Vec::new(),
788 worktree_id: project_path.worktree_id.to_usize(),
789 path: project_path.path,
790 path_prefix,
791 is_dir: false,
792 distance_to_relative_ancestor: 0,
793 },
794 is_recent: true,
795 })
796 })
797 }),
798 );
799
800 if self.source.supports_context(PromptContextType::Thread, cx) {
801 const RECENT_COUNT: usize = 2;
802 let threads = self
803 .history_store
804 .read(cx)
805 .recently_opened_entries(cx)
806 .into_iter()
807 .filter(|thread| !mentions.contains(&thread.mention_uri()))
808 .take(RECENT_COUNT)
809 .collect::<Vec<_>>();
810
811 recent.extend(threads.into_iter().map(Match::RecentThread));
812 }
813
814 recent
815 }
816
817 fn available_context_picker_entries(
818 &self,
819 workspace: &Entity<Workspace>,
820 cx: &mut App,
821 ) -> Vec<PromptContextEntry> {
822 let mut entries = vec![
823 PromptContextEntry::Mode(PromptContextType::File),
824 PromptContextEntry::Mode(PromptContextType::Symbol),
825 ];
826
827 if self.source.supports_context(PromptContextType::Thread, cx) {
828 entries.push(PromptContextEntry::Mode(PromptContextType::Thread));
829 }
830
831 let has_selection = workspace
832 .read(cx)
833 .active_item(cx)
834 .and_then(|item| item.downcast::<Editor>())
835 .is_some_and(|editor| {
836 editor.update(cx, |editor, cx| {
837 editor.has_non_empty_selection(&editor.display_snapshot(cx))
838 })
839 });
840 if has_selection {
841 entries.push(PromptContextEntry::Action(
842 PromptContextAction::AddSelections,
843 ));
844 }
845
846 if self.prompt_store.is_some() && self.source.supports_context(PromptContextType::Rules, cx)
847 {
848 entries.push(PromptContextEntry::Mode(PromptContextType::Rules));
849 }
850
851 if self.source.supports_context(PromptContextType::Fetch, cx) {
852 entries.push(PromptContextEntry::Mode(PromptContextType::Fetch));
853 }
854
855 entries
856 }
857}
858
859impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletionProvider<T> {
860 fn completions(
861 &self,
862 _excerpt_id: ExcerptId,
863 buffer: &Entity<Buffer>,
864 buffer_position: Anchor,
865 _trigger: CompletionContext,
866 window: &mut Window,
867 cx: &mut Context<Editor>,
868 ) -> Task<Result<Vec<CompletionResponse>>> {
869 let state = buffer.update(cx, |buffer, cx| {
870 let position = buffer_position.to_point(buffer);
871 let line_start = Point::new(position.row, 0);
872 let offset_to_line = buffer.point_to_offset(line_start);
873 let mut lines = buffer.text_for_range(line_start..position).lines();
874 let line = lines.next()?;
875 PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx))
876 });
877 let Some(state) = state else {
878 return Task::ready(Ok(Vec::new()));
879 };
880
881 let Some(workspace) = self.workspace.upgrade() else {
882 return Task::ready(Ok(Vec::new()));
883 };
884
885 let project = workspace.read(cx).project().clone();
886 let snapshot = buffer.read(cx).snapshot();
887 let source_range = snapshot.anchor_before(state.source_range().start)
888 ..snapshot.anchor_after(state.source_range().end);
889
890 let source = self.source.clone();
891 let editor = self.editor.clone();
892 let mention_set = self.mention_set.downgrade();
893 match state {
894 PromptCompletion::SlashCommand(SlashCommandCompletion {
895 command, argument, ..
896 }) => {
897 let search_task = self.search_slash_commands(command.unwrap_or_default(), cx);
898 cx.background_spawn(async move {
899 let completions = search_task
900 .await
901 .into_iter()
902 .map(|command| {
903 let new_text = if let Some(argument) = argument.as_ref() {
904 format!("/{} {}", command.name, argument)
905 } else {
906 format!("/{} ", command.name)
907 };
908
909 let is_missing_argument =
910 command.requires_argument && argument.is_none();
911 Completion {
912 replace_range: source_range.clone(),
913 new_text,
914 label: CodeLabel::plain(command.name.to_string(), None),
915 documentation: Some(CompletionDocumentation::MultiLinePlainText(
916 command.description.into(),
917 )),
918 source: project::CompletionSource::Custom,
919 icon_path: None,
920 match_start: None,
921 snippet_deduplication_key: None,
922 insert_text_mode: None,
923 confirm: Some(Arc::new({
924 let source = source.clone();
925 move |intent, _window, cx| {
926 if !is_missing_argument {
927 cx.defer({
928 let source = source.clone();
929 move |cx| match intent {
930 CompletionIntent::Complete
931 | CompletionIntent::CompleteWithInsert
932 | CompletionIntent::CompleteWithReplace => {
933 source.confirm_command(cx);
934 }
935 CompletionIntent::Compose => {}
936 }
937 });
938 }
939 false
940 }
941 })),
942 }
943 })
944 .collect();
945
946 Ok(vec![CompletionResponse {
947 completions,
948 display_options: CompletionDisplayOptions {
949 dynamic_width: true,
950 },
951 // Since this does its own filtering (see `filter_completions()` returns false),
952 // there is no benefit to computing whether this set of completions is incomplete.
953 is_incomplete: true,
954 }])
955 })
956 }
957 PromptCompletion::Mention(MentionCompletion { mode, argument, .. }) => {
958 let query = argument.unwrap_or_default();
959 let search_task =
960 self.search_mentions(mode, query, Arc::<AtomicBool>::default(), cx);
961
962 // Calculate maximum characters available for the full label (file_name + space + directory)
963 // based on maximum menu width after accounting for padding, spacing, and icon width
964 let label_max_chars = {
965 // Base06 left padding + Base06 gap + Base06 right padding + icon width
966 let used_pixels = DynamicSpacing::Base06.px(cx) * 3.0
967 + IconSize::XSmall.rems() * window.rem_size();
968
969 let style = window.text_style();
970 let font_id = window.text_system().resolve_font(&style.font());
971 let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
972
973 // Fallback em_width of 10px matches file_finder.rs fallback for TextSize::Small
974 let em_width = cx
975 .text_system()
976 .em_width(font_id, font_size)
977 .unwrap_or(px(10.0));
978
979 // Calculate available pixels for text (file_name + directory)
980 // Using max width since dynamic_width allows the menu to expand up to this
981 let available_pixels = COMPLETION_MENU_MAX_WIDTH - used_pixels;
982
983 // Convert to character count (total available for file_name + directory)
984 (f32::from(available_pixels) / f32::from(em_width)) as usize
985 };
986
987 cx.spawn(async move |_, cx| {
988 let matches = search_task.await;
989
990 let completions = cx.update(|cx| {
991 matches
992 .into_iter()
993 .filter_map(|mat| match mat {
994 Match::File(FileMatch { mat, is_recent }) => {
995 let project_path = ProjectPath {
996 worktree_id: WorktreeId::from_usize(mat.worktree_id),
997 path: mat.path.clone(),
998 };
999
1000 // If path is empty, this means we're matching with the root directory itself
1001 // so we use the path_prefix as the name
1002 let path_prefix = if mat.path.is_empty() {
1003 project
1004 .read(cx)
1005 .worktree_for_id(project_path.worktree_id, cx)
1006 .map(|wt| wt.read(cx).root_name().into())
1007 .unwrap_or_else(|| mat.path_prefix.clone())
1008 } else {
1009 mat.path_prefix.clone()
1010 };
1011
1012 Self::completion_for_path(
1013 project_path,
1014 &path_prefix,
1015 is_recent,
1016 mat.is_dir,
1017 source_range.clone(),
1018 source.clone(),
1019 editor.clone(),
1020 mention_set.clone(),
1021 workspace.clone(),
1022 project.clone(),
1023 label_max_chars,
1024 cx,
1025 )
1026 }
1027
1028 Match::Symbol(SymbolMatch { symbol, .. }) => {
1029 Self::completion_for_symbol(
1030 symbol,
1031 source_range.clone(),
1032 source.clone(),
1033 editor.clone(),
1034 mention_set.clone(),
1035 workspace.clone(),
1036 label_max_chars,
1037 cx,
1038 )
1039 }
1040
1041 Match::Thread(thread) => Some(Self::completion_for_thread(
1042 thread,
1043 source_range.clone(),
1044 false,
1045 source.clone(),
1046 editor.clone(),
1047 mention_set.clone(),
1048 workspace.clone(),
1049 cx,
1050 )),
1051
1052 Match::RecentThread(thread) => Some(Self::completion_for_thread(
1053 thread,
1054 source_range.clone(),
1055 true,
1056 source.clone(),
1057 editor.clone(),
1058 mention_set.clone(),
1059 workspace.clone(),
1060 cx,
1061 )),
1062
1063 Match::Rules(user_rules) => Some(Self::completion_for_rules(
1064 user_rules,
1065 source_range.clone(),
1066 source.clone(),
1067 editor.clone(),
1068 mention_set.clone(),
1069 workspace.clone(),
1070 cx,
1071 )),
1072
1073 Match::Fetch(url) => Self::completion_for_fetch(
1074 source_range.clone(),
1075 url,
1076 source.clone(),
1077 editor.clone(),
1078 mention_set.clone(),
1079 workspace.clone(),
1080 cx,
1081 ),
1082
1083 Match::Entry(EntryMatch { entry, .. }) => {
1084 Self::completion_for_entry(
1085 entry,
1086 source_range.clone(),
1087 editor.clone(),
1088 mention_set.clone(),
1089 &workspace,
1090 cx,
1091 )
1092 }
1093 })
1094 .collect()
1095 })?;
1096
1097 Ok(vec![CompletionResponse {
1098 completions,
1099 display_options: CompletionDisplayOptions {
1100 dynamic_width: true,
1101 },
1102 // Since this does its own filtering (see `filter_completions()` returns false),
1103 // there is no benefit to computing whether this set of completions is incomplete.
1104 is_incomplete: true,
1105 }])
1106 })
1107 }
1108 }
1109 }
1110
1111 fn is_completion_trigger(
1112 &self,
1113 buffer: &Entity<language::Buffer>,
1114 position: language::Anchor,
1115 _text: &str,
1116 _trigger_in_words: bool,
1117 _menu_is_open: bool,
1118 cx: &mut Context<Editor>,
1119 ) -> bool {
1120 let buffer = buffer.read(cx);
1121 let position = position.to_point(buffer);
1122 let line_start = Point::new(position.row, 0);
1123 let offset_to_line = buffer.point_to_offset(line_start);
1124 let mut lines = buffer.text_for_range(line_start..position).lines();
1125 if let Some(line) = lines.next() {
1126 PromptCompletion::try_parse(line, offset_to_line, &self.source.supported_modes(cx))
1127 .filter(|completion| {
1128 // Right now we don't support completing arguments of slash commands
1129 let is_slash_command_with_argument = matches!(
1130 completion,
1131 PromptCompletion::SlashCommand(SlashCommandCompletion {
1132 argument: Some(_),
1133 ..
1134 })
1135 );
1136 !is_slash_command_with_argument
1137 })
1138 .map(|completion| {
1139 completion.source_range().start <= offset_to_line + position.column as usize
1140 && completion.source_range().end
1141 >= offset_to_line + position.column as usize
1142 })
1143 .unwrap_or(false)
1144 } else {
1145 false
1146 }
1147 }
1148
1149 fn sort_completions(&self) -> bool {
1150 false
1151 }
1152
1153 fn filter_completions(&self) -> bool {
1154 false
1155 }
1156}
1157
1158fn confirm_completion_callback<T: PromptCompletionProviderDelegate>(
1159 crease_text: SharedString,
1160 start: Anchor,
1161 content_len: usize,
1162 mention_uri: MentionUri,
1163 source: Arc<T>,
1164 editor: WeakEntity<Editor>,
1165 mention_set: WeakEntity<MentionSet>,
1166 workspace: Entity<Workspace>,
1167) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
1168 Arc::new(move |_, window, cx| {
1169 let source = source.clone();
1170 let editor = editor.clone();
1171 let mention_set = mention_set.clone();
1172 let crease_text = crease_text.clone();
1173 let mention_uri = mention_uri.clone();
1174 let workspace = workspace.clone();
1175 window.defer(cx, move |window, cx| {
1176 if let Some(editor) = editor.upgrade() {
1177 mention_set
1178 .clone()
1179 .update(cx, |mention_set, cx| {
1180 mention_set
1181 .confirm_mention_completion(
1182 crease_text,
1183 start,
1184 content_len,
1185 mention_uri,
1186 source.supports_images(cx),
1187 editor,
1188 &workspace,
1189 window,
1190 cx,
1191 )
1192 .detach();
1193 })
1194 .ok();
1195 }
1196 });
1197 false
1198 })
1199}
1200
1201#[derive(Debug, PartialEq)]
1202enum PromptCompletion {
1203 SlashCommand(SlashCommandCompletion),
1204 Mention(MentionCompletion),
1205}
1206
1207impl PromptCompletion {
1208 fn source_range(&self) -> Range<usize> {
1209 match self {
1210 Self::SlashCommand(completion) => completion.source_range.clone(),
1211 Self::Mention(completion) => completion.source_range.clone(),
1212 }
1213 }
1214
1215 fn try_parse(
1216 line: &str,
1217 offset_to_line: usize,
1218 supported_modes: &[PromptContextType],
1219 ) -> Option<Self> {
1220 if line.contains('@') {
1221 if let Some(mention) =
1222 MentionCompletion::try_parse(line, offset_to_line, supported_modes)
1223 {
1224 return Some(Self::Mention(mention));
1225 }
1226 }
1227 SlashCommandCompletion::try_parse(line, offset_to_line).map(Self::SlashCommand)
1228 }
1229}
1230
1231#[derive(Debug, Default, PartialEq)]
1232pub struct SlashCommandCompletion {
1233 pub source_range: Range<usize>,
1234 pub command: Option<String>,
1235 pub argument: Option<String>,
1236}
1237
1238impl SlashCommandCompletion {
1239 pub fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
1240 // If we decide to support commands that are not at the beginning of the prompt, we can remove this check
1241 if !line.starts_with('/') || offset_to_line != 0 {
1242 return None;
1243 }
1244
1245 let (prefix, last_command) = line.rsplit_once('/')?;
1246 if prefix.chars().last().is_some_and(|c| !c.is_whitespace())
1247 || last_command.starts_with(char::is_whitespace)
1248 {
1249 return None;
1250 }
1251
1252 let mut argument = None;
1253 let mut command = None;
1254 if let Some((command_text, args)) = last_command.split_once(char::is_whitespace) {
1255 if !args.is_empty() {
1256 argument = Some(args.trim_end().to_string());
1257 }
1258 command = Some(command_text.to_string());
1259 } else if !last_command.is_empty() {
1260 command = Some(last_command.to_string());
1261 };
1262
1263 Some(Self {
1264 source_range: prefix.len() + offset_to_line
1265 ..line
1266 .rfind(|c: char| !c.is_whitespace())
1267 .unwrap_or_else(|| line.len())
1268 + 1
1269 + offset_to_line,
1270 command,
1271 argument,
1272 })
1273 }
1274}
1275
1276#[derive(Debug, Default, PartialEq)]
1277struct MentionCompletion {
1278 source_range: Range<usize>,
1279 mode: Option<PromptContextType>,
1280 argument: Option<String>,
1281}
1282
1283impl MentionCompletion {
1284 fn try_parse(
1285 line: &str,
1286 offset_to_line: usize,
1287 supported_modes: &[PromptContextType],
1288 ) -> Option<Self> {
1289 let last_mention_start = line.rfind('@')?;
1290
1291 // No whitespace immediately after '@'
1292 if line[last_mention_start + 1..]
1293 .chars()
1294 .next()
1295 .is_some_and(|c| c.is_whitespace())
1296 {
1297 return None;
1298 }
1299
1300 // Must be a word boundary before '@'
1301 if last_mention_start > 0
1302 && line[..last_mention_start]
1303 .chars()
1304 .last()
1305 .is_some_and(|c| !c.is_whitespace())
1306 {
1307 return None;
1308 }
1309
1310 let rest_of_line = &line[last_mention_start + 1..];
1311
1312 let mut mode = None;
1313 let mut argument = None;
1314
1315 let mut parts = rest_of_line.split_whitespace();
1316 let mut end = last_mention_start + 1;
1317
1318 if let Some(mode_text) = parts.next() {
1319 // Safe since we check no leading whitespace above
1320 end += mode_text.len();
1321
1322 if let Some(parsed_mode) = PromptContextType::try_from(mode_text).ok()
1323 && supported_modes.contains(&parsed_mode)
1324 {
1325 mode = Some(parsed_mode);
1326 } else {
1327 argument = Some(mode_text.to_string());
1328 }
1329 match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) {
1330 Some(whitespace_count) => {
1331 if let Some(argument_text) = parts.next() {
1332 // If mode wasn't recognized but we have an argument, don't suggest completions
1333 // (e.g. '@something word')
1334 if mode.is_none() && !argument_text.is_empty() {
1335 return None;
1336 }
1337
1338 argument = Some(argument_text.to_string());
1339 end += whitespace_count + argument_text.len();
1340 }
1341 }
1342 None => {
1343 // Rest of line is entirely whitespace
1344 end += rest_of_line.len() - mode_text.len();
1345 }
1346 }
1347 }
1348
1349 Some(Self {
1350 source_range: last_mention_start + offset_to_line..end + offset_to_line,
1351 mode,
1352 argument,
1353 })
1354 }
1355}
1356
1357pub(crate) fn search_files(
1358 query: String,
1359 cancellation_flag: Arc<AtomicBool>,
1360 workspace: &Entity<Workspace>,
1361 cx: &App,
1362) -> Task<Vec<FileMatch>> {
1363 if query.is_empty() {
1364 let workspace = workspace.read(cx);
1365 let project = workspace.project().read(cx);
1366 let visible_worktrees = workspace.visible_worktrees(cx).collect::<Vec<_>>();
1367 let include_root_name = visible_worktrees.len() > 1;
1368
1369 let recent_matches = workspace
1370 .recent_navigation_history(Some(10), cx)
1371 .into_iter()
1372 .map(|(project_path, _)| {
1373 let path_prefix = if include_root_name {
1374 project
1375 .worktree_for_id(project_path.worktree_id, cx)
1376 .map(|wt| wt.read(cx).root_name().into())
1377 .unwrap_or_else(|| RelPath::empty().into())
1378 } else {
1379 RelPath::empty().into()
1380 };
1381
1382 FileMatch {
1383 mat: PathMatch {
1384 score: 0.,
1385 positions: Vec::new(),
1386 worktree_id: project_path.worktree_id.to_usize(),
1387 path: project_path.path,
1388 path_prefix,
1389 distance_to_relative_ancestor: 0,
1390 is_dir: false,
1391 },
1392 is_recent: true,
1393 }
1394 });
1395
1396 let file_matches = visible_worktrees.into_iter().flat_map(|worktree| {
1397 let worktree = worktree.read(cx);
1398 let path_prefix: Arc<RelPath> = if include_root_name {
1399 worktree.root_name().into()
1400 } else {
1401 RelPath::empty().into()
1402 };
1403 worktree.entries(false, 0).map(move |entry| FileMatch {
1404 mat: PathMatch {
1405 score: 0.,
1406 positions: Vec::new(),
1407 worktree_id: worktree.id().to_usize(),
1408 path: entry.path.clone(),
1409 path_prefix: path_prefix.clone(),
1410 distance_to_relative_ancestor: 0,
1411 is_dir: entry.is_dir(),
1412 },
1413 is_recent: false,
1414 })
1415 });
1416
1417 Task::ready(recent_matches.chain(file_matches).collect())
1418 } else {
1419 let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
1420 let include_root_name = worktrees.len() > 1;
1421 let candidate_sets = worktrees
1422 .into_iter()
1423 .map(|worktree| {
1424 let worktree = worktree.read(cx);
1425
1426 PathMatchCandidateSet {
1427 snapshot: worktree.snapshot(),
1428 include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored),
1429 include_root_name,
1430 candidates: project::Candidates::Entries,
1431 }
1432 })
1433 .collect::<Vec<_>>();
1434
1435 let executor = cx.background_executor().clone();
1436 cx.foreground_executor().spawn(async move {
1437 fuzzy::match_path_sets(
1438 candidate_sets.as_slice(),
1439 query.as_str(),
1440 &None,
1441 false,
1442 100,
1443 &cancellation_flag,
1444 executor,
1445 )
1446 .await
1447 .into_iter()
1448 .map(|mat| FileMatch {
1449 mat,
1450 is_recent: false,
1451 })
1452 .collect::<Vec<_>>()
1453 })
1454 }
1455}
1456
1457pub(crate) fn search_symbols(
1458 query: String,
1459 cancellation_flag: Arc<AtomicBool>,
1460 workspace: &Entity<Workspace>,
1461 cx: &mut App,
1462) -> Task<Vec<SymbolMatch>> {
1463 let symbols_task = workspace.update(cx, |workspace, cx| {
1464 workspace
1465 .project()
1466 .update(cx, |project, cx| project.symbols(&query, cx))
1467 });
1468 let project = workspace.read(cx).project().clone();
1469 cx.spawn(async move |cx| {
1470 let Some(symbols) = symbols_task.await.log_err() else {
1471 return Vec::new();
1472 };
1473 let Some((visible_match_candidates, external_match_candidates)): Option<(Vec<_>, Vec<_>)> =
1474 project
1475 .update(cx, |project, cx| {
1476 symbols
1477 .iter()
1478 .enumerate()
1479 .map(|(id, symbol)| {
1480 StringMatchCandidate::new(id, symbol.label.filter_text())
1481 })
1482 .partition(|candidate| match &symbols[candidate.id].path {
1483 SymbolLocation::InProject(project_path) => project
1484 .entry_for_path(project_path, cx)
1485 .is_some_and(|e| !e.is_ignored),
1486 SymbolLocation::OutsideProject { .. } => false,
1487 })
1488 })
1489 .log_err()
1490 else {
1491 return Vec::new();
1492 };
1493
1494 const MAX_MATCHES: usize = 100;
1495 let mut visible_matches = cx.background_executor().block(fuzzy::match_strings(
1496 &visible_match_candidates,
1497 &query,
1498 false,
1499 true,
1500 MAX_MATCHES,
1501 &cancellation_flag,
1502 cx.background_executor().clone(),
1503 ));
1504 let mut external_matches = cx.background_executor().block(fuzzy::match_strings(
1505 &external_match_candidates,
1506 &query,
1507 false,
1508 true,
1509 MAX_MATCHES - visible_matches.len().min(MAX_MATCHES),
1510 &cancellation_flag,
1511 cx.background_executor().clone(),
1512 ));
1513 let sort_key_for_match = |mat: &StringMatch| {
1514 let symbol = &symbols[mat.candidate_id];
1515 (Reverse(OrderedFloat(mat.score)), symbol.label.filter_text())
1516 };
1517
1518 visible_matches.sort_unstable_by_key(sort_key_for_match);
1519 external_matches.sort_unstable_by_key(sort_key_for_match);
1520 let mut matches = visible_matches;
1521 matches.append(&mut external_matches);
1522
1523 matches
1524 .into_iter()
1525 .map(|mut mat| {
1526 let symbol = symbols[mat.candidate_id].clone();
1527 let filter_start = symbol.label.filter_range.start;
1528 for position in &mut mat.positions {
1529 *position += filter_start;
1530 }
1531 SymbolMatch { symbol }
1532 })
1533 .collect()
1534 })
1535}
1536
1537pub(crate) fn search_threads(
1538 query: String,
1539 cancellation_flag: Arc<AtomicBool>,
1540 thread_store: &Entity<HistoryStore>,
1541 cx: &mut App,
1542) -> Task<Vec<HistoryEntry>> {
1543 let threads = thread_store.read(cx).entries().collect();
1544 if query.is_empty() {
1545 return Task::ready(threads);
1546 }
1547
1548 let executor = cx.background_executor().clone();
1549 cx.background_spawn(async move {
1550 let candidates = threads
1551 .iter()
1552 .enumerate()
1553 .map(|(id, thread)| StringMatchCandidate::new(id, thread.title()))
1554 .collect::<Vec<_>>();
1555 let matches = fuzzy::match_strings(
1556 &candidates,
1557 &query,
1558 false,
1559 true,
1560 100,
1561 &cancellation_flag,
1562 executor,
1563 )
1564 .await;
1565
1566 matches
1567 .into_iter()
1568 .map(|mat| threads[mat.candidate_id].clone())
1569 .collect()
1570 })
1571}
1572
1573pub(crate) fn search_rules(
1574 query: String,
1575 cancellation_flag: Arc<AtomicBool>,
1576 prompt_store: &Entity<PromptStore>,
1577 cx: &mut App,
1578) -> Task<Vec<RulesContextEntry>> {
1579 let search_task = prompt_store.read(cx).search(query, cancellation_flag, cx);
1580 cx.background_spawn(async move {
1581 search_task
1582 .await
1583 .into_iter()
1584 .flat_map(|metadata| {
1585 // Default prompts are filtered out as they are automatically included.
1586 if metadata.default {
1587 None
1588 } else {
1589 match metadata.id {
1590 PromptId::EditWorkflow => None,
1591 PromptId::User { uuid } => Some(RulesContextEntry {
1592 prompt_id: uuid,
1593 title: metadata.title?,
1594 }),
1595 }
1596 }
1597 })
1598 .collect::<Vec<_>>()
1599 })
1600}
1601
1602pub struct SymbolMatch {
1603 pub symbol: Symbol,
1604}
1605
1606pub struct FileMatch {
1607 pub mat: PathMatch,
1608 pub is_recent: bool,
1609}
1610
1611pub fn extract_file_name_and_directory(
1612 path: &RelPath,
1613 path_prefix: &RelPath,
1614 path_style: PathStyle,
1615) -> (SharedString, Option<SharedString>) {
1616 // If path is empty, this means we're matching with the root directory itself
1617 // so we use the path_prefix as the name
1618 if path.is_empty() && !path_prefix.is_empty() {
1619 return (path_prefix.display(path_style).to_string().into(), None);
1620 }
1621
1622 let full_path = path_prefix.join(path);
1623 let file_name = full_path.file_name().unwrap_or_default();
1624 let display_path = full_path.display(path_style);
1625 let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len());
1626 (
1627 file_name.to_string().into(),
1628 Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()),
1629 )
1630}
1631
1632fn build_code_label_for_path(
1633 file: &str,
1634 directory: Option<&str>,
1635 line_number: Option<u32>,
1636 label_max_chars: usize,
1637 cx: &App,
1638) -> CodeLabel {
1639 let variable_highlight_id = cx
1640 .theme()
1641 .syntax()
1642 .highlight_id("variable")
1643 .map(HighlightId);
1644 let mut label = CodeLabelBuilder::default();
1645
1646 label.push_str(file, None);
1647 label.push_str(" ", None);
1648
1649 if let Some(directory) = directory {
1650 let file_name_chars = file.chars().count();
1651 // Account for: file_name + space (ellipsis is handled by truncate_and_remove_front)
1652 let directory_max_chars = label_max_chars
1653 .saturating_sub(file_name_chars)
1654 .saturating_sub(1);
1655 let truncated_directory = truncate_and_remove_front(directory, directory_max_chars.max(5));
1656 label.push_str(&truncated_directory, variable_highlight_id);
1657 }
1658 if let Some(line_number) = line_number {
1659 label.push_str(&format!(" L{}", line_number), variable_highlight_id);
1660 }
1661 label.build()
1662}
1663
1664fn selection_ranges(
1665 workspace: &Entity<Workspace>,
1666 cx: &mut App,
1667) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
1668 let Some(editor) = workspace
1669 .read(cx)
1670 .active_item(cx)
1671 .and_then(|item| item.act_as::<Editor>(cx))
1672 else {
1673 return Vec::new();
1674 };
1675
1676 editor.update(cx, |editor, cx| {
1677 let selections = editor.selections.all_adjusted(&editor.display_snapshot(cx));
1678
1679 let buffer = editor.buffer().clone().read(cx);
1680 let snapshot = buffer.snapshot(cx);
1681
1682 selections
1683 .into_iter()
1684 .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end))
1685 .flat_map(|range| {
1686 let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?;
1687 let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?;
1688 if start_buffer != end_buffer {
1689 return None;
1690 }
1691 Some((start_buffer, start..end))
1692 })
1693 .collect::<Vec<_>>()
1694 })
1695}
1696
1697#[cfg(test)]
1698mod tests {
1699 use super::*;
1700
1701 #[test]
1702 fn test_prompt_completion_parse() {
1703 let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol];
1704
1705 assert_eq!(
1706 PromptCompletion::try_parse("/", 0, &supported_modes),
1707 Some(PromptCompletion::SlashCommand(SlashCommandCompletion {
1708 source_range: 0..1,
1709 command: None,
1710 argument: None,
1711 }))
1712 );
1713
1714 assert_eq!(
1715 PromptCompletion::try_parse("@", 0, &supported_modes),
1716 Some(PromptCompletion::Mention(MentionCompletion {
1717 source_range: 0..1,
1718 mode: None,
1719 argument: None,
1720 }))
1721 );
1722
1723 assert_eq!(
1724 PromptCompletion::try_parse("/test @file", 0, &supported_modes),
1725 Some(PromptCompletion::Mention(MentionCompletion {
1726 source_range: 6..11,
1727 mode: Some(PromptContextType::File),
1728 argument: None,
1729 }))
1730 );
1731 }
1732
1733 #[test]
1734 fn test_slash_command_completion_parse() {
1735 assert_eq!(
1736 SlashCommandCompletion::try_parse("/", 0),
1737 Some(SlashCommandCompletion {
1738 source_range: 0..1,
1739 command: None,
1740 argument: None,
1741 })
1742 );
1743
1744 assert_eq!(
1745 SlashCommandCompletion::try_parse("/help", 0),
1746 Some(SlashCommandCompletion {
1747 source_range: 0..5,
1748 command: Some("help".to_string()),
1749 argument: None,
1750 })
1751 );
1752
1753 assert_eq!(
1754 SlashCommandCompletion::try_parse("/help ", 0),
1755 Some(SlashCommandCompletion {
1756 source_range: 0..5,
1757 command: Some("help".to_string()),
1758 argument: None,
1759 })
1760 );
1761
1762 assert_eq!(
1763 SlashCommandCompletion::try_parse("/help arg1", 0),
1764 Some(SlashCommandCompletion {
1765 source_range: 0..10,
1766 command: Some("help".to_string()),
1767 argument: Some("arg1".to_string()),
1768 })
1769 );
1770
1771 assert_eq!(
1772 SlashCommandCompletion::try_parse("/help arg1 arg2", 0),
1773 Some(SlashCommandCompletion {
1774 source_range: 0..15,
1775 command: Some("help".to_string()),
1776 argument: Some("arg1 arg2".to_string()),
1777 })
1778 );
1779
1780 assert_eq!(
1781 SlashCommandCompletion::try_parse("/拿不到命令 拿不到命令 ", 0),
1782 Some(SlashCommandCompletion {
1783 source_range: 0..30,
1784 command: Some("拿不到命令".to_string()),
1785 argument: Some("拿不到命令".to_string()),
1786 })
1787 );
1788
1789 assert_eq!(SlashCommandCompletion::try_parse("Lorem Ipsum", 0), None);
1790
1791 assert_eq!(SlashCommandCompletion::try_parse("Lorem /", 0), None);
1792
1793 assert_eq!(SlashCommandCompletion::try_parse("Lorem /help", 0), None);
1794
1795 assert_eq!(SlashCommandCompletion::try_parse("Lorem/", 0), None);
1796
1797 assert_eq!(SlashCommandCompletion::try_parse("/ ", 0), None);
1798 }
1799
1800 #[test]
1801 fn test_mention_completion_parse() {
1802 let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol];
1803
1804 assert_eq!(
1805 MentionCompletion::try_parse("Lorem Ipsum", 0, &supported_modes),
1806 None
1807 );
1808
1809 assert_eq!(
1810 MentionCompletion::try_parse("Lorem @", 0, &supported_modes),
1811 Some(MentionCompletion {
1812 source_range: 6..7,
1813 mode: None,
1814 argument: None,
1815 })
1816 );
1817
1818 assert_eq!(
1819 MentionCompletion::try_parse("Lorem @file", 0, &supported_modes),
1820 Some(MentionCompletion {
1821 source_range: 6..11,
1822 mode: Some(PromptContextType::File),
1823 argument: None,
1824 })
1825 );
1826
1827 assert_eq!(
1828 MentionCompletion::try_parse("Lorem @file ", 0, &supported_modes),
1829 Some(MentionCompletion {
1830 source_range: 6..12,
1831 mode: Some(PromptContextType::File),
1832 argument: None,
1833 })
1834 );
1835
1836 assert_eq!(
1837 MentionCompletion::try_parse("Lorem @file main.rs", 0, &supported_modes),
1838 Some(MentionCompletion {
1839 source_range: 6..19,
1840 mode: Some(PromptContextType::File),
1841 argument: Some("main.rs".to_string()),
1842 })
1843 );
1844
1845 assert_eq!(
1846 MentionCompletion::try_parse("Lorem @file main.rs ", 0, &supported_modes),
1847 Some(MentionCompletion {
1848 source_range: 6..19,
1849 mode: Some(PromptContextType::File),
1850 argument: Some("main.rs".to_string()),
1851 })
1852 );
1853
1854 assert_eq!(
1855 MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0, &supported_modes),
1856 Some(MentionCompletion {
1857 source_range: 6..19,
1858 mode: Some(PromptContextType::File),
1859 argument: Some("main.rs".to_string()),
1860 })
1861 );
1862
1863 assert_eq!(
1864 MentionCompletion::try_parse("Lorem @main", 0, &supported_modes),
1865 Some(MentionCompletion {
1866 source_range: 6..11,
1867 mode: None,
1868 argument: Some("main".to_string()),
1869 })
1870 );
1871
1872 assert_eq!(
1873 MentionCompletion::try_parse("Lorem @main ", 0, &supported_modes),
1874 Some(MentionCompletion {
1875 source_range: 6..12,
1876 mode: None,
1877 argument: Some("main".to_string()),
1878 })
1879 );
1880
1881 assert_eq!(
1882 MentionCompletion::try_parse("Lorem @main m", 0, &supported_modes),
1883 None
1884 );
1885
1886 assert_eq!(
1887 MentionCompletion::try_parse("test@", 0, &supported_modes),
1888 None
1889 );
1890
1891 // Allowed non-file mentions
1892
1893 assert_eq!(
1894 MentionCompletion::try_parse("Lorem @symbol main", 0, &supported_modes),
1895 Some(MentionCompletion {
1896 source_range: 6..18,
1897 mode: Some(PromptContextType::Symbol),
1898 argument: Some("main".to_string()),
1899 })
1900 );
1901
1902 // Disallowed non-file mentions
1903 assert_eq!(
1904 MentionCompletion::try_parse("Lorem @symbol main", 0, &[PromptContextType::File]),
1905 None
1906 );
1907
1908 assert_eq!(
1909 MentionCompletion::try_parse("Lorem@symbol", 0, &supported_modes),
1910 None,
1911 "Should not parse mention inside word"
1912 );
1913
1914 assert_eq!(
1915 MentionCompletion::try_parse("Lorem @ file", 0, &supported_modes),
1916 None,
1917 "Should not parse with a space after @"
1918 );
1919
1920 assert_eq!(
1921 MentionCompletion::try_parse("@ file", 0, &supported_modes),
1922 None,
1923 "Should not parse with a space after @ at the start of the line"
1924 );
1925 }
1926}