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