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}