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