context_picker.rs

  1mod completion_provider;
  2mod fetch_context_picker;
  3mod file_context_picker;
  4mod symbol_context_picker;
  5mod thread_context_picker;
  6
  7use std::ops::Range;
  8use std::path::PathBuf;
  9use std::sync::Arc;
 10
 11use anyhow::{Result, anyhow};
 12use editor::display_map::{Crease, FoldId};
 13use editor::{Anchor, AnchorRangeExt as _, Editor, ExcerptId, FoldPlaceholder, ToOffset};
 14use file_context_picker::render_file_context_entry;
 15use gpui::{
 16    App, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task,
 17    WeakEntity,
 18};
 19use multi_buffer::MultiBufferRow;
 20use project::{Entry, ProjectPath};
 21use symbol_context_picker::SymbolContextPicker;
 22use thread_context_picker::{ThreadContextEntry, render_thread_context_entry};
 23use ui::{
 24    ButtonLike, ContextMenu, ContextMenuEntry, ContextMenuItem, Disclosure, TintColor, prelude::*,
 25};
 26use workspace::{Workspace, notifications::NotifyResultExt};
 27
 28use crate::AssistantPanel;
 29pub use crate::context_picker::completion_provider::ContextPickerCompletionProvider;
 30use crate::context_picker::fetch_context_picker::FetchContextPicker;
 31use crate::context_picker::file_context_picker::FileContextPicker;
 32use crate::context_picker::thread_context_picker::ThreadContextPicker;
 33use crate::context_store::ContextStore;
 34use crate::thread::ThreadId;
 35use crate::thread_store::ThreadStore;
 36
 37#[derive(Debug, Clone, Copy)]
 38pub enum ConfirmBehavior {
 39    KeepOpen,
 40    Close,
 41}
 42
 43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 44enum ContextPickerMode {
 45    File,
 46    Symbol,
 47    Fetch,
 48    Thread,
 49}
 50
 51impl TryFrom<&str> for ContextPickerMode {
 52    type Error = String;
 53
 54    fn try_from(value: &str) -> Result<Self, Self::Error> {
 55        match value {
 56            "file" => Ok(Self::File),
 57            "symbol" => Ok(Self::Symbol),
 58            "fetch" => Ok(Self::Fetch),
 59            "thread" => Ok(Self::Thread),
 60            _ => Err(format!("Invalid context picker mode: {}", value)),
 61        }
 62    }
 63}
 64
 65impl ContextPickerMode {
 66    pub fn mention_prefix(&self) -> &'static str {
 67        match self {
 68            Self::File => "file",
 69            Self::Symbol => "symbol",
 70            Self::Fetch => "fetch",
 71            Self::Thread => "thread",
 72        }
 73    }
 74
 75    pub fn label(&self) -> &'static str {
 76        match self {
 77            Self::File => "Files & Directories",
 78            Self::Symbol => "Symbols",
 79            Self::Fetch => "Fetch",
 80            Self::Thread => "Threads",
 81        }
 82    }
 83
 84    pub fn icon(&self) -> IconName {
 85        match self {
 86            Self::File => IconName::File,
 87            Self::Symbol => IconName::Code,
 88            Self::Fetch => IconName::Globe,
 89            Self::Thread => IconName::MessageBubbles,
 90        }
 91    }
 92}
 93
 94#[derive(Debug, Clone)]
 95enum ContextPickerState {
 96    Default(Entity<ContextMenu>),
 97    File(Entity<FileContextPicker>),
 98    Symbol(Entity<SymbolContextPicker>),
 99    Fetch(Entity<FetchContextPicker>),
100    Thread(Entity<ThreadContextPicker>),
101}
102
103pub(super) struct ContextPicker {
104    mode: ContextPickerState,
105    workspace: WeakEntity<Workspace>,
106    context_store: WeakEntity<ContextStore>,
107    thread_store: Option<WeakEntity<ThreadStore>>,
108    confirm_behavior: ConfirmBehavior,
109    _subscriptions: Vec<Subscription>,
110}
111
112impl ContextPicker {
113    pub fn new(
114        workspace: WeakEntity<Workspace>,
115        thread_store: Option<WeakEntity<ThreadStore>>,
116        context_store: WeakEntity<ContextStore>,
117        confirm_behavior: ConfirmBehavior,
118        window: &mut Window,
119        cx: &mut Context<Self>,
120    ) -> Self {
121        let subscriptions = context_store
122            .upgrade()
123            .map(|context_store| {
124                cx.observe(&context_store, |this, _, cx| this.notify_current_picker(cx))
125            })
126            .into_iter()
127            .chain(
128                thread_store
129                    .as_ref()
130                    .and_then(|thread_store| thread_store.upgrade())
131                    .map(|thread_store| {
132                        cx.observe(&thread_store, |this, _, cx| this.notify_current_picker(cx))
133                    }),
134            )
135            .collect::<Vec<Subscription>>();
136
137        ContextPicker {
138            mode: ContextPickerState::Default(ContextMenu::build(
139                window,
140                cx,
141                |menu, _window, _cx| menu,
142            )),
143            workspace,
144            context_store,
145            thread_store,
146            confirm_behavior,
147            _subscriptions: subscriptions,
148        }
149    }
150
151    pub fn init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
152        self.mode = ContextPickerState::Default(self.build_menu(window, cx));
153        cx.notify();
154    }
155
156    fn build_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Entity<ContextMenu> {
157        let context_picker = cx.entity().clone();
158
159        let menu = ContextMenu::build(window, cx, move |menu, _window, cx| {
160            let recent = self.recent_entries(cx);
161            let has_recent = !recent.is_empty();
162            let recent_entries = recent
163                .into_iter()
164                .enumerate()
165                .map(|(ix, entry)| self.recent_menu_item(context_picker.clone(), ix, entry));
166
167            let modes = supported_context_picker_modes(&self.thread_store);
168
169            let menu = menu
170                .when(has_recent, |menu| {
171                    menu.custom_row(|_, _| {
172                        div()
173                            .mb_1()
174                            .child(
175                                Label::new("Recent")
176                                    .color(Color::Muted)
177                                    .size(LabelSize::Small),
178                            )
179                            .into_any_element()
180                    })
181                })
182                .extend(recent_entries)
183                .when(has_recent, |menu| menu.separator())
184                .extend(modes.into_iter().map(|mode| {
185                    let context_picker = context_picker.clone();
186
187                    ContextMenuEntry::new(mode.label())
188                        .icon(mode.icon())
189                        .icon_size(IconSize::XSmall)
190                        .icon_color(Color::Muted)
191                        .handler(move |window, cx| {
192                            context_picker.update(cx, |this, cx| this.select_mode(mode, window, cx))
193                        })
194                }));
195
196            match self.confirm_behavior {
197                ConfirmBehavior::KeepOpen => menu.keep_open_on_confirm(),
198                ConfirmBehavior::Close => menu,
199            }
200        });
201
202        cx.subscribe(&menu, move |_, _, _: &DismissEvent, cx| {
203            cx.emit(DismissEvent);
204        })
205        .detach();
206
207        menu
208    }
209
210    /// Whether threads are allowed as context.
211    pub fn allow_threads(&self) -> bool {
212        self.thread_store.is_some()
213    }
214
215    fn select_mode(
216        &mut self,
217        mode: ContextPickerMode,
218        window: &mut Window,
219        cx: &mut Context<Self>,
220    ) {
221        let context_picker = cx.entity().downgrade();
222
223        match mode {
224            ContextPickerMode::File => {
225                self.mode = ContextPickerState::File(cx.new(|cx| {
226                    FileContextPicker::new(
227                        context_picker.clone(),
228                        self.workspace.clone(),
229                        self.context_store.clone(),
230                        self.confirm_behavior,
231                        window,
232                        cx,
233                    )
234                }));
235            }
236            ContextPickerMode::Symbol => {
237                self.mode = ContextPickerState::Symbol(cx.new(|cx| {
238                    SymbolContextPicker::new(
239                        context_picker.clone(),
240                        self.workspace.clone(),
241                        self.context_store.clone(),
242                        self.confirm_behavior,
243                        window,
244                        cx,
245                    )
246                }));
247            }
248            ContextPickerMode::Fetch => {
249                self.mode = ContextPickerState::Fetch(cx.new(|cx| {
250                    FetchContextPicker::new(
251                        context_picker.clone(),
252                        self.workspace.clone(),
253                        self.context_store.clone(),
254                        self.confirm_behavior,
255                        window,
256                        cx,
257                    )
258                }));
259            }
260            ContextPickerMode::Thread => {
261                if let Some(thread_store) = self.thread_store.as_ref() {
262                    self.mode = ContextPickerState::Thread(cx.new(|cx| {
263                        ThreadContextPicker::new(
264                            thread_store.clone(),
265                            context_picker.clone(),
266                            self.context_store.clone(),
267                            self.confirm_behavior,
268                            window,
269                            cx,
270                        )
271                    }));
272                }
273            }
274        }
275
276        cx.notify();
277        cx.focus_self(window);
278    }
279
280    fn recent_menu_item(
281        &self,
282        context_picker: Entity<ContextPicker>,
283        ix: usize,
284        entry: RecentEntry,
285    ) -> ContextMenuItem {
286        match entry {
287            RecentEntry::File {
288                project_path,
289                path_prefix,
290            } => {
291                let context_store = self.context_store.clone();
292                let worktree_id = project_path.worktree_id;
293                let path = project_path.path.clone();
294
295                ContextMenuItem::custom_entry(
296                    move |_window, cx| {
297                        render_file_context_entry(
298                            ElementId::NamedInteger("ctx-recent".into(), ix),
299                            worktree_id,
300                            &path,
301                            &path_prefix,
302                            false,
303                            context_store.clone(),
304                            cx,
305                        )
306                        .into_any()
307                    },
308                    move |window, cx| {
309                        context_picker.update(cx, |this, cx| {
310                            this.add_recent_file(project_path.clone(), window, cx);
311                        })
312                    },
313                )
314            }
315            RecentEntry::Thread(thread) => {
316                let context_store = self.context_store.clone();
317                let view_thread = thread.clone();
318
319                ContextMenuItem::custom_entry(
320                    move |_window, cx| {
321                        render_thread_context_entry(&view_thread, context_store.clone(), cx)
322                            .into_any()
323                    },
324                    move |_window, cx| {
325                        context_picker.update(cx, |this, cx| {
326                            this.add_recent_thread(thread.clone(), cx)
327                                .detach_and_log_err(cx);
328                        })
329                    },
330                )
331            }
332        }
333    }
334
335    fn add_recent_file(
336        &self,
337        project_path: ProjectPath,
338        window: &mut Window,
339        cx: &mut Context<Self>,
340    ) {
341        let Some(context_store) = self.context_store.upgrade() else {
342            return;
343        };
344
345        let task = context_store.update(cx, |context_store, cx| {
346            context_store.add_file_from_path(project_path.clone(), true, cx)
347        });
348
349        cx.spawn_in(window, async move |_, cx| task.await.notify_async_err(cx))
350            .detach();
351
352        cx.notify();
353    }
354
355    fn add_recent_thread(
356        &self,
357        thread: ThreadContextEntry,
358        cx: &mut Context<Self>,
359    ) -> Task<Result<()>> {
360        let Some(context_store) = self.context_store.upgrade() else {
361            return Task::ready(Err(anyhow!("context store not available")));
362        };
363
364        let Some(thread_store) = self
365            .thread_store
366            .as_ref()
367            .and_then(|thread_store| thread_store.upgrade())
368        else {
369            return Task::ready(Err(anyhow!("thread store not available")));
370        };
371
372        let open_thread_task = thread_store.update(cx, |this, cx| this.open_thread(&thread.id, cx));
373        cx.spawn(async move |this, cx| {
374            let thread = open_thread_task.await?;
375            context_store.update(cx, |context_store, cx| {
376                context_store.add_thread(thread, true, cx);
377            })?;
378
379            this.update(cx, |_this, cx| cx.notify())
380        })
381    }
382
383    fn recent_entries(&self, cx: &mut App) -> Vec<RecentEntry> {
384        let Some(workspace) = self.workspace.upgrade() else {
385            return vec![];
386        };
387
388        let Some(context_store) = self.context_store.upgrade() else {
389            return vec![];
390        };
391
392        recent_context_picker_entries(context_store, self.thread_store.clone(), workspace, cx)
393    }
394
395    fn notify_current_picker(&mut self, cx: &mut Context<Self>) {
396        match &self.mode {
397            ContextPickerState::Default(entity) => entity.update(cx, |_, cx| cx.notify()),
398            ContextPickerState::File(entity) => entity.update(cx, |_, cx| cx.notify()),
399            ContextPickerState::Symbol(entity) => entity.update(cx, |_, cx| cx.notify()),
400            ContextPickerState::Fetch(entity) => entity.update(cx, |_, cx| cx.notify()),
401            ContextPickerState::Thread(entity) => entity.update(cx, |_, cx| cx.notify()),
402        }
403    }
404}
405
406impl EventEmitter<DismissEvent> for ContextPicker {}
407
408impl Focusable for ContextPicker {
409    fn focus_handle(&self, cx: &App) -> FocusHandle {
410        match &self.mode {
411            ContextPickerState::Default(menu) => menu.focus_handle(cx),
412            ContextPickerState::File(file_picker) => file_picker.focus_handle(cx),
413            ContextPickerState::Symbol(symbol_picker) => symbol_picker.focus_handle(cx),
414            ContextPickerState::Fetch(fetch_picker) => fetch_picker.focus_handle(cx),
415            ContextPickerState::Thread(thread_picker) => thread_picker.focus_handle(cx),
416        }
417    }
418}
419
420impl Render for ContextPicker {
421    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
422        v_flex()
423            .w(px(400.))
424            .min_w(px(400.))
425            .map(|parent| match &self.mode {
426                ContextPickerState::Default(menu) => parent.child(menu.clone()),
427                ContextPickerState::File(file_picker) => parent.child(file_picker.clone()),
428                ContextPickerState::Symbol(symbol_picker) => parent.child(symbol_picker.clone()),
429                ContextPickerState::Fetch(fetch_picker) => parent.child(fetch_picker.clone()),
430                ContextPickerState::Thread(thread_picker) => parent.child(thread_picker.clone()),
431            })
432    }
433}
434enum RecentEntry {
435    File {
436        project_path: ProjectPath,
437        path_prefix: Arc<str>,
438    },
439    Thread(ThreadContextEntry),
440}
441
442fn supported_context_picker_modes(
443    thread_store: &Option<WeakEntity<ThreadStore>>,
444) -> Vec<ContextPickerMode> {
445    let mut modes = vec![
446        ContextPickerMode::File,
447        ContextPickerMode::Symbol,
448        ContextPickerMode::Fetch,
449    ];
450    if thread_store.is_some() {
451        modes.push(ContextPickerMode::Thread);
452    }
453    modes
454}
455
456fn recent_context_picker_entries(
457    context_store: Entity<ContextStore>,
458    thread_store: Option<WeakEntity<ThreadStore>>,
459    workspace: Entity<Workspace>,
460    cx: &App,
461) -> Vec<RecentEntry> {
462    let mut recent = Vec::with_capacity(6);
463
464    let current_files = context_store.read(cx).file_paths(cx);
465    let workspace = workspace.read(cx);
466    let project = workspace.project().read(cx);
467
468    recent.extend(
469        workspace
470            .recent_navigation_history_iter(cx)
471            .filter(|(path, _)| !current_files.contains(path))
472            .take(4)
473            .filter_map(|(project_path, _)| {
474                project
475                    .worktree_for_id(project_path.worktree_id, cx)
476                    .map(|worktree| RecentEntry::File {
477                        project_path,
478                        path_prefix: worktree.read(cx).root_name().into(),
479                    })
480            }),
481    );
482
483    let mut current_threads = context_store.read(cx).thread_ids();
484
485    if let Some(active_thread) = workspace
486        .panel::<AssistantPanel>(cx)
487        .map(|panel| panel.read(cx).active_thread(cx))
488    {
489        current_threads.insert(active_thread.read(cx).id().clone());
490    }
491
492    if let Some(thread_store) = thread_store.and_then(|thread_store| thread_store.upgrade()) {
493        recent.extend(
494            thread_store
495                .read(cx)
496                .threads()
497                .into_iter()
498                .filter(|thread| !current_threads.contains(&thread.id))
499                .take(2)
500                .map(|thread| {
501                    RecentEntry::Thread(ThreadContextEntry {
502                        id: thread.id,
503                        summary: thread.summary,
504                    })
505                }),
506        );
507    }
508
509    recent
510}
511
512pub(crate) fn insert_fold_for_mention(
513    excerpt_id: ExcerptId,
514    crease_start: text::Anchor,
515    content_len: usize,
516    crease_label: SharedString,
517    crease_icon_path: SharedString,
518    editor_entity: Entity<Editor>,
519    cx: &mut App,
520) {
521    editor_entity.update(cx, |editor, cx| {
522        let snapshot = editor.buffer().read(cx).snapshot(cx);
523
524        let Some(start) = snapshot.anchor_in_excerpt(excerpt_id, crease_start) else {
525            return;
526        };
527
528        let start = start.bias_right(&snapshot);
529        let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
530
531        let placeholder = FoldPlaceholder {
532            render: render_fold_icon_button(
533                crease_icon_path,
534                crease_label,
535                editor_entity.downgrade(),
536            ),
537            merge_adjacent: false,
538            ..Default::default()
539        };
540
541        let render_trailer =
542            move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
543
544        let crease = Crease::inline(
545            start..end,
546            placeholder.clone(),
547            fold_toggle("mention"),
548            render_trailer,
549        );
550
551        editor.display_map.update(cx, |display_map, cx| {
552            display_map.fold(vec![crease], cx);
553        });
554    });
555}
556
557fn render_fold_icon_button(
558    icon_path: SharedString,
559    label: SharedString,
560    editor: WeakEntity<Editor>,
561) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
562    Arc::new({
563        move |fold_id, fold_range, cx| {
564            let is_in_text_selection = editor.upgrade().is_some_and(|editor| {
565                editor.update(cx, |editor, cx| {
566                    let snapshot = editor
567                        .buffer()
568                        .update(cx, |multi_buffer, cx| multi_buffer.snapshot(cx));
569
570                    let is_in_pending_selection = || {
571                        editor
572                            .selections
573                            .pending
574                            .as_ref()
575                            .is_some_and(|pending_selection| {
576                                pending_selection
577                                    .selection
578                                    .range()
579                                    .includes(&fold_range, &snapshot)
580                            })
581                    };
582
583                    let mut is_in_complete_selection = || {
584                        editor
585                            .selections
586                            .disjoint_in_range::<usize>(fold_range.clone(), cx)
587                            .into_iter()
588                            .any(|selection| {
589                                // This is needed to cover a corner case, if we just check for an existing
590                                // selection in the fold range, having a cursor at the start of the fold
591                                // marks it as selected. Non-empty selections don't cause this.
592                                let length = selection.end - selection.start;
593                                length > 0
594                            })
595                    };
596
597                    is_in_pending_selection() || is_in_complete_selection()
598                })
599            });
600
601            ButtonLike::new(fold_id)
602                .style(ButtonStyle::Filled)
603                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
604                .toggle_state(is_in_text_selection)
605                .child(
606                    h_flex()
607                        .gap_1()
608                        .child(
609                            Icon::from_path(icon_path.clone())
610                                .size(IconSize::XSmall)
611                                .color(Color::Muted),
612                        )
613                        .child(
614                            Label::new(label.clone())
615                                .size(LabelSize::Small)
616                                .buffer_font(cx)
617                                .single_line(),
618                        ),
619                )
620                .into_any_element()
621        }
622    })
623}
624
625fn fold_toggle(
626    name: &'static str,
627) -> impl Fn(
628    MultiBufferRow,
629    bool,
630    Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
631    &mut Window,
632    &mut App,
633) -> AnyElement {
634    move |row, is_folded, fold, _window, _cx| {
635        Disclosure::new((name, row.0 as u64), !is_folded)
636            .toggle_state(is_folded)
637            .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
638            .into_any_element()
639    }
640}
641
642pub enum MentionLink {
643    File(ProjectPath, Entry),
644    Symbol(ProjectPath, String),
645    Fetch(String),
646    Thread(ThreadId),
647}
648
649impl MentionLink {
650    const FILE: &str = "@file";
651    const SYMBOL: &str = "@symbol";
652    const THREAD: &str = "@thread";
653    const FETCH: &str = "@fetch";
654
655    const SEPARATOR: &str = ":";
656
657    pub fn is_valid(url: &str) -> bool {
658        url.starts_with(Self::FILE)
659            || url.starts_with(Self::SYMBOL)
660            || url.starts_with(Self::FETCH)
661            || url.starts_with(Self::THREAD)
662    }
663
664    pub fn for_file(file_name: &str, full_path: &str) -> String {
665        format!("[@{}]({}:{})", file_name, Self::FILE, full_path)
666    }
667
668    pub fn for_symbol(symbol_name: &str, full_path: &str) -> String {
669        format!(
670            "[@{}]({}:{}:{})",
671            symbol_name,
672            Self::SYMBOL,
673            full_path,
674            symbol_name
675        )
676    }
677
678    pub fn for_fetch(url: &str) -> String {
679        format!("[@{}]({}:{})", url, Self::FETCH, url)
680    }
681
682    pub fn for_thread(thread: &ThreadContextEntry) -> String {
683        format!("[@{}]({}:{})", thread.summary, Self::THREAD, thread.id)
684    }
685
686    pub fn try_parse(link: &str, workspace: &Entity<Workspace>, cx: &App) -> Option<Self> {
687        fn extract_project_path_from_link(
688            path: &str,
689            workspace: &Entity<Workspace>,
690            cx: &App,
691        ) -> Option<ProjectPath> {
692            let path = PathBuf::from(path);
693            let worktree_name = path.iter().next()?;
694            let path: PathBuf = path.iter().skip(1).collect();
695            let worktree_id = workspace
696                .read(cx)
697                .visible_worktrees(cx)
698                .find(|worktree| worktree.read(cx).root_name() == worktree_name)
699                .map(|worktree| worktree.read(cx).id())?;
700            Some(ProjectPath {
701                worktree_id,
702                path: path.into(),
703            })
704        }
705
706        let (prefix, argument) = link.split_once(Self::SEPARATOR)?;
707        match prefix {
708            Self::FILE => {
709                let project_path = extract_project_path_from_link(argument, workspace, cx)?;
710                let entry = workspace
711                    .read(cx)
712                    .project()
713                    .read(cx)
714                    .entry_for_path(&project_path, cx)?;
715                Some(MentionLink::File(project_path, entry))
716            }
717            Self::SYMBOL => {
718                let (path, symbol) = argument.split_once(Self::SEPARATOR)?;
719                let project_path = extract_project_path_from_link(path, workspace, cx)?;
720                Some(MentionLink::Symbol(project_path, symbol.to_string()))
721            }
722            Self::THREAD => {
723                let thread_id = ThreadId::from(argument);
724                Some(MentionLink::Thread(thread_id))
725            }
726            Self::FETCH => Some(MentionLink::Fetch(argument.to_string())),
727            _ => None,
728        }
729    }
730}