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