1use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration};
2
3use file_icons::FileIcons;
4use futures::FutureExt as _;
5use gpui::{
6 Animation, AnimationExt as _, AnyView, ClickEvent, Entity, Image, MouseButton, Task,
7 pulsating_between,
8};
9use language_model::LanguageModelImage;
10use project::Project;
11use prompt_store::PromptStore;
12use rope::Point;
13use ui::{IconButtonShape, Tooltip, prelude::*, tooltip_container};
14
15use crate::context::{
16 AgentContext, AgentContextHandle, ContextId, ContextKind, DirectoryContext,
17 DirectoryContextHandle, FetchedUrlContext, FileContext, FileContextHandle, ImageContext,
18 ImageStatus, RulesContext, RulesContextHandle, SelectionContext, SelectionContextHandle,
19 SymbolContext, SymbolContextHandle, ThreadContext, ThreadContextHandle,
20};
21
22#[derive(IntoElement)]
23pub enum ContextPill {
24 Added {
25 context: AddedContext,
26 dupe_name: bool,
27 focused: bool,
28 on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
29 on_remove: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
30 },
31 Suggested {
32 name: SharedString,
33 icon_path: Option<SharedString>,
34 kind: ContextKind,
35 focused: bool,
36 on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
37 },
38}
39
40impl ContextPill {
41 pub fn added(
42 context: AddedContext,
43 dupe_name: bool,
44 focused: bool,
45 on_remove: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
46 ) -> Self {
47 Self::Added {
48 context,
49 dupe_name,
50 on_remove,
51 focused,
52 on_click: None,
53 }
54 }
55
56 pub fn suggested(
57 name: SharedString,
58 icon_path: Option<SharedString>,
59 kind: ContextKind,
60 focused: bool,
61 ) -> Self {
62 Self::Suggested {
63 name,
64 icon_path,
65 kind,
66 focused,
67 on_click: None,
68 }
69 }
70
71 pub fn on_click(mut self, listener: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>) -> Self {
72 match &mut self {
73 ContextPill::Added { on_click, .. } => {
74 *on_click = Some(listener);
75 }
76 ContextPill::Suggested { on_click, .. } => {
77 *on_click = Some(listener);
78 }
79 }
80 self
81 }
82
83 pub fn id(&self) -> ElementId {
84 match self {
85 Self::Added { context, .. } => context.handle.element_id("context-pill".into()),
86 Self::Suggested { .. } => "suggested-context-pill".into(),
87 }
88 }
89
90 pub fn icon(&self) -> Icon {
91 match self {
92 Self::Suggested {
93 icon_path: Some(icon_path),
94 ..
95 }
96 | Self::Added {
97 context:
98 AddedContext {
99 icon_path: Some(icon_path),
100 ..
101 },
102 ..
103 } => Icon::from_path(icon_path),
104 Self::Suggested { kind, .. }
105 | Self::Added {
106 context: AddedContext { kind, .. },
107 ..
108 } => Icon::new(kind.icon()),
109 }
110 }
111}
112
113impl RenderOnce for ContextPill {
114 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
115 let color = cx.theme().colors();
116
117 let base_pill = h_flex()
118 .id(self.id())
119 .pl_1()
120 .pb(px(1.))
121 .border_1()
122 .rounded_sm()
123 .gap_1()
124 .child(self.icon().size(IconSize::XSmall).color(Color::Muted));
125
126 match &self {
127 ContextPill::Added {
128 context,
129 dupe_name,
130 on_remove,
131 focused,
132 on_click,
133 } => {
134 let status_is_error = matches!(context.status, ContextStatus::Error { .. });
135
136 base_pill
137 .pr(if on_remove.is_some() { px(2.) } else { px(4.) })
138 .map(|pill| {
139 if status_is_error {
140 pill.bg(cx.theme().status().error_background)
141 .border_color(cx.theme().status().error_border)
142 } else if *focused {
143 pill.bg(color.element_background)
144 .border_color(color.border_focused)
145 } else {
146 pill.bg(color.element_background)
147 .border_color(color.border.opacity(0.5))
148 }
149 })
150 .child(
151 h_flex()
152 .id("context-data")
153 .gap_1()
154 .child(
155 div().max_w_64().child(
156 Label::new(context.name.clone())
157 .size(LabelSize::Small)
158 .truncate(),
159 ),
160 )
161 .when_some(context.parent.as_ref(), |element, parent_name| {
162 if *dupe_name {
163 element.child(
164 Label::new(parent_name.clone())
165 .size(LabelSize::XSmall)
166 .color(Color::Muted),
167 )
168 } else {
169 element
170 }
171 })
172 .when_some(context.tooltip.as_ref(), |element, tooltip| {
173 element.tooltip(Tooltip::text(tooltip.clone()))
174 })
175 .map(|element| match &context.status {
176 ContextStatus::Ready => element
177 .when_some(
178 context.render_hover.as_ref(),
179 |element, render_hover| {
180 let render_hover = render_hover.clone();
181 element.hoverable_tooltip(move |window, cx| {
182 render_hover(window, cx)
183 })
184 },
185 )
186 .into_any(),
187 ContextStatus::Loading { message } => element
188 .tooltip(ui::Tooltip::text(message.clone()))
189 .with_animation(
190 "pulsating-ctx-pill",
191 Animation::new(Duration::from_secs(2))
192 .repeat()
193 .with_easing(pulsating_between(0.4, 0.8)),
194 |label, delta| label.opacity(delta),
195 )
196 .into_any_element(),
197 ContextStatus::Error { message } => element
198 .tooltip(ui::Tooltip::text(message.clone()))
199 .into_any_element(),
200 }),
201 )
202 .when_some(on_remove.as_ref(), |element, on_remove| {
203 element.child(
204 IconButton::new(
205 context.handle.element_id("remove".into()),
206 IconName::Close,
207 )
208 .shape(IconButtonShape::Square)
209 .icon_size(IconSize::XSmall)
210 .tooltip(Tooltip::text("Remove Context"))
211 .on_click({
212 let on_remove = on_remove.clone();
213 move |event, window, cx| on_remove(event, window, cx)
214 }),
215 )
216 })
217 .when_some(on_click.as_ref(), |element, on_click| {
218 let on_click = on_click.clone();
219 element
220 .cursor_pointer()
221 .on_click(move |event, window, cx| on_click(event, window, cx))
222 })
223 .into_any_element()
224 }
225 ContextPill::Suggested {
226 name,
227 icon_path: _,
228 kind: _,
229 focused,
230 on_click,
231 } => base_pill
232 .cursor_pointer()
233 .pr_1()
234 .border_dashed()
235 .map(|pill| {
236 if *focused {
237 pill.border_color(color.border_focused)
238 .bg(color.element_background.opacity(0.5))
239 } else {
240 pill.border_color(color.border)
241 }
242 })
243 .hover(|style| style.bg(color.element_hover.opacity(0.5)))
244 .child(
245 div().max_w_64().child(
246 Label::new(name.clone())
247 .size(LabelSize::Small)
248 .color(Color::Muted)
249 .truncate(),
250 ),
251 )
252 .tooltip(|window, cx| {
253 Tooltip::with_meta("Suggested Context", None, "Click to add it", window, cx)
254 })
255 .when_some(on_click.as_ref(), |element, on_click| {
256 let on_click = on_click.clone();
257 element.on_click(move |event, window, cx| on_click(event, window, cx))
258 })
259 .into_any(),
260 }
261 }
262}
263
264pub enum ContextStatus {
265 Ready,
266 Loading { message: SharedString },
267 Error { message: SharedString },
268}
269
270#[derive(RegisterComponent)]
271pub struct AddedContext {
272 pub handle: AgentContextHandle,
273 pub kind: ContextKind,
274 pub name: SharedString,
275 pub parent: Option<SharedString>,
276 pub tooltip: Option<SharedString>,
277 pub icon_path: Option<SharedString>,
278 pub status: ContextStatus,
279 pub render_hover: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
280}
281
282impl AddedContext {
283 /// Creates an `AddedContext` by retrieving relevant details of `AgentContext`. This returns a
284 /// `None` if `DirectoryContext` or `RulesContext` no longer exist.
285 ///
286 /// TODO: `None` cases are unremovable from `ContextStore` and so are a very minor memory leak.
287 pub fn new_pending(
288 handle: AgentContextHandle,
289 prompt_store: Option<&Entity<PromptStore>>,
290 project: &Project,
291 cx: &App,
292 ) -> Option<AddedContext> {
293 match handle {
294 AgentContextHandle::File(handle) => Self::pending_file(handle, cx),
295 AgentContextHandle::Directory(handle) => Self::pending_directory(handle, project, cx),
296 AgentContextHandle::Symbol(handle) => Self::pending_symbol(handle, cx),
297 AgentContextHandle::Selection(handle) => Self::pending_selection(handle, cx),
298 AgentContextHandle::FetchedUrl(handle) => Some(Self::fetched_url(handle)),
299 AgentContextHandle::Thread(handle) => Some(Self::pending_thread(handle, cx)),
300 AgentContextHandle::Rules(handle) => Self::pending_rules(handle, prompt_store, cx),
301 AgentContextHandle::Image(handle) => Some(Self::image(handle)),
302 }
303 }
304
305 pub fn new_attached(context: &AgentContext, cx: &App) -> AddedContext {
306 match context {
307 AgentContext::File(context) => Self::attached_file(context, cx),
308 AgentContext::Directory(context) => Self::attached_directory(context),
309 AgentContext::Symbol(context) => Self::attached_symbol(context, cx),
310 AgentContext::Selection(context) => Self::attached_selection(context, cx),
311 AgentContext::FetchedUrl(context) => Self::fetched_url(context.clone()),
312 AgentContext::Thread(context) => Self::attached_thread(context),
313 AgentContext::Rules(context) => Self::attached_rules(context),
314 AgentContext::Image(context) => Self::image(context.clone()),
315 }
316 }
317
318 fn pending_file(handle: FileContextHandle, cx: &App) -> Option<AddedContext> {
319 let full_path = handle.buffer.read(cx).file()?.full_path(cx);
320 Some(Self::file(handle, &full_path, cx))
321 }
322
323 fn attached_file(context: &FileContext, cx: &App) -> AddedContext {
324 Self::file(context.handle.clone(), &context.full_path, cx)
325 }
326
327 fn file(handle: FileContextHandle, full_path: &Path, cx: &App) -> AddedContext {
328 let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
329 let name = full_path
330 .file_name()
331 .map(|n| n.to_string_lossy().into_owned().into())
332 .unwrap_or_else(|| full_path_string.clone());
333 let parent = full_path
334 .parent()
335 .and_then(|p| p.file_name())
336 .map(|n| n.to_string_lossy().into_owned().into());
337 AddedContext {
338 kind: ContextKind::File,
339 name,
340 parent,
341 tooltip: Some(full_path_string),
342 icon_path: FileIcons::get_icon(&full_path, cx),
343 status: ContextStatus::Ready,
344 render_hover: None,
345 handle: AgentContextHandle::File(handle),
346 }
347 }
348
349 fn pending_directory(
350 handle: DirectoryContextHandle,
351 project: &Project,
352 cx: &App,
353 ) -> Option<AddedContext> {
354 let worktree = project.worktree_for_entry(handle.entry_id, cx)?.read(cx);
355 let entry = worktree.entry_for_id(handle.entry_id)?;
356 let full_path = worktree.full_path(&entry.path);
357 Some(Self::directory(handle, &full_path))
358 }
359
360 fn attached_directory(context: &DirectoryContext) -> AddedContext {
361 Self::directory(context.handle.clone(), &context.full_path)
362 }
363
364 fn directory(handle: DirectoryContextHandle, full_path: &Path) -> AddedContext {
365 let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
366 let name = full_path
367 .file_name()
368 .map(|n| n.to_string_lossy().into_owned().into())
369 .unwrap_or_else(|| full_path_string.clone());
370 let parent = full_path
371 .parent()
372 .and_then(|p| p.file_name())
373 .map(|n| n.to_string_lossy().into_owned().into());
374 AddedContext {
375 kind: ContextKind::Directory,
376 name,
377 parent,
378 tooltip: Some(full_path_string),
379 icon_path: None,
380 status: ContextStatus::Ready,
381 render_hover: None,
382 handle: AgentContextHandle::Directory(handle),
383 }
384 }
385
386 fn pending_symbol(handle: SymbolContextHandle, cx: &App) -> Option<AddedContext> {
387 let excerpt =
388 ContextFileExcerpt::new(&handle.full_path(cx)?, handle.enclosing_line_range(cx), cx);
389 Some(AddedContext {
390 kind: ContextKind::Symbol,
391 name: handle.symbol.clone(),
392 parent: Some(excerpt.file_name_and_range.clone()),
393 tooltip: None,
394 icon_path: None,
395 status: ContextStatus::Ready,
396 render_hover: {
397 let handle = handle.clone();
398 Some(Rc::new(move |_, cx| {
399 excerpt.hover_view(handle.text(cx), cx).into()
400 }))
401 },
402 handle: AgentContextHandle::Symbol(handle),
403 })
404 }
405
406 fn attached_symbol(context: &SymbolContext, cx: &App) -> AddedContext {
407 let excerpt = ContextFileExcerpt::new(&context.full_path, context.line_range.clone(), cx);
408 AddedContext {
409 kind: ContextKind::Symbol,
410 name: context.handle.symbol.clone(),
411 parent: Some(excerpt.file_name_and_range.clone()),
412 tooltip: None,
413 icon_path: None,
414 status: ContextStatus::Ready,
415 render_hover: {
416 let text = context.text.clone();
417 Some(Rc::new(move |_, cx| {
418 excerpt.hover_view(text.clone(), cx).into()
419 }))
420 },
421 handle: AgentContextHandle::Symbol(context.handle.clone()),
422 }
423 }
424
425 fn pending_selection(handle: SelectionContextHandle, cx: &App) -> Option<AddedContext> {
426 let excerpt = ContextFileExcerpt::new(&handle.full_path(cx)?, handle.line_range(cx), cx);
427 Some(AddedContext {
428 kind: ContextKind::Selection,
429 name: excerpt.file_name_and_range.clone(),
430 parent: excerpt.parent_name.clone(),
431 tooltip: None,
432 icon_path: excerpt.icon_path.clone(),
433 status: ContextStatus::Ready,
434 render_hover: {
435 let handle = handle.clone();
436 Some(Rc::new(move |_, cx| {
437 excerpt.hover_view(handle.text(cx), cx).into()
438 }))
439 },
440 handle: AgentContextHandle::Selection(handle),
441 })
442 }
443
444 fn attached_selection(context: &SelectionContext, cx: &App) -> AddedContext {
445 let excerpt = ContextFileExcerpt::new(&context.full_path, context.line_range.clone(), cx);
446 AddedContext {
447 kind: ContextKind::Selection,
448 name: excerpt.file_name_and_range.clone(),
449 parent: excerpt.parent_name.clone(),
450 tooltip: None,
451 icon_path: excerpt.icon_path.clone(),
452 status: ContextStatus::Ready,
453 render_hover: {
454 let text = context.text.clone();
455 Some(Rc::new(move |_, cx| {
456 excerpt.hover_view(text.clone(), cx).into()
457 }))
458 },
459 handle: AgentContextHandle::Selection(context.handle.clone()),
460 }
461 }
462
463 fn fetched_url(context: FetchedUrlContext) -> AddedContext {
464 AddedContext {
465 kind: ContextKind::FetchedUrl,
466 name: context.url.clone(),
467 parent: None,
468 tooltip: None,
469 icon_path: None,
470 status: ContextStatus::Ready,
471 render_hover: None,
472 handle: AgentContextHandle::FetchedUrl(context),
473 }
474 }
475
476 fn pending_thread(handle: ThreadContextHandle, cx: &App) -> AddedContext {
477 AddedContext {
478 kind: ContextKind::Thread,
479 name: handle.title(cx),
480 parent: None,
481 tooltip: None,
482 icon_path: None,
483 status: if handle.thread.read(cx).is_generating_detailed_summary() {
484 ContextStatus::Loading {
485 message: "Summarizing…".into(),
486 }
487 } else {
488 ContextStatus::Ready
489 },
490 render_hover: {
491 let thread = handle.thread.clone();
492 Some(Rc::new(move |_, cx| {
493 let text = thread.read(cx).latest_detailed_summary_or_text();
494 ContextPillHover::new_text(text.clone(), cx).into()
495 }))
496 },
497 handle: AgentContextHandle::Thread(handle),
498 }
499 }
500
501 fn attached_thread(context: &ThreadContext) -> AddedContext {
502 AddedContext {
503 kind: ContextKind::Thread,
504 name: context.title.clone(),
505 parent: None,
506 tooltip: None,
507 icon_path: None,
508 status: ContextStatus::Ready,
509 render_hover: {
510 let text = context.text.clone();
511 Some(Rc::new(move |_, cx| {
512 ContextPillHover::new_text(text.clone(), cx).into()
513 }))
514 },
515 handle: AgentContextHandle::Thread(context.handle.clone()),
516 }
517 }
518
519 fn pending_rules(
520 handle: RulesContextHandle,
521 prompt_store: Option<&Entity<PromptStore>>,
522 cx: &App,
523 ) -> Option<AddedContext> {
524 let title = prompt_store
525 .as_ref()?
526 .read(cx)
527 .metadata(handle.prompt_id.into())?
528 .title
529 .unwrap_or_else(|| "Unnamed Rule".into());
530 Some(AddedContext {
531 kind: ContextKind::Rules,
532 name: title.clone(),
533 parent: None,
534 tooltip: None,
535 icon_path: None,
536 status: ContextStatus::Ready,
537 render_hover: None,
538 handle: AgentContextHandle::Rules(handle),
539 })
540 }
541
542 fn attached_rules(context: &RulesContext) -> AddedContext {
543 let title = context
544 .title
545 .clone()
546 .unwrap_or_else(|| "Unnamed Rule".into());
547 AddedContext {
548 kind: ContextKind::Rules,
549 name: title,
550 parent: None,
551 tooltip: None,
552 icon_path: None,
553 status: ContextStatus::Ready,
554 render_hover: {
555 let text = context.text.clone();
556 Some(Rc::new(move |_, cx| {
557 ContextPillHover::new_text(text.clone(), cx).into()
558 }))
559 },
560 handle: AgentContextHandle::Rules(context.handle.clone()),
561 }
562 }
563
564 fn image(context: ImageContext) -> AddedContext {
565 AddedContext {
566 kind: ContextKind::Image,
567 name: "Image".into(),
568 parent: None,
569 tooltip: None,
570 icon_path: None,
571 status: match context.status() {
572 ImageStatus::Loading => ContextStatus::Loading {
573 message: "Loading…".into(),
574 },
575 ImageStatus::Error => ContextStatus::Error {
576 message: "Failed to load image".into(),
577 },
578 ImageStatus::Ready => ContextStatus::Ready,
579 },
580 render_hover: Some(Rc::new({
581 let image = context.original_image.clone();
582 move |_, cx| {
583 let image = image.clone();
584 ContextPillHover::new(cx, move |_, _| {
585 gpui::img(image.clone())
586 .max_w_96()
587 .max_h_96()
588 .into_any_element()
589 })
590 .into()
591 }
592 })),
593 handle: AgentContextHandle::Image(context),
594 }
595 }
596}
597
598#[derive(Debug, Clone)]
599struct ContextFileExcerpt {
600 pub file_name_and_range: SharedString,
601 pub full_path_and_range: SharedString,
602 pub parent_name: Option<SharedString>,
603 pub icon_path: Option<SharedString>,
604}
605
606impl ContextFileExcerpt {
607 pub fn new(full_path: &Path, line_range: Range<Point>, cx: &App) -> Self {
608 let full_path_string = full_path.to_string_lossy().into_owned();
609 let file_name = full_path
610 .file_name()
611 .map(|n| n.to_string_lossy().into_owned())
612 .unwrap_or_else(|| full_path_string.clone());
613
614 let line_range_text = format!(" ({}-{})", line_range.start.row + 1, line_range.end.row + 1);
615 let mut full_path_and_range = full_path_string;
616 full_path_and_range.push_str(&line_range_text);
617 let mut file_name_and_range = file_name;
618 file_name_and_range.push_str(&line_range_text);
619
620 let parent_name = full_path
621 .parent()
622 .and_then(|p| p.file_name())
623 .map(|n| n.to_string_lossy().into_owned().into());
624
625 let icon_path = FileIcons::get_icon(&full_path, cx);
626
627 ContextFileExcerpt {
628 file_name_and_range: file_name_and_range.into(),
629 full_path_and_range: full_path_and_range.into(),
630 parent_name,
631 icon_path,
632 }
633 }
634
635 fn hover_view(&self, text: SharedString, cx: &mut App) -> Entity<ContextPillHover> {
636 let icon_path = self.icon_path.clone();
637 let full_path_and_range = self.full_path_and_range.clone();
638 ContextPillHover::new(cx, move |_, cx| {
639 v_flex()
640 .child(
641 h_flex()
642 .gap_0p5()
643 .w_full()
644 .max_w_full()
645 .border_b_1()
646 .border_color(cx.theme().colors().border.opacity(0.6))
647 .children(
648 icon_path
649 .clone()
650 .map(Icon::from_path)
651 .map(|icon| icon.color(Color::Muted).size(IconSize::XSmall)),
652 )
653 .child(
654 // TODO: make this truncate on the left.
655 Label::new(full_path_and_range.clone())
656 .size(LabelSize::Small)
657 .ml_1(),
658 ),
659 )
660 .child(
661 div()
662 .id("context-pill-hover-contents")
663 .overflow_scroll()
664 .max_w_128()
665 .max_h_96()
666 .child(Label::new(text.clone()).buffer_font(cx)),
667 )
668 .into_any_element()
669 })
670 }
671}
672
673struct ContextPillHover {
674 render_hover: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
675}
676
677impl ContextPillHover {
678 fn new(
679 cx: &mut App,
680 render_hover: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
681 ) -> Entity<Self> {
682 cx.new(|_| Self {
683 render_hover: Box::new(render_hover),
684 })
685 }
686
687 fn new_text(content: SharedString, cx: &mut App) -> Entity<Self> {
688 Self::new(cx, move |_, _| {
689 div()
690 .id("context-pill-hover-contents")
691 .overflow_scroll()
692 .max_w_128()
693 .max_h_96()
694 .child(content.clone())
695 .into_any_element()
696 })
697 }
698}
699
700impl Render for ContextPillHover {
701 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
702 tooltip_container(window, cx, move |this, window, cx| {
703 this.occlude()
704 .on_mouse_move(|_, _, cx| cx.stop_propagation())
705 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
706 .child((self.render_hover)(window, cx))
707 })
708 }
709}
710
711impl Component for AddedContext {
712 fn scope() -> ComponentScope {
713 ComponentScope::Agent
714 }
715
716 fn sort_name() -> &'static str {
717 "AddedContext"
718 }
719
720 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
721 let mut next_context_id = ContextId::zero();
722 let image_ready = (
723 "Ready",
724 AddedContext::image(ImageContext {
725 context_id: next_context_id.post_inc(),
726 original_image: Arc::new(Image::empty()),
727 image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
728 }),
729 );
730
731 let image_loading = (
732 "Loading",
733 AddedContext::image(ImageContext {
734 context_id: next_context_id.post_inc(),
735 original_image: Arc::new(Image::empty()),
736 image_task: cx
737 .background_spawn(async move {
738 smol::Timer::after(Duration::from_secs(60 * 5)).await;
739 Some(LanguageModelImage::empty())
740 })
741 .shared(),
742 }),
743 );
744
745 let image_error = (
746 "Error",
747 AddedContext::image(ImageContext {
748 context_id: next_context_id.post_inc(),
749 original_image: Arc::new(Image::empty()),
750 image_task: Task::ready(None).shared(),
751 }),
752 );
753
754 Some(
755 v_flex()
756 .gap_6()
757 .children(
758 vec![image_ready, image_loading, image_error]
759 .into_iter()
760 .map(|(text, context)| {
761 single_example(
762 text,
763 ContextPill::added(context, false, false, None).into_any_element(),
764 )
765 }),
766 )
767 .into_any(),
768 )
769 }
770}