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 pub fn select_first(&mut self, window: &mut Window, cx: &mut Context<Self>) {
385 match &self.mode {
386 ContextPickerState::Default(entity) => entity.update(cx, |entity, cx| {
387 entity.select_first(&Default::default(), window, cx)
388 }),
389 // Other variants already select their first entry on open automatically
390 _ => {}
391 }
392 }
393
394 fn recent_menu_item(
395 &self,
396 context_picker: Entity<ContextPicker>,
397 ix: usize,
398 entry: RecentEntry,
399 ) -> ContextMenuItem {
400 match entry {
401 RecentEntry::File {
402 project_path,
403 path_prefix,
404 } => {
405 let context_store = self.context_store.clone();
406 let worktree_id = project_path.worktree_id;
407 let path = project_path.path.clone();
408
409 ContextMenuItem::custom_entry(
410 move |_window, cx| {
411 render_file_context_entry(
412 ElementId::named_usize("ctx-recent", ix),
413 worktree_id,
414 &path,
415 &path_prefix,
416 false,
417 context_store.clone(),
418 cx,
419 )
420 .into_any()
421 },
422 move |window, cx| {
423 context_picker.update(cx, |this, cx| {
424 this.add_recent_file(project_path.clone(), window, cx);
425 })
426 },
427 )
428 }
429 RecentEntry::Thread(thread) => {
430 let context_store = self.context_store.clone();
431 let view_thread = thread.clone();
432
433 ContextMenuItem::custom_entry(
434 move |_window, cx| {
435 render_thread_context_entry(&view_thread, context_store.clone(), cx)
436 .into_any()
437 },
438 move |window, cx| {
439 context_picker.update(cx, |this, cx| {
440 this.add_recent_thread(thread.clone(), window, cx)
441 .detach_and_log_err(cx);
442 })
443 },
444 )
445 }
446 }
447 }
448
449 fn add_recent_file(
450 &self,
451 project_path: ProjectPath,
452 window: &mut Window,
453 cx: &mut Context<Self>,
454 ) {
455 let Some(context_store) = self.context_store.upgrade() else {
456 return;
457 };
458
459 let task = context_store.update(cx, |context_store, cx| {
460 context_store.add_file_from_path(project_path.clone(), true, cx)
461 });
462
463 cx.spawn_in(window, async move |_, cx| task.await.notify_async_err(cx))
464 .detach();
465
466 cx.notify();
467 }
468
469 fn add_recent_thread(
470 &self,
471 entry: ThreadContextEntry,
472 window: &mut Window,
473 cx: &mut Context<Self>,
474 ) -> Task<Result<()>> {
475 let Some(context_store) = self.context_store.upgrade() else {
476 return Task::ready(Err(anyhow!("context store not available")));
477 };
478
479 match entry {
480 ThreadContextEntry::Thread { id, .. } => {
481 let Some(thread_store) = self
482 .thread_store
483 .as_ref()
484 .and_then(|thread_store| thread_store.upgrade())
485 else {
486 return Task::ready(Err(anyhow!("thread store not available")));
487 };
488
489 let open_thread_task =
490 thread_store.update(cx, |this, cx| this.open_thread(&id, window, cx));
491 cx.spawn(async move |this, cx| {
492 let thread = open_thread_task.await?;
493 context_store.update(cx, |context_store, cx| {
494 context_store.add_thread(thread, true, cx);
495 })?;
496 this.update(cx, |_this, cx| cx.notify())
497 })
498 }
499 ThreadContextEntry::Context { path, .. } => {
500 let Some(text_thread_store) = self
501 .text_thread_store
502 .as_ref()
503 .and_then(|thread_store| thread_store.upgrade())
504 else {
505 return Task::ready(Err(anyhow!("text thread store not available")));
506 };
507
508 let task = text_thread_store
509 .update(cx, |this, cx| this.open_local_context(path.clone(), cx));
510 cx.spawn(async move |this, cx| {
511 let thread = task.await?;
512 context_store.update(cx, |context_store, cx| {
513 context_store.add_text_thread(thread, true, cx);
514 })?;
515 this.update(cx, |_this, cx| cx.notify())
516 })
517 }
518 }
519 }
520
521 fn recent_entries(&self, cx: &mut App) -> Vec<RecentEntry> {
522 let Some(workspace) = self.workspace.upgrade() else {
523 return vec![];
524 };
525
526 let Some(context_store) = self.context_store.upgrade() else {
527 return vec![];
528 };
529
530 recent_context_picker_entries(
531 context_store,
532 self.thread_store.clone(),
533 self.text_thread_store.clone(),
534 workspace,
535 None,
536 cx,
537 )
538 }
539
540 fn notify_current_picker(&mut self, cx: &mut Context<Self>) {
541 match &self.mode {
542 ContextPickerState::Default(entity) => entity.update(cx, |_, cx| cx.notify()),
543 ContextPickerState::File(entity) => entity.update(cx, |_, cx| cx.notify()),
544 ContextPickerState::Symbol(entity) => entity.update(cx, |_, cx| cx.notify()),
545 ContextPickerState::Fetch(entity) => entity.update(cx, |_, cx| cx.notify()),
546 ContextPickerState::Thread(entity) => entity.update(cx, |_, cx| cx.notify()),
547 ContextPickerState::Rules(entity) => entity.update(cx, |_, cx| cx.notify()),
548 }
549 }
550}
551
552impl EventEmitter<DismissEvent> for ContextPicker {}
553
554impl Focusable for ContextPicker {
555 fn focus_handle(&self, cx: &App) -> FocusHandle {
556 match &self.mode {
557 ContextPickerState::Default(menu) => menu.focus_handle(cx),
558 ContextPickerState::File(file_picker) => file_picker.focus_handle(cx),
559 ContextPickerState::Symbol(symbol_picker) => symbol_picker.focus_handle(cx),
560 ContextPickerState::Fetch(fetch_picker) => fetch_picker.focus_handle(cx),
561 ContextPickerState::Thread(thread_picker) => thread_picker.focus_handle(cx),
562 ContextPickerState::Rules(user_rules_picker) => user_rules_picker.focus_handle(cx),
563 }
564 }
565}
566
567impl Render for ContextPicker {
568 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
569 v_flex()
570 .w(px(400.))
571 .min_w(px(400.))
572 .map(|parent| match &self.mode {
573 ContextPickerState::Default(menu) => parent.child(menu.clone()),
574 ContextPickerState::File(file_picker) => parent.child(file_picker.clone()),
575 ContextPickerState::Symbol(symbol_picker) => parent.child(symbol_picker.clone()),
576 ContextPickerState::Fetch(fetch_picker) => parent.child(fetch_picker.clone()),
577 ContextPickerState::Thread(thread_picker) => parent.child(thread_picker.clone()),
578 ContextPickerState::Rules(user_rules_picker) => {
579 parent.child(user_rules_picker.clone())
580 }
581 })
582 }
583}
584enum RecentEntry {
585 File {
586 project_path: ProjectPath,
587 path_prefix: Arc<str>,
588 },
589 Thread(ThreadContextEntry),
590}
591
592fn available_context_picker_entries(
593 prompt_store: &Option<Entity<PromptStore>>,
594 thread_store: &Option<WeakEntity<ThreadStore>>,
595 workspace: &Entity<Workspace>,
596 cx: &mut App,
597) -> Vec<ContextPickerEntry> {
598 let mut entries = vec![
599 ContextPickerEntry::Mode(ContextPickerMode::File),
600 ContextPickerEntry::Mode(ContextPickerMode::Symbol),
601 ];
602
603 let has_selection = workspace
604 .read(cx)
605 .active_item(cx)
606 .and_then(|item| item.downcast::<Editor>())
607 .map_or(false, |editor| {
608 editor.update(cx, |editor, cx| editor.has_non_empty_selection(cx))
609 });
610 if has_selection {
611 entries.push(ContextPickerEntry::Action(
612 ContextPickerAction::AddSelections,
613 ));
614 }
615
616 if thread_store.is_some() {
617 entries.push(ContextPickerEntry::Mode(ContextPickerMode::Thread));
618 }
619
620 if prompt_store.is_some() {
621 entries.push(ContextPickerEntry::Mode(ContextPickerMode::Rules));
622 }
623
624 entries.push(ContextPickerEntry::Mode(ContextPickerMode::Fetch));
625
626 entries
627}
628
629fn recent_context_picker_entries(
630 context_store: Entity<ContextStore>,
631 thread_store: Option<WeakEntity<ThreadStore>>,
632 text_thread_store: Option<WeakEntity<TextThreadStore>>,
633 workspace: Entity<Workspace>,
634 exclude_path: Option<ProjectPath>,
635 cx: &App,
636) -> Vec<RecentEntry> {
637 let mut recent = Vec::with_capacity(6);
638 let mut current_files = context_store.read(cx).file_paths(cx);
639 current_files.extend(exclude_path);
640 let workspace = workspace.read(cx);
641 let project = workspace.project().read(cx);
642
643 recent.extend(
644 workspace
645 .recent_navigation_history_iter(cx)
646 .filter(|(path, _)| !current_files.contains(path))
647 .take(4)
648 .filter_map(|(project_path, _)| {
649 project
650 .worktree_for_id(project_path.worktree_id, cx)
651 .map(|worktree| RecentEntry::File {
652 project_path,
653 path_prefix: worktree.read(cx).root_name().into(),
654 })
655 }),
656 );
657
658 let current_threads = context_store.read(cx).thread_ids();
659
660 let active_thread_id = workspace
661 .panel::<AgentPanel>(cx)
662 .and_then(|panel| Some(panel.read(cx).active_thread()?.read(cx).id()));
663
664 if let Some((thread_store, text_thread_store)) = thread_store
665 .and_then(|store| store.upgrade())
666 .zip(text_thread_store.and_then(|store| store.upgrade()))
667 {
668 let mut threads = unordered_thread_entries(thread_store, text_thread_store, cx)
669 .filter(|(_, thread)| match thread {
670 ThreadContextEntry::Thread { id, .. } => {
671 Some(id) != active_thread_id && !current_threads.contains(id)
672 }
673 ThreadContextEntry::Context { .. } => true,
674 })
675 .collect::<Vec<_>>();
676
677 const RECENT_COUNT: usize = 2;
678 if threads.len() > RECENT_COUNT {
679 threads.select_nth_unstable_by_key(RECENT_COUNT - 1, |(updated_at, _)| {
680 std::cmp::Reverse(*updated_at)
681 });
682 threads.truncate(RECENT_COUNT);
683 }
684 threads.sort_unstable_by_key(|(updated_at, _)| std::cmp::Reverse(*updated_at));
685
686 recent.extend(
687 threads
688 .into_iter()
689 .map(|(_, thread)| RecentEntry::Thread(thread)),
690 );
691 }
692
693 recent
694}
695
696fn add_selections_as_context(
697 context_store: &Entity<ContextStore>,
698 workspace: &Entity<Workspace>,
699 cx: &mut App,
700) {
701 let selection_ranges = selection_ranges(workspace, cx);
702 context_store.update(cx, |context_store, cx| {
703 for (buffer, range) in selection_ranges {
704 context_store.add_selection(buffer, range, cx);
705 }
706 })
707}
708
709fn selection_ranges(
710 workspace: &Entity<Workspace>,
711 cx: &mut App,
712) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
713 let Some(editor) = workspace
714 .read(cx)
715 .active_item(cx)
716 .and_then(|item| item.act_as::<Editor>(cx))
717 else {
718 return Vec::new();
719 };
720
721 editor.update(cx, |editor, cx| {
722 let selections = editor.selections.all_adjusted(cx);
723
724 let buffer = editor.buffer().clone().read(cx);
725 let snapshot = buffer.snapshot(cx);
726
727 selections
728 .into_iter()
729 .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end))
730 .flat_map(|range| {
731 let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?;
732 let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?;
733 if start_buffer != end_buffer {
734 return None;
735 }
736 Some((start_buffer, start..end))
737 })
738 .collect::<Vec<_>>()
739 })
740}
741
742pub(crate) fn insert_crease_for_mention(
743 excerpt_id: ExcerptId,
744 crease_start: text::Anchor,
745 content_len: usize,
746 crease_label: SharedString,
747 crease_icon_path: SharedString,
748 editor_entity: Entity<Editor>,
749 window: &mut Window,
750 cx: &mut App,
751) -> Option<CreaseId> {
752 editor_entity.update(cx, |editor, cx| {
753 let snapshot = editor.buffer().read(cx).snapshot(cx);
754
755 let start = snapshot.anchor_in_excerpt(excerpt_id, crease_start)?;
756
757 let start = start.bias_right(&snapshot);
758 let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
759
760 let crease = crease_for_mention(
761 crease_label,
762 crease_icon_path,
763 start..end,
764 editor_entity.downgrade(),
765 );
766
767 let ids = editor.insert_creases(vec![crease.clone()], cx);
768 editor.fold_creases(vec![crease], false, window, cx);
769
770 Some(ids[0])
771 })
772}
773
774pub fn crease_for_mention(
775 label: SharedString,
776 icon_path: SharedString,
777 range: Range<Anchor>,
778 editor_entity: WeakEntity<Editor>,
779) -> Crease<Anchor> {
780 let placeholder = FoldPlaceholder {
781 render: render_fold_icon_button(icon_path.clone(), label.clone(), editor_entity),
782 merge_adjacent: false,
783 ..Default::default()
784 };
785
786 let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
787
788 Crease::inline(
789 range,
790 placeholder.clone(),
791 fold_toggle("mention"),
792 render_trailer,
793 )
794 .with_metadata(CreaseMetadata { icon_path, label })
795}
796
797fn render_fold_icon_button(
798 icon_path: SharedString,
799 label: SharedString,
800 editor: WeakEntity<Editor>,
801) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
802 Arc::new({
803 move |fold_id, fold_range, cx| {
804 let is_in_text_selection = editor.upgrade().is_some_and(|editor| {
805 editor.update(cx, |editor, cx| {
806 let snapshot = editor
807 .buffer()
808 .update(cx, |multi_buffer, cx| multi_buffer.snapshot(cx));
809
810 let is_in_pending_selection = || {
811 editor
812 .selections
813 .pending
814 .as_ref()
815 .is_some_and(|pending_selection| {
816 pending_selection
817 .selection
818 .range()
819 .includes(&fold_range, &snapshot)
820 })
821 };
822
823 let mut is_in_complete_selection = || {
824 editor
825 .selections
826 .disjoint_in_range::<usize>(fold_range.clone(), cx)
827 .into_iter()
828 .any(|selection| {
829 // This is needed to cover a corner case, if we just check for an existing
830 // selection in the fold range, having a cursor at the start of the fold
831 // marks it as selected. Non-empty selections don't cause this.
832 let length = selection.end - selection.start;
833 length > 0
834 })
835 };
836
837 is_in_pending_selection() || is_in_complete_selection()
838 })
839 });
840
841 ButtonLike::new(fold_id)
842 .style(ButtonStyle::Filled)
843 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
844 .toggle_state(is_in_text_selection)
845 .child(
846 h_flex()
847 .gap_1()
848 .child(
849 Icon::from_path(icon_path.clone())
850 .size(IconSize::XSmall)
851 .color(Color::Muted),
852 )
853 .child(
854 Label::new(label.clone())
855 .size(LabelSize::Small)
856 .buffer_font(cx)
857 .single_line(),
858 ),
859 )
860 .into_any_element()
861 }
862 })
863}
864
865fn fold_toggle(
866 name: &'static str,
867) -> impl Fn(
868 MultiBufferRow,
869 bool,
870 Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
871 &mut Window,
872 &mut App,
873) -> AnyElement {
874 move |row, is_folded, fold, _window, _cx| {
875 Disclosure::new((name, row.0 as u64), !is_folded)
876 .toggle_state(is_folded)
877 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
878 .into_any_element()
879 }
880}
881
882pub enum MentionLink {
883 File(ProjectPath, Entry),
884 Symbol(ProjectPath, String),
885 Selection(ProjectPath, Range<usize>),
886 Fetch(String),
887 Thread(ThreadId),
888 TextThread(Arc<Path>),
889 Rule(UserPromptId),
890}
891
892impl MentionLink {
893 const FILE: &str = "@file";
894 const SYMBOL: &str = "@symbol";
895 const SELECTION: &str = "@selection";
896 const THREAD: &str = "@thread";
897 const FETCH: &str = "@fetch";
898 const RULE: &str = "@rule";
899
900 const TEXT_THREAD_URL_PREFIX: &str = "text-thread://";
901
902 const SEPARATOR: &str = ":";
903
904 pub fn is_valid(url: &str) -> bool {
905 url.starts_with(Self::FILE)
906 || url.starts_with(Self::SYMBOL)
907 || url.starts_with(Self::FETCH)
908 || url.starts_with(Self::SELECTION)
909 || url.starts_with(Self::THREAD)
910 || url.starts_with(Self::RULE)
911 }
912
913 pub fn for_file(file_name: &str, full_path: &str) -> String {
914 format!("[@{}]({}:{})", file_name, Self::FILE, full_path)
915 }
916
917 pub fn for_symbol(symbol_name: &str, full_path: &str) -> String {
918 format!(
919 "[@{}]({}:{}:{})",
920 symbol_name,
921 Self::SYMBOL,
922 full_path,
923 symbol_name
924 )
925 }
926
927 pub fn for_selection(file_name: &str, full_path: &str, line_range: Range<usize>) -> String {
928 format!(
929 "[@{} ({}-{})]({}:{}:{}-{})",
930 file_name,
931 line_range.start,
932 line_range.end,
933 Self::SELECTION,
934 full_path,
935 line_range.start,
936 line_range.end
937 )
938 }
939
940 pub fn for_thread(thread: &ThreadContextEntry) -> String {
941 match thread {
942 ThreadContextEntry::Thread { id, title } => {
943 format!("[@{}]({}:{})", title, Self::THREAD, id)
944 }
945 ThreadContextEntry::Context { path, title } => {
946 let filename = path.file_name().unwrap_or_default().to_string_lossy();
947 let escaped_filename = urlencoding::encode(&filename);
948 format!(
949 "[@{}]({}:{}{})",
950 title,
951 Self::THREAD,
952 Self::TEXT_THREAD_URL_PREFIX,
953 escaped_filename
954 )
955 }
956 }
957 }
958
959 pub fn for_fetch(url: &str) -> String {
960 format!("[@{}]({}:{})", url, Self::FETCH, url)
961 }
962
963 pub fn for_rule(rule: &RulesContextEntry) -> String {
964 format!("[@{}]({}:{})", rule.title, Self::RULE, rule.prompt_id.0)
965 }
966
967 pub fn try_parse(link: &str, workspace: &Entity<Workspace>, cx: &App) -> Option<Self> {
968 fn extract_project_path_from_link(
969 path: &str,
970 workspace: &Entity<Workspace>,
971 cx: &App,
972 ) -> Option<ProjectPath> {
973 let path = PathBuf::from(path);
974 let worktree_name = path.iter().next()?;
975 let path: PathBuf = path.iter().skip(1).collect();
976 let worktree_id = workspace
977 .read(cx)
978 .visible_worktrees(cx)
979 .find(|worktree| worktree.read(cx).root_name() == worktree_name)
980 .map(|worktree| worktree.read(cx).id())?;
981 Some(ProjectPath {
982 worktree_id,
983 path: path.into(),
984 })
985 }
986
987 let (prefix, argument) = link.split_once(Self::SEPARATOR)?;
988 match prefix {
989 Self::FILE => {
990 let project_path = extract_project_path_from_link(argument, workspace, cx)?;
991 let entry = workspace
992 .read(cx)
993 .project()
994 .read(cx)
995 .entry_for_path(&project_path, cx)?;
996 Some(MentionLink::File(project_path, entry))
997 }
998 Self::SYMBOL => {
999 let (path, symbol) = argument.split_once(Self::SEPARATOR)?;
1000 let project_path = extract_project_path_from_link(path, workspace, cx)?;
1001 Some(MentionLink::Symbol(project_path, symbol.to_string()))
1002 }
1003 Self::SELECTION => {
1004 let (path, line_args) = argument.split_once(Self::SEPARATOR)?;
1005 let project_path = extract_project_path_from_link(path, workspace, cx)?;
1006
1007 let line_range = {
1008 let (start, end) = line_args
1009 .trim_start_matches('(')
1010 .trim_end_matches(')')
1011 .split_once('-')?;
1012 start.parse::<usize>().ok()?..end.parse::<usize>().ok()?
1013 };
1014
1015 Some(MentionLink::Selection(project_path, line_range))
1016 }
1017 Self::THREAD => {
1018 if let Some(encoded_filename) = argument.strip_prefix(Self::TEXT_THREAD_URL_PREFIX)
1019 {
1020 let filename = urlencoding::decode(encoded_filename).ok()?;
1021 let path = contexts_dir().join(filename.as_ref()).into();
1022 Some(MentionLink::TextThread(path))
1023 } else {
1024 let thread_id = ThreadId::from(argument);
1025 Some(MentionLink::Thread(thread_id))
1026 }
1027 }
1028 Self::FETCH => Some(MentionLink::Fetch(argument.to_string())),
1029 Self::RULE => {
1030 let prompt_id = UserPromptId(Uuid::try_parse(argument).ok()?);
1031 Some(MentionLink::Rule(prompt_id))
1032 }
1033 _ => None,
1034 }
1035 }
1036}