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 path = project_path.path.clone();
293
294                ContextMenuItem::custom_entry(
295                    move |_window, cx| {
296                        render_file_context_entry(
297                            ElementId::NamedInteger("ctx-recent".into(), ix),
298                            &path,
299                            &path_prefix,
300                            false,
301                            context_store.clone(),
302                            cx,
303                        )
304                        .into_any()
305                    },
306                    move |window, cx| {
307                        context_picker.update(cx, |this, cx| {
308                            this.add_recent_file(project_path.clone(), window, cx);
309                        })
310                    },
311                )
312            }
313            RecentEntry::Thread(thread) => {
314                let context_store = self.context_store.clone();
315                let view_thread = thread.clone();
316
317                ContextMenuItem::custom_entry(
318                    move |_window, cx| {
319                        render_thread_context_entry(&view_thread, context_store.clone(), cx)
320                            .into_any()
321                    },
322                    move |_window, cx| {
323                        context_picker.update(cx, |this, cx| {
324                            this.add_recent_thread(thread.clone(), cx)
325                                .detach_and_log_err(cx);
326                        })
327                    },
328                )
329            }
330        }
331    }
332
333    fn add_recent_file(
334        &self,
335        project_path: ProjectPath,
336        window: &mut Window,
337        cx: &mut Context<Self>,
338    ) {
339        let Some(context_store) = self.context_store.upgrade() else {
340            return;
341        };
342
343        let task = context_store.update(cx, |context_store, cx| {
344            context_store.add_file_from_path(project_path.clone(), true, cx)
345        });
346
347        cx.spawn_in(window, async move |_, cx| task.await.notify_async_err(cx))
348            .detach();
349
350        cx.notify();
351    }
352
353    fn add_recent_thread(
354        &self,
355        thread: ThreadContextEntry,
356        cx: &mut Context<Self>,
357    ) -> Task<Result<()>> {
358        let Some(context_store) = self.context_store.upgrade() else {
359            return Task::ready(Err(anyhow!("context store not available")));
360        };
361
362        let Some(thread_store) = self
363            .thread_store
364            .as_ref()
365            .and_then(|thread_store| thread_store.upgrade())
366        else {
367            return Task::ready(Err(anyhow!("thread store not available")));
368        };
369
370        let open_thread_task = thread_store.update(cx, |this, cx| this.open_thread(&thread.id, cx));
371        cx.spawn(async move |this, cx| {
372            let thread = open_thread_task.await?;
373            context_store.update(cx, |context_store, cx| {
374                context_store.add_thread(thread, true, cx);
375            })?;
376
377            this.update(cx, |_this, cx| cx.notify())
378        })
379    }
380
381    fn recent_entries(&self, cx: &mut App) -> Vec<RecentEntry> {
382        let Some(workspace) = self.workspace.upgrade() else {
383            return vec![];
384        };
385
386        let Some(context_store) = self.context_store.upgrade() else {
387            return vec![];
388        };
389
390        recent_context_picker_entries(context_store, self.thread_store.clone(), workspace, cx)
391    }
392
393    fn notify_current_picker(&mut self, cx: &mut Context<Self>) {
394        match &self.mode {
395            ContextPickerState::Default(entity) => entity.update(cx, |_, cx| cx.notify()),
396            ContextPickerState::File(entity) => entity.update(cx, |_, cx| cx.notify()),
397            ContextPickerState::Symbol(entity) => entity.update(cx, |_, cx| cx.notify()),
398            ContextPickerState::Fetch(entity) => entity.update(cx, |_, cx| cx.notify()),
399            ContextPickerState::Thread(entity) => entity.update(cx, |_, cx| cx.notify()),
400        }
401    }
402}
403
404impl EventEmitter<DismissEvent> for ContextPicker {}
405
406impl Focusable for ContextPicker {
407    fn focus_handle(&self, cx: &App) -> FocusHandle {
408        match &self.mode {
409            ContextPickerState::Default(menu) => menu.focus_handle(cx),
410            ContextPickerState::File(file_picker) => file_picker.focus_handle(cx),
411            ContextPickerState::Symbol(symbol_picker) => symbol_picker.focus_handle(cx),
412            ContextPickerState::Fetch(fetch_picker) => fetch_picker.focus_handle(cx),
413            ContextPickerState::Thread(thread_picker) => thread_picker.focus_handle(cx),
414        }
415    }
416}
417
418impl Render for ContextPicker {
419    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
420        v_flex()
421            .w(px(400.))
422            .min_w(px(400.))
423            .map(|parent| match &self.mode {
424                ContextPickerState::Default(menu) => parent.child(menu.clone()),
425                ContextPickerState::File(file_picker) => parent.child(file_picker.clone()),
426                ContextPickerState::Symbol(symbol_picker) => parent.child(symbol_picker.clone()),
427                ContextPickerState::Fetch(fetch_picker) => parent.child(fetch_picker.clone()),
428                ContextPickerState::Thread(thread_picker) => parent.child(thread_picker.clone()),
429            })
430    }
431}
432enum RecentEntry {
433    File {
434        project_path: ProjectPath,
435        path_prefix: Arc<str>,
436    },
437    Thread(ThreadContextEntry),
438}
439
440fn supported_context_picker_modes(
441    thread_store: &Option<WeakEntity<ThreadStore>>,
442) -> Vec<ContextPickerMode> {
443    let mut modes = vec![
444        ContextPickerMode::File,
445        ContextPickerMode::Symbol,
446        ContextPickerMode::Fetch,
447    ];
448    if thread_store.is_some() {
449        modes.push(ContextPickerMode::Thread);
450    }
451    modes
452}
453
454fn recent_context_picker_entries(
455    context_store: Entity<ContextStore>,
456    thread_store: Option<WeakEntity<ThreadStore>>,
457    workspace: Entity<Workspace>,
458    cx: &App,
459) -> Vec<RecentEntry> {
460    let mut recent = Vec::with_capacity(6);
461
462    let current_files = context_store.read(cx).file_paths(cx);
463    let workspace = workspace.read(cx);
464    let project = workspace.project().read(cx);
465
466    recent.extend(
467        workspace
468            .recent_navigation_history_iter(cx)
469            .filter(|(path, _)| !current_files.contains(&path.path.to_path_buf()))
470            .take(4)
471            .filter_map(|(project_path, _)| {
472                project
473                    .worktree_for_id(project_path.worktree_id, cx)
474                    .map(|worktree| RecentEntry::File {
475                        project_path,
476                        path_prefix: worktree.read(cx).root_name().into(),
477                    })
478            }),
479    );
480
481    let mut current_threads = context_store.read(cx).thread_ids();
482
483    if let Some(active_thread) = workspace
484        .panel::<AssistantPanel>(cx)
485        .map(|panel| panel.read(cx).active_thread(cx))
486    {
487        current_threads.insert(active_thread.read(cx).id().clone());
488    }
489
490    if let Some(thread_store) = thread_store.and_then(|thread_store| thread_store.upgrade()) {
491        recent.extend(
492            thread_store
493                .read(cx)
494                .threads()
495                .into_iter()
496                .filter(|thread| !current_threads.contains(&thread.id))
497                .take(2)
498                .map(|thread| {
499                    RecentEntry::Thread(ThreadContextEntry {
500                        id: thread.id,
501                        summary: thread.summary,
502                    })
503                }),
504        );
505    }
506
507    recent
508}
509
510pub(crate) fn insert_crease_for_mention(
511    excerpt_id: ExcerptId,
512    crease_start: text::Anchor,
513    content_len: usize,
514    crease_label: SharedString,
515    crease_icon_path: SharedString,
516    editor_entity: Entity<Editor>,
517    window: &mut Window,
518    cx: &mut App,
519) {
520    editor_entity.update(cx, |editor, cx| {
521        let snapshot = editor.buffer().read(cx).snapshot(cx);
522
523        let Some(start) = snapshot.anchor_in_excerpt(excerpt_id, crease_start) else {
524            return;
525        };
526
527        let start = start.bias_right(&snapshot);
528        let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
529
530        let placeholder = FoldPlaceholder {
531            render: render_fold_icon_button(
532                crease_icon_path,
533                crease_label,
534                editor_entity.downgrade(),
535            ),
536            ..Default::default()
537        };
538
539        let render_trailer =
540            move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
541
542        let crease = Crease::inline(
543            start..end,
544            placeholder.clone(),
545            fold_toggle("mention"),
546            render_trailer,
547        );
548
549        editor.insert_creases(vec![crease.clone()], cx);
550        editor.fold_creases(vec![crease], false, window, cx);
551    });
552}
553
554fn render_fold_icon_button(
555    icon_path: SharedString,
556    label: SharedString,
557    editor: WeakEntity<Editor>,
558) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
559    Arc::new({
560        move |fold_id, fold_range, cx| {
561            let is_in_text_selection = editor.upgrade().is_some_and(|editor| {
562                editor.update(cx, |editor, cx| {
563                    let snapshot = editor
564                        .buffer()
565                        .update(cx, |multi_buffer, cx| multi_buffer.snapshot(cx));
566
567                    let is_in_pending_selection = || {
568                        editor
569                            .selections
570                            .pending
571                            .as_ref()
572                            .is_some_and(|pending_selection| {
573                                pending_selection
574                                    .selection
575                                    .range()
576                                    .includes(&fold_range, &snapshot)
577                            })
578                    };
579
580                    let mut is_in_complete_selection = || {
581                        editor
582                            .selections
583                            .disjoint_in_range::<usize>(fold_range.clone(), cx)
584                            .into_iter()
585                            .any(|selection| {
586                                // This is needed to cover a corner case, if we just check for an existing
587                                // selection in the fold range, having a cursor at the start of the fold
588                                // marks it as selected. Non-empty selections don't cause this.
589                                let length = selection.end - selection.start;
590                                length > 0
591                            })
592                    };
593
594                    is_in_pending_selection() || is_in_complete_selection()
595                })
596            });
597
598            ButtonLike::new(fold_id)
599                .style(ButtonStyle::Filled)
600                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
601                .toggle_state(is_in_text_selection)
602                .child(
603                    h_flex()
604                        .gap_1()
605                        .child(
606                            Icon::from_path(icon_path.clone())
607                                .size(IconSize::Small)
608                                .color(Color::Muted),
609                        )
610                        .child(
611                            Label::new(label.clone())
612                                .size(LabelSize::Small)
613                                .single_line(),
614                        ),
615                )
616                .into_any_element()
617        }
618    })
619}
620
621fn fold_toggle(
622    name: &'static str,
623) -> impl Fn(
624    MultiBufferRow,
625    bool,
626    Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
627    &mut Window,
628    &mut App,
629) -> AnyElement {
630    move |row, is_folded, fold, _window, _cx| {
631        Disclosure::new((name, row.0 as u64), !is_folded)
632            .toggle_state(is_folded)
633            .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
634            .into_any_element()
635    }
636}
637
638pub enum MentionLink {
639    File(ProjectPath, Entry),
640    Symbol(ProjectPath, String),
641    Fetch(String),
642    Thread(ThreadId),
643}
644
645impl MentionLink {
646    const FILE: &str = "@file";
647    const SYMBOL: &str = "@symbol";
648    const THREAD: &str = "@thread";
649    const FETCH: &str = "@fetch";
650
651    const SEPARATOR: &str = ":";
652
653    pub fn is_valid(url: &str) -> bool {
654        url.starts_with(Self::FILE)
655            || url.starts_with(Self::SYMBOL)
656            || url.starts_with(Self::FETCH)
657            || url.starts_with(Self::THREAD)
658    }
659
660    pub fn for_file(file_name: &str, full_path: &str) -> String {
661        format!("[@{}]({}:{})", file_name, Self::FILE, full_path)
662    }
663
664    pub fn for_symbol(symbol_name: &str, full_path: &str) -> String {
665        format!(
666            "[@{}]({}:{}:{})",
667            symbol_name,
668            Self::SYMBOL,
669            full_path,
670            symbol_name
671        )
672    }
673
674    pub fn for_fetch(url: &str) -> String {
675        format!("[@{}]({}:{})", url, Self::FETCH, url)
676    }
677
678    pub fn for_thread(thread: &ThreadContextEntry) -> String {
679        format!("[@{}]({}:{})", thread.summary, Self::THREAD, thread.id)
680    }
681
682    pub fn try_parse(link: &str, workspace: &Entity<Workspace>, cx: &App) -> Option<Self> {
683        fn extract_project_path_from_link(
684            path: &str,
685            workspace: &Entity<Workspace>,
686            cx: &App,
687        ) -> Option<ProjectPath> {
688            let path = PathBuf::from(path);
689            let worktree_name = path.iter().next()?;
690            let path: PathBuf = path.iter().skip(1).collect();
691            let worktree_id = workspace
692                .read(cx)
693                .visible_worktrees(cx)
694                .find(|worktree| worktree.read(cx).root_name() == worktree_name)
695                .map(|worktree| worktree.read(cx).id())?;
696            Some(ProjectPath {
697                worktree_id,
698                path: path.into(),
699            })
700        }
701
702        let (prefix, argument) = link.split_once(Self::SEPARATOR)?;
703        match prefix {
704            Self::FILE => {
705                let project_path = extract_project_path_from_link(argument, workspace, cx)?;
706                let entry = workspace
707                    .read(cx)
708                    .project()
709                    .read(cx)
710                    .entry_for_path(&project_path, cx)?;
711                Some(MentionLink::File(project_path, entry))
712            }
713            Self::SYMBOL => {
714                let (path, symbol) = argument.split_once(Self::SEPARATOR)?;
715                let project_path = extract_project_path_from_link(path, workspace, cx)?;
716                Some(MentionLink::Symbol(project_path, symbol.to_string()))
717            }
718            Self::THREAD => {
719                let thread_id = ThreadId::from(argument);
720                Some(MentionLink::Thread(thread_id))
721            }
722            Self::FETCH => Some(MentionLink::Fetch(argument.to_string())),
723            _ => None,
724        }
725    }
726}