component_preview.rs

   1//! # Component Preview
   2//!
   3//! A view for exploring Zed components.
   4
   5mod persistence;
   6mod preview_support;
   7
   8use std::iter::Iterator;
   9use std::sync::Arc;
  10
  11use agent::{ActiveThread, TextThreadStore, ThreadStore};
  12use client::UserStore;
  13use component::{ComponentId, ComponentMetadata, components};
  14use gpui::{
  15    App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*,
  16};
  17
  18use collections::HashMap;
  19
  20use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle};
  21use languages::LanguageRegistry;
  22use notifications::status_toast::{StatusToast, ToastIcon};
  23use persistence::COMPONENT_PREVIEW_DB;
  24use preview_support::active_thread::{
  25    load_preview_text_thread_store, load_preview_thread_store, static_active_thread,
  26};
  27use project::Project;
  28use ui::{Divider, HighlightedLabel, ListItem, ListSubHeader, prelude::*};
  29use ui_input::SingleLineInput;
  30use util::ResultExt as _;
  31use workspace::{AppState, ItemId, SerializableItem, delete_unloaded_items};
  32use workspace::{Item, Workspace, WorkspaceId, item::ItemEvent};
  33
  34pub fn init(app_state: Arc<AppState>, cx: &mut App) {
  35    workspace::register_serializable_item::<ComponentPreview>(cx);
  36
  37    let app_state = app_state.clone();
  38
  39    cx.observe_new(move |workspace: &mut Workspace, _window, cx| {
  40        let app_state = app_state.clone();
  41        let project = workspace.project().clone();
  42        let weak_workspace = cx.entity().downgrade();
  43
  44        workspace.register_action(
  45            move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
  46                let app_state = app_state.clone();
  47
  48                let language_registry = app_state.languages.clone();
  49                let user_store = app_state.user_store.clone();
  50
  51                let component_preview = cx.new(|cx| {
  52                    ComponentPreview::new(
  53                        weak_workspace.clone(),
  54                        project.clone(),
  55                        language_registry,
  56                        user_store,
  57                        None,
  58                        None,
  59                        window,
  60                        cx,
  61                    )
  62                    .expect("Failed to create component preview")
  63                });
  64
  65                workspace.add_item_to_active_pane(
  66                    Box::new(component_preview),
  67                    None,
  68                    true,
  69                    window,
  70                    cx,
  71                )
  72            },
  73        );
  74    })
  75    .detach();
  76}
  77
  78enum PreviewEntry {
  79    AllComponents,
  80    ActiveThread,
  81    Separator,
  82    Component(ComponentMetadata, Option<Vec<usize>>),
  83    SectionHeader(SharedString),
  84}
  85
  86impl From<ComponentMetadata> for PreviewEntry {
  87    fn from(component: ComponentMetadata) -> Self {
  88        PreviewEntry::Component(component, None)
  89    }
  90}
  91
  92impl From<SharedString> for PreviewEntry {
  93    fn from(section_header: SharedString) -> Self {
  94        PreviewEntry::SectionHeader(section_header)
  95    }
  96}
  97
  98#[derive(Default, Debug, Clone, PartialEq, Eq)]
  99enum PreviewPage {
 100    #[default]
 101    AllComponents,
 102    Component(ComponentId),
 103    ActiveThread,
 104}
 105
 106struct ComponentPreview {
 107    workspace_id: Option<WorkspaceId>,
 108    focus_handle: FocusHandle,
 109    _view_scroll_handle: ScrollHandle,
 110    nav_scroll_handle: UniformListScrollHandle,
 111    component_map: HashMap<ComponentId, ComponentMetadata>,
 112    active_page: PreviewPage,
 113    components: Vec<ComponentMetadata>,
 114    component_list: ListState,
 115    cursor_index: usize,
 116    language_registry: Arc<LanguageRegistry>,
 117    workspace: WeakEntity<Workspace>,
 118    project: Entity<Project>,
 119    user_store: Entity<UserStore>,
 120    filter_editor: Entity<SingleLineInput>,
 121    filter_text: String,
 122
 123    // preview support
 124    thread_store: Option<Entity<ThreadStore>>,
 125    text_thread_store: Option<Entity<TextThreadStore>>,
 126    active_thread: Option<Entity<ActiveThread>>,
 127}
 128
 129impl ComponentPreview {
 130    pub fn new(
 131        workspace: WeakEntity<Workspace>,
 132        project: Entity<Project>,
 133        language_registry: Arc<LanguageRegistry>,
 134        user_store: Entity<UserStore>,
 135        selected_index: impl Into<Option<usize>>,
 136        active_page: Option<PreviewPage>,
 137        window: &mut Window,
 138        cx: &mut Context<Self>,
 139    ) -> anyhow::Result<Self> {
 140        let workspace_clone = workspace.clone();
 141        let project_clone = project.clone();
 142
 143        cx.spawn_in(window, async move |entity, cx| {
 144            let thread_store_future =
 145                load_preview_thread_store(workspace_clone.clone(), project_clone.clone(), cx);
 146            let text_thread_store_future =
 147                load_preview_text_thread_store(workspace_clone.clone(), project_clone.clone(), cx);
 148
 149            let (thread_store_result, text_thread_store_result) =
 150                futures::join!(thread_store_future, text_thread_store_future);
 151
 152            if let (Some(thread_store), Some(text_thread_store)) = (
 153                thread_store_result.log_err(),
 154                text_thread_store_result.log_err(),
 155            ) {
 156                entity
 157                    .update_in(cx, |this, window, cx| {
 158                        this.thread_store = Some(thread_store.clone());
 159                        this.text_thread_store = Some(text_thread_store.clone());
 160                        this.create_active_thread(window, cx);
 161                    })
 162                    .ok();
 163            }
 164        })
 165        .detach();
 166
 167        let sorted_components = components().all_sorted();
 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            workspace_id: None,
 192            focus_handle: cx.focus_handle(),
 193            _view_scroll_handle: ScrollHandle::new(),
 194            nav_scroll_handle: UniformListScrollHandle::new(),
 195            language_registry,
 196            user_store,
 197            workspace,
 198            project,
 199            active_page,
 200            component_map: components().0,
 201            components: sorted_components,
 202            component_list,
 203            cursor_index: selected_index,
 204            filter_editor,
 205            filter_text: String::new(),
 206            thread_store: None,
 207            text_thread_store: None,
 208            active_thread: None,
 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 render_sidebar_entry(
 416        &self,
 417        ix: usize,
 418        entry: &PreviewEntry,
 419        cx: &Context<Self>,
 420    ) -> impl IntoElement + use<> {
 421        match entry {
 422            PreviewEntry::Component(component_metadata, highlight_positions) => {
 423                let id = component_metadata.id();
 424                let selected = self.active_page == PreviewPage::Component(id.clone());
 425                let name = component_metadata.scopeless_name();
 426
 427                ListItem::new(ix)
 428                    .child(if let Some(_positions) = highlight_positions {
 429                        let name_lower = name.to_lowercase();
 430                        let filter_lower = self.filter_text.to_lowercase();
 431                        let valid_positions = if let Some(start) = name_lower.find(&filter_lower) {
 432                            let end = start + filter_lower.len();
 433                            (start..end).collect()
 434                        } else {
 435                            Vec::new()
 436                        };
 437                        if valid_positions.is_empty() {
 438                            Label::new(name.clone())
 439                                .color(Color::Default)
 440                                .into_any_element()
 441                        } else {
 442                            HighlightedLabel::new(name.clone(), valid_positions).into_any_element()
 443                        }
 444                    } else {
 445                        Label::new(name.clone())
 446                            .color(Color::Default)
 447                            .into_any_element()
 448                    })
 449                    .selectable(true)
 450                    .toggle_state(selected)
 451                    .inset(true)
 452                    .on_click(cx.listener(move |this, _, _, cx| {
 453                        let id = id.clone();
 454                        this.set_active_page(PreviewPage::Component(id), cx);
 455                    }))
 456                    .into_any_element()
 457            }
 458            PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
 459                .inset(true)
 460                .into_any_element(),
 461            PreviewEntry::AllComponents => {
 462                let selected = self.active_page == PreviewPage::AllComponents;
 463
 464                ListItem::new(ix)
 465                    .child(Label::new("All Components").color(Color::Default))
 466                    .selectable(true)
 467                    .toggle_state(selected)
 468                    .inset(true)
 469                    .on_click(cx.listener(move |this, _, _, cx| {
 470                        this.set_active_page(PreviewPage::AllComponents, cx);
 471                    }))
 472                    .into_any_element()
 473            }
 474            PreviewEntry::ActiveThread => {
 475                let selected = self.active_page == PreviewPage::ActiveThread;
 476
 477                ListItem::new(ix)
 478                    .child(Label::new("Active Thread").color(Color::Default))
 479                    .selectable(true)
 480                    .toggle_state(selected)
 481                    .inset(true)
 482                    .on_click(cx.listener(move |this, _, _, cx| {
 483                        this.set_active_page(PreviewPage::ActiveThread, cx);
 484                    }))
 485                    .into_any_element()
 486            }
 487            PreviewEntry::Separator => ListItem::new(ix)
 488                .child(
 489                    h_flex()
 490                        .occlude()
 491                        .pt_3()
 492                        .child(Divider::horizontal_dashed()),
 493                )
 494                .into_any_element(),
 495        }
 496    }
 497
 498    fn update_component_list(&mut self, cx: &mut Context<Self>) {
 499        let entries = self.scope_ordered_entries();
 500        let new_len = entries.len();
 501        let weak_entity = cx.entity().downgrade();
 502
 503        if new_len > 0 {
 504            self.nav_scroll_handle
 505                .scroll_to_item(0, ScrollStrategy::Top);
 506        }
 507
 508        let filtered_components = self.filtered_components();
 509
 510        if !self.filter_text.is_empty() && !matches!(self.active_page, PreviewPage::AllComponents) {
 511            if let PreviewPage::Component(ref component_id) = self.active_page {
 512                let component_still_visible = filtered_components
 513                    .iter()
 514                    .any(|component| component.id() == *component_id);
 515
 516                if !component_still_visible {
 517                    if !filtered_components.is_empty() {
 518                        let first_component = &filtered_components[0];
 519                        self.set_active_page(PreviewPage::Component(first_component.id()), cx);
 520                    } else {
 521                        self.set_active_page(PreviewPage::AllComponents, cx);
 522                    }
 523                }
 524            }
 525        }
 526
 527        self.component_list = ListState::new(
 528            filtered_components.len(),
 529            gpui::ListAlignment::Top,
 530            px(1500.0),
 531            {
 532                let components = filtered_components.clone();
 533                let this = cx.entity().downgrade();
 534                move |ix, window: &mut Window, cx: &mut App| {
 535                    if ix >= components.len() {
 536                        return div().w_full().h_0().into_any_element();
 537                    }
 538
 539                    this.update(cx, |this, cx| {
 540                        let component = &components[ix];
 541                        this.render_preview(component, window, cx)
 542                            .into_any_element()
 543                    })
 544                    .unwrap()
 545                }
 546            },
 547        );
 548
 549        let new_list = ListState::new(
 550            new_len,
 551            gpui::ListAlignment::Top,
 552            px(1500.0),
 553            move |ix, window, cx| {
 554                if ix >= entries.len() {
 555                    return div().w_full().h_0().into_any_element();
 556                }
 557
 558                let entry = &entries[ix];
 559
 560                weak_entity
 561                    .update(cx, |this, cx| match entry {
 562                        PreviewEntry::Component(component, _) => this
 563                            .render_preview(component, window, cx)
 564                            .into_any_element(),
 565                        PreviewEntry::SectionHeader(shared_string) => this
 566                            .render_scope_header(ix, shared_string.clone(), window, cx)
 567                            .into_any_element(),
 568                        PreviewEntry::AllComponents => div().w_full().h_0().into_any_element(),
 569                        PreviewEntry::ActiveThread => div().w_full().h_0().into_any_element(),
 570                        PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
 571                    })
 572                    .unwrap()
 573            },
 574        );
 575
 576        self.component_list = new_list;
 577        cx.emit(ItemEvent::UpdateTab);
 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                .size_full()
 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.all();
 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    fn render_header(&self, _: &Window, cx: &App) -> impl IntoElement {
1069        v_flex()
1070            .px_12()
1071            .pt_16()
1072            .pb_12()
1073            .gap_6()
1074            .bg(cx.theme().colors().surface_background)
1075            .border_b_1()
1076            .border_color(cx.theme().colors().border)
1077            .child(
1078                v_flex()
1079                    .gap_0p5()
1080                    .child(
1081                        Label::new(self.component.scope().to_string())
1082                            .size(LabelSize::Small)
1083                            .color(Color::Muted),
1084                    )
1085                    .child(
1086                        Headline::new(self.component.scopeless_name()).size(HeadlineSize::XLarge),
1087                    ),
1088            )
1089            .when_some(self.component.description(), |this, description| {
1090                this.child(div().text_sm().child(description))
1091            })
1092    }
1093
1094    fn render_preview(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1095        // Try to get agent preview first if we have an active thread
1096        let maybe_agent_preview = if let Some(active_thread) = self.active_thread.as_ref() {
1097            agent::get_agent_preview(
1098                &self.component.id(),
1099                self.workspace.clone(),
1100                active_thread.clone(),
1101                window,
1102                cx,
1103            )
1104        } else {
1105            None
1106        };
1107
1108        v_flex()
1109            .flex_1()
1110            .px_12()
1111            .py_6()
1112            .bg(cx.theme().colors().editor_background)
1113            .child(if let Some(element) = maybe_agent_preview {
1114                // Use agent preview if available
1115                element
1116            } else if let Some(preview) = self.component.preview() {
1117                // Fall back to component preview
1118                preview(window, cx).unwrap_or_else(|| {
1119                    div()
1120                        .child("Failed to load preview. This path should be unreachable")
1121                        .into_any_element()
1122                })
1123            } else {
1124                div().child("No preview available").into_any_element()
1125            })
1126    }
1127}
1128
1129impl RenderOnce for ComponentPreviewPage {
1130    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1131        v_flex()
1132            .id("component-preview-page")
1133            .overflow_y_scroll()
1134            .overflow_x_hidden()
1135            .w_full()
1136            .child(self.render_header(window, cx))
1137            .child(self.render_preview(window, cx))
1138    }
1139}