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