1use std::{rc::Rc, time::Duration};
2
3use file_icons::FileIcons;
4use gpui::{Animation, AnimationExt as _, ClickEvent, Entity, MouseButton, pulsating_between};
5use project::Project;
6use prompt_store::PromptStore;
7use text::OffsetRangeExt;
8use ui::{IconButtonShape, Tooltip, prelude::*, tooltip_container};
9
10use crate::context::{AgentContext, ContextKind, ImageStatus};
11
12#[derive(IntoElement)]
13pub enum ContextPill {
14 Added {
15 context: AddedContext,
16 dupe_name: bool,
17 focused: bool,
18 on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
19 on_remove: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
20 },
21 Suggested {
22 name: SharedString,
23 icon_path: Option<SharedString>,
24 kind: ContextKind,
25 focused: bool,
26 on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
27 },
28}
29
30impl ContextPill {
31 pub fn added(
32 context: AddedContext,
33 dupe_name: bool,
34 focused: bool,
35 on_remove: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
36 ) -> Self {
37 Self::Added {
38 context,
39 dupe_name,
40 on_remove,
41 focused,
42 on_click: None,
43 }
44 }
45
46 pub fn suggested(
47 name: SharedString,
48 icon_path: Option<SharedString>,
49 kind: ContextKind,
50 focused: bool,
51 ) -> Self {
52 Self::Suggested {
53 name,
54 icon_path,
55 kind,
56 focused,
57 on_click: None,
58 }
59 }
60
61 pub fn on_click(mut self, listener: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>) -> Self {
62 match &mut self {
63 ContextPill::Added { on_click, .. } => {
64 *on_click = Some(listener);
65 }
66 ContextPill::Suggested { on_click, .. } => {
67 *on_click = Some(listener);
68 }
69 }
70 self
71 }
72
73 pub fn id(&self) -> ElementId {
74 match self {
75 Self::Added { context, .. } => context.context.element_id("context-pill".into()),
76 Self::Suggested { .. } => "suggested-context-pill".into(),
77 }
78 }
79
80 pub fn icon(&self) -> Icon {
81 match self {
82 Self::Suggested {
83 icon_path: Some(icon_path),
84 ..
85 }
86 | Self::Added {
87 context:
88 AddedContext {
89 icon_path: Some(icon_path),
90 ..
91 },
92 ..
93 } => Icon::from_path(icon_path),
94 Self::Suggested { kind, .. }
95 | Self::Added {
96 context: AddedContext { kind, .. },
97 ..
98 } => Icon::new(kind.icon()),
99 }
100 }
101}
102
103impl RenderOnce for ContextPill {
104 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
105 let color = cx.theme().colors();
106
107 let base_pill = h_flex()
108 .id(self.id())
109 .pl_1()
110 .pb(px(1.))
111 .border_1()
112 .rounded_sm()
113 .gap_1()
114 .child(self.icon().size(IconSize::XSmall).color(Color::Muted));
115
116 match &self {
117 ContextPill::Added {
118 context,
119 dupe_name,
120 on_remove,
121 focused,
122 on_click,
123 } => {
124 let status_is_error = matches!(context.status, ContextStatus::Error { .. });
125
126 base_pill
127 .pr(if on_remove.is_some() { px(2.) } else { px(4.) })
128 .map(|pill| {
129 if status_is_error {
130 pill.bg(cx.theme().status().error_background)
131 .border_color(cx.theme().status().error_border)
132 } else if *focused {
133 pill.bg(color.element_background)
134 .border_color(color.border_focused)
135 } else {
136 pill.bg(color.element_background)
137 .border_color(color.border.opacity(0.5))
138 }
139 })
140 .child(
141 h_flex()
142 .id("context-data")
143 .gap_1()
144 .child(
145 div().max_w_64().child(
146 Label::new(context.name.clone())
147 .size(LabelSize::Small)
148 .truncate(),
149 ),
150 )
151 .when_some(context.parent.as_ref(), |element, parent_name| {
152 if *dupe_name {
153 element.child(
154 Label::new(parent_name.clone())
155 .size(LabelSize::XSmall)
156 .color(Color::Muted),
157 )
158 } else {
159 element
160 }
161 })
162 .when_some(context.tooltip.as_ref(), |element, tooltip| {
163 element.tooltip(Tooltip::text(tooltip.clone()))
164 })
165 .map(|element| match &context.status {
166 ContextStatus::Ready => element
167 .when_some(
168 context.render_preview.as_ref(),
169 |element, render_preview| {
170 element.hoverable_tooltip({
171 let render_preview = render_preview.clone();
172 move |_, cx| {
173 cx.new(|_| ContextPillPreview {
174 render_preview: render_preview.clone(),
175 })
176 .into()
177 }
178 })
179 },
180 )
181 .into_any(),
182 ContextStatus::Loading { message } => element
183 .tooltip(ui::Tooltip::text(message.clone()))
184 .with_animation(
185 "pulsating-ctx-pill",
186 Animation::new(Duration::from_secs(2))
187 .repeat()
188 .with_easing(pulsating_between(0.4, 0.8)),
189 |label, delta| label.opacity(delta),
190 )
191 .into_any_element(),
192 ContextStatus::Error { message } => element
193 .tooltip(ui::Tooltip::text(message.clone()))
194 .into_any_element(),
195 }),
196 )
197 .when_some(on_remove.as_ref(), |element, on_remove| {
198 element.child(
199 IconButton::new(
200 context.context.element_id("remove".into()),
201 IconName::Close,
202 )
203 .shape(IconButtonShape::Square)
204 .icon_size(IconSize::XSmall)
205 .tooltip(Tooltip::text("Remove Context"))
206 .on_click({
207 let on_remove = on_remove.clone();
208 move |event, window, cx| on_remove(event, window, cx)
209 }),
210 )
211 })
212 .when_some(on_click.as_ref(), |element, on_click| {
213 let on_click = on_click.clone();
214 element
215 .cursor_pointer()
216 .on_click(move |event, window, cx| on_click(event, window, cx))
217 })
218 .into_any_element()
219 }
220 ContextPill::Suggested {
221 name,
222 icon_path: _,
223 kind: _,
224 focused,
225 on_click,
226 } => base_pill
227 .cursor_pointer()
228 .pr_1()
229 .border_dashed()
230 .map(|pill| {
231 if *focused {
232 pill.border_color(color.border_focused)
233 .bg(color.element_background.opacity(0.5))
234 } else {
235 pill.border_color(color.border)
236 }
237 })
238 .hover(|style| style.bg(color.element_hover.opacity(0.5)))
239 .child(
240 div().max_w_64().child(
241 Label::new(name.clone())
242 .size(LabelSize::Small)
243 .color(Color::Muted)
244 .truncate(),
245 ),
246 )
247 .tooltip(|window, cx| {
248 Tooltip::with_meta("Suggested Context", None, "Click to add it", window, cx)
249 })
250 .when_some(on_click.as_ref(), |element, on_click| {
251 let on_click = on_click.clone();
252 element.on_click(move |event, window, cx| on_click(event, window, cx))
253 })
254 .into_any(),
255 }
256 }
257}
258
259pub enum ContextStatus {
260 Ready,
261 Loading { message: SharedString },
262 Error { message: SharedString },
263}
264
265// TODO: Component commented out due to new dependency on `Project`.
266//
267// #[derive(RegisterComponent)]
268pub struct AddedContext {
269 pub context: AgentContext,
270 pub kind: ContextKind,
271 pub name: SharedString,
272 pub parent: Option<SharedString>,
273 pub tooltip: Option<SharedString>,
274 pub icon_path: Option<SharedString>,
275 pub status: ContextStatus,
276 pub render_preview: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
277}
278
279impl AddedContext {
280 /// Creates an `AddedContext` by retrieving relevant details of `AgentContext`. This returns a
281 /// `None` if `DirectoryContext` or `RulesContext` no longer exist.
282 ///
283 /// TODO: `None` cases are unremovable from `ContextStore` and so are a very minor memory leak.
284 pub fn new(
285 context: AgentContext,
286 prompt_store: Option<&Entity<PromptStore>>,
287 project: &Project,
288 cx: &App,
289 ) -> Option<AddedContext> {
290 match context {
291 AgentContext::File(ref file_context) => {
292 let full_path = file_context.buffer.read(cx).file()?.full_path(cx);
293 let full_path_string: SharedString =
294 full_path.to_string_lossy().into_owned().into();
295 let name = full_path
296 .file_name()
297 .map(|n| n.to_string_lossy().into_owned().into())
298 .unwrap_or_else(|| full_path_string.clone());
299 let parent = full_path
300 .parent()
301 .and_then(|p| p.file_name())
302 .map(|n| n.to_string_lossy().into_owned().into());
303 Some(AddedContext {
304 kind: ContextKind::File,
305 name,
306 parent,
307 tooltip: Some(full_path_string),
308 icon_path: FileIcons::get_icon(&full_path, cx),
309 status: ContextStatus::Ready,
310 render_preview: None,
311 context,
312 })
313 }
314
315 AgentContext::Directory(ref directory_context) => {
316 let worktree = project
317 .worktree_for_entry(directory_context.entry_id, cx)?
318 .read(cx);
319 let entry = worktree.entry_for_id(directory_context.entry_id)?;
320 let full_path = worktree.full_path(&entry.path);
321 let full_path_string: SharedString =
322 full_path.to_string_lossy().into_owned().into();
323 let name = full_path
324 .file_name()
325 .map(|n| n.to_string_lossy().into_owned().into())
326 .unwrap_or_else(|| full_path_string.clone());
327 let parent = full_path
328 .parent()
329 .and_then(|p| p.file_name())
330 .map(|n| n.to_string_lossy().into_owned().into());
331 Some(AddedContext {
332 kind: ContextKind::Directory,
333 name,
334 parent,
335 tooltip: Some(full_path_string),
336 icon_path: None,
337 status: ContextStatus::Ready,
338 render_preview: None,
339 context,
340 })
341 }
342
343 AgentContext::Symbol(ref symbol_context) => Some(AddedContext {
344 kind: ContextKind::Symbol,
345 name: symbol_context.symbol.clone(),
346 parent: None,
347 tooltip: None,
348 icon_path: None,
349 status: ContextStatus::Ready,
350 render_preview: None,
351 context,
352 }),
353
354 AgentContext::Selection(ref selection_context) => {
355 let buffer = selection_context.buffer.read(cx);
356 let full_path = buffer.file()?.full_path(cx);
357 let mut full_path_string = full_path.to_string_lossy().into_owned();
358 let mut name = full_path
359 .file_name()
360 .map(|n| n.to_string_lossy().into_owned())
361 .unwrap_or_else(|| full_path_string.clone());
362
363 let line_range = selection_context.range.to_point(&buffer.snapshot());
364
365 let line_range_text =
366 format!(" ({}-{})", line_range.start.row + 1, line_range.end.row + 1);
367
368 full_path_string.push_str(&line_range_text);
369 name.push_str(&line_range_text);
370
371 let parent = full_path
372 .parent()
373 .and_then(|p| p.file_name())
374 .map(|n| n.to_string_lossy().into_owned().into());
375
376 Some(AddedContext {
377 kind: ContextKind::Selection,
378 name: name.into(),
379 parent,
380 tooltip: None,
381 icon_path: FileIcons::get_icon(&full_path, cx),
382 status: ContextStatus::Ready,
383 render_preview: None,
384 /*
385 render_preview: Some(Rc::new({
386 let content = selection_context.text.clone();
387 move |_, cx| {
388 div()
389 .id("context-pill-selection-preview")
390 .overflow_scroll()
391 .max_w_128()
392 .max_h_96()
393 .child(Label::new(content.clone()).buffer_font(cx))
394 .into_any_element()
395 }
396 })),
397 */
398 context,
399 })
400 }
401
402 AgentContext::FetchedUrl(ref fetched_url_context) => Some(AddedContext {
403 kind: ContextKind::FetchedUrl,
404 name: fetched_url_context.url.clone(),
405 parent: None,
406 tooltip: None,
407 icon_path: None,
408 status: ContextStatus::Ready,
409 render_preview: None,
410 context,
411 }),
412
413 AgentContext::Thread(ref thread_context) => Some(AddedContext {
414 kind: ContextKind::Thread,
415 name: thread_context.name(cx),
416 parent: None,
417 tooltip: None,
418 icon_path: None,
419 status: if thread_context
420 .thread
421 .read(cx)
422 .is_generating_detailed_summary()
423 {
424 ContextStatus::Loading {
425 message: "Summarizing…".into(),
426 }
427 } else {
428 ContextStatus::Ready
429 },
430 render_preview: None,
431 context,
432 }),
433
434 AgentContext::Rules(ref user_rules_context) => {
435 let name = prompt_store
436 .as_ref()?
437 .read(cx)
438 .metadata(user_rules_context.prompt_id.into())?
439 .title?;
440 Some(AddedContext {
441 kind: ContextKind::Rules,
442 name: name.clone(),
443 parent: None,
444 tooltip: None,
445 icon_path: None,
446 status: ContextStatus::Ready,
447 render_preview: None,
448 context,
449 })
450 }
451
452 AgentContext::Image(ref image_context) => Some(AddedContext {
453 kind: ContextKind::Image,
454 name: "Image".into(),
455 parent: None,
456 tooltip: None,
457 icon_path: None,
458 status: match image_context.status() {
459 ImageStatus::Loading => ContextStatus::Loading {
460 message: "Loading…".into(),
461 },
462 ImageStatus::Error => ContextStatus::Error {
463 message: "Failed to load image".into(),
464 },
465 ImageStatus::Ready => ContextStatus::Ready,
466 },
467 render_preview: Some(Rc::new({
468 let image = image_context.original_image.clone();
469 move |_, _| {
470 gpui::img(image.clone())
471 .max_w_96()
472 .max_h_96()
473 .into_any_element()
474 }
475 })),
476 context,
477 }),
478 }
479 }
480}
481
482struct ContextPillPreview {
483 render_preview: Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>,
484}
485
486impl Render for ContextPillPreview {
487 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
488 tooltip_container(window, cx, move |this, window, cx| {
489 this.occlude()
490 .on_mouse_move(|_, _, cx| cx.stop_propagation())
491 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
492 .child((self.render_preview)(window, cx))
493 })
494 }
495}
496
497// TODO: Component commented out due to new dependency on `Project`.
498/*
499impl Component for AddedContext {
500 fn scope() -> ComponentScope {
501 ComponentScope::Agent
502 }
503
504 fn sort_name() -> &'static str {
505 "AddedContext"
506 }
507
508 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
509 let next_context_id = ContextId::zero();
510 let image_ready = (
511 "Ready",
512 AddedContext::new(
513 AgentContext::Image(ImageContext {
514 context_id: next_context_id.post_inc(),
515 original_image: Arc::new(Image::empty()),
516 image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
517 }),
518 cx,
519 ),
520 );
521
522 let image_loading = (
523 "Loading",
524 AddedContext::new(
525 AgentContext::Image(ImageContext {
526 context_id: next_context_id.post_inc(),
527 original_image: Arc::new(Image::empty()),
528 image_task: cx
529 .background_spawn(async move {
530 smol::Timer::after(Duration::from_secs(60 * 5)).await;
531 Some(LanguageModelImage::empty())
532 })
533 .shared(),
534 }),
535 cx,
536 ),
537 );
538
539 let image_error = (
540 "Error",
541 AddedContext::new(
542 AgentContext::Image(ImageContext {
543 context_id: next_context_id.post_inc(),
544 original_image: Arc::new(Image::empty()),
545 image_task: Task::ready(None).shared(),
546 }),
547 cx,
548 ),
549 );
550
551 Some(
552 v_flex()
553 .gap_6()
554 .children(
555 vec![image_ready, image_loading, image_error]
556 .into_iter()
557 .map(|(text, context)| {
558 single_example(
559 text,
560 ContextPill::added(context, false, false, None).into_any_element(),
561 )
562 }),
563 )
564 .into_any(),
565 )
566
567 None
568 }
569}
570*/