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