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