component_preview.rs

   1mod persistence;
   2
   3use client::UserStore;
   4use collections::HashMap;
   5use component::{ComponentId, ComponentMetadata, ComponentStatus, components};
   6use gpui::{
   7    App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
   8};
   9use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
  10use language::LanguageRegistry;
  11use notifications::status_toast::StatusToast;
  12use persistence::ComponentPreviewDb;
  13use project::Project;
  14use std::{iter::Iterator, ops::Range, sync::Arc};
  15use ui::{ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Tooltip, prelude::*};
  16use ui_input::InputField;
  17use workspace::AppState;
  18use workspace::{
  19    Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items, item::ItemEvent,
  20};
  21
  22pub fn init(app_state: Arc<AppState>, cx: &mut App) {
  23    workspace::register_serializable_item::<ComponentPreview>(cx);
  24
  25    cx.observe_new(move |workspace: &mut Workspace, _window, cx| {
  26        let app_state = app_state.clone();
  27        let project = workspace.project().clone();
  28        let weak_workspace = cx.entity().downgrade();
  29
  30        workspace.register_action(
  31            move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
  32                let app_state = app_state.clone();
  33
  34                let language_registry = app_state.languages.clone();
  35                let user_store = app_state.user_store.clone();
  36
  37                let component_preview = cx.new(|cx| {
  38                    ComponentPreview::new(
  39                        weak_workspace.clone(),
  40                        project.clone(),
  41                        language_registry,
  42                        user_store,
  43                        None,
  44                        None,
  45                        window,
  46                        cx,
  47                    )
  48                    .expect("Failed to create component preview")
  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, Option<Vec<usize>>),
  68    SectionHeader(SharedString),
  69}
  70
  71impl From<ComponentMetadata> for PreviewEntry {
  72    fn from(component: ComponentMetadata) -> Self {
  73        PreviewEntry::Component(component, None)
  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)]
  84pub enum PreviewPage {
  85    #[default]
  86    AllComponents,
  87    Component(ComponentId),
  88}
  89
  90pub struct ComponentPreview {
  91    active_page: PreviewPage,
  92    reset_key: usize,
  93    component_list: ListState,
  94    entries: Vec<PreviewEntry>,
  95    component_map: HashMap<ComponentId, ComponentMetadata>,
  96    components: Vec<ComponentMetadata>,
  97    cursor_index: usize,
  98    filter_editor: Entity<InputField>,
  99    filter_text: String,
 100    focus_handle: FocusHandle,
 101    language_registry: Arc<LanguageRegistry>,
 102    nav_scroll_handle: UniformListScrollHandle,
 103    project: Entity<Project>,
 104    user_store: Entity<UserStore>,
 105    workspace: WeakEntity<Workspace>,
 106    workspace_id: Option<WorkspaceId>,
 107    _view_scroll_handle: ScrollHandle,
 108}
 109
 110impl ComponentPreview {
 111    pub fn new(
 112        workspace: WeakEntity<Workspace>,
 113        project: Entity<Project>,
 114        language_registry: Arc<LanguageRegistry>,
 115        user_store: Entity<UserStore>,
 116        selected_index: impl Into<Option<usize>>,
 117        active_page: Option<PreviewPage>,
 118        window: &mut Window,
 119        cx: &mut Context<Self>,
 120    ) -> anyhow::Result<Self> {
 121        let component_registry = Arc::new(components());
 122        let sorted_components = component_registry.sorted_components();
 123        let selected_index = selected_index.into().unwrap_or(0);
 124        let active_page = active_page.unwrap_or(PreviewPage::AllComponents);
 125        let filter_editor = cx.new(|cx| InputField::new(window, cx, "Find components or usages…"));
 126
 127        let component_list = ListState::new(
 128            sorted_components.len(),
 129            gpui::ListAlignment::Top,
 130            px(1500.0),
 131        );
 132
 133        let mut component_preview = Self {
 134            active_page,
 135            reset_key: 0,
 136            component_list,
 137            entries: Vec::new(),
 138            component_map: component_registry.component_map(),
 139            components: sorted_components,
 140            cursor_index: selected_index,
 141            filter_editor,
 142            filter_text: String::new(),
 143            focus_handle: cx.focus_handle(),
 144            language_registry,
 145            nav_scroll_handle: UniformListScrollHandle::new(),
 146            project,
 147            user_store,
 148            workspace,
 149            workspace_id: None,
 150            _view_scroll_handle: ScrollHandle::new(),
 151        };
 152
 153        if component_preview.cursor_index > 0 {
 154            component_preview.scroll_to_preview(component_preview.cursor_index, cx);
 155        }
 156
 157        component_preview.update_component_list(cx);
 158
 159        let focus_handle = component_preview.filter_editor.read(cx).focus_handle(cx);
 160        window.focus(&focus_handle, cx);
 161
 162        Ok(component_preview)
 163    }
 164
 165    pub fn active_page_id(&self, _cx: &App) -> ActivePageId {
 166        match &self.active_page {
 167            PreviewPage::AllComponents => ActivePageId::default(),
 168            PreviewPage::Component(component_id) => ActivePageId(component_id.0.to_string()),
 169        }
 170    }
 171
 172    fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
 173        self.component_list.scroll_to_reveal_item(ix);
 174        self.cursor_index = ix;
 175        cx.notify();
 176    }
 177
 178    fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) {
 179        if self.active_page == page {
 180            // Force the current preview page to render again
 181            self.reset_key = self.reset_key.wrapping_add(1);
 182        } else {
 183            self.active_page = page;
 184            cx.emit(ItemEvent::UpdateTab);
 185        }
 186        cx.notify();
 187    }
 188
 189    fn filtered_components(&self) -> Vec<ComponentMetadata> {
 190        if self.filter_text.is_empty() {
 191            return self.components.clone();
 192        }
 193
 194        let filter = self.filter_text.to_lowercase();
 195        self.components
 196            .iter()
 197            .filter(|component| {
 198                let component_name = component.name().to_lowercase();
 199                let scope_name = component.scope().to_string().to_lowercase();
 200                let description = component
 201                    .description()
 202                    .map(|d| d.to_lowercase())
 203                    .unwrap_or_default();
 204
 205                component_name.contains(&filter)
 206                    || scope_name.contains(&filter)
 207                    || description.contains(&filter)
 208            })
 209            .cloned()
 210            .collect()
 211    }
 212
 213    fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
 214        use collections::HashMap;
 215
 216        let mut scope_groups: HashMap<
 217            ComponentScope,
 218            Vec<(ComponentMetadata, Option<Vec<usize>>)>,
 219        > = HashMap::default();
 220        let lowercase_filter = self.filter_text.to_lowercase();
 221
 222        for component in &self.components {
 223            if self.filter_text.is_empty() {
 224                scope_groups
 225                    .entry(component.scope())
 226                    .or_insert_with(Vec::new)
 227                    .push((component.clone(), None));
 228                continue;
 229            }
 230
 231            // let full_component_name = component.name();
 232            let scopeless_name = component.scopeless_name();
 233            let scope_name = component.scope().to_string();
 234            let description = component.description().unwrap_or_default();
 235
 236            let lowercase_scopeless = scopeless_name.to_lowercase();
 237            let lowercase_scope = scope_name.to_lowercase();
 238            let lowercase_desc = description.to_lowercase();
 239
 240            if lowercase_scopeless.contains(&lowercase_filter)
 241                && let Some(index) = lowercase_scopeless.find(&lowercase_filter)
 242            {
 243                let end = index + lowercase_filter.len();
 244
 245                if end <= scopeless_name.len() {
 246                    let mut positions = Vec::new();
 247                    for i in index..end {
 248                        if scopeless_name.is_char_boundary(i) {
 249                            positions.push(i);
 250                        }
 251                    }
 252
 253                    if !positions.is_empty() {
 254                        scope_groups
 255                            .entry(component.scope())
 256                            .or_insert_with(Vec::new)
 257                            .push((component.clone(), Some(positions)));
 258                        continue;
 259                    }
 260                }
 261            }
 262
 263            if lowercase_scopeless.contains(&lowercase_filter)
 264                || lowercase_scope.contains(&lowercase_filter)
 265                || lowercase_desc.contains(&lowercase_filter)
 266            {
 267                scope_groups
 268                    .entry(component.scope())
 269                    .or_insert_with(Vec::new)
 270                    .push((component.clone(), None));
 271            }
 272        }
 273
 274        // Sort the components in each group
 275        for components in scope_groups.values_mut() {
 276            components.sort_by_key(|(c, _)| c.sort_name());
 277        }
 278
 279        let mut entries = Vec::new();
 280
 281        // Always show all components first
 282        entries.push(PreviewEntry::AllComponents);
 283
 284        let mut scopes: Vec<_> = scope_groups
 285            .keys()
 286            .filter(|scope| !matches!(**scope, ComponentScope::None))
 287            .cloned()
 288            .collect();
 289
 290        scopes.sort_by_key(|s| s.to_string());
 291
 292        for scope in scopes {
 293            if let Some(components) = scope_groups.remove(&scope)
 294                && !components.is_empty()
 295            {
 296                entries.push(PreviewEntry::Separator);
 297                entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
 298
 299                let mut sorted_components = components;
 300                sorted_components.sort_by_key(|(component, _)| component.sort_name());
 301
 302                for (component, positions) in sorted_components {
 303                    entries.push(PreviewEntry::Component(component, positions));
 304                }
 305            }
 306        }
 307
 308        // Add uncategorized components last
 309        if let Some(components) = scope_groups.get(&ComponentScope::None)
 310            && !components.is_empty()
 311        {
 312            entries.push(PreviewEntry::Separator);
 313            entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
 314            let mut sorted_components = components.clone();
 315            sorted_components.sort_by_key(|(c, _)| c.sort_name());
 316
 317            for (component, positions) in sorted_components {
 318                entries.push(PreviewEntry::Component(component, positions));
 319            }
 320        }
 321
 322        entries
 323    }
 324
 325    fn update_component_list(&mut self, cx: &mut Context<Self>) {
 326        let entries = self.scope_ordered_entries();
 327        let new_len = entries.len();
 328
 329        if new_len > 0 {
 330            self.nav_scroll_handle
 331                .scroll_to_item(0, ScrollStrategy::Top);
 332        }
 333
 334        let filtered_components = self.filtered_components();
 335
 336        if !self.filter_text.is_empty()
 337            && !matches!(self.active_page, PreviewPage::AllComponents)
 338            && let PreviewPage::Component(ref component_id) = self.active_page
 339        {
 340            let component_still_visible = filtered_components
 341                .iter()
 342                .any(|component| component.id() == *component_id);
 343
 344            if !component_still_visible {
 345                if !filtered_components.is_empty() {
 346                    let first_component = &filtered_components[0];
 347                    self.set_active_page(PreviewPage::Component(first_component.id()), cx);
 348                } else {
 349                    self.set_active_page(PreviewPage::AllComponents, cx);
 350                }
 351            }
 352        }
 353
 354        self.component_list = ListState::new(new_len, gpui::ListAlignment::Top, px(1500.0));
 355        self.entries = entries;
 356
 357        cx.emit(ItemEvent::UpdateTab);
 358    }
 359
 360    fn render_sidebar_entry(
 361        &self,
 362        ix: usize,
 363        entry: &PreviewEntry,
 364        cx: &Context<Self>,
 365    ) -> impl IntoElement + use<> {
 366        match entry {
 367            PreviewEntry::Component(component_metadata, highlight_positions) => {
 368                let id = component_metadata.id();
 369                let selected = self.active_page == PreviewPage::Component(id.clone());
 370                let name = component_metadata.scopeless_name();
 371
 372                ListItem::new(ix)
 373                    .child(if let Some(_positions) = highlight_positions {
 374                        let name_lower = name.to_lowercase();
 375                        let filter_lower = self.filter_text.to_lowercase();
 376                        let valid_positions = if let Some(start) = name_lower.find(&filter_lower) {
 377                            let end = start + filter_lower.len();
 378                            (start..end).collect()
 379                        } else {
 380                            Vec::new()
 381                        };
 382                        if valid_positions.is_empty() {
 383                            Label::new(name).into_any_element()
 384                        } else {
 385                            HighlightedLabel::new(name, valid_positions).into_any_element()
 386                        }
 387                    } else {
 388                        Label::new(name).into_any_element()
 389                    })
 390                    .selectable(true)
 391                    .toggle_state(selected)
 392                    .inset(true)
 393                    .on_click(cx.listener(move |this, _, _, cx| {
 394                        let id = id.clone();
 395                        this.set_active_page(PreviewPage::Component(id), cx);
 396                    }))
 397                    .into_any_element()
 398            }
 399            PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
 400                .inset(true)
 401                .into_any_element(),
 402            PreviewEntry::AllComponents => {
 403                let selected = self.active_page == PreviewPage::AllComponents;
 404
 405                ListItem::new(ix)
 406                    .child(Label::new("All Components"))
 407                    .selectable(true)
 408                    .toggle_state(selected)
 409                    .inset(true)
 410                    .on_click(cx.listener(move |this, _, _, cx| {
 411                        this.set_active_page(PreviewPage::AllComponents, cx);
 412                    }))
 413                    .into_any_element()
 414            }
 415            PreviewEntry::Separator => ListItem::new(ix)
 416                .disabled(true)
 417                .child(div().w_full().py_2().child(Divider::horizontal()))
 418                .into_any_element(),
 419        }
 420    }
 421
 422    fn render_scope_header(
 423        &self,
 424        _ix: usize,
 425        title: SharedString,
 426        _window: &Window,
 427        _cx: &App,
 428    ) -> impl IntoElement {
 429        h_flex()
 430            .w_full()
 431            .h_10()
 432            .child(Headline::new(title).size(HeadlineSize::XSmall))
 433            .child(Divider::horizontal())
 434    }
 435
 436    fn render_preview(
 437        &self,
 438        component: &ComponentMetadata,
 439        window: &mut Window,
 440        cx: &mut App,
 441    ) -> impl IntoElement {
 442        let name = component.scopeless_name();
 443        let scope = component.scope();
 444
 445        let description = component.description();
 446
 447        // Build the content container
 448        let mut preview_container = v_flex().py_2().child(
 449            v_flex()
 450                .border_1()
 451                .border_color(cx.theme().colors().border)
 452                .rounded_sm()
 453                .w_full()
 454                .gap_4()
 455                .py_4()
 456                .px_6()
 457                .flex_none()
 458                .child(
 459                    v_flex()
 460                        .gap_1()
 461                        .child(
 462                            h_flex()
 463                                .gap_1()
 464                                .text_xl()
 465                                .child(div().child(name))
 466                                .when(!matches!(scope, ComponentScope::None), |this| {
 467                                    this.child(div().opacity(0.5).child(format!("({})", scope)))
 468                                }),
 469                        )
 470                        .when_some(description, |this, description| {
 471                            this.child(
 472                                div()
 473                                    .text_ui_sm(cx)
 474                                    .text_color(cx.theme().colors().text_muted)
 475                                    .max_w(px(600.0))
 476                                    .child(description),
 477                            )
 478                        }),
 479                ),
 480        );
 481
 482        if let Some(preview) = component.preview() {
 483            preview_container = preview_container.children(preview(window, cx));
 484        }
 485
 486        preview_container.into_any_element()
 487    }
 488
 489    fn render_all_components(&self, cx: &Context<Self>) -> impl IntoElement {
 490        v_flex()
 491            .id("component-list")
 492            .px_8()
 493            .pt_4()
 494            .size_full()
 495            .child(
 496                if self.filtered_components().is_empty() && !self.filter_text.is_empty() {
 497                    div()
 498                        .size_full()
 499                        .items_center()
 500                        .justify_center()
 501                        .text_color(cx.theme().colors().text_muted)
 502                        .child(format!("No components matching '{}'.", self.filter_text))
 503                        .into_any_element()
 504                } else {
 505                    list(
 506                        self.component_list.clone(),
 507                        cx.processor(|this, ix, window, cx| {
 508                            if ix >= this.entries.len() {
 509                                return div().w_full().h_0().into_any_element();
 510                            }
 511
 512                            let entry = &this.entries[ix];
 513
 514                            match entry {
 515                                PreviewEntry::Component(component, _) => this
 516                                    .render_preview(component, window, cx)
 517                                    .into_any_element(),
 518                                PreviewEntry::SectionHeader(shared_string) => this
 519                                    .render_scope_header(ix, shared_string.clone(), window, cx)
 520                                    .into_any_element(),
 521                                PreviewEntry::AllComponents => {
 522                                    div().w_full().h_0().into_any_element()
 523                                }
 524                                PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
 525                            }
 526                        }),
 527                    )
 528                    .flex_grow()
 529                    .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
 530                    .into_any_element()
 531                },
 532            )
 533    }
 534
 535    fn render_component_page(
 536        &mut self,
 537        component_id: &ComponentId,
 538        _window: &mut Window,
 539        _cx: &mut Context<Self>,
 540    ) -> impl IntoElement {
 541        let component = self.component_map.get(component_id);
 542
 543        if let Some(component) = component {
 544            v_flex()
 545                .id("render-component-page")
 546                .flex_1()
 547                .child(ComponentPreviewPage::new(component.clone(), self.reset_key))
 548                .into_any_element()
 549        } else {
 550            v_flex()
 551                .size_full()
 552                .items_center()
 553                .justify_center()
 554                .child("Component not found")
 555                .into_any_element()
 556        }
 557    }
 558
 559    fn test_status_toast(&self, cx: &mut Context<Self>) {
 560        if let Some(workspace) = self.workspace.upgrade() {
 561            workspace.update(cx, |workspace, cx| {
 562                let status_toast =
 563                    StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
 564                        this.icon(
 565                            Icon::new(IconName::GitBranch)
 566                                .size(IconSize::Small)
 567                                .color(Color::Muted),
 568                        )
 569                        .action("Open Pull Request", |_, cx| {
 570                            cx.open_url("https://github.com/")
 571                        })
 572                    });
 573                workspace.toggle_status_toast(status_toast, cx)
 574            });
 575        }
 576    }
 577}
 578
 579impl Render for ComponentPreview {
 580    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 581        // TODO: move this into the struct
 582        let current_filter = self.filter_editor.update(cx, |input, cx| {
 583            if input.is_empty(cx) {
 584                String::new()
 585            } else {
 586                input.text(cx)
 587            }
 588        });
 589
 590        if current_filter != self.filter_text {
 591            self.filter_text = current_filter;
 592            self.update_component_list(cx);
 593        }
 594        let sidebar_entries = self.scope_ordered_entries();
 595        let active_page = self.active_page.clone();
 596
 597        h_flex()
 598            .id("component-preview")
 599            .key_context("ComponentPreview")
 600            .items_start()
 601            .overflow_hidden()
 602            .size_full()
 603            .track_focus(&self.focus_handle)
 604            .bg(cx.theme().colors().editor_background)
 605            .child(
 606                v_flex()
 607                    .h_full()
 608                    .border_r_1()
 609                    .border_color(cx.theme().colors().border)
 610                    .child(
 611                        gpui::uniform_list(
 612                            "component-nav",
 613                            sidebar_entries.len(),
 614                            cx.processor(move |this, range: Range<usize>, _window, cx| {
 615                                range
 616                                    .filter_map(|ix| {
 617                                        if ix < sidebar_entries.len() {
 618                                            Some(this.render_sidebar_entry(
 619                                                ix,
 620                                                &sidebar_entries[ix],
 621                                                cx,
 622                                            ))
 623                                        } else {
 624                                            None
 625                                        }
 626                                    })
 627                                    .collect()
 628                            }),
 629                        )
 630                        .track_scroll(&self.nav_scroll_handle)
 631                        .p_2p5()
 632                        .w(px(231.)) // Matches perfectly with the size of the "Component Preview" tab, if that's the first one in the pane
 633                        .h_full()
 634                        .flex_1(),
 635                    )
 636                    .child(
 637                        div()
 638                            .w_full()
 639                            .p_2p5()
 640                            .border_t_1()
 641                            .border_color(cx.theme().colors().border)
 642                            .child(
 643                                Button::new("toast-test", "Launch Toast")
 644                                    .full_width()
 645                                    .on_click(cx.listener({
 646                                        move |this, _, _window, cx| {
 647                                            this.test_status_toast(cx);
 648                                            cx.notify();
 649                                        }
 650                                    })),
 651                            ),
 652                    ),
 653            )
 654            .child(
 655                v_flex()
 656                    .flex_1()
 657                    .size_full()
 658                    .child(
 659                        div()
 660                            .p_2()
 661                            .w_full()
 662                            .border_b_1()
 663                            .border_color(cx.theme().colors().border)
 664                            .child(self.filter_editor.clone()),
 665                    )
 666                    .child(
 667                        div().id("content-area").flex_1().overflow_y_scroll().child(
 668                            match active_page {
 669                                PreviewPage::AllComponents => {
 670                                    self.render_all_components(cx).into_any_element()
 671                                }
 672                                PreviewPage::Component(id) => self
 673                                    .render_component_page(&id, window, cx)
 674                                    .into_any_element(),
 675                            },
 676                        ),
 677                    ),
 678            )
 679    }
 680}
 681
 682impl EventEmitter<ItemEvent> for ComponentPreview {}
 683
 684impl Focusable for ComponentPreview {
 685    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
 686        self.focus_handle.clone()
 687    }
 688}
 689
 690#[derive(Debug, Clone, PartialEq, Eq, Hash)]
 691pub struct ActivePageId(pub String);
 692
 693impl Default for ActivePageId {
 694    fn default() -> Self {
 695        ActivePageId("AllComponents".to_string())
 696    }
 697}
 698
 699impl From<ComponentId> for ActivePageId {
 700    fn from(id: ComponentId) -> Self {
 701        Self(id.0.to_string())
 702    }
 703}
 704
 705impl Item for ComponentPreview {
 706    type Event = ItemEvent;
 707
 708    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
 709        "Component Preview".into()
 710    }
 711
 712    fn telemetry_event_text(&self) -> Option<&'static str> {
 713        None
 714    }
 715
 716    fn show_toolbar(&self) -> bool {
 717        false
 718    }
 719
 720    fn can_split(&self) -> bool {
 721        true
 722    }
 723
 724    fn clone_on_split(
 725        &self,
 726        _workspace_id: Option<WorkspaceId>,
 727        window: &mut Window,
 728        cx: &mut Context<Self>,
 729    ) -> Task<Option<gpui::Entity<Self>>>
 730    where
 731        Self: Sized,
 732    {
 733        let language_registry = self.language_registry.clone();
 734        let user_store = self.user_store.clone();
 735        let weak_workspace = self.workspace.clone();
 736        let project = self.project.clone();
 737        let selected_index = self.cursor_index;
 738        let active_page = self.active_page.clone();
 739
 740        let self_result = Self::new(
 741            weak_workspace,
 742            project,
 743            language_registry,
 744            user_store,
 745            selected_index,
 746            Some(active_page),
 747            window,
 748            cx,
 749        );
 750
 751        Task::ready(match self_result {
 752            Ok(preview) => Some(cx.new(|_cx| preview)),
 753            Err(e) => {
 754                log::error!("Failed to clone component preview: {}", e);
 755                None
 756            }
 757        })
 758    }
 759
 760    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
 761        f(*event)
 762    }
 763
 764    fn added_to_workspace(
 765        &mut self,
 766        workspace: &mut Workspace,
 767        window: &mut Window,
 768        cx: &mut Context<Self>,
 769    ) {
 770        self.workspace_id = workspace.database_id();
 771
 772        let focus_handle = self.filter_editor.read(cx).focus_handle(cx);
 773        window.focus(&focus_handle, cx);
 774    }
 775}
 776
 777impl SerializableItem for ComponentPreview {
 778    fn serialized_item_kind() -> &'static str {
 779        "ComponentPreview"
 780    }
 781
 782    fn deserialize(
 783        project: Entity<Project>,
 784        workspace: WeakEntity<Workspace>,
 785        workspace_id: WorkspaceId,
 786        item_id: ItemId,
 787        window: &mut Window,
 788        cx: &mut App,
 789    ) -> Task<anyhow::Result<Entity<Self>>> {
 790        let deserialized_active_page =
 791            match ComponentPreviewDb::global(cx).get_active_page(item_id, workspace_id) {
 792                Ok(page) => {
 793                    if let Some(page) = page {
 794                        ActivePageId(page)
 795                    } else {
 796                        ActivePageId::default()
 797                    }
 798                }
 799                Err(_) => ActivePageId::default(),
 800            };
 801
 802        let user_store = project.read(cx).user_store();
 803        let language_registry = project.read(cx).languages().clone();
 804        let preview_page = if deserialized_active_page.0 == ActivePageId::default().0 {
 805            Some(PreviewPage::default())
 806        } else {
 807            let component_str = deserialized_active_page.0;
 808            let component_registry = components();
 809            let all_components = component_registry.components();
 810            let found_component = all_components.iter().find(|c| c.id().0 == component_str);
 811
 812            if let Some(component) = found_component {
 813                Some(PreviewPage::Component(component.id()))
 814            } else {
 815                Some(PreviewPage::default())
 816            }
 817        };
 818
 819        window.spawn(cx, async move |cx| {
 820            let user_store = user_store.clone();
 821            let language_registry = language_registry.clone();
 822            let weak_workspace = workspace.clone();
 823            let project = project.clone();
 824            cx.update(move |window, cx| {
 825                Ok(cx.new(|cx| {
 826                    ComponentPreview::new(
 827                        weak_workspace,
 828                        project,
 829                        language_registry,
 830                        user_store,
 831                        None,
 832                        preview_page,
 833                        window,
 834                        cx,
 835                    )
 836                    .expect("Failed to create component preview")
 837                }))
 838            })?
 839        })
 840    }
 841
 842    fn cleanup(
 843        workspace_id: WorkspaceId,
 844        alive_items: Vec<ItemId>,
 845        _window: &mut Window,
 846        cx: &mut App,
 847    ) -> Task<anyhow::Result<()>> {
 848        delete_unloaded_items(
 849            alive_items,
 850            workspace_id,
 851            "component_previews",
 852            &ComponentPreviewDb::global(cx),
 853            cx,
 854        )
 855    }
 856
 857    fn serialize(
 858        &mut self,
 859        _workspace: &mut Workspace,
 860        item_id: ItemId,
 861        _closing: bool,
 862        _window: &mut Window,
 863        cx: &mut Context<Self>,
 864    ) -> Option<Task<anyhow::Result<()>>> {
 865        let active_page = self.active_page_id(cx);
 866        let workspace_id = self.workspace_id?;
 867        let db = ComponentPreviewDb::global(cx);
 868        Some(cx.background_spawn(async move {
 869            db.save_active_page(item_id, workspace_id, active_page.0)
 870                .await
 871        }))
 872    }
 873
 874    fn should_serialize(&self, event: &Self::Event) -> bool {
 875        matches!(event, ItemEvent::UpdateTab)
 876    }
 877}
 878
 879// TODO: use language registry to allow rendering markdown
 880#[derive(IntoElement)]
 881pub struct ComponentPreviewPage {
 882    // languages: Arc<LanguageRegistry>,
 883    component: ComponentMetadata,
 884    reset_key: usize,
 885}
 886
 887impl ComponentPreviewPage {
 888    pub fn new(
 889        component: ComponentMetadata,
 890        reset_key: usize,
 891        // languages: Arc<LanguageRegistry>
 892    ) -> Self {
 893        Self {
 894            // languages,
 895            component,
 896            reset_key,
 897        }
 898    }
 899
 900    /// Renders the component status when it would be useful
 901    ///
 902    /// Doesn't render if the component is `ComponentStatus::Live`
 903    /// as that is the default state
 904    fn render_component_status(&self, cx: &App) -> Option<impl IntoElement> {
 905        let status = self.component.status();
 906        let status_description = status.description().to_string();
 907
 908        let color = match status {
 909            ComponentStatus::Deprecated => Color::Error,
 910            ComponentStatus::EngineeringReady => Color::Info,
 911            ComponentStatus::Live => Color::Success,
 912            ComponentStatus::WorkInProgress => Color::Warning,
 913        };
 914
 915        if status != ComponentStatus::Live {
 916            Some(
 917                ButtonLike::new("component_status")
 918                    .child(
 919                        div()
 920                            .px_1p5()
 921                            .rounded_sm()
 922                            .bg(color.color(cx).alpha(0.12))
 923                            .child(
 924                                Label::new(status.to_string())
 925                                    .size(LabelSize::Small)
 926                                    .color(color),
 927                            ),
 928                    )
 929                    .tooltip(Tooltip::text(status_description))
 930                    .disabled(true),
 931            )
 932        } else {
 933            None
 934        }
 935    }
 936
 937    fn render_header(&self, _: &Window, cx: &App) -> impl IntoElement {
 938        v_flex()
 939            .min_w_0()
 940            .w_full()
 941            .p_12()
 942            .gap_6()
 943            .bg(cx.theme().colors().surface_background)
 944            .border_b_1()
 945            .border_color(cx.theme().colors().border)
 946            .child(
 947                v_flex()
 948                    .gap_1()
 949                    .child(
 950                        Label::new(self.component.scope().to_string())
 951                            .size(LabelSize::Small)
 952                            .color(Color::Muted),
 953                    )
 954                    .child(
 955                        h_flex()
 956                            .gap_2()
 957                            .child(
 958                                Headline::new(self.component.scopeless_name())
 959                                    .size(HeadlineSize::XLarge),
 960                            )
 961                            .children(self.render_component_status(cx)),
 962                    ),
 963            )
 964            .when_some(self.component.description(), |this, description| {
 965                this.child(Label::new(description).size(LabelSize::Small))
 966            })
 967    }
 968
 969    fn render_preview(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
 970        let content = if let Some(preview) = self.component.preview() {
 971            // Fall back to component preview
 972            preview(window, cx).unwrap_or_else(|| {
 973                div()
 974                    .child("Failed to load preview. This path should be unreachable")
 975                    .into_any_element()
 976            })
 977        } else {
 978            div().child("No preview available").into_any_element()
 979        };
 980
 981        v_flex()
 982            .id(("component-preview", self.reset_key))
 983            .size_full()
 984            .flex_1()
 985            .px_12()
 986            .py_6()
 987            .bg(cx.theme().colors().editor_background)
 988            .child(content)
 989    }
 990}
 991
 992impl RenderOnce for ComponentPreviewPage {
 993    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
 994        v_flex()
 995            .size_full()
 996            .flex_1()
 997            .overflow_x_hidden()
 998            .child(self.render_header(window, cx))
 999            .child(self.render_preview(window, cx))
1000    }
1001}