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