1mod completion_provider;
2mod fetch_context_picker;
3mod file_context_picker;
4mod rules_context_picker;
5mod symbol_context_picker;
6mod thread_context_picker;
7
8use std::ops::Range;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use anyhow::{Result, anyhow};
13pub use completion_provider::ContextPickerCompletionProvider;
14use editor::display_map::{Crease, CreaseId, CreaseMetadata, FoldId};
15use editor::{Anchor, AnchorRangeExt as _, Editor, ExcerptId, FoldPlaceholder, ToOffset};
16use fetch_context_picker::FetchContextPicker;
17use file_context_picker::FileContextPicker;
18use file_context_picker::render_file_context_entry;
19use gpui::{
20 App, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task,
21 WeakEntity,
22};
23use language::Buffer;
24use multi_buffer::MultiBufferRow;
25use paths::contexts_dir;
26use project::{Entry, ProjectPath};
27use prompt_store::{PromptStore, UserPromptId};
28use rules_context_picker::{RulesContextEntry, RulesContextPicker};
29use symbol_context_picker::SymbolContextPicker;
30use thread_context_picker::{
31 ThreadContextEntry, ThreadContextPicker, render_thread_context_entry, unordered_thread_entries,
32};
33use ui::{
34 ButtonLike, ContextMenu, ContextMenuEntry, ContextMenuItem, Disclosure, TintColor, prelude::*,
35};
36use uuid::Uuid;
37use workspace::{Workspace, notifications::NotifyResultExt};
38
39use crate::AgentPanel;
40use crate::context::RULES_ICON;
41use crate::context_store::ContextStore;
42use crate::thread::ThreadId;
43use crate::thread_store::{TextThreadStore, ThreadStore};
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46enum ContextPickerEntry {
47 Mode(ContextPickerMode),
48 Action(ContextPickerAction),
49}
50
51impl ContextPickerEntry {
52 pub fn keyword(&self) -> &'static str {
53 match self {
54 Self::Mode(mode) => mode.keyword(),
55 Self::Action(action) => action.keyword(),
56 }
57 }
58
59 pub fn label(&self) -> &'static str {
60 match self {
61 Self::Mode(mode) => mode.label(),
62 Self::Action(action) => action.label(),
63 }
64 }
65
66 pub fn icon(&self) -> IconName {
67 match self {
68 Self::Mode(mode) => mode.icon(),
69 Self::Action(action) => action.icon(),
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75enum ContextPickerMode {
76 File,
77 Symbol,
78 Fetch,
79 Thread,
80 Rules,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84enum ContextPickerAction {
85 AddSelections,
86}
87
88impl ContextPickerAction {
89 pub fn keyword(&self) -> &'static str {
90 match self {
91 Self::AddSelections => "selection",
92 }
93 }
94
95 pub fn label(&self) -> &'static str {
96 match self {
97 Self::AddSelections => "Selection",
98 }
99 }
100
101 pub fn icon(&self) -> IconName {
102 match self {
103 Self::AddSelections => IconName::Context,
104 }
105 }
106}
107
108impl TryFrom<&str> for ContextPickerMode {
109 type Error = String;
110
111 fn try_from(value: &str) -> Result<Self, Self::Error> {
112 match value {
113 "file" => Ok(Self::File),
114 "symbol" => Ok(Self::Symbol),
115 "fetch" => Ok(Self::Fetch),
116 "thread" => Ok(Self::Thread),
117 "rule" => Ok(Self::Rules),
118 _ => Err(format!("Invalid context picker mode: {}", value)),
119 }
120 }
121}
122
123impl ContextPickerMode {
124 pub fn keyword(&self) -> &'static str {
125 match self {
126 Self::File => "file",
127 Self::Symbol => "symbol",
128 Self::Fetch => "fetch",
129 Self::Thread => "thread",
130 Self::Rules => "rule",
131 }
132 }
133
134 pub fn label(&self) -> &'static str {
135 match self {
136 Self::File => "Files & Directories",
137 Self::Symbol => "Symbols",
138 Self::Fetch => "Fetch",
139 Self::Thread => "Threads",
140 Self::Rules => "Rules",
141 }
142 }
143
144 pub fn icon(&self) -> IconName {
145 match self {
146 Self::File => IconName::File,
147 Self::Symbol => IconName::Code,
148 Self::Fetch => IconName::Globe,
149 Self::Thread => IconName::MessageBubbles,
150 Self::Rules => RULES_ICON,
151 }
152 }
153}
154
155#[derive(Debug, Clone)]
156enum ContextPickerState {
157 Default(Entity<ContextMenu>),
158 File(Entity<FileContextPicker>),
159 Symbol(Entity<SymbolContextPicker>),
160 Fetch(Entity<FetchContextPicker>),
161 Thread(Entity<ThreadContextPicker>),
162 Rules(Entity<RulesContextPicker>),
163}
164
165pub(super) struct ContextPicker {
166 mode: ContextPickerState,
167 workspace: WeakEntity<Workspace>,
168 context_store: WeakEntity<ContextStore>,
169 thread_store: Option<WeakEntity<ThreadStore>>,
170 text_thread_store: Option<WeakEntity<TextThreadStore>>,
171 prompt_store: Option<Entity<PromptStore>>,
172 _subscriptions: Vec<Subscription>,
173}
174
175impl ContextPicker {
176 pub fn new(
177 workspace: WeakEntity<Workspace>,
178 thread_store: Option<WeakEntity<ThreadStore>>,
179 text_thread_store: Option<WeakEntity<TextThreadStore>>,
180 context_store: WeakEntity<ContextStore>,
181 window: &mut Window,
182 cx: &mut Context<Self>,
183 ) -> Self {
184 let subscriptions = context_store
185 .upgrade()
186 .map(|context_store| {
187 cx.observe(&context_store, |this, _, cx| this.notify_current_picker(cx))
188 })
189 .into_iter()
190 .chain(
191 thread_store
192 .as_ref()
193 .and_then(|thread_store| thread_store.upgrade())
194 .map(|thread_store| {
195 cx.observe(&thread_store, |this, _, cx| this.notify_current_picker(cx))
196 }),
197 )
198 .collect::<Vec<Subscription>>();
199
200 let prompt_store = thread_store.as_ref().and_then(|thread_store| {
201 thread_store
202 .read_with(cx, |thread_store, _cx| thread_store.prompt_store().clone())
203 .ok()
204 .flatten()
205 });
206
207 ContextPicker {
208 mode: ContextPickerState::Default(ContextMenu::build(
209 window,
210 cx,
211 |menu, _window, _cx| menu,
212 )),
213 workspace,
214 context_store,
215 thread_store,
216 text_thread_store,
217 prompt_store,
218 _subscriptions: subscriptions,
219 }
220 }
221
222 pub fn init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
223 self.mode = ContextPickerState::Default(self.build_menu(window, cx));
224 cx.notify();
225 }
226
227 fn build_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Entity<ContextMenu> {
228 let context_picker = cx.entity().clone();
229
230 let menu = ContextMenu::build(window, cx, move |menu, _window, cx| {
231 let recent = self.recent_entries(cx);
232 let has_recent = !recent.is_empty();
233 let recent_entries = recent
234 .into_iter()
235 .enumerate()
236 .map(|(ix, entry)| self.recent_menu_item(context_picker.clone(), ix, entry));
237
238 let entries = self
239 .workspace
240 .upgrade()
241 .map(|workspace| {
242 available_context_picker_entries(
243 &self.prompt_store,
244 &self.thread_store,
245 &workspace,
246 cx,
247 )
248 })
249 .unwrap_or_default();
250
251 menu.when(has_recent, |menu| {
252 menu.custom_row(|_, _| {
253 div()
254 .mb_1()
255 .child(
256 Label::new("Recent")
257 .color(Color::Muted)
258 .size(LabelSize::Small),
259 )
260 .into_any_element()
261 })
262 })
263 .extend(recent_entries)
264 .when(has_recent, |menu| menu.separator())
265 .extend(entries.into_iter().map(|entry| {
266 let context_picker = context_picker.clone();
267
268 ContextMenuEntry::new(entry.label())
269 .icon(entry.icon())
270 .icon_size(IconSize::XSmall)
271 .icon_color(Color::Muted)
272 .handler(move |window, cx| {
273 context_picker.update(cx, |this, cx| this.select_entry(entry, window, cx))
274 })
275 }))
276 .keep_open_on_confirm(true)
277 });
278
279 cx.subscribe(&menu, move |_, _, _: &DismissEvent, cx| {
280 cx.emit(DismissEvent);
281 })
282 .detach();
283
284 menu
285 }
286
287 /// Whether threads are allowed as context.
288 pub fn allow_threads(&self) -> bool {
289 self.thread_store.is_some()
290 }
291
292 fn select_entry(
293 &mut self,
294 entry: ContextPickerEntry,
295 window: &mut Window,
296 cx: &mut Context<Self>,
297 ) {
298 let context_picker = cx.entity().downgrade();
299
300 match entry {
301 ContextPickerEntry::Mode(mode) => match mode {
302 ContextPickerMode::File => {
303 self.mode = ContextPickerState::File(cx.new(|cx| {
304 FileContextPicker::new(
305 context_picker.clone(),
306 self.workspace.clone(),
307 self.context_store.clone(),
308 window,
309 cx,
310 )
311 }));
312 }
313 ContextPickerMode::Symbol => {
314 self.mode = ContextPickerState::Symbol(cx.new(|cx| {
315 SymbolContextPicker::new(
316 context_picker.clone(),
317 self.workspace.clone(),
318 self.context_store.clone(),
319 window,
320 cx,
321 )
322 }));
323 }
324 ContextPickerMode::Rules => {
325 if let Some(prompt_store) = self.prompt_store.as_ref() {
326 self.mode = ContextPickerState::Rules(cx.new(|cx| {
327 RulesContextPicker::new(
328 prompt_store.clone(),
329 context_picker.clone(),
330 self.context_store.clone(),
331 window,
332 cx,
333 )
334 }));
335 }
336 }
337 ContextPickerMode::Fetch => {
338 self.mode = ContextPickerState::Fetch(cx.new(|cx| {
339 FetchContextPicker::new(
340 context_picker.clone(),
341 self.workspace.clone(),
342 self.context_store.clone(),
343 window,
344 cx,
345 )
346 }));
347 }
348 ContextPickerMode::Thread => {
349 if let Some((thread_store, text_thread_store)) = self
350 .thread_store
351 .as_ref()
352 .zip(self.text_thread_store.as_ref())
353 {
354 self.mode = ContextPickerState::Thread(cx.new(|cx| {
355 ThreadContextPicker::new(
356 thread_store.clone(),
357 text_thread_store.clone(),
358 context_picker.clone(),
359 self.context_store.clone(),
360 window,
361 cx,
362 )
363 }));
364 }
365 }
366 },
367 ContextPickerEntry::Action(action) => match action {
368 ContextPickerAction::AddSelections => {
369 if let Some((context_store, workspace)) =
370 self.context_store.upgrade().zip(self.workspace.upgrade())
371 {
372 add_selections_as_context(&context_store, &workspace, cx);
373 }
374
375 cx.emit(DismissEvent);
376 }
377 },
378 }
379
380 cx.notify();
381 cx.focus_self(window);
382 }
383
384 fn recent_menu_item(
385 &self,
386 context_picker: Entity<ContextPicker>,
387 ix: usize,
388 entry: RecentEntry,
389 ) -> ContextMenuItem {
390 match entry {
391 RecentEntry::File {
392 project_path,
393 path_prefix,
394 } => {
395 let context_store = self.context_store.clone();
396 let worktree_id = project_path.worktree_id;
397 let path = project_path.path.clone();
398
399 ContextMenuItem::custom_entry(
400 move |_window, cx| {
401 render_file_context_entry(
402 ElementId::named_usize("ctx-recent", ix),
403 worktree_id,
404 &path,
405 &path_prefix,
406 false,
407 context_store.clone(),
408 cx,
409 )
410 .into_any()
411 },
412 move |window, cx| {
413 context_picker.update(cx, |this, cx| {
414 this.add_recent_file(project_path.clone(), window, cx);
415 })
416 },
417 )
418 }
419 RecentEntry::Thread(thread) => {
420 let context_store = self.context_store.clone();
421 let view_thread = thread.clone();
422
423 ContextMenuItem::custom_entry(
424 move |_window, cx| {
425 render_thread_context_entry(&view_thread, context_store.clone(), cx)
426 .into_any()
427 },
428 move |window, cx| {
429 context_picker.update(cx, |this, cx| {
430 this.add_recent_thread(thread.clone(), window, cx)
431 .detach_and_log_err(cx);
432 })
433 },
434 )
435 }
436 }
437 }
438
439 fn add_recent_file(
440 &self,
441 project_path: ProjectPath,
442 window: &mut Window,
443 cx: &mut Context<Self>,
444 ) {
445 let Some(context_store) = self.context_store.upgrade() else {
446 return;
447 };
448
449 let task = context_store.update(cx, |context_store, cx| {
450 context_store.add_file_from_path(project_path.clone(), true, cx)
451 });
452
453 cx.spawn_in(window, async move |_, cx| task.await.notify_async_err(cx))
454 .detach();
455
456 cx.notify();
457 }
458
459 fn add_recent_thread(
460 &self,
461 entry: ThreadContextEntry,
462 window: &mut Window,
463 cx: &mut Context<Self>,
464 ) -> Task<Result<()>> {
465 let Some(context_store) = self.context_store.upgrade() else {
466 return Task::ready(Err(anyhow!("context store not available")));
467 };
468
469 match entry {
470 ThreadContextEntry::Thread { id, .. } => {
471 let Some(thread_store) = self
472 .thread_store
473 .as_ref()
474 .and_then(|thread_store| thread_store.upgrade())
475 else {
476 return Task::ready(Err(anyhow!("thread store not available")));
477 };
478
479 let open_thread_task =
480 thread_store.update(cx, |this, cx| this.open_thread(&id, window, cx));
481 cx.spawn(async move |this, cx| {
482 let thread = open_thread_task.await?;
483 context_store.update(cx, |context_store, cx| {
484 context_store.add_thread(thread, true, cx);
485 })?;
486 this.update(cx, |_this, cx| cx.notify())
487 })
488 }
489 ThreadContextEntry::Context { path, .. } => {
490 let Some(text_thread_store) = self
491 .text_thread_store
492 .as_ref()
493 .and_then(|thread_store| thread_store.upgrade())
494 else {
495 return Task::ready(Err(anyhow!("text thread store not available")));
496 };
497
498 let task = text_thread_store
499 .update(cx, |this, cx| this.open_local_context(path.clone(), cx));
500 cx.spawn(async move |this, cx| {
501 let thread = task.await?;
502 context_store.update(cx, |context_store, cx| {
503 context_store.add_text_thread(thread, true, cx);
504 })?;
505 this.update(cx, |_this, cx| cx.notify())
506 })
507 }
508 }
509 }
510
511 fn recent_entries(&self, cx: &mut App) -> Vec<RecentEntry> {
512 let Some(workspace) = self.workspace.upgrade() else {
513 return vec![];
514 };
515
516 let Some(context_store) = self.context_store.upgrade() else {
517 return vec![];
518 };
519
520 recent_context_picker_entries(
521 context_store,
522 self.thread_store.clone(),
523 self.text_thread_store.clone(),
524 workspace,
525 None,
526 cx,
527 )
528 }
529
530 fn notify_current_picker(&mut self, cx: &mut Context<Self>) {
531 match &self.mode {
532 ContextPickerState::Default(entity) => entity.update(cx, |_, cx| cx.notify()),
533 ContextPickerState::File(entity) => entity.update(cx, |_, cx| cx.notify()),
534 ContextPickerState::Symbol(entity) => entity.update(cx, |_, cx| cx.notify()),
535 ContextPickerState::Fetch(entity) => entity.update(cx, |_, cx| cx.notify()),
536 ContextPickerState::Thread(entity) => entity.update(cx, |_, cx| cx.notify()),
537 ContextPickerState::Rules(entity) => entity.update(cx, |_, cx| cx.notify()),
538 }
539 }
540}
541
542impl EventEmitter<DismissEvent> for ContextPicker {}
543
544impl Focusable for ContextPicker {
545 fn focus_handle(&self, cx: &App) -> FocusHandle {
546 match &self.mode {
547 ContextPickerState::Default(menu) => menu.focus_handle(cx),
548 ContextPickerState::File(file_picker) => file_picker.focus_handle(cx),
549 ContextPickerState::Symbol(symbol_picker) => symbol_picker.focus_handle(cx),
550 ContextPickerState::Fetch(fetch_picker) => fetch_picker.focus_handle(cx),
551 ContextPickerState::Thread(thread_picker) => thread_picker.focus_handle(cx),
552 ContextPickerState::Rules(user_rules_picker) => user_rules_picker.focus_handle(cx),
553 }
554 }
555}
556
557impl Render for ContextPicker {
558 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
559 v_flex()
560 .w(px(400.))
561 .min_w(px(400.))
562 .map(|parent| match &self.mode {
563 ContextPickerState::Default(menu) => parent.child(menu.clone()),
564 ContextPickerState::File(file_picker) => parent.child(file_picker.clone()),
565 ContextPickerState::Symbol(symbol_picker) => parent.child(symbol_picker.clone()),
566 ContextPickerState::Fetch(fetch_picker) => parent.child(fetch_picker.clone()),
567 ContextPickerState::Thread(thread_picker) => parent.child(thread_picker.clone()),
568 ContextPickerState::Rules(user_rules_picker) => {
569 parent.child(user_rules_picker.clone())
570 }
571 })
572 }
573}
574enum RecentEntry {
575 File {
576 project_path: ProjectPath,
577 path_prefix: Arc<str>,
578 },
579 Thread(ThreadContextEntry),
580}
581
582fn available_context_picker_entries(
583 prompt_store: &Option<Entity<PromptStore>>,
584 thread_store: &Option<WeakEntity<ThreadStore>>,
585 workspace: &Entity<Workspace>,
586 cx: &mut App,
587) -> Vec<ContextPickerEntry> {
588 let mut entries = vec![
589 ContextPickerEntry::Mode(ContextPickerMode::File),
590 ContextPickerEntry::Mode(ContextPickerMode::Symbol),
591 ];
592
593 let has_selection = workspace
594 .read(cx)
595 .active_item(cx)
596 .and_then(|item| item.downcast::<Editor>())
597 .map_or(false, |editor| {
598 editor.update(cx, |editor, cx| editor.has_non_empty_selection(cx))
599 });
600 if has_selection {
601 entries.push(ContextPickerEntry::Action(
602 ContextPickerAction::AddSelections,
603 ));
604 }
605
606 if thread_store.is_some() {
607 entries.push(ContextPickerEntry::Mode(ContextPickerMode::Thread));
608 }
609
610 if prompt_store.is_some() {
611 entries.push(ContextPickerEntry::Mode(ContextPickerMode::Rules));
612 }
613
614 entries.push(ContextPickerEntry::Mode(ContextPickerMode::Fetch));
615
616 entries
617}
618
619fn recent_context_picker_entries(
620 context_store: Entity<ContextStore>,
621 thread_store: Option<WeakEntity<ThreadStore>>,
622 text_thread_store: Option<WeakEntity<TextThreadStore>>,
623 workspace: Entity<Workspace>,
624 exclude_path: Option<ProjectPath>,
625 cx: &App,
626) -> Vec<RecentEntry> {
627 let mut recent = Vec::with_capacity(6);
628 let mut current_files = context_store.read(cx).file_paths(cx);
629 current_files.extend(exclude_path);
630 let workspace = workspace.read(cx);
631 let project = workspace.project().read(cx);
632
633 recent.extend(
634 workspace
635 .recent_navigation_history_iter(cx)
636 .filter(|(path, _)| !current_files.contains(path))
637 .take(4)
638 .filter_map(|(project_path, _)| {
639 project
640 .worktree_for_id(project_path.worktree_id, cx)
641 .map(|worktree| RecentEntry::File {
642 project_path,
643 path_prefix: worktree.read(cx).root_name().into(),
644 })
645 }),
646 );
647
648 let current_threads = context_store.read(cx).thread_ids();
649
650 let active_thread_id = workspace
651 .panel::<AgentPanel>(cx)
652 .and_then(|panel| Some(panel.read(cx).active_thread()?.read(cx).id()));
653
654 if let Some((thread_store, text_thread_store)) = thread_store
655 .and_then(|store| store.upgrade())
656 .zip(text_thread_store.and_then(|store| store.upgrade()))
657 {
658 let mut threads = unordered_thread_entries(thread_store, text_thread_store, cx)
659 .filter(|(_, thread)| match thread {
660 ThreadContextEntry::Thread { id, .. } => {
661 Some(id) != active_thread_id && !current_threads.contains(id)
662 }
663 ThreadContextEntry::Context { .. } => true,
664 })
665 .collect::<Vec<_>>();
666
667 const RECENT_COUNT: usize = 2;
668 if threads.len() > RECENT_COUNT {
669 threads.select_nth_unstable_by_key(RECENT_COUNT - 1, |(updated_at, _)| {
670 std::cmp::Reverse(*updated_at)
671 });
672 threads.truncate(RECENT_COUNT);
673 }
674 threads.sort_unstable_by_key(|(updated_at, _)| std::cmp::Reverse(*updated_at));
675
676 recent.extend(
677 threads
678 .into_iter()
679 .map(|(_, thread)| RecentEntry::Thread(thread)),
680 );
681 }
682
683 recent
684}
685
686fn add_selections_as_context(
687 context_store: &Entity<ContextStore>,
688 workspace: &Entity<Workspace>,
689 cx: &mut App,
690) {
691 let selection_ranges = selection_ranges(workspace, cx);
692 context_store.update(cx, |context_store, cx| {
693 for (buffer, range) in selection_ranges {
694 context_store.add_selection(buffer, range, cx);
695 }
696 })
697}
698
699fn selection_ranges(
700 workspace: &Entity<Workspace>,
701 cx: &mut App,
702) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
703 let Some(editor) = workspace
704 .read(cx)
705 .active_item(cx)
706 .and_then(|item| item.act_as::<Editor>(cx))
707 else {
708 return Vec::new();
709 };
710
711 editor.update(cx, |editor, cx| {
712 let selections = editor.selections.all_adjusted(cx);
713
714 let buffer = editor.buffer().clone().read(cx);
715 let snapshot = buffer.snapshot(cx);
716
717 selections
718 .into_iter()
719 .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end))
720 .flat_map(|range| {
721 let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?;
722 let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?;
723 if start_buffer != end_buffer {
724 return None;
725 }
726 Some((start_buffer, start..end))
727 })
728 .collect::<Vec<_>>()
729 })
730}
731
732pub(crate) fn insert_crease_for_mention(
733 excerpt_id: ExcerptId,
734 crease_start: text::Anchor,
735 content_len: usize,
736 crease_label: SharedString,
737 crease_icon_path: SharedString,
738 editor_entity: Entity<Editor>,
739 window: &mut Window,
740 cx: &mut App,
741) -> Option<CreaseId> {
742 editor_entity.update(cx, |editor, cx| {
743 let snapshot = editor.buffer().read(cx).snapshot(cx);
744
745 let start = snapshot.anchor_in_excerpt(excerpt_id, crease_start)?;
746
747 let start = start.bias_right(&snapshot);
748 let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
749
750 let crease = crease_for_mention(
751 crease_label,
752 crease_icon_path,
753 start..end,
754 editor_entity.downgrade(),
755 );
756
757 let ids = editor.insert_creases(vec![crease.clone()], cx);
758 editor.fold_creases(vec![crease], false, window, cx);
759 Some(ids[0])
760 })
761}
762
763pub fn crease_for_mention(
764 label: SharedString,
765 icon_path: SharedString,
766 range: Range<Anchor>,
767 editor_entity: WeakEntity<Editor>,
768) -> Crease<Anchor> {
769 let placeholder = FoldPlaceholder {
770 render: render_fold_icon_button(icon_path.clone(), label.clone(), editor_entity),
771 merge_adjacent: false,
772 ..Default::default()
773 };
774
775 let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
776
777 Crease::inline(
778 range,
779 placeholder.clone(),
780 fold_toggle("mention"),
781 render_trailer,
782 )
783 .with_metadata(CreaseMetadata { icon_path, label })
784}
785
786fn render_fold_icon_button(
787 icon_path: SharedString,
788 label: SharedString,
789 editor: WeakEntity<Editor>,
790) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
791 Arc::new({
792 move |fold_id, fold_range, cx| {
793 let is_in_text_selection = editor.upgrade().is_some_and(|editor| {
794 editor.update(cx, |editor, cx| {
795 let snapshot = editor
796 .buffer()
797 .update(cx, |multi_buffer, cx| multi_buffer.snapshot(cx));
798
799 let is_in_pending_selection = || {
800 editor
801 .selections
802 .pending
803 .as_ref()
804 .is_some_and(|pending_selection| {
805 pending_selection
806 .selection
807 .range()
808 .includes(&fold_range, &snapshot)
809 })
810 };
811
812 let mut is_in_complete_selection = || {
813 editor
814 .selections
815 .disjoint_in_range::<usize>(fold_range.clone(), cx)
816 .into_iter()
817 .any(|selection| {
818 // This is needed to cover a corner case, if we just check for an existing
819 // selection in the fold range, having a cursor at the start of the fold
820 // marks it as selected. Non-empty selections don't cause this.
821 let length = selection.end - selection.start;
822 length > 0
823 })
824 };
825
826 is_in_pending_selection() || is_in_complete_selection()
827 })
828 });
829
830 ButtonLike::new(fold_id)
831 .style(ButtonStyle::Filled)
832 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
833 .toggle_state(is_in_text_selection)
834 .child(
835 h_flex()
836 .gap_1()
837 .child(
838 Icon::from_path(icon_path.clone())
839 .size(IconSize::XSmall)
840 .color(Color::Muted),
841 )
842 .child(
843 Label::new(label.clone())
844 .size(LabelSize::Small)
845 .buffer_font(cx)
846 .single_line(),
847 ),
848 )
849 .into_any_element()
850 }
851 })
852}
853
854fn fold_toggle(
855 name: &'static str,
856) -> impl Fn(
857 MultiBufferRow,
858 bool,
859 Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
860 &mut Window,
861 &mut App,
862) -> AnyElement {
863 move |row, is_folded, fold, _window, _cx| {
864 Disclosure::new((name, row.0 as u64), !is_folded)
865 .toggle_state(is_folded)
866 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
867 .into_any_element()
868 }
869}
870
871pub enum MentionLink {
872 File(ProjectPath, Entry),
873 Symbol(ProjectPath, String),
874 Selection(ProjectPath, Range<usize>),
875 Fetch(String),
876 Thread(ThreadId),
877 TextThread(Arc<Path>),
878 Rule(UserPromptId),
879}
880
881impl MentionLink {
882 const FILE: &str = "@file";
883 const SYMBOL: &str = "@symbol";
884 const SELECTION: &str = "@selection";
885 const THREAD: &str = "@thread";
886 const FETCH: &str = "@fetch";
887 const RULE: &str = "@rule";
888
889 const TEXT_THREAD_URL_PREFIX: &str = "text-thread://";
890
891 const SEPARATOR: &str = ":";
892
893 pub fn is_valid(url: &str) -> bool {
894 url.starts_with(Self::FILE)
895 || url.starts_with(Self::SYMBOL)
896 || url.starts_with(Self::FETCH)
897 || url.starts_with(Self::SELECTION)
898 || url.starts_with(Self::THREAD)
899 || url.starts_with(Self::RULE)
900 }
901
902 pub fn for_file(file_name: &str, full_path: &str) -> String {
903 format!("[@{}]({}:{})", file_name, Self::FILE, full_path)
904 }
905
906 pub fn for_symbol(symbol_name: &str, full_path: &str) -> String {
907 format!(
908 "[@{}]({}:{}:{})",
909 symbol_name,
910 Self::SYMBOL,
911 full_path,
912 symbol_name
913 )
914 }
915
916 pub fn for_selection(file_name: &str, full_path: &str, line_range: Range<usize>) -> String {
917 format!(
918 "[@{} ({}-{})]({}:{}:{}-{})",
919 file_name,
920 line_range.start,
921 line_range.end,
922 Self::SELECTION,
923 full_path,
924 line_range.start,
925 line_range.end
926 )
927 }
928
929 pub fn for_thread(thread: &ThreadContextEntry) -> String {
930 match thread {
931 ThreadContextEntry::Thread { id, title } => {
932 format!("[@{}]({}:{})", title, Self::THREAD, id)
933 }
934 ThreadContextEntry::Context { path, title } => {
935 let filename = path.file_name().unwrap_or_default();
936 let escaped_filename = urlencoding::encode(&filename.to_string_lossy()).to_string();
937 format!(
938 "[@{}]({}:{}{})",
939 title,
940 Self::THREAD,
941 Self::TEXT_THREAD_URL_PREFIX,
942 escaped_filename
943 )
944 }
945 }
946 }
947
948 pub fn for_fetch(url: &str) -> String {
949 format!("[@{}]({}:{})", url, Self::FETCH, url)
950 }
951
952 pub fn for_rule(rule: &RulesContextEntry) -> String {
953 format!("[@{}]({}:{})", rule.title, Self::RULE, rule.prompt_id.0)
954 }
955
956 pub fn try_parse(link: &str, workspace: &Entity<Workspace>, cx: &App) -> Option<Self> {
957 fn extract_project_path_from_link(
958 path: &str,
959 workspace: &Entity<Workspace>,
960 cx: &App,
961 ) -> Option<ProjectPath> {
962 let path = PathBuf::from(path);
963 let worktree_name = path.iter().next()?;
964 let path: PathBuf = path.iter().skip(1).collect();
965 let worktree_id = workspace
966 .read(cx)
967 .visible_worktrees(cx)
968 .find(|worktree| worktree.read(cx).root_name() == worktree_name)
969 .map(|worktree| worktree.read(cx).id())?;
970 Some(ProjectPath {
971 worktree_id,
972 path: path.into(),
973 })
974 }
975
976 let (prefix, argument) = link.split_once(Self::SEPARATOR)?;
977 match prefix {
978 Self::FILE => {
979 let project_path = extract_project_path_from_link(argument, workspace, cx)?;
980 let entry = workspace
981 .read(cx)
982 .project()
983 .read(cx)
984 .entry_for_path(&project_path, cx)?;
985 Some(MentionLink::File(project_path, entry))
986 }
987 Self::SYMBOL => {
988 let (path, symbol) = argument.split_once(Self::SEPARATOR)?;
989 let project_path = extract_project_path_from_link(path, workspace, cx)?;
990 Some(MentionLink::Symbol(project_path, symbol.to_string()))
991 }
992 Self::SELECTION => {
993 let (path, line_args) = argument.split_once(Self::SEPARATOR)?;
994 let project_path = extract_project_path_from_link(path, workspace, cx)?;
995
996 let line_range = {
997 let (start, end) = line_args
998 .trim_start_matches('(')
999 .trim_end_matches(')')
1000 .split_once('-')?;
1001 start.parse::<usize>().ok()?..end.parse::<usize>().ok()?
1002 };
1003
1004 Some(MentionLink::Selection(project_path, line_range))
1005 }
1006 Self::THREAD => {
1007 if let Some(encoded_filename) = argument.strip_prefix(Self::TEXT_THREAD_URL_PREFIX)
1008 {
1009 let filename = urlencoding::decode(encoded_filename).ok()?;
1010 let path = contexts_dir().join(filename.as_ref()).into();
1011 Some(MentionLink::TextThread(path))
1012 } else {
1013 let thread_id = ThreadId::from(argument);
1014 Some(MentionLink::Thread(thread_id))
1015 }
1016 }
1017 Self::FETCH => Some(MentionLink::Fetch(argument.to_string())),
1018 Self::RULE => {
1019 let prompt_id = UserPromptId(Uuid::try_parse(argument).ok()?);
1020 Some(MentionLink::Rule(prompt_id))
1021 }
1022 _ => None,
1023 }
1024 }
1025}