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