component_preview.rs

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