1//! # Component Preview
2//!
3//! A view for exploring Zed components.
4
5mod persistence;
6mod preview_support;
7
8use std::sync::Arc;
9
10use std::iter::Iterator;
11
12use agent::{ActiveThread, TextThreadStore, ThreadStore};
13use client::UserStore;
14use component::{ComponentId, ComponentMetadata, ComponentStatus, components};
15use gpui::{
16 App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
17};
18
19use collections::HashMap;
20
21use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
22use languages::LanguageRegistry;
23use notifications::status_toast::{StatusToast, ToastIcon};
24use persistence::COMPONENT_PREVIEW_DB;
25use preview_support::active_thread::{
26 load_preview_text_thread_store, load_preview_thread_store, static_active_thread,
27};
28use project::Project;
29use ui::{ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Tooltip, prelude::*};
30use ui_input::SingleLineInput;
31use util::ResultExt as _;
32use workspace::{AppState, ItemId, SerializableItem, delete_unloaded_items};
33use workspace::{Item, Workspace, WorkspaceId, item::ItemEvent};
34
35pub fn init(app_state: Arc<AppState>, cx: &mut App) {
36 workspace::register_serializable_item::<ComponentPreview>(cx);
37
38 let app_state = app_state.clone();
39
40 cx.observe_new(move |workspace: &mut Workspace, _window, cx| {
41 let app_state = app_state.clone();
42 let project = workspace.project().clone();
43 let weak_workspace = cx.entity().downgrade();
44
45 workspace.register_action(
46 move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
47 let app_state = app_state.clone();
48
49 let language_registry = app_state.languages.clone();
50 let user_store = app_state.user_store.clone();
51
52 let component_preview = cx.new(|cx| {
53 ComponentPreview::new(
54 weak_workspace.clone(),
55 project.clone(),
56 language_registry,
57 user_store,
58 None,
59 None,
60 window,
61 cx,
62 )
63 .expect("Failed to create component preview")
64 });
65
66 workspace.add_item_to_active_pane(
67 Box::new(component_preview),
68 None,
69 true,
70 window,
71 cx,
72 )
73 },
74 );
75 })
76 .detach();
77}
78
79enum PreviewEntry {
80 AllComponents,
81 ActiveThread,
82 Separator,
83 Component(ComponentMetadata, Option<Vec<usize>>),
84 SectionHeader(SharedString),
85}
86
87impl From<ComponentMetadata> for PreviewEntry {
88 fn from(component: ComponentMetadata) -> Self {
89 PreviewEntry::Component(component, None)
90 }
91}
92
93impl From<SharedString> for PreviewEntry {
94 fn from(section_header: SharedString) -> Self {
95 PreviewEntry::SectionHeader(section_header)
96 }
97}
98
99#[derive(Default, Debug, Clone, PartialEq, Eq)]
100enum PreviewPage {
101 #[default]
102 AllComponents,
103 Component(ComponentId),
104 ActiveThread,
105}
106
107struct ComponentPreview {
108 active_page: PreviewPage,
109 active_thread: Option<Entity<ActiveThread>>,
110 component_list: ListState,
111 component_map: HashMap<ComponentId, ComponentMetadata>,
112 components: Vec<ComponentMetadata>,
113 cursor_index: usize,
114 filter_editor: Entity<SingleLineInput>,
115 filter_text: String,
116 focus_handle: FocusHandle,
117 language_registry: Arc<LanguageRegistry>,
118 nav_scroll_handle: UniformListScrollHandle,
119 project: Entity<Project>,
120 text_thread_store: Option<Entity<TextThreadStore>>,
121 thread_store: Option<Entity<ThreadStore>>,
122 user_store: Entity<UserStore>,
123 workspace: WeakEntity<Workspace>,
124 workspace_id: Option<WorkspaceId>,
125 _view_scroll_handle: ScrollHandle,
126}
127
128impl ComponentPreview {
129 pub fn new(
130 workspace: WeakEntity<Workspace>,
131 project: Entity<Project>,
132 language_registry: Arc<LanguageRegistry>,
133 user_store: Entity<UserStore>,
134 selected_index: impl Into<Option<usize>>,
135 active_page: Option<PreviewPage>,
136 window: &mut Window,
137 cx: &mut Context<Self>,
138 ) -> anyhow::Result<Self> {
139 let workspace_clone = workspace.clone();
140 let project_clone = project.clone();
141
142 cx.spawn_in(window, async move |entity, cx| {
143 let thread_store_future =
144 load_preview_thread_store(workspace_clone.clone(), project_clone.clone(), cx);
145 let text_thread_store_future =
146 load_preview_text_thread_store(workspace_clone.clone(), project_clone.clone(), cx);
147
148 let (thread_store_result, text_thread_store_result) =
149 futures::join!(thread_store_future, text_thread_store_future);
150
151 if let (Some(thread_store), Some(text_thread_store)) = (
152 thread_store_result.log_err(),
153 text_thread_store_result.log_err(),
154 ) {
155 entity
156 .update_in(cx, |this, window, cx| {
157 this.thread_store = Some(thread_store.clone());
158 this.text_thread_store = Some(text_thread_store.clone());
159 this.create_active_thread(window, cx);
160 })
161 .ok();
162 }
163 })
164 .detach();
165
166 let component_registry = Arc::new(components());
167 let sorted_components = component_registry.sorted_components();
168 let selected_index = selected_index.into().unwrap_or(0);
169 let active_page = active_page.unwrap_or(PreviewPage::AllComponents);
170 let filter_editor =
171 cx.new(|cx| SingleLineInput::new(window, cx, "Find components or usages…"));
172
173 let component_list = ListState::new(
174 sorted_components.len(),
175 gpui::ListAlignment::Top,
176 px(1500.0),
177 {
178 let this = cx.entity().downgrade();
179 move |ix, window: &mut Window, cx: &mut App| {
180 this.update(cx, |this, cx| {
181 let component = this.get_component(ix);
182 this.render_preview(&component, window, cx)
183 .into_any_element()
184 })
185 .unwrap()
186 }
187 },
188 );
189
190 let mut component_preview = Self {
191 active_page,
192 active_thread: None,
193 component_list,
194 component_map: component_registry.component_map(),
195 components: sorted_components,
196 cursor_index: selected_index,
197 filter_editor,
198 filter_text: String::new(),
199 focus_handle: cx.focus_handle(),
200 language_registry,
201 nav_scroll_handle: UniformListScrollHandle::new(),
202 project,
203 text_thread_store: None,
204 thread_store: None,
205 user_store,
206 workspace,
207 workspace_id: None,
208 _view_scroll_handle: ScrollHandle::new(),
209 };
210
211 if component_preview.cursor_index > 0 {
212 component_preview.scroll_to_preview(component_preview.cursor_index, cx);
213 }
214
215 component_preview.update_component_list(cx);
216
217 let focus_handle = component_preview.filter_editor.read(cx).focus_handle(cx);
218 window.focus(&focus_handle);
219
220 Ok(component_preview)
221 }
222
223 pub fn create_active_thread(
224 &mut self,
225 window: &mut Window,
226 cx: &mut Context<Self>,
227 ) -> &mut Self {
228 let workspace = self.workspace.clone();
229 let language_registry = self.language_registry.clone();
230 let weak_handle = self.workspace.clone();
231 if let Some(workspace) = workspace.upgrade() {
232 let project = workspace.read(cx).project().clone();
233 if let Some((thread_store, text_thread_store)) = self
234 .thread_store
235 .clone()
236 .zip(self.text_thread_store.clone())
237 {
238 let active_thread = static_active_thread(
239 weak_handle,
240 project,
241 language_registry,
242 thread_store,
243 text_thread_store,
244 window,
245 cx,
246 );
247 self.active_thread = Some(active_thread);
248 cx.notify();
249 }
250 }
251
252 self
253 }
254
255 pub fn active_page_id(&self, _cx: &App) -> ActivePageId {
256 match &self.active_page {
257 PreviewPage::AllComponents => ActivePageId::default(),
258 PreviewPage::Component(component_id) => ActivePageId(component_id.0.to_string()),
259 PreviewPage::ActiveThread => ActivePageId("active_thread".to_string()),
260 }
261 }
262
263 fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
264 self.component_list.scroll_to_reveal_item(ix);
265 self.cursor_index = ix;
266 cx.notify();
267 }
268
269 fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) {
270 self.active_page = page;
271 cx.emit(ItemEvent::UpdateTab);
272 cx.notify();
273 }
274
275 fn get_component(&self, ix: usize) -> ComponentMetadata {
276 self.components[ix].clone()
277 }
278
279 fn filtered_components(&self) -> Vec<ComponentMetadata> {
280 if self.filter_text.is_empty() {
281 return self.components.clone();
282 }
283
284 let filter = self.filter_text.to_lowercase();
285 self.components
286 .iter()
287 .filter(|component| {
288 let component_name = component.name().to_lowercase();
289 let scope_name = component.scope().to_string().to_lowercase();
290 let description = component
291 .description()
292 .map(|d| d.to_lowercase())
293 .unwrap_or_default();
294
295 component_name.contains(&filter)
296 || scope_name.contains(&filter)
297 || description.contains(&filter)
298 })
299 .cloned()
300 .collect()
301 }
302
303 fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
304 use std::collections::HashMap;
305
306 let mut scope_groups: HashMap<
307 ComponentScope,
308 Vec<(ComponentMetadata, Option<Vec<usize>>)>,
309 > = HashMap::default();
310 let lowercase_filter = self.filter_text.to_lowercase();
311
312 for component in &self.components {
313 if self.filter_text.is_empty() {
314 scope_groups
315 .entry(component.scope())
316 .or_insert_with(Vec::new)
317 .push((component.clone(), None));
318 continue;
319 }
320
321 // let full_component_name = component.name();
322 let scopeless_name = component.scopeless_name();
323 let scope_name = component.scope().to_string();
324 let description = component.description().unwrap_or_default();
325
326 let lowercase_scopeless = scopeless_name.to_lowercase();
327 let lowercase_scope = scope_name.to_lowercase();
328 let lowercase_desc = description.to_lowercase();
329
330 if lowercase_scopeless.contains(&lowercase_filter) {
331 if let Some(index) = lowercase_scopeless.find(&lowercase_filter) {
332 let end = index + lowercase_filter.len();
333
334 if end <= scopeless_name.len() {
335 let mut positions = Vec::new();
336 for i in index..end {
337 if scopeless_name.is_char_boundary(i) {
338 positions.push(i);
339 }
340 }
341
342 if !positions.is_empty() {
343 scope_groups
344 .entry(component.scope())
345 .or_insert_with(Vec::new)
346 .push((component.clone(), Some(positions)));
347 continue;
348 }
349 }
350 }
351 }
352
353 if lowercase_scopeless.contains(&lowercase_filter)
354 || lowercase_scope.contains(&lowercase_filter)
355 || lowercase_desc.contains(&lowercase_filter)
356 {
357 scope_groups
358 .entry(component.scope())
359 .or_insert_with(Vec::new)
360 .push((component.clone(), None));
361 }
362 }
363
364 // Sort the components in each group
365 for components in scope_groups.values_mut() {
366 components.sort_by_key(|(c, _)| c.sort_name());
367 }
368
369 let mut entries = Vec::new();
370
371 // Always show all components first
372 entries.push(PreviewEntry::AllComponents);
373 entries.push(PreviewEntry::ActiveThread);
374 entries.push(PreviewEntry::Separator);
375
376 let mut scopes: Vec<_> = scope_groups
377 .keys()
378 .filter(|scope| !matches!(**scope, ComponentScope::None))
379 .cloned()
380 .collect();
381
382 scopes.sort_by_key(|s| s.to_string());
383
384 for scope in scopes {
385 if let Some(components) = scope_groups.remove(&scope) {
386 if !components.is_empty() {
387 entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
388 let mut sorted_components = components;
389 sorted_components.sort_by_key(|(component, _)| component.sort_name());
390
391 for (component, positions) in sorted_components {
392 entries.push(PreviewEntry::Component(component, positions));
393 }
394 }
395 }
396 }
397
398 // Add uncategorized components last
399 if let Some(components) = scope_groups.get(&ComponentScope::None) {
400 if !components.is_empty() {
401 entries.push(PreviewEntry::Separator);
402 entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
403 let mut sorted_components = components.clone();
404 sorted_components.sort_by_key(|(c, _)| c.sort_name());
405
406 for (component, positions) in sorted_components {
407 entries.push(PreviewEntry::Component(component, positions));
408 }
409 }
410 }
411
412 entries
413 }
414
415 fn update_component_list(&mut self, cx: &mut Context<Self>) {
416 let entries = self.scope_ordered_entries();
417 let new_len = entries.len();
418 let weak_entity = cx.entity().downgrade();
419
420 if new_len > 0 {
421 self.nav_scroll_handle
422 .scroll_to_item(0, ScrollStrategy::Top);
423 }
424
425 let filtered_components = self.filtered_components();
426
427 if !self.filter_text.is_empty() && !matches!(self.active_page, PreviewPage::AllComponents) {
428 if let PreviewPage::Component(ref component_id) = self.active_page {
429 let component_still_visible = filtered_components
430 .iter()
431 .any(|component| component.id() == *component_id);
432
433 if !component_still_visible {
434 if !filtered_components.is_empty() {
435 let first_component = &filtered_components[0];
436 self.set_active_page(PreviewPage::Component(first_component.id()), cx);
437 } else {
438 self.set_active_page(PreviewPage::AllComponents, cx);
439 }
440 }
441 }
442 }
443
444 self.component_list = ListState::new(
445 filtered_components.len(),
446 gpui::ListAlignment::Top,
447 px(1500.0),
448 {
449 let components = filtered_components.clone();
450 let this = cx.entity().downgrade();
451 move |ix, window: &mut Window, cx: &mut App| {
452 if ix >= components.len() {
453 return div().w_full().h_0().into_any_element();
454 }
455
456 this.update(cx, |this, cx| {
457 let component = &components[ix];
458 this.render_preview(component, window, cx)
459 .into_any_element()
460 })
461 .unwrap()
462 }
463 },
464 );
465
466 let new_list = ListState::new(
467 new_len,
468 gpui::ListAlignment::Top,
469 px(1500.0),
470 move |ix, window, cx| {
471 if ix >= entries.len() {
472 return div().w_full().h_0().into_any_element();
473 }
474
475 let entry = &entries[ix];
476
477 weak_entity
478 .update(cx, |this, cx| match entry {
479 PreviewEntry::Component(component, _) => this
480 .render_preview(component, window, cx)
481 .into_any_element(),
482 PreviewEntry::SectionHeader(shared_string) => this
483 .render_scope_header(ix, shared_string.clone(), window, cx)
484 .into_any_element(),
485 PreviewEntry::AllComponents => div().w_full().h_0().into_any_element(),
486 PreviewEntry::ActiveThread => div().w_full().h_0().into_any_element(),
487 PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
488 })
489 .unwrap()
490 },
491 );
492
493 self.component_list = new_list;
494 cx.emit(ItemEvent::UpdateTab);
495 }
496
497 fn render_sidebar_entry(
498 &self,
499 ix: usize,
500 entry: &PreviewEntry,
501 cx: &Context<Self>,
502 ) -> impl IntoElement + use<> {
503 match entry {
504 PreviewEntry::Component(component_metadata, highlight_positions) => {
505 let id = component_metadata.id();
506 let selected = self.active_page == PreviewPage::Component(id.clone());
507 let name = component_metadata.scopeless_name();
508
509 ListItem::new(ix)
510 .child(if let Some(_positions) = highlight_positions {
511 let name_lower = name.to_lowercase();
512 let filter_lower = self.filter_text.to_lowercase();
513 let valid_positions = if let Some(start) = name_lower.find(&filter_lower) {
514 let end = start + filter_lower.len();
515 (start..end).collect()
516 } else {
517 Vec::new()
518 };
519 if valid_positions.is_empty() {
520 Label::new(name.clone())
521 .color(Color::Default)
522 .into_any_element()
523 } else {
524 HighlightedLabel::new(name.clone(), valid_positions).into_any_element()
525 }
526 } else {
527 Label::new(name.clone())
528 .color(Color::Default)
529 .into_any_element()
530 })
531 .selectable(true)
532 .toggle_state(selected)
533 .inset(true)
534 .on_click(cx.listener(move |this, _, _, cx| {
535 let id = id.clone();
536 this.set_active_page(PreviewPage::Component(id), cx);
537 }))
538 .into_any_element()
539 }
540 PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
541 .inset(true)
542 .into_any_element(),
543 PreviewEntry::AllComponents => {
544 let selected = self.active_page == PreviewPage::AllComponents;
545
546 ListItem::new(ix)
547 .child(Label::new("All Components").color(Color::Default))
548 .selectable(true)
549 .toggle_state(selected)
550 .inset(true)
551 .on_click(cx.listener(move |this, _, _, cx| {
552 this.set_active_page(PreviewPage::AllComponents, cx);
553 }))
554 .into_any_element()
555 }
556 PreviewEntry::ActiveThread => {
557 let selected = self.active_page == PreviewPage::ActiveThread;
558
559 ListItem::new(ix)
560 .child(Label::new("Active Thread").color(Color::Default))
561 .selectable(true)
562 .toggle_state(selected)
563 .inset(true)
564 .on_click(cx.listener(move |this, _, _, cx| {
565 this.set_active_page(PreviewPage::ActiveThread, cx);
566 }))
567 .into_any_element()
568 }
569 PreviewEntry::Separator => ListItem::new(ix)
570 .child(
571 h_flex()
572 .occlude()
573 .pt_3()
574 .child(Divider::horizontal_dashed()),
575 )
576 .into_any_element(),
577 }
578 }
579
580 fn render_scope_header(
581 &self,
582 _ix: usize,
583 title: SharedString,
584 _window: &Window,
585 _cx: &App,
586 ) -> impl IntoElement {
587 h_flex()
588 .w_full()
589 .h_10()
590 .items_center()
591 .child(Headline::new(title).size(HeadlineSize::XSmall))
592 .child(Divider::horizontal())
593 }
594
595 fn render_preview(
596 &self,
597 component: &ComponentMetadata,
598 window: &mut Window,
599 cx: &mut App,
600 ) -> impl IntoElement {
601 let name = component.scopeless_name();
602 let scope = component.scope();
603
604 let description = component.description();
605
606 // Build the content container
607 let mut preview_container = v_flex().py_2().child(
608 v_flex()
609 .border_1()
610 .border_color(cx.theme().colors().border)
611 .rounded_sm()
612 .w_full()
613 .gap_4()
614 .py_4()
615 .px_6()
616 .flex_none()
617 .child(
618 v_flex()
619 .gap_1()
620 .child(
621 h_flex()
622 .gap_1()
623 .text_xl()
624 .child(div().child(name))
625 .when(!matches!(scope, ComponentScope::None), |this| {
626 this.child(div().opacity(0.5).child(format!("({})", scope)))
627 }),
628 )
629 .when_some(description, |this, description| {
630 this.child(
631 div()
632 .text_ui_sm(cx)
633 .text_color(cx.theme().colors().text_muted)
634 .max_w(px(600.0))
635 .child(description),
636 )
637 }),
638 ),
639 );
640
641 // Check if the component's scope is Agent
642 if scope == ComponentScope::Agent {
643 if let Some(active_thread) = self.active_thread.clone() {
644 if let Some(element) = agent::get_agent_preview(
645 &component.id(),
646 self.workspace.clone(),
647 active_thread,
648 window,
649 cx,
650 ) {
651 preview_container = preview_container.child(element);
652 } else if let Some(preview) = component.preview() {
653 preview_container = preview_container.children(preview(window, cx));
654 }
655 }
656 } else if let Some(preview) = component.preview() {
657 preview_container = preview_container.children(preview(window, cx));
658 }
659
660 preview_container.into_any_element()
661 }
662
663 fn render_all_components(&self, cx: &Context<Self>) -> impl IntoElement {
664 v_flex()
665 .id("component-list")
666 .px_8()
667 .pt_4()
668 .size_full()
669 .child(
670 if self.filtered_components().is_empty() && !self.filter_text.is_empty() {
671 div()
672 .size_full()
673 .items_center()
674 .justify_center()
675 .text_color(cx.theme().colors().text_muted)
676 .child(format!("No components matching '{}'.", self.filter_text))
677 .into_any_element()
678 } else {
679 list(self.component_list.clone())
680 .flex_grow()
681 .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
682 .into_any_element()
683 },
684 )
685 }
686
687 fn render_component_page(
688 &mut self,
689 component_id: &ComponentId,
690 _window: &mut Window,
691 _cx: &mut Context<Self>,
692 ) -> impl IntoElement {
693 let component = self.component_map.get(&component_id);
694
695 if let Some(component) = component {
696 v_flex()
697 .id("render-component-page")
698 .flex_1()
699 .child(ComponentPreviewPage::new(
700 component.clone(),
701 self.workspace.clone(),
702 self.active_thread.clone(),
703 ))
704 .into_any_element()
705 } else {
706 v_flex()
707 .size_full()
708 .items_center()
709 .justify_center()
710 .child("Component not found")
711 .into_any_element()
712 }
713 }
714
715 fn render_active_thread(&self, cx: &mut Context<Self>) -> impl IntoElement {
716 v_flex()
717 .id("render-active-thread")
718 .size_full()
719 .child(
720 div()
721 .mx_auto()
722 .w(px(640.))
723 .h_full()
724 .py_8()
725 .bg(cx.theme().colors().panel_background)
726 .children(self.active_thread.clone().map(|thread| thread.clone()))
727 .when_none(&self.active_thread.clone(), |this| {
728 this.child("No active thread")
729 }),
730 )
731 .into_any_element()
732 }
733
734 fn test_status_toast(&self, cx: &mut Context<Self>) {
735 if let Some(workspace) = self.workspace.upgrade() {
736 workspace.update(cx, |workspace, cx| {
737 let status_toast =
738 StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
739 this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
740 .action("Open Pull Request", |_, cx| {
741 cx.open_url("https://github.com/")
742 })
743 });
744 workspace.toggle_status_toast(status_toast, cx)
745 });
746 }
747 }
748}
749
750impl Render for ComponentPreview {
751 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
752 // TODO: move this into the struct
753 let current_filter = self.filter_editor.update(cx, |input, cx| {
754 if input.is_empty(cx) {
755 String::new()
756 } else {
757 input.editor().read(cx).text(cx).to_string()
758 }
759 });
760
761 if current_filter != self.filter_text {
762 self.filter_text = current_filter;
763 self.update_component_list(cx);
764 }
765 let sidebar_entries = self.scope_ordered_entries();
766 let active_page = self.active_page.clone();
767
768 h_flex()
769 .id("component-preview")
770 .key_context("ComponentPreview")
771 .items_start()
772 .overflow_hidden()
773 .size_full()
774 .track_focus(&self.focus_handle)
775 .bg(cx.theme().colors().editor_background)
776 .child(
777 v_flex()
778 .border_r_1()
779 .border_color(cx.theme().colors().border)
780 .h_full()
781 .child(
782 gpui::uniform_list(
783 cx.entity().clone(),
784 "component-nav",
785 sidebar_entries.len(),
786 move |this, range, _window, cx| {
787 range
788 .filter_map(|ix| {
789 if ix < sidebar_entries.len() {
790 Some(this.render_sidebar_entry(
791 ix,
792 &sidebar_entries[ix],
793 cx,
794 ))
795 } else {
796 None
797 }
798 })
799 .collect()
800 },
801 )
802 .track_scroll(self.nav_scroll_handle.clone())
803 .pt_4()
804 .px_4()
805 .w(px(240.))
806 .h_full()
807 .flex_1(),
808 )
809 .child(
810 div().w_full().pb_4().child(
811 Button::new("toast-test", "Launch Toast")
812 .on_click(cx.listener({
813 move |this, _, _window, cx| {
814 this.test_status_toast(cx);
815 cx.notify();
816 }
817 }))
818 .full_width(),
819 ),
820 ),
821 )
822 .child(
823 v_flex()
824 .id("content-area")
825 .flex_1()
826 .size_full()
827 .overflow_hidden()
828 .child(
829 div()
830 .p_2()
831 .w_full()
832 .border_b_1()
833 .border_color(cx.theme().colors().border)
834 .child(self.filter_editor.clone()),
835 )
836 .child(match active_page {
837 PreviewPage::AllComponents => {
838 self.render_all_components(cx).into_any_element()
839 }
840 PreviewPage::Component(id) => self
841 .render_component_page(&id, window, cx)
842 .into_any_element(),
843 PreviewPage::ActiveThread => {
844 self.render_active_thread(cx).into_any_element()
845 }
846 }),
847 )
848 }
849}
850
851impl EventEmitter<ItemEvent> for ComponentPreview {}
852
853impl Focusable for ComponentPreview {
854 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
855 self.focus_handle.clone()
856 }
857}
858
859#[derive(Debug, Clone, PartialEq, Eq, Hash)]
860pub struct ActivePageId(pub String);
861
862impl Default for ActivePageId {
863 fn default() -> Self {
864 ActivePageId("AllComponents".to_string())
865 }
866}
867
868impl From<ComponentId> for ActivePageId {
869 fn from(id: ComponentId) -> Self {
870 Self(id.0.to_string())
871 }
872}
873
874impl Item for ComponentPreview {
875 type Event = ItemEvent;
876
877 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
878 "Component Preview".into()
879 }
880
881 fn telemetry_event_text(&self) -> Option<&'static str> {
882 None
883 }
884
885 fn show_toolbar(&self) -> bool {
886 false
887 }
888
889 fn clone_on_split(
890 &self,
891 _workspace_id: Option<WorkspaceId>,
892 window: &mut Window,
893 cx: &mut Context<Self>,
894 ) -> Option<gpui::Entity<Self>>
895 where
896 Self: Sized,
897 {
898 let language_registry = self.language_registry.clone();
899 let user_store = self.user_store.clone();
900 let weak_workspace = self.workspace.clone();
901 let project = self.project.clone();
902 let selected_index = self.cursor_index;
903 let active_page = self.active_page.clone();
904
905 let self_result = Self::new(
906 weak_workspace,
907 project,
908 language_registry,
909 user_store,
910 selected_index,
911 Some(active_page),
912 window,
913 cx,
914 );
915
916 match self_result {
917 Ok(preview) => Some(cx.new(|_cx| preview)),
918 Err(e) => {
919 log::error!("Failed to clone component preview: {}", e);
920 None
921 }
922 }
923 }
924
925 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
926 f(*event)
927 }
928
929 fn added_to_workspace(
930 &mut self,
931 workspace: &mut Workspace,
932 window: &mut Window,
933 cx: &mut Context<Self>,
934 ) {
935 self.workspace_id = workspace.database_id();
936
937 let focus_handle = self.filter_editor.read(cx).focus_handle(cx);
938 window.focus(&focus_handle);
939 }
940}
941
942impl SerializableItem for ComponentPreview {
943 fn serialized_item_kind() -> &'static str {
944 "ComponentPreview"
945 }
946
947 fn deserialize(
948 project: Entity<Project>,
949 workspace: WeakEntity<Workspace>,
950 workspace_id: WorkspaceId,
951 item_id: ItemId,
952 window: &mut Window,
953 cx: &mut App,
954 ) -> Task<gpui::Result<Entity<Self>>> {
955 let deserialized_active_page =
956 match COMPONENT_PREVIEW_DB.get_active_page(item_id, workspace_id) {
957 Ok(page) => {
958 if let Some(page) = page {
959 ActivePageId(page)
960 } else {
961 ActivePageId::default()
962 }
963 }
964 Err(_) => ActivePageId::default(),
965 };
966
967 let user_store = project.read(cx).user_store().clone();
968 let language_registry = project.read(cx).languages().clone();
969 let preview_page = if deserialized_active_page.0 == ActivePageId::default().0 {
970 Some(PreviewPage::default())
971 } else {
972 let component_str = deserialized_active_page.0;
973 let component_registry = components();
974 let all_components = component_registry.components();
975 let found_component = all_components.iter().find(|c| c.id().0 == component_str);
976
977 if let Some(component) = found_component {
978 Some(PreviewPage::Component(component.id().clone()))
979 } else {
980 Some(PreviewPage::default())
981 }
982 };
983
984 window.spawn(cx, async move |cx| {
985 let user_store = user_store.clone();
986 let language_registry = language_registry.clone();
987 let weak_workspace = workspace.clone();
988 let project = project.clone();
989 cx.update(move |window, cx| {
990 Ok(cx.new(|cx| {
991 ComponentPreview::new(
992 weak_workspace,
993 project,
994 language_registry,
995 user_store,
996 None,
997 preview_page,
998 window,
999 cx,
1000 )
1001 .expect("Failed to create component preview")
1002 }))
1003 })?
1004 })
1005 }
1006
1007 fn cleanup(
1008 workspace_id: WorkspaceId,
1009 alive_items: Vec<ItemId>,
1010 _window: &mut Window,
1011 cx: &mut App,
1012 ) -> Task<gpui::Result<()>> {
1013 delete_unloaded_items(
1014 alive_items,
1015 workspace_id,
1016 "component_previews",
1017 &COMPONENT_PREVIEW_DB,
1018 cx,
1019 )
1020 }
1021
1022 fn serialize(
1023 &mut self,
1024 _workspace: &mut Workspace,
1025 item_id: ItemId,
1026 _closing: bool,
1027 _window: &mut Window,
1028 cx: &mut Context<Self>,
1029 ) -> Option<Task<gpui::Result<()>>> {
1030 let active_page = self.active_page_id(cx);
1031 let workspace_id = self.workspace_id?;
1032 Some(cx.background_spawn(async move {
1033 COMPONENT_PREVIEW_DB
1034 .save_active_page(item_id, workspace_id, active_page.0)
1035 .await
1036 }))
1037 }
1038
1039 fn should_serialize(&self, event: &Self::Event) -> bool {
1040 matches!(event, ItemEvent::UpdateTab)
1041 }
1042}
1043
1044// TODO: use language registry to allow rendering markdown
1045#[derive(IntoElement)]
1046pub struct ComponentPreviewPage {
1047 // languages: Arc<LanguageRegistry>,
1048 component: ComponentMetadata,
1049 workspace: WeakEntity<Workspace>,
1050 active_thread: Option<Entity<ActiveThread>>,
1051}
1052
1053impl ComponentPreviewPage {
1054 pub fn new(
1055 component: ComponentMetadata,
1056 workspace: WeakEntity<Workspace>,
1057 active_thread: Option<Entity<ActiveThread>>,
1058 // languages: Arc<LanguageRegistry>
1059 ) -> Self {
1060 Self {
1061 // languages,
1062 component,
1063 workspace,
1064 active_thread,
1065 }
1066 }
1067
1068 /// Renders the component status when it would be useful
1069 ///
1070 /// Doesn't render if the component is `ComponentStatus::Live`
1071 /// as that is the default state
1072 fn render_component_status(&self, cx: &App) -> Option<impl IntoElement> {
1073 let status = self.component.status();
1074 let status_description = status.description().to_string();
1075
1076 let color = match status {
1077 ComponentStatus::Deprecated => Color::Error,
1078 ComponentStatus::EngineeringReady => Color::Info,
1079 ComponentStatus::Live => Color::Success,
1080 ComponentStatus::WorkInProgress => Color::Warning,
1081 };
1082
1083 if status != ComponentStatus::Live {
1084 Some(
1085 ButtonLike::new("component_status")
1086 .child(
1087 div()
1088 .px_1p5()
1089 .rounded_sm()
1090 .bg(color.color(cx).alpha(0.12))
1091 .child(
1092 Label::new(status.clone().to_string())
1093 .size(LabelSize::Small)
1094 .color(color),
1095 ),
1096 )
1097 .tooltip(Tooltip::text(status_description))
1098 .disabled(true),
1099 )
1100 } else {
1101 None
1102 }
1103 }
1104
1105 fn render_header(&self, _: &Window, cx: &App) -> impl IntoElement {
1106 v_flex()
1107 .px_12()
1108 .pt_16()
1109 .pb_12()
1110 .gap_6()
1111 .bg(cx.theme().colors().surface_background)
1112 .border_b_1()
1113 .border_color(cx.theme().colors().border)
1114 .child(
1115 v_flex()
1116 .gap_0p5()
1117 .child(
1118 Label::new(self.component.scope().to_string())
1119 .size(LabelSize::Small)
1120 .color(Color::Muted),
1121 )
1122 .child(
1123 h_flex()
1124 .items_center()
1125 .gap_2()
1126 .child(
1127 Headline::new(self.component.scopeless_name())
1128 .size(HeadlineSize::XLarge),
1129 )
1130 .children(self.render_component_status(cx)),
1131 ),
1132 )
1133 .when_some(self.component.description(), |this, description| {
1134 this.child(div().text_sm().child(description))
1135 })
1136 }
1137
1138 fn render_preview(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1139 // Try to get agent preview first if we have an active thread
1140 let maybe_agent_preview = if let Some(active_thread) = self.active_thread.as_ref() {
1141 agent::get_agent_preview(
1142 &self.component.id(),
1143 self.workspace.clone(),
1144 active_thread.clone(),
1145 window,
1146 cx,
1147 )
1148 } else {
1149 None
1150 };
1151
1152 v_flex()
1153 .flex_1()
1154 .px_12()
1155 .py_6()
1156 .bg(cx.theme().colors().editor_background)
1157 .child(if let Some(element) = maybe_agent_preview {
1158 // Use agent preview if available
1159 element
1160 } else if let Some(preview) = self.component.preview() {
1161 // Fall back to component preview
1162 preview(window, cx).unwrap_or_else(|| {
1163 div()
1164 .child("Failed to load preview. This path should be unreachable")
1165 .into_any_element()
1166 })
1167 } else {
1168 div().child("No preview available").into_any_element()
1169 })
1170 }
1171}
1172
1173impl RenderOnce for ComponentPreviewPage {
1174 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1175 v_flex()
1176 .id("component-preview-page")
1177 .overflow_y_scroll()
1178 .overflow_x_hidden()
1179 .w_full()
1180 .child(self.render_header(window, cx))
1181 .child(self.render_preview(window, cx))
1182 }
1183}