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, Task, WeakEntity,
17};
18use multi_buffer::MultiBufferRow;
19use project::{Entry, ProjectPath};
20use symbol_context_picker::SymbolContextPicker;
21use thread_context_picker::{ThreadContextEntry, render_thread_context_entry};
22use ui::{
23 ButtonLike, ContextMenu, ContextMenuEntry, ContextMenuItem, Disclosure, TintColor, prelude::*,
24};
25use workspace::{Workspace, notifications::NotifyResultExt};
26
27use crate::AssistantPanel;
28pub use crate::context_picker::completion_provider::ContextPickerCompletionProvider;
29use crate::context_picker::fetch_context_picker::FetchContextPicker;
30use crate::context_picker::file_context_picker::FileContextPicker;
31use crate::context_picker::thread_context_picker::ThreadContextPicker;
32use crate::context_store::ContextStore;
33use crate::thread::ThreadId;
34use crate::thread_store::ThreadStore;
35
36#[derive(Debug, Clone, Copy)]
37pub enum ConfirmBehavior {
38 KeepOpen,
39 Close,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum ContextPickerMode {
44 File,
45 Symbol,
46 Fetch,
47 Thread,
48}
49
50impl TryFrom<&str> for ContextPickerMode {
51 type Error = String;
52
53 fn try_from(value: &str) -> Result<Self, Self::Error> {
54 match value {
55 "file" => Ok(Self::File),
56 "symbol" => Ok(Self::Symbol),
57 "fetch" => Ok(Self::Fetch),
58 "thread" => Ok(Self::Thread),
59 _ => Err(format!("Invalid context picker mode: {}", value)),
60 }
61 }
62}
63
64impl ContextPickerMode {
65 pub fn mention_prefix(&self) -> &'static str {
66 match self {
67 Self::File => "file",
68 Self::Symbol => "symbol",
69 Self::Fetch => "fetch",
70 Self::Thread => "thread",
71 }
72 }
73
74 pub fn label(&self) -> &'static str {
75 match self {
76 Self::File => "Files & Directories",
77 Self::Symbol => "Symbols",
78 Self::Fetch => "Fetch",
79 Self::Thread => "Thread",
80 }
81 }
82
83 pub fn icon(&self) -> IconName {
84 match self {
85 Self::File => IconName::File,
86 Self::Symbol => IconName::Code,
87 Self::Fetch => IconName::Globe,
88 Self::Thread => IconName::MessageBubbles,
89 }
90 }
91}
92
93#[derive(Debug, Clone)]
94enum ContextPickerState {
95 Default(Entity<ContextMenu>),
96 File(Entity<FileContextPicker>),
97 Symbol(Entity<SymbolContextPicker>),
98 Fetch(Entity<FetchContextPicker>),
99 Thread(Entity<ThreadContextPicker>),
100}
101
102pub(super) struct ContextPicker {
103 mode: ContextPickerState,
104 workspace: WeakEntity<Workspace>,
105 context_store: WeakEntity<ContextStore>,
106 thread_store: Option<WeakEntity<ThreadStore>>,
107 confirm_behavior: ConfirmBehavior,
108}
109
110impl ContextPicker {
111 pub fn new(
112 workspace: WeakEntity<Workspace>,
113 thread_store: Option<WeakEntity<ThreadStore>>,
114 context_store: WeakEntity<ContextStore>,
115 confirm_behavior: ConfirmBehavior,
116 window: &mut Window,
117 cx: &mut Context<Self>,
118 ) -> Self {
119 ContextPicker {
120 mode: ContextPickerState::Default(ContextMenu::build(
121 window,
122 cx,
123 |menu, _window, _cx| menu,
124 )),
125 workspace,
126 context_store,
127 thread_store,
128 confirm_behavior,
129 }
130 }
131
132 pub fn init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
133 self.mode = ContextPickerState::Default(self.build_menu(window, cx));
134 cx.notify();
135 }
136
137 fn build_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Entity<ContextMenu> {
138 let context_picker = cx.entity().clone();
139
140 let menu = ContextMenu::build(window, cx, move |menu, _window, cx| {
141 let recent = self.recent_entries(cx);
142 let has_recent = !recent.is_empty();
143 let recent_entries = recent
144 .into_iter()
145 .enumerate()
146 .map(|(ix, entry)| self.recent_menu_item(context_picker.clone(), ix, entry));
147
148 let modes = supported_context_picker_modes(&self.thread_store);
149
150 let menu = menu
151 .when(has_recent, |menu| {
152 menu.custom_row(|_, _| {
153 div()
154 .mb_1()
155 .child(
156 Label::new("Recent")
157 .color(Color::Muted)
158 .size(LabelSize::Small),
159 )
160 .into_any_element()
161 })
162 })
163 .extend(recent_entries)
164 .when(has_recent, |menu| menu.separator())
165 .extend(modes.into_iter().map(|mode| {
166 let context_picker = context_picker.clone();
167
168 ContextMenuEntry::new(mode.label())
169 .icon(mode.icon())
170 .icon_size(IconSize::XSmall)
171 .icon_color(Color::Muted)
172 .handler(move |window, cx| {
173 context_picker.update(cx, |this, cx| this.select_mode(mode, window, cx))
174 })
175 }));
176
177 match self.confirm_behavior {
178 ConfirmBehavior::KeepOpen => menu.keep_open_on_confirm(),
179 ConfirmBehavior::Close => menu,
180 }
181 });
182
183 cx.subscribe(&menu, move |_, _, _: &DismissEvent, cx| {
184 cx.emit(DismissEvent);
185 })
186 .detach();
187
188 menu
189 }
190
191 /// Whether threads are allowed as context.
192 pub fn allow_threads(&self) -> bool {
193 self.thread_store.is_some()
194 }
195
196 fn select_mode(
197 &mut self,
198 mode: ContextPickerMode,
199 window: &mut Window,
200 cx: &mut Context<Self>,
201 ) {
202 let context_picker = cx.entity().downgrade();
203
204 match mode {
205 ContextPickerMode::File => {
206 self.mode = ContextPickerState::File(cx.new(|cx| {
207 FileContextPicker::new(
208 context_picker.clone(),
209 self.workspace.clone(),
210 self.context_store.clone(),
211 self.confirm_behavior,
212 window,
213 cx,
214 )
215 }));
216 }
217 ContextPickerMode::Symbol => {
218 self.mode = ContextPickerState::Symbol(cx.new(|cx| {
219 SymbolContextPicker::new(
220 context_picker.clone(),
221 self.workspace.clone(),
222 self.context_store.clone(),
223 self.confirm_behavior,
224 window,
225 cx,
226 )
227 }));
228 }
229 ContextPickerMode::Fetch => {
230 self.mode = ContextPickerState::Fetch(cx.new(|cx| {
231 FetchContextPicker::new(
232 context_picker.clone(),
233 self.workspace.clone(),
234 self.context_store.clone(),
235 self.confirm_behavior,
236 window,
237 cx,
238 )
239 }));
240 }
241 ContextPickerMode::Thread => {
242 if let Some(thread_store) = self.thread_store.as_ref() {
243 self.mode = ContextPickerState::Thread(cx.new(|cx| {
244 ThreadContextPicker::new(
245 thread_store.clone(),
246 context_picker.clone(),
247 self.context_store.clone(),
248 self.confirm_behavior,
249 window,
250 cx,
251 )
252 }));
253 }
254 }
255 }
256
257 cx.notify();
258 cx.focus_self(window);
259 }
260
261 fn recent_menu_item(
262 &self,
263 context_picker: Entity<ContextPicker>,
264 ix: usize,
265 entry: RecentEntry,
266 ) -> ContextMenuItem {
267 match entry {
268 RecentEntry::File {
269 project_path,
270 path_prefix,
271 } => {
272 let context_store = self.context_store.clone();
273 let path = project_path.path.clone();
274
275 ContextMenuItem::custom_entry(
276 move |_window, cx| {
277 render_file_context_entry(
278 ElementId::NamedInteger("ctx-recent".into(), ix),
279 &path,
280 &path_prefix,
281 false,
282 context_store.clone(),
283 cx,
284 )
285 .into_any()
286 },
287 move |window, cx| {
288 context_picker.update(cx, |this, cx| {
289 this.add_recent_file(project_path.clone(), window, cx);
290 })
291 },
292 )
293 }
294 RecentEntry::Thread(thread) => {
295 let context_store = self.context_store.clone();
296 let view_thread = thread.clone();
297
298 ContextMenuItem::custom_entry(
299 move |_window, cx| {
300 render_thread_context_entry(&view_thread, context_store.clone(), cx)
301 .into_any()
302 },
303 move |_window, cx| {
304 context_picker.update(cx, |this, cx| {
305 this.add_recent_thread(thread.clone(), cx)
306 .detach_and_log_err(cx);
307 })
308 },
309 )
310 }
311 }
312 }
313
314 fn add_recent_file(
315 &self,
316 project_path: ProjectPath,
317 window: &mut Window,
318 cx: &mut Context<Self>,
319 ) {
320 let Some(context_store) = self.context_store.upgrade() else {
321 return;
322 };
323
324 let task = context_store.update(cx, |context_store, cx| {
325 context_store.add_file_from_path(project_path.clone(), true, cx)
326 });
327
328 cx.spawn_in(window, async move |_, cx| task.await.notify_async_err(cx))
329 .detach();
330
331 cx.notify();
332 }
333
334 fn add_recent_thread(
335 &self,
336 thread: ThreadContextEntry,
337 cx: &mut Context<Self>,
338 ) -> Task<Result<()>> {
339 let Some(context_store) = self.context_store.upgrade() else {
340 return Task::ready(Err(anyhow!("context store not available")));
341 };
342
343 let Some(thread_store) = self
344 .thread_store
345 .as_ref()
346 .and_then(|thread_store| thread_store.upgrade())
347 else {
348 return Task::ready(Err(anyhow!("thread store not available")));
349 };
350
351 let open_thread_task = thread_store.update(cx, |this, cx| this.open_thread(&thread.id, cx));
352 cx.spawn(async move |this, cx| {
353 let thread = open_thread_task.await?;
354 context_store.update(cx, |context_store, cx| {
355 context_store.add_thread(thread, true, cx);
356 })?;
357
358 this.update(cx, |_this, cx| cx.notify())
359 })
360 }
361
362 fn recent_entries(&self, cx: &mut App) -> Vec<RecentEntry> {
363 let Some(workspace) = self.workspace.upgrade().map(|w| w.read(cx)) else {
364 return vec![];
365 };
366
367 let Some(context_store) = self.context_store.upgrade().map(|cs| cs.read(cx)) else {
368 return vec![];
369 };
370
371 let mut recent = Vec::with_capacity(6);
372
373 let mut current_files = context_store.file_paths(cx);
374
375 if let Some(active_path) = active_singleton_buffer_path(&workspace, cx) {
376 current_files.insert(active_path);
377 }
378
379 let project = workspace.project().read(cx);
380
381 recent.extend(
382 workspace
383 .recent_navigation_history_iter(cx)
384 .filter(|(path, _)| !current_files.contains(&path.path.to_path_buf()))
385 .take(4)
386 .filter_map(|(project_path, _)| {
387 project
388 .worktree_for_id(project_path.worktree_id, cx)
389 .map(|worktree| RecentEntry::File {
390 project_path,
391 path_prefix: worktree.read(cx).root_name().into(),
392 })
393 }),
394 );
395
396 let mut current_threads = context_store.thread_ids();
397
398 if let Some(active_thread) = workspace
399 .panel::<AssistantPanel>(cx)
400 .map(|panel| panel.read(cx).active_thread(cx))
401 {
402 current_threads.insert(active_thread.read(cx).id().clone());
403 }
404
405 let Some(thread_store) = self
406 .thread_store
407 .as_ref()
408 .and_then(|thread_store| thread_store.upgrade())
409 else {
410 return recent;
411 };
412
413 thread_store.update(cx, |thread_store, _cx| {
414 recent.extend(
415 thread_store
416 .threads()
417 .into_iter()
418 .filter(|thread| !current_threads.contains(&thread.id))
419 .take(2)
420 .map(|thread| {
421 RecentEntry::Thread(ThreadContextEntry {
422 id: thread.id,
423 summary: thread.summary,
424 })
425 }),
426 )
427 });
428
429 recent
430 }
431}
432
433impl EventEmitter<DismissEvent> for ContextPicker {}
434
435impl Focusable for ContextPicker {
436 fn focus_handle(&self, cx: &App) -> FocusHandle {
437 match &self.mode {
438 ContextPickerState::Default(menu) => menu.focus_handle(cx),
439 ContextPickerState::File(file_picker) => file_picker.focus_handle(cx),
440 ContextPickerState::Symbol(symbol_picker) => symbol_picker.focus_handle(cx),
441 ContextPickerState::Fetch(fetch_picker) => fetch_picker.focus_handle(cx),
442 ContextPickerState::Thread(thread_picker) => thread_picker.focus_handle(cx),
443 }
444 }
445}
446
447impl Render for ContextPicker {
448 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
449 v_flex()
450 .w(px(400.))
451 .min_w(px(400.))
452 .map(|parent| match &self.mode {
453 ContextPickerState::Default(menu) => parent.child(menu.clone()),
454 ContextPickerState::File(file_picker) => parent.child(file_picker.clone()),
455 ContextPickerState::Symbol(symbol_picker) => parent.child(symbol_picker.clone()),
456 ContextPickerState::Fetch(fetch_picker) => parent.child(fetch_picker.clone()),
457 ContextPickerState::Thread(thread_picker) => parent.child(thread_picker.clone()),
458 })
459 }
460}
461enum RecentEntry {
462 File {
463 project_path: ProjectPath,
464 path_prefix: Arc<str>,
465 },
466 Thread(ThreadContextEntry),
467}
468
469fn supported_context_picker_modes(
470 thread_store: &Option<WeakEntity<ThreadStore>>,
471) -> Vec<ContextPickerMode> {
472 let mut modes = vec![
473 ContextPickerMode::File,
474 ContextPickerMode::Symbol,
475 ContextPickerMode::Fetch,
476 ];
477 if thread_store.is_some() {
478 modes.push(ContextPickerMode::Thread);
479 }
480 modes
481}
482
483fn active_singleton_buffer_path(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
484 let active_item = workspace.active_item(cx)?;
485
486 let editor = active_item.to_any().downcast::<Editor>().ok()?.read(cx);
487 let buffer = editor.buffer().read(cx).as_singleton()?;
488
489 let path = buffer.read(cx).file()?.path().to_path_buf();
490 Some(path)
491}
492
493fn recent_context_picker_entries(
494 context_store: Entity<ContextStore>,
495 thread_store: Option<WeakEntity<ThreadStore>>,
496 workspace: Entity<Workspace>,
497 cx: &App,
498) -> Vec<RecentEntry> {
499 let mut recent = Vec::with_capacity(6);
500
501 let mut current_files = context_store.read(cx).file_paths(cx);
502
503 let workspace = workspace.read(cx);
504
505 if let Some(active_path) = active_singleton_buffer_path(workspace, cx) {
506 current_files.insert(active_path);
507 }
508
509 let project = workspace.project().read(cx);
510
511 recent.extend(
512 workspace
513 .recent_navigation_history_iter(cx)
514 .filter(|(path, _)| !current_files.contains(&path.path.to_path_buf()))
515 .take(4)
516 .filter_map(|(project_path, _)| {
517 project
518 .worktree_for_id(project_path.worktree_id, cx)
519 .map(|worktree| RecentEntry::File {
520 project_path,
521 path_prefix: worktree.read(cx).root_name().into(),
522 })
523 }),
524 );
525
526 let mut current_threads = context_store.read(cx).thread_ids();
527
528 if let Some(active_thread) = workspace
529 .panel::<AssistantPanel>(cx)
530 .map(|panel| panel.read(cx).active_thread(cx))
531 {
532 current_threads.insert(active_thread.read(cx).id().clone());
533 }
534
535 if let Some(thread_store) = thread_store.and_then(|thread_store| thread_store.upgrade()) {
536 recent.extend(
537 thread_store
538 .read(cx)
539 .threads()
540 .into_iter()
541 .filter(|thread| !current_threads.contains(&thread.id))
542 .take(2)
543 .map(|thread| {
544 RecentEntry::Thread(ThreadContextEntry {
545 id: thread.id,
546 summary: thread.summary,
547 })
548 }),
549 );
550 }
551
552 recent
553}
554
555pub(crate) fn insert_crease_for_mention(
556 excerpt_id: ExcerptId,
557 crease_start: text::Anchor,
558 content_len: usize,
559 crease_label: SharedString,
560 crease_icon_path: SharedString,
561 editor_entity: Entity<Editor>,
562 window: &mut Window,
563 cx: &mut App,
564) {
565 editor_entity.update(cx, |editor, cx| {
566 let snapshot = editor.buffer().read(cx).snapshot(cx);
567
568 let Some(start) = snapshot.anchor_in_excerpt(excerpt_id, crease_start) else {
569 return;
570 };
571
572 let start = start.bias_right(&snapshot);
573 let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
574
575 let placeholder = FoldPlaceholder {
576 render: render_fold_icon_button(
577 crease_icon_path,
578 crease_label,
579 editor_entity.downgrade(),
580 ),
581 ..Default::default()
582 };
583
584 let render_trailer =
585 move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
586
587 let crease = Crease::inline(
588 start..end,
589 placeholder.clone(),
590 fold_toggle("mention"),
591 render_trailer,
592 );
593
594 editor.insert_creases(vec![crease.clone()], cx);
595 editor.fold_creases(vec![crease], false, window, cx);
596 });
597}
598
599fn render_fold_icon_button(
600 icon_path: SharedString,
601 label: SharedString,
602 editor: WeakEntity<Editor>,
603) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
604 Arc::new({
605 move |fold_id, fold_range, cx| {
606 let is_in_text_selection = editor.upgrade().is_some_and(|editor| {
607 editor.update(cx, |editor, cx| {
608 let snapshot = editor
609 .buffer()
610 .update(cx, |multi_buffer, cx| multi_buffer.snapshot(cx));
611
612 let is_in_pending_selection = || {
613 editor
614 .selections
615 .pending
616 .as_ref()
617 .is_some_and(|pending_selection| {
618 pending_selection
619 .selection
620 .range()
621 .includes(&fold_range, &snapshot)
622 })
623 };
624
625 let mut is_in_complete_selection = || {
626 editor
627 .selections
628 .disjoint_in_range::<usize>(fold_range.clone(), cx)
629 .into_iter()
630 .any(|selection| {
631 // This is needed to cover a corner case, if we just check for an existing
632 // selection in the fold range, having a cursor at the start of the fold
633 // marks it as selected. Non-empty selections don't cause this.
634 let length = selection.end - selection.start;
635 length > 0
636 })
637 };
638
639 is_in_pending_selection() || is_in_complete_selection()
640 })
641 });
642
643 ButtonLike::new(fold_id)
644 .style(ButtonStyle::Filled)
645 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
646 .toggle_state(is_in_text_selection)
647 .child(
648 h_flex()
649 .gap_1()
650 .child(
651 Icon::from_path(icon_path.clone())
652 .size(IconSize::Small)
653 .color(Color::Muted),
654 )
655 .child(
656 Label::new(label.clone())
657 .size(LabelSize::Small)
658 .single_line(),
659 ),
660 )
661 .into_any_element()
662 }
663 })
664}
665
666fn fold_toggle(
667 name: &'static str,
668) -> impl Fn(
669 MultiBufferRow,
670 bool,
671 Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
672 &mut Window,
673 &mut App,
674) -> AnyElement {
675 move |row, is_folded, fold, _window, _cx| {
676 Disclosure::new((name, row.0 as u64), !is_folded)
677 .toggle_state(is_folded)
678 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
679 .into_any_element()
680 }
681}
682
683pub enum MentionLink {
684 File(ProjectPath, Entry),
685 Symbol(ProjectPath, String),
686 Thread(ThreadId),
687}
688
689impl MentionLink {
690 pub fn for_file(file_name: &str, full_path: &str) -> String {
691 format!("[@{}](file:{})", file_name, full_path)
692 }
693
694 pub fn for_symbol(symbol_name: &str, full_path: &str) -> String {
695 format!("[@{}](symbol:{}:{})", symbol_name, full_path, symbol_name)
696 }
697
698 pub fn for_fetch(url: &str) -> String {
699 format!("[@{}]({})", url, url)
700 }
701
702 pub fn for_thread(thread: &ThreadContextEntry) -> String {
703 format!("[@{}](thread:{})", thread.summary, thread.id)
704 }
705
706 pub fn try_parse(link: &str, workspace: &Entity<Workspace>, cx: &App) -> Option<Self> {
707 fn extract_project_path_from_link(
708 path: &str,
709 workspace: &Entity<Workspace>,
710 cx: &App,
711 ) -> Option<ProjectPath> {
712 let path = PathBuf::from(path);
713 let worktree_name = path.iter().next()?;
714 let path: PathBuf = path.iter().skip(1).collect();
715 let worktree_id = workspace
716 .read(cx)
717 .visible_worktrees(cx)
718 .find(|worktree| worktree.read(cx).root_name() == worktree_name)
719 .map(|worktree| worktree.read(cx).id())?;
720 Some(ProjectPath {
721 worktree_id,
722 path: path.into(),
723 })
724 }
725
726 let (prefix, link, target) = {
727 let mut parts = link.splitn(3, ':');
728 let prefix = parts.next();
729 let link = parts.next();
730 let target = parts.next();
731 (prefix, link, target)
732 };
733
734 match (prefix, link, target) {
735 (Some("file"), Some(path), _) => {
736 let project_path = extract_project_path_from_link(path, workspace, cx)?;
737 let entry = workspace
738 .read(cx)
739 .project()
740 .read(cx)
741 .entry_for_path(&project_path, cx)?;
742 Some(MentionLink::File(project_path, entry))
743 }
744 (Some("symbol"), Some(path), Some(symbol_name)) => {
745 let project_path = extract_project_path_from_link(path, workspace, cx)?;
746 Some(MentionLink::Symbol(project_path, symbol_name.to_string()))
747 }
748 (Some("thread"), Some(thread_id), _) => {
749 let thread_id = ThreadId::from(thread_id);
750 Some(MentionLink::Thread(thread_id))
751 }
752 _ => None,
753 }
754 }
755}