context_picker.rs

  1mod directory_context_picker;
  2mod fetch_context_picker;
  3mod file_context_picker;
  4mod thread_context_picker;
  5
  6use std::path::PathBuf;
  7use std::sync::Arc;
  8
  9use anyhow::{anyhow, Result};
 10use editor::Editor;
 11use file_context_picker::render_file_context_entry;
 12use gpui::{App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity};
 13use project::ProjectPath;
 14use thread_context_picker::{render_thread_context_entry, ThreadContextEntry};
 15use ui::{prelude::*, ContextMenu, ContextMenuEntry, ContextMenuItem};
 16use workspace::{notifications::NotifyResultExt, Workspace};
 17
 18use crate::context::ContextKind;
 19use crate::context_picker::directory_context_picker::DirectoryContextPicker;
 20use crate::context_picker::fetch_context_picker::FetchContextPicker;
 21use crate::context_picker::file_context_picker::FileContextPicker;
 22use crate::context_picker::thread_context_picker::ThreadContextPicker;
 23use crate::context_store::ContextStore;
 24use crate::thread_store::ThreadStore;
 25use crate::AssistantPanel;
 26
 27#[derive(Debug, Clone, Copy)]
 28pub enum ConfirmBehavior {
 29    KeepOpen,
 30    Close,
 31}
 32
 33#[derive(Debug, Clone)]
 34enum ContextPickerMode {
 35    Default(Entity<ContextMenu>),
 36    File(Entity<FileContextPicker>),
 37    Directory(Entity<DirectoryContextPicker>),
 38    Fetch(Entity<FetchContextPicker>),
 39    Thread(Entity<ThreadContextPicker>),
 40}
 41
 42pub(super) struct ContextPicker {
 43    mode: ContextPickerMode,
 44    workspace: WeakEntity<Workspace>,
 45    editor: WeakEntity<Editor>,
 46    context_store: WeakEntity<ContextStore>,
 47    thread_store: Option<WeakEntity<ThreadStore>>,
 48    confirm_behavior: ConfirmBehavior,
 49}
 50
 51impl ContextPicker {
 52    pub fn new(
 53        workspace: WeakEntity<Workspace>,
 54        thread_store: Option<WeakEntity<ThreadStore>>,
 55        context_store: WeakEntity<ContextStore>,
 56        editor: WeakEntity<Editor>,
 57        confirm_behavior: ConfirmBehavior,
 58        window: &mut Window,
 59        cx: &mut Context<Self>,
 60    ) -> Self {
 61        ContextPicker {
 62            mode: ContextPickerMode::Default(ContextMenu::build(
 63                window,
 64                cx,
 65                |menu, _window, _cx| menu,
 66            )),
 67            workspace,
 68            context_store,
 69            thread_store,
 70            editor,
 71            confirm_behavior,
 72        }
 73    }
 74
 75    pub fn init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 76        self.mode = ContextPickerMode::Default(self.build_menu(window, cx));
 77        cx.notify();
 78    }
 79
 80    fn build_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Entity<ContextMenu> {
 81        let context_picker = cx.entity().clone();
 82
 83        let menu = ContextMenu::build(window, cx, move |menu, _window, cx| {
 84            let recent = self.recent_entries(cx);
 85            let has_recent = !recent.is_empty();
 86            let recent_entries = recent
 87                .into_iter()
 88                .enumerate()
 89                .map(|(ix, entry)| self.recent_menu_item(context_picker.clone(), ix, entry));
 90
 91            let mut context_kinds = vec![
 92                ContextKind::File,
 93                ContextKind::Directory,
 94                ContextKind::FetchedUrl,
 95            ];
 96            if self.allow_threads() {
 97                context_kinds.push(ContextKind::Thread);
 98            }
 99
100            let menu = menu
101                .when(has_recent, |menu| {
102                    menu.custom_row(|_, _| {
103                        div()
104                            .mb_1()
105                            .child(
106                                Label::new("Recent")
107                                    .color(Color::Muted)
108                                    .size(LabelSize::Small),
109                            )
110                            .into_any_element()
111                    })
112                })
113                .extend(recent_entries)
114                .when(has_recent, |menu| menu.separator())
115                .extend(context_kinds.into_iter().map(|kind| {
116                    let context_picker = context_picker.clone();
117
118                    ContextMenuEntry::new(kind.label())
119                        .icon(kind.icon())
120                        .icon_size(IconSize::XSmall)
121                        .icon_color(Color::Muted)
122                        .handler(move |window, cx| {
123                            context_picker.update(cx, |this, cx| this.select_kind(kind, window, cx))
124                        })
125                }));
126
127            match self.confirm_behavior {
128                ConfirmBehavior::KeepOpen => menu.keep_open_on_confirm(),
129                ConfirmBehavior::Close => menu,
130            }
131        });
132
133        cx.subscribe(&menu, move |_, _, _: &DismissEvent, cx| {
134            cx.emit(DismissEvent);
135        })
136        .detach();
137
138        menu
139    }
140
141    /// Whether threads are allowed as context.
142    pub fn allow_threads(&self) -> bool {
143        self.thread_store.is_some()
144    }
145
146    fn select_kind(&mut self, kind: ContextKind, window: &mut Window, cx: &mut Context<Self>) {
147        let context_picker = cx.entity().downgrade();
148
149        match kind {
150            ContextKind::File => {
151                self.mode = ContextPickerMode::File(cx.new(|cx| {
152                    FileContextPicker::new(
153                        context_picker.clone(),
154                        self.workspace.clone(),
155                        self.editor.clone(),
156                        self.context_store.clone(),
157                        self.confirm_behavior,
158                        window,
159                        cx,
160                    )
161                }));
162            }
163            ContextKind::Directory => {
164                self.mode = ContextPickerMode::Directory(cx.new(|cx| {
165                    DirectoryContextPicker::new(
166                        context_picker.clone(),
167                        self.workspace.clone(),
168                        self.context_store.clone(),
169                        self.confirm_behavior,
170                        window,
171                        cx,
172                    )
173                }));
174            }
175            ContextKind::FetchedUrl => {
176                self.mode = ContextPickerMode::Fetch(cx.new(|cx| {
177                    FetchContextPicker::new(
178                        context_picker.clone(),
179                        self.workspace.clone(),
180                        self.context_store.clone(),
181                        self.confirm_behavior,
182                        window,
183                        cx,
184                    )
185                }));
186            }
187            ContextKind::Thread => {
188                if let Some(thread_store) = self.thread_store.as_ref() {
189                    self.mode = ContextPickerMode::Thread(cx.new(|cx| {
190                        ThreadContextPicker::new(
191                            thread_store.clone(),
192                            context_picker.clone(),
193                            self.context_store.clone(),
194                            self.confirm_behavior,
195                            window,
196                            cx,
197                        )
198                    }));
199                }
200            }
201        }
202
203        cx.notify();
204        cx.focus_self(window);
205    }
206
207    fn recent_menu_item(
208        &self,
209        context_picker: Entity<ContextPicker>,
210        ix: usize,
211        entry: RecentEntry,
212    ) -> ContextMenuItem {
213        match entry {
214            RecentEntry::File {
215                project_path,
216                path_prefix,
217            } => {
218                let context_store = self.context_store.clone();
219                let path = project_path.path.clone();
220
221                ContextMenuItem::custom_entry(
222                    move |_window, cx| {
223                        render_file_context_entry(
224                            ElementId::NamedInteger("ctx-recent".into(), ix),
225                            &path,
226                            &path_prefix,
227                            context_store.clone(),
228                            cx,
229                        )
230                        .into_any()
231                    },
232                    move |window, cx| {
233                        context_picker.update(cx, |this, cx| {
234                            this.add_recent_file(project_path.clone(), window, cx);
235                        })
236                    },
237                )
238            }
239            RecentEntry::Thread(thread) => {
240                let context_store = self.context_store.clone();
241                let view_thread = thread.clone();
242
243                ContextMenuItem::custom_entry(
244                    move |_window, cx| {
245                        render_thread_context_entry(&view_thread, context_store.clone(), cx)
246                            .into_any()
247                    },
248                    move |_window, cx| {
249                        context_picker.update(cx, |this, cx| {
250                            this.add_recent_thread(thread.clone(), cx)
251                                .detach_and_log_err(cx);
252                        })
253                    },
254                )
255            }
256        }
257    }
258
259    fn add_recent_file(
260        &self,
261        project_path: ProjectPath,
262        window: &mut Window,
263        cx: &mut Context<Self>,
264    ) {
265        let Some(context_store) = self.context_store.upgrade() else {
266            return;
267        };
268
269        let task = context_store.update(cx, |context_store, cx| {
270            context_store.add_file_from_path(project_path.clone(), cx)
271        });
272
273        cx.spawn_in(window, |_, mut cx| async move {
274            task.await.notify_async_err(&mut cx)
275        })
276        .detach();
277
278        cx.notify();
279    }
280
281    fn add_recent_thread(
282        &self,
283        thread: ThreadContextEntry,
284        cx: &mut Context<Self>,
285    ) -> Task<Result<()>> {
286        let Some(context_store) = self.context_store.upgrade() else {
287            return Task::ready(Err(anyhow!("context store not available")));
288        };
289
290        let Some(thread_store) = self
291            .thread_store
292            .as_ref()
293            .and_then(|thread_store| thread_store.upgrade())
294        else {
295            return Task::ready(Err(anyhow!("thread store not available")));
296        };
297
298        let open_thread_task = thread_store.update(cx, |this, cx| this.open_thread(&thread.id, cx));
299        cx.spawn(|this, mut cx| async move {
300            let thread = open_thread_task.await?;
301            context_store.update(&mut cx, |context_store, cx| {
302                context_store.add_thread(thread, cx);
303            })?;
304
305            this.update(&mut cx, |_this, cx| cx.notify())
306        })
307    }
308
309    fn recent_entries(&self, cx: &mut App) -> Vec<RecentEntry> {
310        let Some(workspace) = self.workspace.upgrade().map(|w| w.read(cx)) else {
311            return vec![];
312        };
313
314        let Some(context_store) = self.context_store.upgrade().map(|cs| cs.read(cx)) else {
315            return vec![];
316        };
317
318        let mut recent = Vec::with_capacity(6);
319
320        let mut current_files = context_store.file_paths(cx);
321
322        if let Some(active_path) = Self::active_singleton_buffer_path(&workspace, cx) {
323            current_files.insert(active_path);
324        }
325
326        let project = workspace.project().read(cx);
327
328        recent.extend(
329            workspace
330                .recent_navigation_history_iter(cx)
331                .filter(|(path, _)| !current_files.contains(&path.path.to_path_buf()))
332                .take(4)
333                .filter_map(|(project_path, _)| {
334                    project
335                        .worktree_for_id(project_path.worktree_id, cx)
336                        .map(|worktree| RecentEntry::File {
337                            project_path,
338                            path_prefix: worktree.read(cx).root_name().into(),
339                        })
340                }),
341        );
342
343        let mut current_threads = context_store.thread_ids();
344
345        if let Some(active_thread) = workspace
346            .panel::<AssistantPanel>(cx)
347            .map(|panel| panel.read(cx).active_thread(cx))
348        {
349            current_threads.insert(active_thread.read(cx).id().clone());
350        }
351
352        let Some(thread_store) = self
353            .thread_store
354            .as_ref()
355            .and_then(|thread_store| thread_store.upgrade())
356        else {
357            return recent;
358        };
359
360        thread_store.update(cx, |thread_store, _cx| {
361            recent.extend(
362                thread_store
363                    .threads()
364                    .into_iter()
365                    .filter(|thread| !current_threads.contains(&thread.id))
366                    .take(2)
367                    .map(|thread| {
368                        RecentEntry::Thread(ThreadContextEntry {
369                            id: thread.id,
370                            summary: thread.summary,
371                        })
372                    }),
373            )
374        });
375
376        recent
377    }
378
379    fn active_singleton_buffer_path(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
380        let active_item = workspace.active_item(cx)?;
381
382        let editor = active_item.to_any().downcast::<Editor>().ok()?.read(cx);
383        let buffer = editor.buffer().read(cx).as_singleton()?;
384
385        let path = buffer.read(cx).file()?.path().to_path_buf();
386        Some(path)
387    }
388}
389
390impl EventEmitter<DismissEvent> for ContextPicker {}
391
392impl Focusable for ContextPicker {
393    fn focus_handle(&self, cx: &App) -> FocusHandle {
394        match &self.mode {
395            ContextPickerMode::Default(menu) => menu.focus_handle(cx),
396            ContextPickerMode::File(file_picker) => file_picker.focus_handle(cx),
397            ContextPickerMode::Directory(directory_picker) => directory_picker.focus_handle(cx),
398            ContextPickerMode::Fetch(fetch_picker) => fetch_picker.focus_handle(cx),
399            ContextPickerMode::Thread(thread_picker) => thread_picker.focus_handle(cx),
400        }
401    }
402}
403
404impl Render for ContextPicker {
405    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
406        v_flex()
407            .w(px(400.))
408            .min_w(px(400.))
409            .map(|parent| match &self.mode {
410                ContextPickerMode::Default(menu) => parent.child(menu.clone()),
411                ContextPickerMode::File(file_picker) => parent.child(file_picker.clone()),
412                ContextPickerMode::Directory(directory_picker) => {
413                    parent.child(directory_picker.clone())
414                }
415                ContextPickerMode::Fetch(fetch_picker) => parent.child(fetch_picker.clone()),
416                ContextPickerMode::Thread(thread_picker) => parent.child(thread_picker.clone()),
417            })
418    }
419}
420enum RecentEntry {
421    File {
422        project_path: ProjectPath,
423        path_prefix: Arc<str>,
424    },
425    Thread(ThreadContextEntry),
426}