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}