context_pill.rs

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