1use std::sync::Arc;
2use std::{rc::Rc, time::Duration};
3
4use file_icons::FileIcons;
5use futures::FutureExt;
6use gpui::{Animation, AnimationExt as _, 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.render_preview.as_ref(),
172 |element, render_preview| {
173 element.hoverable_tooltip({
174 let render_preview = render_preview.clone();
175 move |_, cx| {
176 cx.new(|_| ContextPillPreview {
177 render_preview: render_preview.clone(),
178 })
179 .into()
180 }
181 })
182 },
183 )
184 .into_any(),
185 ContextStatus::Loading { message } => element
186 .tooltip(ui::Tooltip::text(message.clone()))
187 .with_animation(
188 "pulsating-ctx-pill",
189 Animation::new(Duration::from_secs(2))
190 .repeat()
191 .with_easing(pulsating_between(0.4, 0.8)),
192 |label, delta| label.opacity(delta),
193 )
194 .into_any_element(),
195 ContextStatus::Error { message } => element
196 .tooltip(ui::Tooltip::text(message.clone()))
197 .into_any_element(),
198 }),
199 )
200 .when_some(on_remove.as_ref(), |element, on_remove| {
201 element.child(
202 IconButton::new(("remove", context.id.0), IconName::Close)
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#[derive(RegisterComponent)]
266pub struct AddedContext {
267 pub id: ContextId,
268 pub kind: ContextKind,
269 pub name: SharedString,
270 pub parent: Option<SharedString>,
271 pub tooltip: Option<SharedString>,
272 pub icon_path: Option<SharedString>,
273 pub status: ContextStatus,
274 pub render_preview: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
275}
276
277impl AddedContext {
278 pub fn new(context: &AssistantContext, cx: &App) -> AddedContext {
279 match context {
280 AssistantContext::File(file_context) => {
281 let full_path = file_context.context_buffer.full_path(cx);
282 let full_path_string: SharedString =
283 full_path.to_string_lossy().into_owned().into();
284 let name = full_path
285 .file_name()
286 .map(|n| n.to_string_lossy().into_owned().into())
287 .unwrap_or_else(|| full_path_string.clone());
288 let parent = full_path
289 .parent()
290 .and_then(|p| p.file_name())
291 .map(|n| n.to_string_lossy().into_owned().into());
292 AddedContext {
293 id: file_context.id,
294 kind: ContextKind::File,
295 name,
296 parent,
297 tooltip: Some(full_path_string),
298 icon_path: FileIcons::get_icon(&full_path, cx),
299 status: ContextStatus::Ready,
300 render_preview: None,
301 }
302 }
303
304 AssistantContext::Directory(directory_context) => {
305 let worktree = directory_context.worktree.read(cx);
306 // If the directory no longer exists, use its last known path.
307 let full_path = worktree
308 .entry_for_id(directory_context.entry_id)
309 .map_or_else(
310 || directory_context.last_path.clone(),
311 |entry| worktree.full_path(&entry.path).into(),
312 );
313 let full_path_string: SharedString =
314 full_path.to_string_lossy().into_owned().into();
315 let name = full_path
316 .file_name()
317 .map(|n| n.to_string_lossy().into_owned().into())
318 .unwrap_or_else(|| full_path_string.clone());
319 let parent = full_path
320 .parent()
321 .and_then(|p| p.file_name())
322 .map(|n| n.to_string_lossy().into_owned().into());
323 AddedContext {
324 id: directory_context.id,
325 kind: ContextKind::Directory,
326 name,
327 parent,
328 tooltip: Some(full_path_string),
329 icon_path: None,
330 status: ContextStatus::Ready,
331 render_preview: None,
332 }
333 }
334
335 AssistantContext::Symbol(symbol_context) => AddedContext {
336 id: symbol_context.id,
337 kind: ContextKind::Symbol,
338 name: symbol_context.context_symbol.id.name.clone(),
339 parent: None,
340 tooltip: None,
341 icon_path: None,
342 status: ContextStatus::Ready,
343 render_preview: None,
344 },
345
346 AssistantContext::Selection(selection_context) => {
347 let full_path = selection_context.context_buffer.full_path(cx);
348 let mut full_path_string = full_path.to_string_lossy().into_owned();
349 let mut name = full_path
350 .file_name()
351 .map(|n| n.to_string_lossy().into_owned())
352 .unwrap_or_else(|| full_path_string.clone());
353
354 let line_range_text = format!(
355 " ({}-{})",
356 selection_context.line_range.start.row + 1,
357 selection_context.line_range.end.row + 1
358 );
359
360 full_path_string.push_str(&line_range_text);
361 name.push_str(&line_range_text);
362
363 let parent = full_path
364 .parent()
365 .and_then(|p| p.file_name())
366 .map(|n| n.to_string_lossy().into_owned().into());
367
368 AddedContext {
369 id: selection_context.id,
370 kind: ContextKind::Selection,
371 name: name.into(),
372 parent,
373 tooltip: None,
374 icon_path: FileIcons::get_icon(&full_path, cx),
375 status: ContextStatus::Ready,
376 render_preview: Some(Rc::new({
377 let content = selection_context.context_buffer.text.clone();
378 move |_, cx| {
379 div()
380 .id("context-pill-selection-preview")
381 .overflow_scroll()
382 .max_w_128()
383 .max_h_96()
384 .child(Label::new(content.clone()).buffer_font(cx))
385 .into_any_element()
386 }
387 })),
388 }
389 }
390
391 AssistantContext::FetchedUrl(fetched_url_context) => AddedContext {
392 id: fetched_url_context.id,
393 kind: ContextKind::FetchedUrl,
394 name: fetched_url_context.url.clone(),
395 parent: None,
396 tooltip: None,
397 icon_path: None,
398 status: ContextStatus::Ready,
399 render_preview: None,
400 },
401
402 AssistantContext::Thread(thread_context) => AddedContext {
403 id: thread_context.id,
404 kind: ContextKind::Thread,
405 name: thread_context.summary(cx),
406 parent: None,
407 tooltip: None,
408 icon_path: None,
409 status: if thread_context
410 .thread
411 .read(cx)
412 .is_generating_detailed_summary()
413 {
414 ContextStatus::Loading {
415 message: "Summarizing…".into(),
416 }
417 } else {
418 ContextStatus::Ready
419 },
420 render_preview: None,
421 },
422
423 AssistantContext::Rules(user_rules_context) => AddedContext {
424 id: user_rules_context.id,
425 kind: ContextKind::Rules,
426 name: user_rules_context.title.clone(),
427 parent: None,
428 tooltip: None,
429 icon_path: None,
430 status: ContextStatus::Ready,
431 render_preview: None,
432 },
433
434 AssistantContext::Image(image_context) => AddedContext {
435 id: image_context.id,
436 kind: ContextKind::Image,
437 name: "Image".into(),
438 parent: None,
439 tooltip: None,
440 icon_path: None,
441 status: if image_context.is_loading() {
442 ContextStatus::Loading {
443 message: "Loading…".into(),
444 }
445 } else if image_context.is_error() {
446 ContextStatus::Error {
447 message: "Failed to load image".into(),
448 }
449 } else {
450 ContextStatus::Ready
451 },
452 render_preview: Some(Rc::new({
453 let image = image_context.original_image.clone();
454 move |_, _| {
455 gpui::img(image.clone())
456 .max_w_96()
457 .max_h_96()
458 .into_any_element()
459 }
460 })),
461 },
462 }
463 }
464}
465
466struct ContextPillPreview {
467 render_preview: Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>,
468}
469
470impl Render for ContextPillPreview {
471 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
472 tooltip_container(window, cx, move |this, window, cx| {
473 this.occlude()
474 .on_mouse_move(|_, _, cx| cx.stop_propagation())
475 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
476 .child((self.render_preview)(window, cx))
477 })
478 }
479}
480
481impl Component for AddedContext {
482 fn scope() -> ComponentScope {
483 ComponentScope::Agent
484 }
485
486 fn sort_name() -> &'static str {
487 "AddedContext"
488 }
489
490 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
491 let image_ready = (
492 "Ready",
493 AddedContext::new(
494 &AssistantContext::Image(ImageContext {
495 id: ContextId(0),
496 original_image: Arc::new(Image::empty()),
497 image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
498 }),
499 cx,
500 ),
501 );
502
503 let image_loading = (
504 "Loading",
505 AddedContext::new(
506 &AssistantContext::Image(ImageContext {
507 id: ContextId(1),
508 original_image: Arc::new(Image::empty()),
509 image_task: cx
510 .background_spawn(async move {
511 smol::Timer::after(Duration::from_secs(60 * 5)).await;
512 Some(LanguageModelImage::empty())
513 })
514 .shared(),
515 }),
516 cx,
517 ),
518 );
519
520 let image_error = (
521 "Error",
522 AddedContext::new(
523 &AssistantContext::Image(ImageContext {
524 id: ContextId(2),
525 original_image: Arc::new(Image::empty()),
526 image_task: Task::ready(None).shared(),
527 }),
528 cx,
529 ),
530 );
531
532 Some(
533 v_flex()
534 .gap_6()
535 .children(
536 vec![image_ready, image_loading, image_error]
537 .into_iter()
538 .map(|(text, context)| {
539 single_example(
540 text,
541 ContextPill::added(context, false, false, None).into_any_element(),
542 )
543 }),
544 )
545 .into_any(),
546 )
547 }
548}