context_picker.rs

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