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