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