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 path = project_path.path.clone();
293
294 ContextMenuItem::custom_entry(
295 move |_window, cx| {
296 render_file_context_entry(
297 ElementId::NamedInteger("ctx-recent".into(), ix),
298 &path,
299 &path_prefix,
300 false,
301 context_store.clone(),
302 cx,
303 )
304 .into_any()
305 },
306 move |window, cx| {
307 context_picker.update(cx, |this, cx| {
308 this.add_recent_file(project_path.clone(), window, cx);
309 })
310 },
311 )
312 }
313 RecentEntry::Thread(thread) => {
314 let context_store = self.context_store.clone();
315 let view_thread = thread.clone();
316
317 ContextMenuItem::custom_entry(
318 move |_window, cx| {
319 render_thread_context_entry(&view_thread, context_store.clone(), cx)
320 .into_any()
321 },
322 move |_window, cx| {
323 context_picker.update(cx, |this, cx| {
324 this.add_recent_thread(thread.clone(), cx)
325 .detach_and_log_err(cx);
326 })
327 },
328 )
329 }
330 }
331 }
332
333 fn add_recent_file(
334 &self,
335 project_path: ProjectPath,
336 window: &mut Window,
337 cx: &mut Context<Self>,
338 ) {
339 let Some(context_store) = self.context_store.upgrade() else {
340 return;
341 };
342
343 let task = context_store.update(cx, |context_store, cx| {
344 context_store.add_file_from_path(project_path.clone(), true, cx)
345 });
346
347 cx.spawn_in(window, async move |_, cx| task.await.notify_async_err(cx))
348 .detach();
349
350 cx.notify();
351 }
352
353 fn add_recent_thread(
354 &self,
355 thread: ThreadContextEntry,
356 cx: &mut Context<Self>,
357 ) -> Task<Result<()>> {
358 let Some(context_store) = self.context_store.upgrade() else {
359 return Task::ready(Err(anyhow!("context store not available")));
360 };
361
362 let Some(thread_store) = self
363 .thread_store
364 .as_ref()
365 .and_then(|thread_store| thread_store.upgrade())
366 else {
367 return Task::ready(Err(anyhow!("thread store not available")));
368 };
369
370 let open_thread_task = thread_store.update(cx, |this, cx| this.open_thread(&thread.id, cx));
371 cx.spawn(async move |this, cx| {
372 let thread = open_thread_task.await?;
373 context_store.update(cx, |context_store, cx| {
374 context_store.add_thread(thread, true, cx);
375 })?;
376
377 this.update(cx, |_this, cx| cx.notify())
378 })
379 }
380
381 fn recent_entries(&self, cx: &mut App) -> Vec<RecentEntry> {
382 let Some(workspace) = self.workspace.upgrade() else {
383 return vec![];
384 };
385
386 let Some(context_store) = self.context_store.upgrade() else {
387 return vec![];
388 };
389
390 recent_context_picker_entries(context_store, self.thread_store.clone(), workspace, cx)
391 }
392
393 fn notify_current_picker(&mut self, cx: &mut Context<Self>) {
394 match &self.mode {
395 ContextPickerState::Default(entity) => entity.update(cx, |_, cx| cx.notify()),
396 ContextPickerState::File(entity) => entity.update(cx, |_, cx| cx.notify()),
397 ContextPickerState::Symbol(entity) => entity.update(cx, |_, cx| cx.notify()),
398 ContextPickerState::Fetch(entity) => entity.update(cx, |_, cx| cx.notify()),
399 ContextPickerState::Thread(entity) => entity.update(cx, |_, cx| cx.notify()),
400 }
401 }
402}
403
404impl EventEmitter<DismissEvent> for ContextPicker {}
405
406impl Focusable for ContextPicker {
407 fn focus_handle(&self, cx: &App) -> FocusHandle {
408 match &self.mode {
409 ContextPickerState::Default(menu) => menu.focus_handle(cx),
410 ContextPickerState::File(file_picker) => file_picker.focus_handle(cx),
411 ContextPickerState::Symbol(symbol_picker) => symbol_picker.focus_handle(cx),
412 ContextPickerState::Fetch(fetch_picker) => fetch_picker.focus_handle(cx),
413 ContextPickerState::Thread(thread_picker) => thread_picker.focus_handle(cx),
414 }
415 }
416}
417
418impl Render for ContextPicker {
419 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
420 v_flex()
421 .w(px(400.))
422 .min_w(px(400.))
423 .map(|parent| match &self.mode {
424 ContextPickerState::Default(menu) => parent.child(menu.clone()),
425 ContextPickerState::File(file_picker) => parent.child(file_picker.clone()),
426 ContextPickerState::Symbol(symbol_picker) => parent.child(symbol_picker.clone()),
427 ContextPickerState::Fetch(fetch_picker) => parent.child(fetch_picker.clone()),
428 ContextPickerState::Thread(thread_picker) => parent.child(thread_picker.clone()),
429 })
430 }
431}
432enum RecentEntry {
433 File {
434 project_path: ProjectPath,
435 path_prefix: Arc<str>,
436 },
437 Thread(ThreadContextEntry),
438}
439
440fn supported_context_picker_modes(
441 thread_store: &Option<WeakEntity<ThreadStore>>,
442) -> Vec<ContextPickerMode> {
443 let mut modes = vec![
444 ContextPickerMode::File,
445 ContextPickerMode::Symbol,
446 ContextPickerMode::Fetch,
447 ];
448 if thread_store.is_some() {
449 modes.push(ContextPickerMode::Thread);
450 }
451 modes
452}
453
454fn recent_context_picker_entries(
455 context_store: Entity<ContextStore>,
456 thread_store: Option<WeakEntity<ThreadStore>>,
457 workspace: Entity<Workspace>,
458 cx: &App,
459) -> Vec<RecentEntry> {
460 let mut recent = Vec::with_capacity(6);
461
462 let current_files = context_store.read(cx).file_paths(cx);
463 let workspace = workspace.read(cx);
464 let project = workspace.project().read(cx);
465
466 recent.extend(
467 workspace
468 .recent_navigation_history_iter(cx)
469 .filter(|(path, _)| !current_files.contains(&path.path.to_path_buf()))
470 .take(4)
471 .filter_map(|(project_path, _)| {
472 project
473 .worktree_for_id(project_path.worktree_id, cx)
474 .map(|worktree| RecentEntry::File {
475 project_path,
476 path_prefix: worktree.read(cx).root_name().into(),
477 })
478 }),
479 );
480
481 let mut current_threads = context_store.read(cx).thread_ids();
482
483 if let Some(active_thread) = workspace
484 .panel::<AssistantPanel>(cx)
485 .map(|panel| panel.read(cx).active_thread(cx))
486 {
487 current_threads.insert(active_thread.read(cx).id().clone());
488 }
489
490 if let Some(thread_store) = thread_store.and_then(|thread_store| thread_store.upgrade()) {
491 recent.extend(
492 thread_store
493 .read(cx)
494 .threads()
495 .into_iter()
496 .filter(|thread| !current_threads.contains(&thread.id))
497 .take(2)
498 .map(|thread| {
499 RecentEntry::Thread(ThreadContextEntry {
500 id: thread.id,
501 summary: thread.summary,
502 })
503 }),
504 );
505 }
506
507 recent
508}
509
510pub(crate) fn insert_crease_for_mention(
511 excerpt_id: ExcerptId,
512 crease_start: text::Anchor,
513 content_len: usize,
514 crease_label: SharedString,
515 crease_icon_path: SharedString,
516 editor_entity: Entity<Editor>,
517 window: &mut Window,
518 cx: &mut App,
519) {
520 editor_entity.update(cx, |editor, cx| {
521 let snapshot = editor.buffer().read(cx).snapshot(cx);
522
523 let Some(start) = snapshot.anchor_in_excerpt(excerpt_id, crease_start) else {
524 return;
525 };
526
527 let start = start.bias_right(&snapshot);
528 let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
529
530 let placeholder = FoldPlaceholder {
531 render: render_fold_icon_button(
532 crease_icon_path,
533 crease_label,
534 editor_entity.downgrade(),
535 ),
536 ..Default::default()
537 };
538
539 let render_trailer =
540 move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
541
542 let crease = Crease::inline(
543 start..end,
544 placeholder.clone(),
545 fold_toggle("mention"),
546 render_trailer,
547 );
548
549 editor.insert_creases(vec![crease.clone()], cx);
550 editor.fold_creases(vec![crease], false, window, cx);
551 });
552}
553
554fn render_fold_icon_button(
555 icon_path: SharedString,
556 label: SharedString,
557 editor: WeakEntity<Editor>,
558) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
559 Arc::new({
560 move |fold_id, fold_range, cx| {
561 let is_in_text_selection = editor.upgrade().is_some_and(|editor| {
562 editor.update(cx, |editor, cx| {
563 let snapshot = editor
564 .buffer()
565 .update(cx, |multi_buffer, cx| multi_buffer.snapshot(cx));
566
567 let is_in_pending_selection = || {
568 editor
569 .selections
570 .pending
571 .as_ref()
572 .is_some_and(|pending_selection| {
573 pending_selection
574 .selection
575 .range()
576 .includes(&fold_range, &snapshot)
577 })
578 };
579
580 let mut is_in_complete_selection = || {
581 editor
582 .selections
583 .disjoint_in_range::<usize>(fold_range.clone(), cx)
584 .into_iter()
585 .any(|selection| {
586 // This is needed to cover a corner case, if we just check for an existing
587 // selection in the fold range, having a cursor at the start of the fold
588 // marks it as selected. Non-empty selections don't cause this.
589 let length = selection.end - selection.start;
590 length > 0
591 })
592 };
593
594 is_in_pending_selection() || is_in_complete_selection()
595 })
596 });
597
598 ButtonLike::new(fold_id)
599 .style(ButtonStyle::Filled)
600 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
601 .toggle_state(is_in_text_selection)
602 .child(
603 h_flex()
604 .gap_1()
605 .child(
606 Icon::from_path(icon_path.clone())
607 .size(IconSize::Small)
608 .color(Color::Muted),
609 )
610 .child(
611 Label::new(label.clone())
612 .size(LabelSize::Small)
613 .single_line(),
614 ),
615 )
616 .into_any_element()
617 }
618 })
619}
620
621fn fold_toggle(
622 name: &'static str,
623) -> impl Fn(
624 MultiBufferRow,
625 bool,
626 Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
627 &mut Window,
628 &mut App,
629) -> AnyElement {
630 move |row, is_folded, fold, _window, _cx| {
631 Disclosure::new((name, row.0 as u64), !is_folded)
632 .toggle_state(is_folded)
633 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
634 .into_any_element()
635 }
636}
637
638pub enum MentionLink {
639 File(ProjectPath, Entry),
640 Symbol(ProjectPath, String),
641 Fetch(String),
642 Thread(ThreadId),
643}
644
645impl MentionLink {
646 const FILE: &str = "@file";
647 const SYMBOL: &str = "@symbol";
648 const THREAD: &str = "@thread";
649 const FETCH: &str = "@fetch";
650
651 const SEPARATOR: &str = ":";
652
653 pub fn is_valid(url: &str) -> bool {
654 url.starts_with(Self::FILE)
655 || url.starts_with(Self::SYMBOL)
656 || url.starts_with(Self::FETCH)
657 || url.starts_with(Self::THREAD)
658 }
659
660 pub fn for_file(file_name: &str, full_path: &str) -> String {
661 format!("[@{}]({}:{})", file_name, Self::FILE, full_path)
662 }
663
664 pub fn for_symbol(symbol_name: &str, full_path: &str) -> String {
665 format!(
666 "[@{}]({}:{}:{})",
667 symbol_name,
668 Self::SYMBOL,
669 full_path,
670 symbol_name
671 )
672 }
673
674 pub fn for_fetch(url: &str) -> String {
675 format!("[@{}]({}:{})", url, Self::FETCH, url)
676 }
677
678 pub fn for_thread(thread: &ThreadContextEntry) -> String {
679 format!("[@{}]({}:{})", thread.summary, Self::THREAD, thread.id)
680 }
681
682 pub fn try_parse(link: &str, workspace: &Entity<Workspace>, cx: &App) -> Option<Self> {
683 fn extract_project_path_from_link(
684 path: &str,
685 workspace: &Entity<Workspace>,
686 cx: &App,
687 ) -> Option<ProjectPath> {
688 let path = PathBuf::from(path);
689 let worktree_name = path.iter().next()?;
690 let path: PathBuf = path.iter().skip(1).collect();
691 let worktree_id = workspace
692 .read(cx)
693 .visible_worktrees(cx)
694 .find(|worktree| worktree.read(cx).root_name() == worktree_name)
695 .map(|worktree| worktree.read(cx).id())?;
696 Some(ProjectPath {
697 worktree_id,
698 path: path.into(),
699 })
700 }
701
702 let (prefix, argument) = link.split_once(Self::SEPARATOR)?;
703 match prefix {
704 Self::FILE => {
705 let project_path = extract_project_path_from_link(argument, workspace, cx)?;
706 let entry = workspace
707 .read(cx)
708 .project()
709 .read(cx)
710 .entry_for_path(&project_path, cx)?;
711 Some(MentionLink::File(project_path, entry))
712 }
713 Self::SYMBOL => {
714 let (path, symbol) = argument.split_once(Self::SEPARATOR)?;
715 let project_path = extract_project_path_from_link(path, workspace, cx)?;
716 Some(MentionLink::Symbol(project_path, symbol.to_string()))
717 }
718 Self::THREAD => {
719 let thread_id = ThreadId::from(argument);
720 Some(MentionLink::Thread(thread_id))
721 }
722 Self::FETCH => Some(MentionLink::Fetch(argument.to_string())),
723 _ => None,
724 }
725 }
726}