context_pill.rs

  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*/