1//! # Component Preview
2//!
3//! A view for exploring Zed components.
4
5use std::iter::Iterator;
6use std::sync::Arc;
7
8use client::UserStore;
9use component::{ComponentId, ComponentMetadata, components};
10use gpui::{
11 App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
12 uniform_list,
13};
14
15use collections::HashMap;
16
17use gpui::{ListState, ScrollHandle, UniformListScrollHandle};
18use languages::LanguageRegistry;
19use notifications::status_toast::{StatusToast, ToastIcon};
20use project::Project;
21use ui::{Divider, ListItem, ListSubHeader, prelude::*};
22
23use workspace::{AppState, ItemId, SerializableItem};
24use workspace::{Item, Workspace, WorkspaceId, item::ItemEvent};
25
26pub fn init(app_state: Arc<AppState>, cx: &mut App) {
27 let app_state = app_state.clone();
28
29 cx.observe_new(move |workspace: &mut Workspace, _, cx| {
30 let app_state = app_state.clone();
31 let weak_workspace = cx.entity().downgrade();
32
33 workspace.register_action(
34 move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
35 let app_state = app_state.clone();
36
37 let language_registry = app_state.languages.clone();
38 let user_store = app_state.user_store.clone();
39
40 let component_preview = cx.new(|cx| {
41 ComponentPreview::new(
42 weak_workspace.clone(),
43 language_registry,
44 user_store,
45 None,
46 None,
47 cx,
48 )
49 });
50
51 workspace.add_item_to_active_pane(
52 Box::new(component_preview),
53 None,
54 true,
55 window,
56 cx,
57 )
58 },
59 );
60 })
61 .detach();
62}
63
64enum PreviewEntry {
65 AllComponents,
66 Separator,
67 Component(ComponentMetadata),
68 SectionHeader(SharedString),
69}
70
71impl From<ComponentMetadata> for PreviewEntry {
72 fn from(component: ComponentMetadata) -> Self {
73 PreviewEntry::Component(component)
74 }
75}
76
77impl From<SharedString> for PreviewEntry {
78 fn from(section_header: SharedString) -> Self {
79 PreviewEntry::SectionHeader(section_header)
80 }
81}
82
83#[derive(Default, Debug, Clone, PartialEq, Eq)]
84enum PreviewPage {
85 #[default]
86 AllComponents,
87 Component(ComponentId),
88}
89
90struct ComponentPreview {
91 focus_handle: FocusHandle,
92 _view_scroll_handle: ScrollHandle,
93 nav_scroll_handle: UniformListScrollHandle,
94 component_map: HashMap<ComponentId, ComponentMetadata>,
95 active_page: PreviewPage,
96 components: Vec<ComponentMetadata>,
97 component_list: ListState,
98 cursor_index: usize,
99 language_registry: Arc<LanguageRegistry>,
100 workspace: WeakEntity<Workspace>,
101 user_store: Entity<UserStore>,
102}
103
104impl ComponentPreview {
105 pub fn new(
106 workspace: WeakEntity<Workspace>,
107 language_registry: Arc<LanguageRegistry>,
108 user_store: Entity<UserStore>,
109 selected_index: impl Into<Option<usize>>,
110 active_page: Option<PreviewPage>,
111 cx: &mut Context<Self>,
112 ) -> Self {
113 let sorted_components = components().all_sorted();
114 let selected_index = selected_index.into().unwrap_or(0);
115 let active_page = active_page.unwrap_or(PreviewPage::AllComponents);
116
117 let component_list = ListState::new(
118 sorted_components.len(),
119 gpui::ListAlignment::Top,
120 px(1500.0),
121 {
122 let this = cx.entity().downgrade();
123 move |ix, window: &mut Window, cx: &mut App| {
124 this.update(cx, |this, cx| {
125 let component = this.get_component(ix);
126 this.render_preview(&component, window, cx)
127 .into_any_element()
128 })
129 .unwrap()
130 }
131 },
132 );
133
134 let mut component_preview = Self {
135 focus_handle: cx.focus_handle(),
136 _view_scroll_handle: ScrollHandle::new(),
137 nav_scroll_handle: UniformListScrollHandle::new(),
138 language_registry,
139 user_store,
140 workspace,
141 active_page,
142 component_map: components().0,
143 components: sorted_components,
144 component_list,
145 cursor_index: selected_index,
146 };
147
148 if component_preview.cursor_index > 0 {
149 component_preview.scroll_to_preview(component_preview.cursor_index, cx);
150 }
151
152 component_preview.update_component_list(cx);
153
154 component_preview
155 }
156
157 fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
158 self.component_list.scroll_to_reveal_item(ix);
159 self.cursor_index = ix;
160 cx.notify();
161 }
162
163 fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) {
164 self.active_page = page;
165 cx.notify();
166 }
167
168 fn get_component(&self, ix: usize) -> ComponentMetadata {
169 self.components[ix].clone()
170 }
171
172 fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
173 use std::collections::HashMap;
174
175 let mut scope_groups: HashMap<ComponentScope, Vec<ComponentMetadata>> = HashMap::default();
176
177 for component in &self.components {
178 scope_groups
179 .entry(component.scope())
180 .or_insert_with(Vec::new)
181 .push(component.clone());
182 }
183
184 for components in scope_groups.values_mut() {
185 components.sort_by_key(|c| c.name().to_lowercase());
186 }
187
188 let mut entries = Vec::new();
189
190 let known_scopes = [
191 ComponentScope::Layout,
192 ComponentScope::Input,
193 ComponentScope::Editor,
194 ComponentScope::Notification,
195 ComponentScope::Collaboration,
196 ComponentScope::VersionControl,
197 ComponentScope::None,
198 ];
199
200 // Always show all components first
201 entries.push(PreviewEntry::AllComponents);
202 entries.push(PreviewEntry::Separator);
203
204 for scope in known_scopes.iter() {
205 if let Some(components) = scope_groups.remove(scope) {
206 if !components.is_empty() {
207 entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
208 let mut sorted_components = components;
209 sorted_components.sort_by_key(|component| component.sort_name());
210
211 for component in sorted_components {
212 entries.push(PreviewEntry::Component(component));
213 }
214 }
215 }
216 }
217
218 if let Some(components) = scope_groups.get(&ComponentScope::None) {
219 if !components.is_empty() {
220 entries.push(PreviewEntry::Separator);
221 entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
222 let mut sorted_components = components.clone();
223 sorted_components.sort_by_key(|c| c.sort_name());
224
225 for component in sorted_components {
226 entries.push(PreviewEntry::Component(component.clone()));
227 }
228 }
229 }
230
231 entries
232 }
233
234 fn render_sidebar_entry(
235 &self,
236 ix: usize,
237 entry: &PreviewEntry,
238 cx: &Context<Self>,
239 ) -> impl IntoElement + use<> {
240 match entry {
241 PreviewEntry::Component(component_metadata) => {
242 let id = component_metadata.id();
243 let selected = self.active_page == PreviewPage::Component(id.clone());
244 ListItem::new(ix)
245 .child(
246 Label::new(component_metadata.scopeless_name().clone())
247 .color(Color::Default),
248 )
249 .selectable(true)
250 .toggle_state(selected)
251 .inset(true)
252 .on_click(cx.listener(move |this, _, _, cx| {
253 let id = id.clone();
254 this.set_active_page(PreviewPage::Component(id), cx);
255 }))
256 .into_any_element()
257 }
258 PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
259 .inset(true)
260 .into_any_element(),
261 PreviewEntry::AllComponents => {
262 let selected = self.active_page == PreviewPage::AllComponents;
263
264 ListItem::new(ix)
265 .child(Label::new("All Components").color(Color::Default))
266 .selectable(true)
267 .toggle_state(selected)
268 .inset(true)
269 .on_click(cx.listener(move |this, _, _, cx| {
270 this.set_active_page(PreviewPage::AllComponents, cx);
271 }))
272 .into_any_element()
273 }
274 PreviewEntry::Separator => ListItem::new(ix)
275 .child(h_flex().pt_3().child(Divider::horizontal_dashed()))
276 .into_any_element(),
277 }
278 }
279
280 fn update_component_list(&mut self, cx: &mut Context<Self>) {
281 let new_len = self.scope_ordered_entries().len();
282 let entries = self.scope_ordered_entries();
283 let weak_entity = cx.entity().downgrade();
284
285 let new_list = ListState::new(
286 new_len,
287 gpui::ListAlignment::Top,
288 px(1500.0),
289 move |ix, window, cx| {
290 let entry = &entries[ix];
291
292 weak_entity
293 .update(cx, |this, cx| match entry {
294 PreviewEntry::Component(component) => this
295 .render_preview(component, window, cx)
296 .into_any_element(),
297 PreviewEntry::SectionHeader(shared_string) => this
298 .render_scope_header(ix, shared_string.clone(), window, cx)
299 .into_any_element(),
300 PreviewEntry::AllComponents => div().w_full().h_0().into_any_element(),
301 PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
302 })
303 .unwrap()
304 },
305 );
306
307 self.component_list = new_list;
308 }
309
310 fn render_scope_header(
311 &self,
312 _ix: usize,
313 title: SharedString,
314 _window: &Window,
315 _cx: &App,
316 ) -> impl IntoElement {
317 h_flex()
318 .w_full()
319 .h_10()
320 .items_center()
321 .child(Headline::new(title).size(HeadlineSize::XSmall))
322 .child(Divider::horizontal())
323 }
324
325 fn render_preview(
326 &self,
327 component: &ComponentMetadata,
328 window: &mut Window,
329 cx: &mut App,
330 ) -> impl IntoElement {
331 let name = component.scopeless_name();
332 let scope = component.scope();
333
334 let description = component.description();
335
336 v_flex()
337 .py_2()
338 .child(
339 v_flex()
340 .border_1()
341 .border_color(cx.theme().colors().border)
342 .rounded_sm()
343 .w_full()
344 .gap_4()
345 .py_4()
346 .px_6()
347 .flex_none()
348 .child(
349 v_flex()
350 .gap_1()
351 .child(
352 h_flex().gap_1().text_xl().child(div().child(name)).when(
353 !matches!(scope, ComponentScope::None),
354 |this| {
355 this.child(div().opacity(0.5).child(format!("({})", scope)))
356 },
357 ),
358 )
359 .when_some(description, |this, description| {
360 this.child(
361 div()
362 .text_ui_sm(cx)
363 .text_color(cx.theme().colors().text_muted)
364 .max_w(px(600.0))
365 .child(description),
366 )
367 }),
368 )
369 .when_some(component.preview(), |this, preview| {
370 this.children(preview(window, cx))
371 }),
372 )
373 .into_any_element()
374 }
375
376 fn render_all_components(&self) -> impl IntoElement {
377 v_flex()
378 .id("component-list")
379 .px_8()
380 .pt_4()
381 .size_full()
382 .child(
383 list(self.component_list.clone())
384 .flex_grow()
385 .with_sizing_behavior(gpui::ListSizingBehavior::Auto),
386 )
387 }
388
389 fn render_component_page(
390 &mut self,
391 component_id: &ComponentId,
392 _window: &mut Window,
393 _cx: &mut Context<Self>,
394 ) -> impl IntoElement {
395 let component = self.component_map.get(&component_id);
396
397 if let Some(component) = component {
398 v_flex()
399 .id("render-component-page")
400 .size_full()
401 .child(ComponentPreviewPage::new(component.clone()))
402 .into_any_element()
403 } else {
404 v_flex()
405 .size_full()
406 .items_center()
407 .justify_center()
408 .child("Component not found")
409 .into_any_element()
410 }
411 }
412
413 fn test_status_toast(&self, cx: &mut Context<Self>) {
414 if let Some(workspace) = self.workspace.upgrade() {
415 workspace.update(cx, |workspace, cx| {
416 let status_toast =
417 StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
418 this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
419 .action("Open Pull Request", |_, cx| {
420 cx.open_url("https://github.com/")
421 })
422 });
423 workspace.toggle_status_toast(status_toast, cx)
424 });
425 }
426 }
427}
428
429impl Render for ComponentPreview {
430 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
431 let sidebar_entries = self.scope_ordered_entries();
432 let active_page = self.active_page.clone();
433
434 h_flex()
435 .id("component-preview")
436 .key_context("ComponentPreview")
437 .items_start()
438 .overflow_hidden()
439 .size_full()
440 .track_focus(&self.focus_handle)
441 .bg(cx.theme().colors().editor_background)
442 .child(
443 v_flex()
444 .border_r_1()
445 .border_color(cx.theme().colors().border)
446 .h_full()
447 .child(
448 uniform_list(
449 cx.entity().clone(),
450 "component-nav",
451 sidebar_entries.len(),
452 move |this, range, _window, cx| {
453 range
454 .map(|ix| {
455 this.render_sidebar_entry(ix, &sidebar_entries[ix], cx)
456 })
457 .collect()
458 },
459 )
460 .track_scroll(self.nav_scroll_handle.clone())
461 .pt_4()
462 .px_4()
463 .w(px(240.))
464 .h_full()
465 .flex_1(),
466 )
467 .child(
468 div().w_full().pb_4().child(
469 Button::new("toast-test", "Launch Toast")
470 .on_click(cx.listener({
471 move |this, _, _window, cx| {
472 this.test_status_toast(cx);
473 cx.notify();
474 }
475 }))
476 .full_width(),
477 ),
478 ),
479 )
480 .child(match active_page {
481 PreviewPage::AllComponents => self.render_all_components().into_any_element(),
482 PreviewPage::Component(id) => self
483 .render_component_page(&id, window, cx)
484 .into_any_element(),
485 })
486 }
487}
488
489impl EventEmitter<ItemEvent> for ComponentPreview {}
490
491impl Focusable for ComponentPreview {
492 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
493 self.focus_handle.clone()
494 }
495}
496
497impl Item for ComponentPreview {
498 type Event = ItemEvent;
499
500 fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option<SharedString> {
501 Some("Component Preview".into())
502 }
503
504 fn telemetry_event_text(&self) -> Option<&'static str> {
505 None
506 }
507
508 fn show_toolbar(&self) -> bool {
509 false
510 }
511
512 fn clone_on_split(
513 &self,
514 _workspace_id: Option<WorkspaceId>,
515 _window: &mut Window,
516 cx: &mut Context<Self>,
517 ) -> Option<gpui::Entity<Self>>
518 where
519 Self: Sized,
520 {
521 let language_registry = self.language_registry.clone();
522 let user_store = self.user_store.clone();
523 let weak_workspace = self.workspace.clone();
524 let selected_index = self.cursor_index;
525 let active_page = self.active_page.clone();
526
527 Some(cx.new(|cx| {
528 Self::new(
529 weak_workspace,
530 language_registry,
531 user_store,
532 selected_index,
533 Some(active_page),
534 cx,
535 )
536 }))
537 }
538
539 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
540 f(*event)
541 }
542}
543
544impl SerializableItem for ComponentPreview {
545 fn serialized_item_kind() -> &'static str {
546 "ComponentPreview"
547 }
548
549 fn deserialize(
550 project: Entity<Project>,
551 workspace: WeakEntity<Workspace>,
552 _workspace_id: WorkspaceId,
553 _item_id: ItemId,
554 window: &mut Window,
555 cx: &mut App,
556 ) -> Task<gpui::Result<Entity<Self>>> {
557 let user_store = project.read(cx).user_store().clone();
558 let language_registry = project.read(cx).languages().clone();
559
560 window.spawn(cx, async move |cx| {
561 let user_store = user_store.clone();
562 let language_registry = language_registry.clone();
563 let weak_workspace = workspace.clone();
564 cx.update(|_, cx| {
565 Ok(cx.new(|cx| {
566 ComponentPreview::new(
567 weak_workspace,
568 language_registry,
569 user_store,
570 None,
571 None,
572 cx,
573 )
574 }))
575 })?
576 })
577 }
578
579 fn cleanup(
580 _workspace_id: WorkspaceId,
581 _alive_items: Vec<ItemId>,
582 _window: &mut Window,
583 _cx: &mut App,
584 ) -> Task<gpui::Result<()>> {
585 Task::ready(Ok(()))
586 // window.spawn(cx, |_| {
587 // ...
588 // })
589 }
590
591 fn serialize(
592 &mut self,
593 _workspace: &mut Workspace,
594 _item_id: ItemId,
595 _closing: bool,
596 _window: &mut Window,
597 _cx: &mut Context<Self>,
598 ) -> Option<Task<gpui::Result<()>>> {
599 // TODO: Serialize the active index so we can re-open to the same place
600 None
601 }
602
603 fn should_serialize(&self, _event: &Self::Event) -> bool {
604 false
605 }
606}
607
608#[derive(IntoElement)]
609pub struct ComponentPreviewPage {
610 // languages: Arc<LanguageRegistry>,
611 component: ComponentMetadata,
612}
613
614impl ComponentPreviewPage {
615 pub fn new(
616 component: ComponentMetadata,
617 // languages: Arc<LanguageRegistry>
618 ) -> Self {
619 Self {
620 // languages,
621 component,
622 }
623 }
624
625 fn render_header(&self, _: &Window, cx: &App) -> impl IntoElement {
626 v_flex()
627 .px_12()
628 .pt_16()
629 .pb_12()
630 .gap_6()
631 .bg(cx.theme().colors().surface_background)
632 .border_b_1()
633 .border_color(cx.theme().colors().border)
634 .child(
635 v_flex()
636 .gap_0p5()
637 .child(
638 Label::new(self.component.scope().to_string())
639 .size(LabelSize::Small)
640 .color(Color::Muted),
641 )
642 .child(
643 Headline::new(self.component.scopeless_name()).size(HeadlineSize::XLarge),
644 ),
645 )
646 .when_some(self.component.description(), |this, description| {
647 this.child(div().text_sm().child(description))
648 })
649 }
650
651 fn render_preview(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
652 v_flex()
653 .flex_1()
654 .px_12()
655 .py_6()
656 .bg(cx.theme().colors().editor_background)
657 .child(if let Some(preview) = self.component.preview() {
658 preview(window, cx).unwrap_or_else(|| {
659 div()
660 .child("Failed to load preview. This path should be unreachable")
661 .into_any_element()
662 })
663 } else {
664 div().child("No preview available").into_any_element()
665 })
666 }
667}
668
669impl RenderOnce for ComponentPreviewPage {
670 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
671 v_flex()
672 .id("component-preview-page")
673 .overflow_y_scroll()
674 .overflow_x_hidden()
675 .w_full()
676 .child(self.render_header(window, cx))
677 .child(self.render_preview(window, cx))
678 }
679}