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