component_preview.rs

   1mod component_preview_example;
   2mod persistence;
   3
   4use client::UserStore;
   5use collections::HashMap;
   6use component::{ComponentId, ComponentMetadata, ComponentStatus, components};
   7use gpui::{
   8    App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
   9};
  10use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
  11use language::LanguageRegistry;
  12use notifications::status_toast::{StatusToast, ToastIcon};
  13use persistence::COMPONENT_PREVIEW_DB;
  14use project::Project;
  15use std::{iter::Iterator, ops::Range, sync::Arc};
  16use ui::{ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Tooltip, prelude::*};
  17use ui_input::InputField;
  18use workspace::AppState;
  19use workspace::{
  20    Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items, item::ItemEvent,
  21};
  22
  23#[allow(unused_imports)]
  24pub use component_preview_example::*;
  25
  26pub fn init(app_state: Arc<AppState>, cx: &mut App) {
  27    workspace::register_serializable_item::<ComponentPreview>(cx);
  28
  29    cx.observe_new(move |workspace: &mut Workspace, _window, cx| {
  30        let app_state = app_state.clone();
  31        let project = workspace.project().clone();
  32        let weak_workspace = cx.entity().downgrade();
  33
  34        workspace.register_action(
  35            move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
  36                let app_state = app_state.clone();
  37
  38                let language_registry = app_state.languages.clone();
  39                let user_store = app_state.user_store.clone();
  40
  41                let component_preview = cx.new(|cx| {
  42                    ComponentPreview::new(
  43                        weak_workspace.clone(),
  44                        project.clone(),
  45                        language_registry,
  46                        user_store,
  47                        None,
  48                        None,
  49                        window,
  50                        cx,
  51                    )
  52                    .expect("Failed to create component preview")
  53                });
  54
  55                workspace.add_item_to_active_pane(
  56                    Box::new(component_preview),
  57                    None,
  58                    true,
  59                    window,
  60                    cx,
  61                )
  62            },
  63        );
  64    })
  65    .detach();
  66}
  67
  68enum PreviewEntry {
  69    AllComponents,
  70    Separator,
  71    Component(ComponentMetadata, Option<Vec<usize>>),
  72    SectionHeader(SharedString),
  73}
  74
  75impl From<ComponentMetadata> for PreviewEntry {
  76    fn from(component: ComponentMetadata) -> Self {
  77        PreviewEntry::Component(component, None)
  78    }
  79}
  80
  81impl From<SharedString> for PreviewEntry {
  82    fn from(section_header: SharedString) -> Self {
  83        PreviewEntry::SectionHeader(section_header)
  84    }
  85}
  86
  87#[derive(Default, Debug, Clone, PartialEq, Eq)]
  88enum PreviewPage {
  89    #[default]
  90    AllComponents,
  91    Component(ComponentId),
  92}
  93
  94struct ComponentPreview {
  95    active_page: PreviewPage,
  96    reset_key: usize,
  97    component_list: ListState,
  98    entries: Vec<PreviewEntry>,
  99    component_map: HashMap<ComponentId, ComponentMetadata>,
 100    components: Vec<ComponentMetadata>,
 101    cursor_index: usize,
 102    filter_editor: Entity<InputField>,
 103    filter_text: String,
 104    focus_handle: FocusHandle,
 105    language_registry: Arc<LanguageRegistry>,
 106    nav_scroll_handle: UniformListScrollHandle,
 107    project: Entity<Project>,
 108    user_store: Entity<UserStore>,
 109    workspace: WeakEntity<Workspace>,
 110    workspace_id: Option<WorkspaceId>,
 111    _view_scroll_handle: ScrollHandle,
 112}
 113
 114impl ComponentPreview {
 115    pub fn new(
 116        workspace: WeakEntity<Workspace>,
 117        project: Entity<Project>,
 118        language_registry: Arc<LanguageRegistry>,
 119        user_store: Entity<UserStore>,
 120        selected_index: impl Into<Option<usize>>,
 121        active_page: Option<PreviewPage>,
 122        window: &mut Window,
 123        cx: &mut Context<Self>,
 124    ) -> anyhow::Result<Self> {
 125        let component_registry = Arc::new(components());
 126        let sorted_components = component_registry.sorted_components();
 127        let selected_index = selected_index.into().unwrap_or(0);
 128        let active_page = active_page.unwrap_or(PreviewPage::AllComponents);
 129        let filter_editor = cx.new(|cx| InputField::new(window, cx, "Find components or usages…"));
 130
 131        let component_list = ListState::new(
 132            sorted_components.len(),
 133            gpui::ListAlignment::Top,
 134            px(1500.0),
 135        );
 136
 137        let mut component_preview = Self {
 138            active_page,
 139            reset_key: 0,
 140            component_list,
 141            entries: Vec::new(),
 142            component_map: component_registry.component_map(),
 143            components: sorted_components,
 144            cursor_index: selected_index,
 145            filter_editor,
 146            filter_text: String::new(),
 147            focus_handle: cx.focus_handle(),
 148            language_registry,
 149            nav_scroll_handle: UniformListScrollHandle::new(),
 150            project,
 151            user_store,
 152            workspace,
 153            workspace_id: None,
 154            _view_scroll_handle: ScrollHandle::new(),
 155        };
 156
 157        if component_preview.cursor_index > 0 {
 158            component_preview.scroll_to_preview(component_preview.cursor_index, cx);
 159        }
 160
 161        component_preview.update_component_list(cx);
 162
 163        let focus_handle = component_preview.filter_editor.read(cx).focus_handle(cx);
 164        window.focus(&focus_handle, cx);
 165
 166        Ok(component_preview)
 167    }
 168
 169    pub fn active_page_id(&self, _cx: &App) -> ActivePageId {
 170        match &self.active_page {
 171            PreviewPage::AllComponents => ActivePageId::default(),
 172            PreviewPage::Component(component_id) => ActivePageId(component_id.0.to_string()),
 173        }
 174    }
 175
 176    fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
 177        self.component_list.scroll_to_reveal_item(ix);
 178        self.cursor_index = ix;
 179        cx.notify();
 180    }
 181
 182    fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) {
 183        if self.active_page == page {
 184            // Force the current preview page to render again
 185            self.reset_key = self.reset_key.wrapping_add(1);
 186        } else {
 187            self.active_page = page;
 188            cx.emit(ItemEvent::UpdateTab);
 189        }
 190        cx.notify();
 191    }
 192
 193    fn filtered_components(&self) -> Vec<ComponentMetadata> {
 194        if self.filter_text.is_empty() {
 195            return self.components.clone();
 196        }
 197
 198        let filter = self.filter_text.to_lowercase();
 199        self.components
 200            .iter()
 201            .filter(|component| {
 202                let component_name = component.name().to_lowercase();
 203                let scope_name = component.scope().to_string().to_lowercase();
 204                let description = component
 205                    .description()
 206                    .map(|d| d.to_lowercase())
 207                    .unwrap_or_default();
 208
 209                component_name.contains(&filter)
 210                    || scope_name.contains(&filter)
 211                    || description.contains(&filter)
 212            })
 213            .cloned()
 214            .collect()
 215    }
 216
 217    fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
 218        use collections::HashMap;
 219
 220        let mut scope_groups: HashMap<
 221            ComponentScope,
 222            Vec<(ComponentMetadata, Option<Vec<usize>>)>,
 223        > = HashMap::default();
 224        let lowercase_filter = self.filter_text.to_lowercase();
 225
 226        for component in &self.components {
 227            if self.filter_text.is_empty() {
 228                scope_groups
 229                    .entry(component.scope())
 230                    .or_insert_with(Vec::new)
 231                    .push((component.clone(), None));
 232                continue;
 233            }
 234
 235            // let full_component_name = component.name();
 236            let scopeless_name = component.scopeless_name();
 237            let scope_name = component.scope().to_string();
 238            let description = component.description().unwrap_or_default();
 239
 240            let lowercase_scopeless = scopeless_name.to_lowercase();
 241            let lowercase_scope = scope_name.to_lowercase();
 242            let lowercase_desc = description.to_lowercase();
 243
 244            if lowercase_scopeless.contains(&lowercase_filter)
 245                && let Some(index) = lowercase_scopeless.find(&lowercase_filter)
 246            {
 247                let end = index + lowercase_filter.len();
 248
 249                if end <= scopeless_name.len() {
 250                    let mut positions = Vec::new();
 251                    for i in index..end {
 252                        if scopeless_name.is_char_boundary(i) {
 253                            positions.push(i);
 254                        }
 255                    }
 256
 257                    if !positions.is_empty() {
 258                        scope_groups
 259                            .entry(component.scope())
 260                            .or_insert_with(Vec::new)
 261                            .push((component.clone(), Some(positions)));
 262                        continue;
 263                    }
 264                }
 265            }
 266
 267            if lowercase_scopeless.contains(&lowercase_filter)
 268                || lowercase_scope.contains(&lowercase_filter)
 269                || lowercase_desc.contains(&lowercase_filter)
 270            {
 271                scope_groups
 272                    .entry(component.scope())
 273                    .or_insert_with(Vec::new)
 274                    .push((component.clone(), None));
 275            }
 276        }
 277
 278        // Sort the components in each group
 279        for components in scope_groups.values_mut() {
 280            components.sort_by_key(|(c, _)| c.sort_name());
 281        }
 282
 283        let mut entries = Vec::new();
 284
 285        // Always show all components first
 286        entries.push(PreviewEntry::AllComponents);
 287
 288        let mut scopes: Vec<_> = scope_groups
 289            .keys()
 290            .filter(|scope| !matches!(**scope, ComponentScope::None))
 291            .cloned()
 292            .collect();
 293
 294        scopes.sort_by_key(|s| s.to_string());
 295
 296        for scope in scopes {
 297            if let Some(components) = scope_groups.remove(&scope)
 298                && !components.is_empty()
 299            {
 300                entries.push(PreviewEntry::Separator);
 301                entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
 302
 303                let mut sorted_components = components;
 304                sorted_components.sort_by_key(|(component, _)| component.sort_name());
 305
 306                for (component, positions) in sorted_components {
 307                    entries.push(PreviewEntry::Component(component, positions));
 308                }
 309            }
 310        }
 311
 312        // Add uncategorized components last
 313        if let Some(components) = scope_groups.get(&ComponentScope::None)
 314            && !components.is_empty()
 315        {
 316            entries.push(PreviewEntry::Separator);
 317            entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
 318            let mut sorted_components = components.clone();
 319            sorted_components.sort_by_key(|(c, _)| c.sort_name());
 320
 321            for (component, positions) in sorted_components {
 322                entries.push(PreviewEntry::Component(component, positions));
 323            }
 324        }
 325
 326        entries
 327    }
 328
 329    fn update_component_list(&mut self, cx: &mut Context<Self>) {
 330        let entries = self.scope_ordered_entries();
 331        let new_len = entries.len();
 332
 333        if new_len > 0 {
 334            self.nav_scroll_handle
 335                .scroll_to_item(0, ScrollStrategy::Top);
 336        }
 337
 338        let filtered_components = self.filtered_components();
 339
 340        if !self.filter_text.is_empty()
 341            && !matches!(self.active_page, PreviewPage::AllComponents)
 342            && let PreviewPage::Component(ref component_id) = self.active_page
 343        {
 344            let component_still_visible = filtered_components
 345                .iter()
 346                .any(|component| component.id() == *component_id);
 347
 348            if !component_still_visible {
 349                if !filtered_components.is_empty() {
 350                    let first_component = &filtered_components[0];
 351                    self.set_active_page(PreviewPage::Component(first_component.id()), cx);
 352                } else {
 353                    self.set_active_page(PreviewPage::AllComponents, cx);
 354                }
 355            }
 356        }
 357
 358        self.component_list = ListState::new(new_len, gpui::ListAlignment::Top, px(1500.0));
 359        self.entries = entries;
 360
 361        cx.emit(ItemEvent::UpdateTab);
 362    }
 363
 364    fn render_sidebar_entry(
 365        &self,
 366        ix: usize,
 367        entry: &PreviewEntry,
 368        cx: &Context<Self>,
 369    ) -> impl IntoElement + use<> {
 370        match entry {
 371            PreviewEntry::Component(component_metadata, highlight_positions) => {
 372                let id = component_metadata.id();
 373                let selected = self.active_page == PreviewPage::Component(id.clone());
 374                let name = component_metadata.scopeless_name();
 375
 376                ListItem::new(ix)
 377                    .child(if let Some(_positions) = highlight_positions {
 378                        let name_lower = name.to_lowercase();
 379                        let filter_lower = self.filter_text.to_lowercase();
 380                        let valid_positions = if let Some(start) = name_lower.find(&filter_lower) {
 381                            let end = start + filter_lower.len();
 382                            (start..end).collect()
 383                        } else {
 384                            Vec::new()
 385                        };
 386                        if valid_positions.is_empty() {
 387                            Label::new(name).into_any_element()
 388                        } else {
 389                            HighlightedLabel::new(name, valid_positions).into_any_element()
 390                        }
 391                    } else {
 392                        Label::new(name).into_any_element()
 393                    })
 394                    .selectable(true)
 395                    .toggle_state(selected)
 396                    .inset(true)
 397                    .on_click(cx.listener(move |this, _, _, cx| {
 398                        let id = id.clone();
 399                        this.set_active_page(PreviewPage::Component(id), cx);
 400                    }))
 401                    .into_any_element()
 402            }
 403            PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
 404                .inset(true)
 405                .into_any_element(),
 406            PreviewEntry::AllComponents => {
 407                let selected = self.active_page == PreviewPage::AllComponents;
 408
 409                ListItem::new(ix)
 410                    .child(Label::new("All Components"))
 411                    .selectable(true)
 412                    .toggle_state(selected)
 413                    .inset(true)
 414                    .on_click(cx.listener(move |this, _, _, cx| {
 415                        this.set_active_page(PreviewPage::AllComponents, cx);
 416                    }))
 417                    .into_any_element()
 418            }
 419            PreviewEntry::Separator => ListItem::new(ix)
 420                .disabled(true)
 421                .child(div().w_full().py_2().child(Divider::horizontal()))
 422                .into_any_element(),
 423        }
 424    }
 425
 426    fn render_scope_header(
 427        &self,
 428        _ix: usize,
 429        title: SharedString,
 430        _window: &Window,
 431        _cx: &App,
 432    ) -> impl IntoElement {
 433        h_flex()
 434            .w_full()
 435            .h_10()
 436            .child(Headline::new(title).size(HeadlineSize::XSmall))
 437            .child(Divider::horizontal())
 438    }
 439
 440    fn render_preview(
 441        &self,
 442        component: &ComponentMetadata,
 443        window: &mut Window,
 444        cx: &mut App,
 445    ) -> impl IntoElement {
 446        let name = component.scopeless_name();
 447        let scope = component.scope();
 448
 449        let description = component.description();
 450
 451        // Build the content container
 452        let mut preview_container = v_flex().py_2().child(
 453            v_flex()
 454                .border_1()
 455                .border_color(cx.theme().colors().border)
 456                .rounded_sm()
 457                .w_full()
 458                .gap_4()
 459                .py_4()
 460                .px_6()
 461                .flex_none()
 462                .child(
 463                    v_flex()
 464                        .gap_1()
 465                        .child(
 466                            h_flex()
 467                                .gap_1()
 468                                .text_xl()
 469                                .child(div().child(name))
 470                                .when(!matches!(scope, ComponentScope::None), |this| {
 471                                    this.child(div().opacity(0.5).child(format!("({})", scope)))
 472                                }),
 473                        )
 474                        .when_some(description, |this, description| {
 475                            this.child(
 476                                div()
 477                                    .text_ui_sm(cx)
 478                                    .text_color(cx.theme().colors().text_muted)
 479                                    .max_w(px(600.0))
 480                                    .child(description),
 481                            )
 482                        }),
 483                ),
 484        );
 485
 486        if let Some(preview) = component.preview() {
 487            preview_container = preview_container.children(preview(window, cx));
 488        }
 489
 490        preview_container.into_any_element()
 491    }
 492
 493    fn render_all_components(&self, cx: &Context<Self>) -> impl IntoElement {
 494        v_flex()
 495            .id("component-list")
 496            .px_8()
 497            .pt_4()
 498            .size_full()
 499            .child(
 500                if self.filtered_components().is_empty() && !self.filter_text.is_empty() {
 501                    div()
 502                        .size_full()
 503                        .items_center()
 504                        .justify_center()
 505                        .text_color(cx.theme().colors().text_muted)
 506                        .child(format!("No components matching '{}'.", self.filter_text))
 507                        .into_any_element()
 508                } else {
 509                    list(
 510                        self.component_list.clone(),
 511                        cx.processor(|this, ix, window, cx| {
 512                            if ix >= this.entries.len() {
 513                                return div().w_full().h_0().into_any_element();
 514                            }
 515
 516                            let entry = &this.entries[ix];
 517
 518                            match entry {
 519                                PreviewEntry::Component(component, _) => this
 520                                    .render_preview(component, window, cx)
 521                                    .into_any_element(),
 522                                PreviewEntry::SectionHeader(shared_string) => this
 523                                    .render_scope_header(ix, shared_string.clone(), window, cx)
 524                                    .into_any_element(),
 525                                PreviewEntry::AllComponents => {
 526                                    div().w_full().h_0().into_any_element()
 527                                }
 528                                PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
 529                            }
 530                        }),
 531                    )
 532                    .flex_grow()
 533                    .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
 534                    .into_any_element()
 535                },
 536            )
 537    }
 538
 539    fn render_component_page(
 540        &mut self,
 541        component_id: &ComponentId,
 542        _window: &mut Window,
 543        _cx: &mut Context<Self>,
 544    ) -> impl IntoElement {
 545        let component = self.component_map.get(component_id);
 546
 547        if let Some(component) = component {
 548            v_flex()
 549                .id("render-component-page")
 550                .flex_1()
 551                .child(ComponentPreviewPage::new(component.clone(), self.reset_key))
 552                .into_any_element()
 553        } else {
 554            v_flex()
 555                .size_full()
 556                .items_center()
 557                .justify_center()
 558                .child("Component not found")
 559                .into_any_element()
 560        }
 561    }
 562
 563    fn test_status_toast(&self, cx: &mut Context<Self>) {
 564        if let Some(workspace) = self.workspace.upgrade() {
 565            workspace.update(cx, |workspace, cx| {
 566                let status_toast =
 567                    StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
 568                        this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
 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.editor().read(cx).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, mut f: impl 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 COMPONENT_PREVIEW_DB.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            &COMPONENT_PREVIEW_DB,
 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        Some(cx.background_spawn(async move {
 868            COMPONENT_PREVIEW_DB
 869                .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}