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_crease_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    window: &mut Window,
520    cx: &mut App,
521) {
522    editor_entity.update(cx, |editor, cx| {
523        let snapshot = editor.buffer().read(cx).snapshot(cx);
524
525        let Some(start) = snapshot.anchor_in_excerpt(excerpt_id, crease_start) else {
526            return;
527        };
528
529        let start = start.bias_right(&snapshot);
530        let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
531
532        let placeholder = FoldPlaceholder {
533            render: render_fold_icon_button(
534                crease_icon_path,
535                crease_label,
536                editor_entity.downgrade(),
537            ),
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.insert_creases(vec![crease.clone()], cx);
552        editor.fold_creases(vec![crease], false, window, cx);
553    });
554}
555
556fn render_fold_icon_button(
557    icon_path: SharedString,
558    label: SharedString,
559    editor: WeakEntity<Editor>,
560) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
561    Arc::new({
562        move |fold_id, fold_range, cx| {
563            let is_in_text_selection = editor.upgrade().is_some_and(|editor| {
564                editor.update(cx, |editor, cx| {
565                    let snapshot = editor
566                        .buffer()
567                        .update(cx, |multi_buffer, cx| multi_buffer.snapshot(cx));
568
569                    let is_in_pending_selection = || {
570                        editor
571                            .selections
572                            .pending
573                            .as_ref()
574                            .is_some_and(|pending_selection| {
575                                pending_selection
576                                    .selection
577                                    .range()
578                                    .includes(&fold_range, &snapshot)
579                            })
580                    };
581
582                    let mut is_in_complete_selection = || {
583                        editor
584                            .selections
585                            .disjoint_in_range::<usize>(fold_range.clone(), cx)
586                            .into_iter()
587                            .any(|selection| {
588                                // This is needed to cover a corner case, if we just check for an existing
589                                // selection in the fold range, having a cursor at the start of the fold
590                                // marks it as selected. Non-empty selections don't cause this.
591                                let length = selection.end - selection.start;
592                                length > 0
593                            })
594                    };
595
596                    is_in_pending_selection() || is_in_complete_selection()
597                })
598            });
599
600            ButtonLike::new(fold_id)
601                .style(ButtonStyle::Filled)
602                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
603                .toggle_state(is_in_text_selection)
604                .child(
605                    h_flex()
606                        .gap_1()
607                        .child(
608                            Icon::from_path(icon_path.clone())
609                                .size(IconSize::XSmall)
610                                .color(Color::Muted),
611                        )
612                        .child(
613                            Label::new(label.clone())
614                                .size(LabelSize::Small)
615                                .buffer_font(cx)
616                                .single_line(),
617                        ),
618                )
619                .into_any_element()
620        }
621    })
622}
623
624fn fold_toggle(
625    name: &'static str,
626) -> impl Fn(
627    MultiBufferRow,
628    bool,
629    Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
630    &mut Window,
631    &mut App,
632) -> AnyElement {
633    move |row, is_folded, fold, _window, _cx| {
634        Disclosure::new((name, row.0 as u64), !is_folded)
635            .toggle_state(is_folded)
636            .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
637            .into_any_element()
638    }
639}
640
641pub enum MentionLink {
642    File(ProjectPath, Entry),
643    Symbol(ProjectPath, String),
644    Fetch(String),
645    Thread(ThreadId),
646}
647
648impl MentionLink {
649    const FILE: &str = "@file";
650    const SYMBOL: &str = "@symbol";
651    const THREAD: &str = "@thread";
652    const FETCH: &str = "@fetch";
653
654    const SEPARATOR: &str = ":";
655
656    pub fn is_valid(url: &str) -> bool {
657        url.starts_with(Self::FILE)
658            || url.starts_with(Self::SYMBOL)
659            || url.starts_with(Self::FETCH)
660            || url.starts_with(Self::THREAD)
661    }
662
663    pub fn for_file(file_name: &str, full_path: &str) -> String {
664        format!("[@{}]({}:{})", file_name, Self::FILE, full_path)
665    }
666
667    pub fn for_symbol(symbol_name: &str, full_path: &str) -> String {
668        format!(
669            "[@{}]({}:{}:{})",
670            symbol_name,
671            Self::SYMBOL,
672            full_path,
673            symbol_name
674        )
675    }
676
677    pub fn for_fetch(url: &str) -> String {
678        format!("[@{}]({}:{})", url, Self::FETCH, url)
679    }
680
681    pub fn for_thread(thread: &ThreadContextEntry) -> String {
682        format!("[@{}]({}:{})", thread.summary, Self::THREAD, thread.id)
683    }
684
685    pub fn try_parse(link: &str, workspace: &Entity<Workspace>, cx: &App) -> Option<Self> {
686        fn extract_project_path_from_link(
687            path: &str,
688            workspace: &Entity<Workspace>,
689            cx: &App,
690        ) -> Option<ProjectPath> {
691            let path = PathBuf::from(path);
692            let worktree_name = path.iter().next()?;
693            let path: PathBuf = path.iter().skip(1).collect();
694            let worktree_id = workspace
695                .read(cx)
696                .visible_worktrees(cx)
697                .find(|worktree| worktree.read(cx).root_name() == worktree_name)
698                .map(|worktree| worktree.read(cx).id())?;
699            Some(ProjectPath {
700                worktree_id,
701                path: path.into(),
702            })
703        }
704
705        let (prefix, argument) = link.split_once(Self::SEPARATOR)?;
706        match prefix {
707            Self::FILE => {
708                let project_path = extract_project_path_from_link(argument, workspace, cx)?;
709                let entry = workspace
710                    .read(cx)
711                    .project()
712                    .read(cx)
713                    .entry_for_path(&project_path, cx)?;
714                Some(MentionLink::File(project_path, entry))
715            }
716            Self::SYMBOL => {
717                let (path, symbol) = argument.split_once(Self::SEPARATOR)?;
718                let project_path = extract_project_path_from_link(path, workspace, cx)?;
719                Some(MentionLink::Symbol(project_path, symbol.to_string()))
720            }
721            Self::THREAD => {
722                let thread_id = ThreadId::from(argument);
723                Some(MentionLink::Thread(thread_id))
724            }
725            Self::FETCH => Some(MentionLink::Fetch(argument.to_string())),
726            _ => None,
727        }
728    }
729}