1//! # Component Preview
2//!
3//! A view for exploring Zed components.
4
5mod persistence;
6mod preview_support;
7
8use std::iter::Iterator;
9use std::sync::Arc;
10
11use agent::{ActiveThread, TextThreadStore, ThreadStore};
12use client::UserStore;
13use component::{ComponentId, ComponentMetadata, components};
14use gpui::{
15 App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
16};
17
18use collections::HashMap;
19
20use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
21use languages::LanguageRegistry;
22use notifications::status_toast::{StatusToast, ToastIcon};
23use persistence::COMPONENT_PREVIEW_DB;
24use preview_support::active_thread::{
25 load_preview_text_thread_store, load_preview_thread_store, static_active_thread,
26};
27use project::Project;
28use ui::{Divider, HighlightedLabel, ListItem, ListSubHeader, prelude::*};
29use ui_input::SingleLineInput;
30use util::ResultExt as _;
31use workspace::{AppState, ItemId, SerializableItem, delete_unloaded_items};
32use workspace::{Item, Workspace, WorkspaceId, item::ItemEvent};
33
34pub fn init(app_state: Arc<AppState>, cx: &mut App) {
35 workspace::register_serializable_item::<ComponentPreview>(cx);
36
37 let app_state = app_state.clone();
38
39 cx.observe_new(move |workspace: &mut Workspace, _window, cx| {
40 let app_state = app_state.clone();
41 let project = workspace.project().clone();
42 let weak_workspace = cx.entity().downgrade();
43
44 workspace.register_action(
45 move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
46 let app_state = app_state.clone();
47
48 let language_registry = app_state.languages.clone();
49 let user_store = app_state.user_store.clone();
50
51 let component_preview = cx.new(|cx| {
52 ComponentPreview::new(
53 weak_workspace.clone(),
54 project.clone(),
55 language_registry,
56 user_store,
57 None,
58 None,
59 window,
60 cx,
61 )
62 .expect("Failed to create component preview")
63 });
64
65 workspace.add_item_to_active_pane(
66 Box::new(component_preview),
67 None,
68 true,
69 window,
70 cx,
71 )
72 },
73 );
74 })
75 .detach();
76}
77
78enum PreviewEntry {
79 AllComponents,
80 ActiveThread,
81 Separator,
82 Component(ComponentMetadata, Option<Vec<usize>>),
83 SectionHeader(SharedString),
84}
85
86impl From<ComponentMetadata> for PreviewEntry {
87 fn from(component: ComponentMetadata) -> Self {
88 PreviewEntry::Component(component, None)
89 }
90}
91
92impl From<SharedString> for PreviewEntry {
93 fn from(section_header: SharedString) -> Self {
94 PreviewEntry::SectionHeader(section_header)
95 }
96}
97
98#[derive(Default, Debug, Clone, PartialEq, Eq)]
99enum PreviewPage {
100 #[default]
101 AllComponents,
102 Component(ComponentId),
103 ActiveThread,
104}
105
106struct ComponentPreview {
107 workspace_id: Option<WorkspaceId>,
108 focus_handle: FocusHandle,
109 _view_scroll_handle: ScrollHandle,
110 nav_scroll_handle: UniformListScrollHandle,
111 component_map: HashMap<ComponentId, ComponentMetadata>,
112 active_page: PreviewPage,
113 components: Vec<ComponentMetadata>,
114 component_list: ListState,
115 cursor_index: usize,
116 language_registry: Arc<LanguageRegistry>,
117 workspace: WeakEntity<Workspace>,
118 project: Entity<Project>,
119 user_store: Entity<UserStore>,
120 filter_editor: Entity<SingleLineInput>,
121 filter_text: String,
122
123 // preview support
124 thread_store: Option<Entity<ThreadStore>>,
125 text_thread_store: Option<Entity<TextThreadStore>>,
126 active_thread: Option<Entity<ActiveThread>>,
127}
128
129impl ComponentPreview {
130 pub fn new(
131 workspace: WeakEntity<Workspace>,
132 project: Entity<Project>,
133 language_registry: Arc<LanguageRegistry>,
134 user_store: Entity<UserStore>,
135 selected_index: impl Into<Option<usize>>,
136 active_page: Option<PreviewPage>,
137 window: &mut Window,
138 cx: &mut Context<Self>,
139 ) -> anyhow::Result<Self> {
140 let workspace_clone = workspace.clone();
141 let project_clone = project.clone();
142
143 cx.spawn_in(window, async move |entity, cx| {
144 let thread_store_future =
145 load_preview_thread_store(workspace_clone.clone(), project_clone.clone(), cx);
146 let text_thread_store_future =
147 load_preview_text_thread_store(workspace_clone.clone(), project_clone.clone(), cx);
148
149 let (thread_store_result, text_thread_store_result) =
150 futures::join!(thread_store_future, text_thread_store_future);
151
152 if let (Some(thread_store), Some(text_thread_store)) = (
153 thread_store_result.log_err(),
154 text_thread_store_result.log_err(),
155 ) {
156 entity
157 .update_in(cx, |this, window, cx| {
158 this.thread_store = Some(thread_store.clone());
159 this.text_thread_store = Some(text_thread_store.clone());
160 this.create_active_thread(window, cx);
161 })
162 .ok();
163 }
164 })
165 .detach();
166
167 let sorted_components = components().all_sorted();
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 workspace_id: None,
192 focus_handle: cx.focus_handle(),
193 _view_scroll_handle: ScrollHandle::new(),
194 nav_scroll_handle: UniformListScrollHandle::new(),
195 language_registry,
196 user_store,
197 workspace,
198 project,
199 active_page,
200 component_map: components().0,
201 components: sorted_components,
202 component_list,
203 cursor_index: selected_index,
204 filter_editor,
205 filter_text: String::new(),
206 thread_store: None,
207 text_thread_store: None,
208 active_thread: None,
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 render_sidebar_entry(
416 &self,
417 ix: usize,
418 entry: &PreviewEntry,
419 cx: &Context<Self>,
420 ) -> impl IntoElement + use<> {
421 match entry {
422 PreviewEntry::Component(component_metadata, highlight_positions) => {
423 let id = component_metadata.id();
424 let selected = self.active_page == PreviewPage::Component(id.clone());
425 let name = component_metadata.scopeless_name();
426
427 ListItem::new(ix)
428 .child(if let Some(_positions) = highlight_positions {
429 let name_lower = name.to_lowercase();
430 let filter_lower = self.filter_text.to_lowercase();
431 let valid_positions = if let Some(start) = name_lower.find(&filter_lower) {
432 let end = start + filter_lower.len();
433 (start..end).collect()
434 } else {
435 Vec::new()
436 };
437 if valid_positions.is_empty() {
438 Label::new(name.clone())
439 .color(Color::Default)
440 .into_any_element()
441 } else {
442 HighlightedLabel::new(name.clone(), valid_positions).into_any_element()
443 }
444 } else {
445 Label::new(name.clone())
446 .color(Color::Default)
447 .into_any_element()
448 })
449 .selectable(true)
450 .toggle_state(selected)
451 .inset(true)
452 .on_click(cx.listener(move |this, _, _, cx| {
453 let id = id.clone();
454 this.set_active_page(PreviewPage::Component(id), cx);
455 }))
456 .into_any_element()
457 }
458 PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
459 .inset(true)
460 .into_any_element(),
461 PreviewEntry::AllComponents => {
462 let selected = self.active_page == PreviewPage::AllComponents;
463
464 ListItem::new(ix)
465 .child(Label::new("All Components").color(Color::Default))
466 .selectable(true)
467 .toggle_state(selected)
468 .inset(true)
469 .on_click(cx.listener(move |this, _, _, cx| {
470 this.set_active_page(PreviewPage::AllComponents, cx);
471 }))
472 .into_any_element()
473 }
474 PreviewEntry::ActiveThread => {
475 let selected = self.active_page == PreviewPage::ActiveThread;
476
477 ListItem::new(ix)
478 .child(Label::new("Active Thread").color(Color::Default))
479 .selectable(true)
480 .toggle_state(selected)
481 .inset(true)
482 .on_click(cx.listener(move |this, _, _, cx| {
483 this.set_active_page(PreviewPage::ActiveThread, cx);
484 }))
485 .into_any_element()
486 }
487 PreviewEntry::Separator => ListItem::new(ix)
488 .child(
489 h_flex()
490 .occlude()
491 .pt_3()
492 .child(Divider::horizontal_dashed()),
493 )
494 .into_any_element(),
495 }
496 }
497
498 fn update_component_list(&mut self, cx: &mut Context<Self>) {
499 let entries = self.scope_ordered_entries();
500 let new_len = entries.len();
501 let weak_entity = cx.entity().downgrade();
502
503 if new_len > 0 {
504 self.nav_scroll_handle
505 .scroll_to_item(0, ScrollStrategy::Top);
506 }
507
508 let filtered_components = self.filtered_components();
509
510 if !self.filter_text.is_empty() && !matches!(self.active_page, PreviewPage::AllComponents) {
511 if let PreviewPage::Component(ref component_id) = self.active_page {
512 let component_still_visible = filtered_components
513 .iter()
514 .any(|component| component.id() == *component_id);
515
516 if !component_still_visible {
517 if !filtered_components.is_empty() {
518 let first_component = &filtered_components[0];
519 self.set_active_page(PreviewPage::Component(first_component.id()), cx);
520 } else {
521 self.set_active_page(PreviewPage::AllComponents, cx);
522 }
523 }
524 }
525 }
526
527 self.component_list = ListState::new(
528 filtered_components.len(),
529 gpui::ListAlignment::Top,
530 px(1500.0),
531 {
532 let components = filtered_components.clone();
533 let this = cx.entity().downgrade();
534 move |ix, window: &mut Window, cx: &mut App| {
535 if ix >= components.len() {
536 return div().w_full().h_0().into_any_element();
537 }
538
539 this.update(cx, |this, cx| {
540 let component = &components[ix];
541 this.render_preview(component, window, cx)
542 .into_any_element()
543 })
544 .unwrap()
545 }
546 },
547 );
548
549 let new_list = ListState::new(
550 new_len,
551 gpui::ListAlignment::Top,
552 px(1500.0),
553 move |ix, window, cx| {
554 if ix >= entries.len() {
555 return div().w_full().h_0().into_any_element();
556 }
557
558 let entry = &entries[ix];
559
560 weak_entity
561 .update(cx, |this, cx| match entry {
562 PreviewEntry::Component(component, _) => this
563 .render_preview(component, window, cx)
564 .into_any_element(),
565 PreviewEntry::SectionHeader(shared_string) => this
566 .render_scope_header(ix, shared_string.clone(), window, cx)
567 .into_any_element(),
568 PreviewEntry::AllComponents => div().w_full().h_0().into_any_element(),
569 PreviewEntry::ActiveThread => div().w_full().h_0().into_any_element(),
570 PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
571 })
572 .unwrap()
573 },
574 );
575
576 self.component_list = new_list;
577 cx.emit(ItemEvent::UpdateTab);
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 .size_full()
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.all();
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 fn render_header(&self, _: &Window, cx: &App) -> impl IntoElement {
1069 v_flex()
1070 .px_12()
1071 .pt_16()
1072 .pb_12()
1073 .gap_6()
1074 .bg(cx.theme().colors().surface_background)
1075 .border_b_1()
1076 .border_color(cx.theme().colors().border)
1077 .child(
1078 v_flex()
1079 .gap_0p5()
1080 .child(
1081 Label::new(self.component.scope().to_string())
1082 .size(LabelSize::Small)
1083 .color(Color::Muted),
1084 )
1085 .child(
1086 Headline::new(self.component.scopeless_name()).size(HeadlineSize::XLarge),
1087 ),
1088 )
1089 .when_some(self.component.description(), |this, description| {
1090 this.child(div().text_sm().child(description))
1091 })
1092 }
1093
1094 fn render_preview(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1095 // Try to get agent preview first if we have an active thread
1096 let maybe_agent_preview = if let Some(active_thread) = self.active_thread.as_ref() {
1097 agent::get_agent_preview(
1098 &self.component.id(),
1099 self.workspace.clone(),
1100 active_thread.clone(),
1101 window,
1102 cx,
1103 )
1104 } else {
1105 None
1106 };
1107
1108 v_flex()
1109 .flex_1()
1110 .px_12()
1111 .py_6()
1112 .bg(cx.theme().colors().editor_background)
1113 .child(if let Some(element) = maybe_agent_preview {
1114 // Use agent preview if available
1115 element
1116 } else if let Some(preview) = self.component.preview() {
1117 // Fall back to component preview
1118 preview(window, cx).unwrap_or_else(|| {
1119 div()
1120 .child("Failed to load preview. This path should be unreachable")
1121 .into_any_element()
1122 })
1123 } else {
1124 div().child("No preview available").into_any_element()
1125 })
1126 }
1127}
1128
1129impl RenderOnce for ComponentPreviewPage {
1130 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1131 v_flex()
1132 .id("component-preview-page")
1133 .overflow_y_scroll()
1134 .overflow_x_hidden()
1135 .w_full()
1136 .child(self.render_header(window, cx))
1137 .child(self.render_preview(window, cx))
1138 }
1139}