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