1use crate::control::ControlMetricsExt as _;
14use crate::decoration::{DecoratedExt as _, Decoration};
15use crate::scrolling::PolicyScrollbarExt as _;
16use crate::surface::CardFrameExt as _;
17use crate::{
18 ButtonLabelExt as _,
19 code_block::CodeBlock,
20 handlers::SharedHandler,
21 status::{StatusBadge, StatusTone},
22 stream::{ProgressState, StreamedContent},
23 surface::{icon_button, meta},
24 theme::SemanticStyledExt as _,
25};
26use gpui::{
27 AnyElement, App, ClickEvent, ElementId, FontWeight, InteractiveElement as _, IntoElement,
28 ParentElement as _, RenderOnce, Role, ScrollHandle, SharedString,
29 StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
30 prelude::FluentBuilder as _,
31};
32use gpui_component::{
33 ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, StyledExt as _,
34 button::Button, h_flex, tab::TabBar, text::TextView, v_flex,
35};
36use std::rc::Rc;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
40pub enum ArtifactKind {
41 #[default]
43 Code,
44 Markdown,
46 Html,
48 Data,
50 Other,
52}
53
54impl ArtifactKind {
55 pub fn label(self) -> &'static str {
57 match self {
58 Self::Code => "Code",
59 Self::Markdown => "Document",
60 Self::Html => "HTML",
61 Self::Data => "Data",
62 Self::Other => "Artifact",
63 }
64 }
65
66 fn icon(self) -> IconName {
67 match self {
68 Self::Code => IconName::SquareTerminal,
69 Self::Markdown => IconName::BookOpen,
70 Self::Html => IconName::Globe,
71 Self::Data => IconName::ChartPie,
72 Self::Other => IconName::File,
73 }
74 }
75
76 pub fn has_preview(self) -> bool {
78 matches!(self, Self::Markdown | Self::Code)
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
84pub enum ArtifactView {
85 #[default]
87 Preview,
88 Source,
90}
91
92impl ArtifactView {
93 fn index(self) -> usize {
94 match self {
95 Self::Preview => 0,
96 Self::Source => 1,
97 }
98 }
99
100 fn from_index(index: usize) -> Self {
101 if index == 0 {
102 Self::Preview
103 } else {
104 Self::Source
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ArtifactVersion {
112 id: SharedString,
113 label: SharedString,
114}
115
116impl ArtifactVersion {
117 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
119 Self {
120 id: id.into(),
121 label: label.into(),
122 }
123 }
124
125 pub fn id(&self) -> &SharedString {
127 &self.id
128 }
129
130 pub fn label(&self) -> &SharedString {
132 &self.label
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct ArtifactAction {
139 id: SharedString,
140 label: SharedString,
141}
142
143impl ArtifactAction {
144 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
146 Self {
147 id: id.into(),
148 label: label.into(),
149 }
150 }
151
152 pub fn id(&self) -> &SharedString {
154 &self.id
155 }
156
157 pub fn label(&self) -> &SharedString {
159 &self.label
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct Artifact {
166 id: SharedString,
167 title: SharedString,
168 kind: ArtifactKind,
169 language: Option<SharedString>,
170 source: StreamedContent,
171 versions: Vec<ArtifactVersion>,
172 active_version: Option<SharedString>,
173}
174
175impl Artifact {
176 pub fn new(
178 id: impl Into<SharedString>,
179 title: impl Into<SharedString>,
180 source: StreamedContent,
181 ) -> Self {
182 Self {
183 id: id.into(),
184 title: title.into(),
185 kind: ArtifactKind::Code,
186 language: None,
187 source,
188 versions: Vec::new(),
189 active_version: None,
190 }
191 }
192
193 pub fn kind(mut self, kind: ArtifactKind) -> Self {
195 self.kind = kind;
196 self
197 }
198
199 pub fn language(mut self, language: impl Into<SharedString>) -> Self {
201 self.language = Some(language.into());
202 self
203 }
204
205 pub fn versions(mut self, versions: impl IntoIterator<Item = ArtifactVersion>) -> Self {
207 self.versions = versions.into_iter().collect();
208 self
209 }
210
211 pub fn active_version(mut self, version_id: impl Into<SharedString>) -> Self {
213 self.active_version = Some(version_id.into());
214 self
215 }
216
217 pub fn id(&self) -> &SharedString {
219 &self.id
220 }
221
222 pub fn title(&self) -> &SharedString {
224 &self.title
225 }
226
227 pub fn artifact_kind(&self) -> ArtifactKind {
229 self.kind
230 }
231
232 pub fn source(&self) -> &StreamedContent {
234 &self.source
235 }
236
237 pub fn version_refs(&self) -> &[ArtifactVersion] {
239 &self.versions
240 }
241
242 pub fn active_version_index(&self) -> Option<usize> {
244 if self.versions.is_empty() {
245 return None;
246 }
247 self.active_version
248 .as_ref()
249 .and_then(|id| self.versions.iter().position(|version| &version.id == id))
250 .or(Some(self.versions.len() - 1))
251 }
252
253 pub fn accessibility_label(&self) -> String {
255 let mut label = format!("Artifact: {}, {}", self.title, self.kind.label());
256 match self.source.state() {
257 ProgressState::Pending | ProgressState::Running => label.push_str(", generating"),
258 ProgressState::Failed(reason) => {
259 label.push_str(", failed: ");
260 label.push_str(reason);
261 }
262 ProgressState::Complete => {}
263 }
264 label
265 }
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
270pub enum ArtifactPanelEvent {
271 Closed {
273 id: SharedString,
275 },
276 ViewSelected {
278 id: SharedString,
280 view: ArtifactView,
282 },
283 VersionSelected {
285 id: SharedString,
287 version_id: SharedString,
289 },
290 ActionActivated {
292 id: SharedString,
294 action_id: SharedString,
296 },
297}
298
299#[derive(IntoElement)]
313pub struct ArtifactPanel {
314 id: ElementId,
315 style: StyleRefinement,
316 decoration: Decoration,
317 artifact: Artifact,
318 view: ArtifactView,
319 actions: Vec<ArtifactAction>,
320 on_event: Option<SharedHandler<ArtifactPanelEvent>>,
321}
322
323impl ArtifactPanel {
324 pub fn decoration(mut self, decoration: Decoration) -> Self {
330 self.decoration = decoration;
331 self
332 }
333
334 pub fn new(id: impl Into<ElementId>, artifact: &Artifact) -> Self {
336 Self {
337 id: id.into(),
338 style: StyleRefinement::default(),
339 decoration: Decoration::default(),
340 artifact: artifact.clone(),
341 view: ArtifactView::Preview,
342 actions: Vec::new(),
343 on_event: None,
344 }
345 }
346
347 pub fn view(mut self, view: ArtifactView) -> Self {
350 self.view = view;
351 self
352 }
353
354 pub fn actions(mut self, actions: impl IntoIterator<Item = ArtifactAction>) -> Self {
356 self.actions = actions.into_iter().collect();
357 self
358 }
359
360 pub fn on_event(
362 mut self,
363 handler: impl Fn(&ArtifactPanelEvent, &mut Window, &mut App) + 'static,
364 ) -> Self {
365 self.on_event = Some(Rc::new(handler));
366 self
367 }
368}
369
370impl Styled for ArtifactPanel {
371 fn style(&mut self) -> &mut StyleRefinement {
372 &mut self.style
373 }
374}
375
376impl RenderOnce for ArtifactPanel {
377 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
378 let tokens = cx.theme().semantic_tokens();
379 let mut decoration = std::mem::take(&mut self.decoration);
382 let artifact = self.artifact;
383 let handler = self.on_event;
384 let artifact_id = artifact.id.clone();
385 let debug_id = artifact_id.clone();
388 let root_id = self.id.clone();
389 let label: SharedString = artifact.accessibility_label().into();
390 let generating =
391 artifact.source.is_streaming() || *artifact.source.state() == ProgressState::Pending;
392 let failed = match artifact.source.state() {
393 ProgressState::Failed(reason) => Some(reason.clone()),
394 _ => None,
395 };
396 let view = if artifact.kind.has_preview() {
397 self.view
398 } else {
399 ArtifactView::Source
400 };
401
402 let version_nav = artifact.active_version_index().map(|index| {
404 let count = artifact.versions.len();
405 let nav_label: SharedString = format!("Version {} of {count}", index + 1).into();
406 let prev = (index > 0).then(|| artifact.versions[index - 1].id.clone());
407 let next = (index + 1 < count).then(|| artifact.versions[index + 1].id.clone());
408 let prev_debug = debug_id.clone();
409 let next_debug = debug_id.clone();
410 let nav_debug = debug_id.clone();
411 let prev_handler = handler.clone();
412 let next_handler = handler.clone();
413 let prev_artifact = artifact_id.clone();
414 let next_artifact = artifact_id.clone();
415 h_flex()
416 .id((root_id.clone(), "versions"))
417 .role(Role::Group)
418 .aria_label(nav_label)
419 .debug_selector(move || format!("artifact-versions-{nav_debug}"))
420 .flex_none()
421 .items_center()
422 .gap(tokens.spacing.xxs)
423 .child(
424 icon_button(
425 (root_id.clone(), "version-prev"),
426 IconName::ChevronLeft,
427 "Previous version",
428 window,
429 cx,
430 )
431 .disabled(prev.is_none() || prev_handler.is_none())
432 .debug_selector(move || format!("artifact-version-prev-{prev_debug}"))
433 .on_click(move |_: &ClickEvent, window, cx| {
434 if let (Some(handler), Some(version_id)) = (&prev_handler, &prev) {
435 handler(
436 &ArtifactPanelEvent::VersionSelected {
437 id: prev_artifact.clone(),
438 version_id: version_id.clone(),
439 },
440 window,
441 cx,
442 )
443 }
444 }),
445 )
446 .child(meta(artifact.versions[index].label.clone(), cx))
447 .child(
448 icon_button(
449 (root_id.clone(), "version-next"),
450 IconName::ChevronRight,
451 "Next version",
452 window,
453 cx,
454 )
455 .disabled(next.is_none() || next_handler.is_none())
456 .debug_selector(move || format!("artifact-version-next-{next_debug}"))
457 .on_click(move |_: &ClickEvent, window, cx| {
458 if let (Some(handler), Some(version_id)) = (&next_handler, &next) {
459 handler(
460 &ArtifactPanelEvent::VersionSelected {
461 id: next_artifact.clone(),
462 version_id: version_id.clone(),
463 },
464 window,
465 cx,
466 )
467 }
468 }),
469 )
470 });
471 let close = handler.clone().map(|handler| {
472 let close_debug = debug_id.clone();
473 let close_id = artifact_id.clone();
474 icon_button(
475 (root_id.clone(), "close"),
476 IconName::Close,
477 "Close artifact",
478 window,
479 cx,
480 )
481 .debug_selector(move || format!("artifact-close-{close_debug}"))
482 .on_click(move |_: &ClickEvent, window, cx| {
483 handler(
484 &ArtifactPanelEvent::Closed {
485 id: close_id.clone(),
486 },
487 window,
488 cx,
489 )
490 })
491 });
492 let generating_debug = debug_id.clone();
493 let header = h_flex()
494 .items_center()
495 .gap(tokens.spacing.sm)
496 .px(tokens.spacing.md)
497 .py(tokens.spacing.sm)
498 .border_b_1()
499 .border_color(cx.theme().border)
500 .child(
501 Icon::new(artifact.kind.icon())
502 .xsmall()
503 .text_color(cx.theme().muted_foreground),
504 )
505 .child(
506 div()
507 .flex()
508 .flex_col()
509 .min_w_0()
510 .flex_1()
511 .child(
512 div()
513 .truncate()
514 .text_token(tokens.typography.sm)
515 .font_weight(FontWeight::SEMIBOLD)
516 .text_color(cx.theme().foreground)
517 .child(artifact.title.clone()),
518 )
519 .child(crate::surface::hint(
520 match (&artifact.language, artifact.kind) {
521 (Some(language), ArtifactKind::Code) => {
522 format!("{} · {language}", artifact.kind.label())
523 }
524 _ => artifact.kind.label().to_owned(),
525 },
526 cx,
527 )),
528 )
529 .when(generating, |this| {
530 this.child(
531 div()
532 .debug_selector(move || format!("artifact-generating-{generating_debug}"))
533 .child(
534 StatusBadge::new((root_id.clone(), "generating"), "Generating")
535 .tone(StatusTone::Info)
536 .active(true),
537 ),
538 )
539 })
540 .when_some(failed.clone(), |this, _| {
541 this.child(
542 StatusBadge::new((root_id.clone(), "failed"), "Failed")
543 .tone(StatusTone::Danger),
544 )
545 })
546 .children(version_nav)
547 .children(close);
548
549 let tabs = artifact.kind.has_preview().then(|| {
551 let tabs_debug = debug_id.clone();
552 let tab_artifact = artifact_id.clone();
553 let tab_handler = handler.clone();
554 h_flex().px(tokens.spacing.md).pt(tokens.spacing.sm).child(
555 div()
556 .flex_none()
557 .debug_selector(move || format!("artifact-tabs-{tabs_debug}"))
558 .child(
559 TabBar::new((root_id.clone(), "tabs"))
560 .segmented()
561 .children(["Preview", "Source"])
562 .selected_index(view.index())
563 .on_click(move |index: &usize, window, cx| {
564 if let Some(handler) = &tab_handler {
565 handler(
566 &ArtifactPanelEvent::ViewSelected {
567 id: tab_artifact.clone(),
568 view: ArtifactView::from_index(*index),
569 },
570 window,
571 cx,
572 )
573 }
574 }),
575 ),
576 )
577 });
578
579 let body_id = ElementId::from((root_id.clone(), "body"));
580 let content: AnyElement = match view {
581 ArtifactView::Preview if artifact.kind == ArtifactKind::Markdown => TextView::markdown(
582 (body_id.clone(), "markdown"),
583 artifact.source.text().to_owned(),
584 )
585 .selectable(true)
586 .into_any_element(),
587 _ => {
588 let mut block = CodeBlock::streamed((body_id.clone(), "code"), &artifact.source);
589 if let Some(language) = artifact.language.clone() {
590 block = block.language(language);
591 } else if artifact.kind == ArtifactKind::Markdown {
592 block = block.language("markdown");
593 }
594 block.into_any_element()
595 }
596 };
597 let body_debug = debug_id.clone();
598 let scroll_handle = window
599 .use_keyed_state((root_id.clone(), "scroll"), cx, |_, _| ScrollHandle::new())
600 .read(cx)
601 .clone();
602 let body = div()
603 .debug_selector(move || format!("artifact-body-{body_debug}"))
604 .flex_1()
605 .min_h_0()
606 .w_full()
607 .policy_vertical_scrollbar(&scroll_handle, cx)
608 .child(
609 div()
610 .id(body_id)
611 .size_full()
612 .overflow_y_scroll()
613 .track_scroll(&scroll_handle)
614 .child(
615 div()
616 .p(tokens.spacing.md)
617 .text_token(tokens.typography.sm)
618 .child(content),
619 ),
620 );
621
622 let footer = (!self.actions.is_empty()).then(|| {
623 let mut row = h_flex()
624 .flex_wrap()
625 .items_center()
626 .gap(tokens.spacing.xs)
627 .px(tokens.spacing.md)
628 .py(tokens.spacing.sm)
629 .border_t_1()
630 .border_color(cx.theme().border);
631 for action in &self.actions {
632 let action_debug = action.id.clone();
633 let artifact_debug = artifact_id.clone();
634 let action_id = action.id.clone();
635 let event_artifact = artifact_id.clone();
636 let handler = handler.clone();
637 row = row.child(
638 div()
639 .debug_selector(move || {
640 format!("artifact-action-{artifact_debug}-{action_debug}")
641 })
642 .child(
643 Button::new((root_id.clone(), format!("action-{}", action.id)))
644 .outline()
645 .small()
646 .control_metrics(cx)
647 .accessibility_id(format!("{artifact_id}-{}", action.id))
648 .text_label(action.label.clone())
649 .disabled(handler.is_none())
650 .on_click(move |_: &ClickEvent, window, cx| {
651 if let Some(handler) = &handler {
652 handler(
653 &ArtifactPanelEvent::ActionActivated {
654 id: event_artifact.clone(),
655 action_id: action_id.clone(),
656 },
657 window,
658 cx,
659 )
660 }
661 }),
662 ),
663 );
664 }
665 row
666 });
667
668 v_flex()
669 .id(self.id)
670 .role(Role::Group)
671 .aria_label(label)
672 .when_some(failed, |this, reason| this.aria_description(reason))
673 .debug_selector(move || format!("artifact-{debug_id}"))
674 .size_full()
675 .min_w_0()
676 .min_h_0()
677 .card_frame(cx)
678 .decoration_under(&mut decoration)
679 .overflow_hidden()
680 .child(header)
681 .children(tabs)
682 .child(body)
683 .children(footer)
684 .decoration_over(&mut decoration)
685 .refine_style(&self.style)
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 #[test]
694 fn the_active_version_defaults_to_the_latest() {
695 let artifact = Artifact::new("doc", "Doc", StreamedContent::done("x")).versions([
696 ArtifactVersion::new("v1", "v1"),
697 ArtifactVersion::new("v2", "v2"),
698 ]);
699 assert_eq!(artifact.active_version_index(), Some(1));
700 assert_eq!(
701 artifact.clone().active_version("v1").active_version_index(),
702 Some(0)
703 );
704 assert_eq!(
705 artifact.active_version("missing").active_version_index(),
706 Some(1)
707 );
708 assert_eq!(
709 Artifact::new("doc", "Doc", StreamedContent::done("x")).active_version_index(),
710 None
711 );
712 }
713
714 #[test]
715 fn accessible_names_carry_kind_and_lifecycle() {
716 let running = Artifact::new("doc", "Plan", StreamedContent::running("…".to_owned()))
717 .kind(ArtifactKind::Markdown);
718 assert_eq!(
719 running.accessibility_label(),
720 "Artifact: Plan, Document, generating"
721 );
722 let failed = Artifact::new(
723 "doc",
724 "Plan",
725 StreamedContent::failed(String::new(), "Quota exceeded"),
726 );
727 assert_eq!(
728 failed.accessibility_label(),
729 "Artifact: Plan, Code, failed: Quota exceeded"
730 );
731 }
732}