extensions_ui.rs

   1mod components;
   2mod extension_suggest;
   3mod extension_version_selector;
   4
   5use std::sync::OnceLock;
   6use std::time::Duration;
   7use std::{ops::Range, sync::Arc};
   8
   9use client::{ExtensionMetadata, ExtensionProvides};
  10use collections::{BTreeMap, BTreeSet};
  11use editor::{Editor, EditorElement, EditorStyle};
  12use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore};
  13use fuzzy::{StringMatchCandidate, match_strings};
  14use gpui::{
  15    Action, App, ClipboardItem, Context, Entity, EventEmitter, Flatten, Focusable,
  16    InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle,
  17    UniformListScrollHandle, WeakEntity, Window, actions, point, uniform_list,
  18};
  19use num_format::{Locale, ToFormattedString};
  20use project::DirectoryLister;
  21use release_channel::ReleaseChannel;
  22use settings::Settings;
  23use strum::IntoEnumIterator as _;
  24use theme::ThemeSettings;
  25use ui::{
  26    CheckboxWithLabel, ContextMenu, PopoverMenu, ScrollableHandle, Scrollbar, ScrollbarState,
  27    ToggleButton, Tooltip, prelude::*,
  28};
  29use vim_mode_setting::VimModeSetting;
  30use workspace::{
  31    Workspace, WorkspaceId,
  32    item::{Item, ItemEvent},
  33};
  34use zed_actions::ExtensionCategoryFilter;
  35
  36use crate::components::{ExtensionCard, FeatureUpsell};
  37use crate::extension_version_selector::{
  38    ExtensionVersionSelector, ExtensionVersionSelectorDelegate,
  39};
  40
  41actions!(zed, [InstallDevExtension]);
  42
  43pub fn init(cx: &mut App) {
  44    cx.observe_new(move |workspace: &mut Workspace, window, cx| {
  45        let Some(window) = window else {
  46            return;
  47        };
  48        workspace
  49            .register_action(
  50                move |workspace, action: &zed_actions::Extensions, window, cx| {
  51                    let provides_filter = action.category_filter.map(|category| match category {
  52                        ExtensionCategoryFilter::Themes => ExtensionProvides::Themes,
  53                        ExtensionCategoryFilter::IconThemes => ExtensionProvides::IconThemes,
  54                        ExtensionCategoryFilter::Languages => ExtensionProvides::Languages,
  55                        ExtensionCategoryFilter::Grammars => ExtensionProvides::Grammars,
  56                        ExtensionCategoryFilter::LanguageServers => {
  57                            ExtensionProvides::LanguageServers
  58                        }
  59                        ExtensionCategoryFilter::ContextServers => {
  60                            ExtensionProvides::ContextServers
  61                        }
  62                        ExtensionCategoryFilter::SlashCommands => ExtensionProvides::SlashCommands,
  63                        ExtensionCategoryFilter::IndexedDocsProviders => {
  64                            ExtensionProvides::IndexedDocsProviders
  65                        }
  66                        ExtensionCategoryFilter::Snippets => ExtensionProvides::Snippets,
  67                        ExtensionCategoryFilter::DebugAdapters => ExtensionProvides::DebugAdapters,
  68                    });
  69
  70                    let existing = workspace
  71                        .active_pane()
  72                        .read(cx)
  73                        .items()
  74                        .find_map(|item| item.downcast::<ExtensionsPage>());
  75
  76                    if let Some(existing) = existing {
  77                        if provides_filter.is_some() {
  78                            existing.update(cx, |extensions_page, cx| {
  79                                extensions_page.change_provides_filter(provides_filter, cx);
  80                            });
  81                        }
  82
  83                        workspace.activate_item(&existing, true, true, window, cx);
  84                    } else {
  85                        let extensions_page =
  86                            ExtensionsPage::new(workspace, provides_filter, window, cx);
  87                        workspace.add_item_to_active_pane(
  88                            Box::new(extensions_page),
  89                            None,
  90                            true,
  91                            window,
  92                            cx,
  93                        )
  94                    }
  95                },
  96            )
  97            .register_action(move |workspace, _: &InstallDevExtension, window, cx| {
  98                let store = ExtensionStore::global(cx);
  99                let prompt = workspace.prompt_for_open_path(
 100                    gpui::PathPromptOptions {
 101                        files: false,
 102                        directories: true,
 103                        multiple: false,
 104                    },
 105                    DirectoryLister::Local(
 106                        workspace.project().clone(),
 107                        workspace.app_state().fs.clone(),
 108                    ),
 109                    window,
 110                    cx,
 111                );
 112
 113                let workspace_handle = cx.entity().downgrade();
 114                window
 115                    .spawn(cx, async move |cx| {
 116                        let extension_path =
 117                            match Flatten::flatten(prompt.await.map_err(|e| e.into())) {
 118                                Ok(Some(mut paths)) => paths.pop()?,
 119                                Ok(None) => return None,
 120                                Err(err) => {
 121                                    workspace_handle
 122                                        .update(cx, |workspace, cx| {
 123                                            workspace.show_portal_error(err.to_string(), cx);
 124                                        })
 125                                        .ok();
 126                                    return None;
 127                                }
 128                            };
 129
 130                        let install_task = store
 131                            .update(cx, |store, cx| {
 132                                store.install_dev_extension(extension_path, cx)
 133                            })
 134                            .ok()?;
 135
 136                        match install_task.await {
 137                            Ok(_) => {}
 138                            Err(err) => {
 139                                log::error!("Failed to install dev extension: {:?}", err);
 140                                workspace_handle
 141                                    .update(cx, |workspace, cx| {
 142                                        workspace.show_error(
 143                                            // NOTE: using `anyhow::context` here ends up not printing
 144                                            // the error
 145                                            &format!("Failed to install dev extension: {}", err),
 146                                            cx,
 147                                        );
 148                                    })
 149                                    .ok();
 150                            }
 151                        }
 152
 153                        Some(())
 154                    })
 155                    .detach();
 156            });
 157
 158        cx.subscribe_in(workspace.project(), window, |_, _, event, window, cx| {
 159            if let project::Event::LanguageNotFound(buffer) = event {
 160                extension_suggest::suggest(buffer.clone(), window, cx);
 161            }
 162        })
 163        .detach();
 164    })
 165    .detach();
 166}
 167
 168fn extension_provides_label(provides: ExtensionProvides) -> &'static str {
 169    match provides {
 170        ExtensionProvides::Themes => "Themes",
 171        ExtensionProvides::IconThemes => "Icon Themes",
 172        ExtensionProvides::Languages => "Languages",
 173        ExtensionProvides::Grammars => "Grammars",
 174        ExtensionProvides::LanguageServers => "Language Servers",
 175        ExtensionProvides::ContextServers => "MCP Servers",
 176        ExtensionProvides::SlashCommands => "Slash Commands",
 177        ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers",
 178        ExtensionProvides::Snippets => "Snippets",
 179        ExtensionProvides::DebugAdapters => "Debug Adapters",
 180    }
 181}
 182
 183#[derive(Clone)]
 184pub enum ExtensionStatus {
 185    NotInstalled,
 186    Installing,
 187    Upgrading,
 188    Installed(Arc<str>),
 189    Removing,
 190}
 191
 192#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 193enum ExtensionFilter {
 194    All,
 195    Installed,
 196    NotInstalled,
 197}
 198
 199impl ExtensionFilter {
 200    pub fn include_dev_extensions(&self) -> bool {
 201        match self {
 202            Self::All | Self::Installed => true,
 203            Self::NotInstalled => false,
 204        }
 205    }
 206}
 207
 208#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 209enum Feature {
 210    Git,
 211    OpenIn,
 212    Vim,
 213    LanguageBash,
 214    LanguageC,
 215    LanguageCpp,
 216    LanguageGo,
 217    LanguagePython,
 218    LanguageReact,
 219    LanguageRust,
 220    LanguageTypescript,
 221}
 222
 223fn keywords_by_feature() -> &'static BTreeMap<Feature, Vec<&'static str>> {
 224    static KEYWORDS_BY_FEATURE: OnceLock<BTreeMap<Feature, Vec<&'static str>>> = OnceLock::new();
 225    KEYWORDS_BY_FEATURE.get_or_init(|| {
 226        BTreeMap::from_iter([
 227            (Feature::Git, vec!["git"]),
 228            (
 229                Feature::OpenIn,
 230                vec![
 231                    "github",
 232                    "gitlab",
 233                    "bitbucket",
 234                    "codeberg",
 235                    "sourcehut",
 236                    "permalink",
 237                    "link",
 238                    "open in",
 239                ],
 240            ),
 241            (Feature::Vim, vec!["vim"]),
 242            (Feature::LanguageBash, vec!["sh", "bash"]),
 243            (Feature::LanguageC, vec!["c", "clang"]),
 244            (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]),
 245            (Feature::LanguageGo, vec!["go", "golang"]),
 246            (Feature::LanguagePython, vec!["python", "py"]),
 247            (Feature::LanguageReact, vec!["react"]),
 248            (Feature::LanguageRust, vec!["rust", "rs"]),
 249            (
 250                Feature::LanguageTypescript,
 251                vec!["type", "typescript", "ts"],
 252            ),
 253        ])
 254    })
 255}
 256
 257struct ExtensionCardButtons {
 258    install_or_uninstall: Button,
 259    upgrade: Option<Button>,
 260    configure: Option<Button>,
 261}
 262
 263pub struct ExtensionsPage {
 264    workspace: WeakEntity<Workspace>,
 265    list: UniformListScrollHandle,
 266    is_fetching_extensions: bool,
 267    filter: ExtensionFilter,
 268    remote_extension_entries: Vec<ExtensionMetadata>,
 269    dev_extension_entries: Vec<Arc<ExtensionManifest>>,
 270    filtered_remote_extension_indices: Vec<usize>,
 271    query_editor: Entity<Editor>,
 272    query_contains_error: bool,
 273    provides_filter: Option<ExtensionProvides>,
 274    _subscriptions: [gpui::Subscription; 2],
 275    extension_fetch_task: Option<Task<()>>,
 276    upsells: BTreeSet<Feature>,
 277    scrollbar_state: ScrollbarState,
 278}
 279
 280impl ExtensionsPage {
 281    pub fn new(
 282        workspace: &Workspace,
 283        provides_filter: Option<ExtensionProvides>,
 284        window: &mut Window,
 285        cx: &mut Context<Workspace>,
 286    ) -> Entity<Self> {
 287        cx.new(|cx| {
 288            let store = ExtensionStore::global(cx);
 289            let workspace_handle = workspace.weak_handle();
 290            let subscriptions = [
 291                cx.observe(&store, |_: &mut Self, _, cx| cx.notify()),
 292                cx.subscribe_in(
 293                    &store,
 294                    window,
 295                    move |this, _, event, window, cx| match event {
 296                        extension_host::Event::ExtensionsUpdated => {
 297                            this.fetch_extensions_debounced(None, cx)
 298                        }
 299                        extension_host::Event::ExtensionInstalled(extension_id) => this
 300                            .on_extension_installed(
 301                                workspace_handle.clone(),
 302                                extension_id,
 303                                window,
 304                                cx,
 305                            ),
 306                        _ => {}
 307                    },
 308                ),
 309            ];
 310
 311            let query_editor = cx.new(|cx| {
 312                let mut input = Editor::single_line(window, cx);
 313                input.set_placeholder_text("Search extensions...", cx);
 314                input
 315            });
 316            cx.subscribe(&query_editor, Self::on_query_change).detach();
 317
 318            let scroll_handle = UniformListScrollHandle::new();
 319
 320            let mut this = Self {
 321                workspace: workspace.weak_handle(),
 322                list: scroll_handle.clone(),
 323                is_fetching_extensions: false,
 324                filter: ExtensionFilter::All,
 325                dev_extension_entries: Vec::new(),
 326                filtered_remote_extension_indices: Vec::new(),
 327                remote_extension_entries: Vec::new(),
 328                query_contains_error: false,
 329                provides_filter,
 330                extension_fetch_task: None,
 331                _subscriptions: subscriptions,
 332                query_editor,
 333                upsells: BTreeSet::default(),
 334                scrollbar_state: ScrollbarState::new(scroll_handle),
 335            };
 336            this.fetch_extensions(
 337                None,
 338                Some(BTreeSet::from_iter(this.provides_filter)),
 339                None,
 340                cx,
 341            );
 342            this
 343        })
 344    }
 345
 346    fn on_extension_installed(
 347        &mut self,
 348        workspace: WeakEntity<Workspace>,
 349        extension_id: &str,
 350        window: &mut Window,
 351        cx: &mut Context<Self>,
 352    ) {
 353        let extension_store = ExtensionStore::global(cx).read(cx);
 354        let themes = extension_store
 355            .extension_themes(extension_id)
 356            .map(|name| name.to_string())
 357            .collect::<Vec<_>>();
 358        if !themes.is_empty() {
 359            workspace
 360                .update(cx, |_workspace, cx| {
 361                    window.dispatch_action(
 362                        zed_actions::theme_selector::Toggle {
 363                            themes_filter: Some(themes),
 364                        }
 365                        .boxed_clone(),
 366                        cx,
 367                    );
 368                })
 369                .ok();
 370            return;
 371        }
 372
 373        let icon_themes = extension_store
 374            .extension_icon_themes(extension_id)
 375            .map(|name| name.to_string())
 376            .collect::<Vec<_>>();
 377        if !icon_themes.is_empty() {
 378            workspace
 379                .update(cx, |_workspace, cx| {
 380                    window.dispatch_action(
 381                        zed_actions::icon_theme_selector::Toggle {
 382                            themes_filter: Some(icon_themes),
 383                        }
 384                        .boxed_clone(),
 385                        cx,
 386                    );
 387                })
 388                .ok();
 389        }
 390    }
 391
 392    /// Returns whether a dev extension currently exists for the extension with the given ID.
 393    fn dev_extension_exists(extension_id: &str, cx: &mut Context<Self>) -> bool {
 394        let extension_store = ExtensionStore::global(cx).read(cx);
 395
 396        extension_store
 397            .dev_extensions()
 398            .any(|dev_extension| dev_extension.id.as_ref() == extension_id)
 399    }
 400
 401    fn extension_status(extension_id: &str, cx: &mut Context<Self>) -> ExtensionStatus {
 402        let extension_store = ExtensionStore::global(cx).read(cx);
 403
 404        match extension_store.outstanding_operations().get(extension_id) {
 405            Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
 406            Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
 407            Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
 408            None => match extension_store.installed_extensions().get(extension_id) {
 409                Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
 410                None => ExtensionStatus::NotInstalled,
 411            },
 412        }
 413    }
 414
 415    fn filter_extension_entries(&mut self, cx: &mut Context<Self>) {
 416        self.filtered_remote_extension_indices.clear();
 417        self.filtered_remote_extension_indices.extend(
 418            self.remote_extension_entries
 419                .iter()
 420                .enumerate()
 421                .filter(|(_, extension)| match self.filter {
 422                    ExtensionFilter::All => true,
 423                    ExtensionFilter::Installed => {
 424                        let status = Self::extension_status(&extension.id, cx);
 425                        matches!(status, ExtensionStatus::Installed(_))
 426                    }
 427                    ExtensionFilter::NotInstalled => {
 428                        let status = Self::extension_status(&extension.id, cx);
 429
 430                        matches!(status, ExtensionStatus::NotInstalled)
 431                    }
 432                })
 433                .map(|(ix, _)| ix),
 434        );
 435        cx.notify();
 436    }
 437
 438    fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
 439        self.list.set_offset(point(px(0.), px(0.)));
 440        cx.notify();
 441    }
 442
 443    fn fetch_extensions(
 444        &mut self,
 445        search: Option<String>,
 446        provides_filter: Option<BTreeSet<ExtensionProvides>>,
 447        on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
 448        cx: &mut Context<Self>,
 449    ) {
 450        self.is_fetching_extensions = true;
 451        cx.notify();
 452
 453        let extension_store = ExtensionStore::global(cx);
 454
 455        let dev_extensions = extension_store
 456            .read(cx)
 457            .dev_extensions()
 458            .cloned()
 459            .collect::<Vec<_>>();
 460
 461        let remote_extensions = extension_store.update(cx, |store, cx| {
 462            store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
 463        });
 464
 465        cx.spawn(async move |this, cx| {
 466            let dev_extensions = if let Some(search) = search {
 467                let match_candidates = dev_extensions
 468                    .iter()
 469                    .enumerate()
 470                    .map(|(ix, manifest)| StringMatchCandidate::new(ix, &manifest.name))
 471                    .collect::<Vec<_>>();
 472
 473                let matches = match_strings(
 474                    &match_candidates,
 475                    &search,
 476                    false,
 477                    true,
 478                    match_candidates.len(),
 479                    &Default::default(),
 480                    cx.background_executor().clone(),
 481                )
 482                .await;
 483                matches
 484                    .into_iter()
 485                    .map(|mat| dev_extensions[mat.candidate_id].clone())
 486                    .collect()
 487            } else {
 488                dev_extensions
 489            };
 490
 491            let fetch_result = remote_extensions.await;
 492            this.update(cx, |this, cx| {
 493                cx.notify();
 494                this.dev_extension_entries = dev_extensions;
 495                this.is_fetching_extensions = false;
 496                this.remote_extension_entries = fetch_result?;
 497                this.filter_extension_entries(cx);
 498                if let Some(callback) = on_complete {
 499                    callback(this, cx);
 500                }
 501                anyhow::Ok(())
 502            })?
 503        })
 504        .detach_and_log_err(cx);
 505    }
 506
 507    fn render_extensions(
 508        &mut self,
 509        range: Range<usize>,
 510        _: &mut Window,
 511        cx: &mut Context<Self>,
 512    ) -> Vec<ExtensionCard> {
 513        let dev_extension_entries_len = if self.filter.include_dev_extensions() {
 514            self.dev_extension_entries.len()
 515        } else {
 516            0
 517        };
 518        range
 519            .map(|ix| {
 520                if ix < dev_extension_entries_len {
 521                    let extension = &self.dev_extension_entries[ix];
 522                    self.render_dev_extension(extension, cx)
 523                } else {
 524                    let extension_ix =
 525                        self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
 526                    let extension = &self.remote_extension_entries[extension_ix];
 527                    self.render_remote_extension(extension, cx)
 528                }
 529            })
 530            .collect()
 531    }
 532
 533    fn render_dev_extension(
 534        &self,
 535        extension: &ExtensionManifest,
 536        cx: &mut Context<Self>,
 537    ) -> ExtensionCard {
 538        let status = Self::extension_status(&extension.id, cx);
 539
 540        let repository_url = extension.repository.clone();
 541
 542        let can_configure = !extension.context_servers.is_empty();
 543
 544        ExtensionCard::new()
 545            .child(
 546                h_flex()
 547                    .justify_between()
 548                    .child(
 549                        h_flex()
 550                            .gap_2()
 551                            .items_end()
 552                            .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
 553                            .child(
 554                                Headline::new(format!("v{}", extension.version))
 555                                    .size(HeadlineSize::XSmall),
 556                            ),
 557                    )
 558                    .child(
 559                        h_flex()
 560                            .gap_1()
 561                            .justify_between()
 562                            .child(
 563                                Button::new(
 564                                    SharedString::from(format!("rebuild-{}", extension.id)),
 565                                    "Rebuild",
 566                                )
 567                                .color(Color::Accent)
 568                                .disabled(matches!(status, ExtensionStatus::Upgrading))
 569                                .on_click({
 570                                    let extension_id = extension.id.clone();
 571                                    move |_, _, cx| {
 572                                        ExtensionStore::global(cx).update(cx, |store, cx| {
 573                                            store.rebuild_dev_extension(extension_id.clone(), cx)
 574                                        });
 575                                    }
 576                                }),
 577                            )
 578                            .child(
 579                                Button::new(SharedString::from(extension.id.clone()), "Uninstall")
 580                                    .color(Color::Accent)
 581                                    .disabled(matches!(status, ExtensionStatus::Removing))
 582                                    .on_click({
 583                                        let extension_id = extension.id.clone();
 584                                        move |_, _, cx| {
 585                                            ExtensionStore::global(cx).update(cx, |store, cx| {
 586                                                store.uninstall_extension(extension_id.clone(), cx).detach_and_log_err(cx);
 587                                            });
 588                                        }
 589                                    }),
 590                            )
 591                            .when(can_configure, |this| {
 592                                this.child(
 593                                    Button::new(
 594                                        SharedString::from(format!("configure-{}", extension.id)),
 595                                        "Configure",
 596                                    )
 597                                    .color(Color::Accent)
 598                                    .disabled(matches!(status, ExtensionStatus::Installing))
 599                                    .on_click({
 600                                        let manifest = Arc::new(extension.clone());
 601                                        move |_, _, cx| {
 602                                            if let Some(events) =
 603                                                extension::ExtensionEvents::try_global(cx)
 604                                            {
 605                                                events.update(cx, |this, cx| {
 606                                                    this.emit(
 607                                                        extension::Event::ConfigureExtensionRequested(
 608                                                            manifest.clone(),
 609                                                        ),
 610                                                        cx,
 611                                                    )
 612                                                });
 613                                            }
 614                                        }
 615                                    }),
 616                                )
 617                            }),
 618                    ),
 619            )
 620            .child(
 621                h_flex()
 622                    .gap_2()
 623                    .justify_between()
 624                    .child(
 625                        Label::new(format!(
 626                            "{}: {}",
 627                            if extension.authors.len() > 1 {
 628                                "Authors"
 629                            } else {
 630                                "Author"
 631                            },
 632                            extension.authors.join(", ")
 633                        ))
 634                        .size(LabelSize::Small)
 635                        .color(Color::Muted)
 636                        .truncate(),
 637                    )
 638                    .child(Label::new("<>").size(LabelSize::Small)),
 639            )
 640            .child(
 641                h_flex()
 642                    .gap_2()
 643                    .justify_between()
 644                    .children(extension.description.as_ref().map(|description| {
 645                        Label::new(description.clone())
 646                            .size(LabelSize::Small)
 647                            .color(Color::Default)
 648                            .truncate()
 649                    }))
 650                    .children(repository_url.map(|repository_url| {
 651                        IconButton::new(
 652                            SharedString::from(format!("repository-{}", extension.id)),
 653                            IconName::Github,
 654                        )
 655                        .icon_color(Color::Accent)
 656                        .icon_size(IconSize::Small)
 657                        .on_click(cx.listener({
 658                            let repository_url = repository_url.clone();
 659                            move |_, _, _, cx| {
 660                                cx.open_url(&repository_url);
 661                            }
 662                        }))
 663                        .tooltip(Tooltip::text(repository_url.clone()))
 664                    })),
 665            )
 666    }
 667
 668    fn render_remote_extension(
 669        &self,
 670        extension: &ExtensionMetadata,
 671        cx: &mut Context<Self>,
 672    ) -> ExtensionCard {
 673        let this = cx.entity().clone();
 674        let status = Self::extension_status(&extension.id, cx);
 675        let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
 676
 677        let extension_id = extension.id.clone();
 678        let buttons = self.buttons_for_entry(extension, &status, has_dev_extension, cx);
 679        let version = extension.manifest.version.clone();
 680        let repository_url = extension.manifest.repository.clone();
 681        let authors = extension.manifest.authors.clone();
 682
 683        let installed_version = match status {
 684            ExtensionStatus::Installed(installed_version) => Some(installed_version),
 685            _ => None,
 686        };
 687
 688        ExtensionCard::new()
 689            .overridden_by_dev_extension(has_dev_extension)
 690            .child(
 691                h_flex()
 692                    .justify_between()
 693                    .child(
 694                        h_flex()
 695                            .gap_2()
 696                            .child(
 697                                Headline::new(extension.manifest.name.clone())
 698                                    .size(HeadlineSize::Medium),
 699                            )
 700                            .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
 701                            .children(
 702                                installed_version
 703                                    .filter(|installed_version| *installed_version != version)
 704                                    .map(|installed_version| {
 705                                        Headline::new(format!("(v{installed_version} installed)",))
 706                                            .size(HeadlineSize::XSmall)
 707                                    }),
 708                            )
 709                            .map(|parent| {
 710                                if extension.manifest.provides.is_empty() {
 711                                    return parent;
 712                                }
 713
 714                                parent.child(
 715                                    h_flex().gap_2().children(
 716                                        extension
 717                                            .manifest
 718                                            .provides
 719                                            .iter()
 720                                            .map(|provides| {
 721                                                div()
 722                                                    .bg(cx.theme().colors().element_background)
 723                                                    .px_0p5()
 724                                                    .border_1()
 725                                                    .border_color(cx.theme().colors().border)
 726                                                    .rounded_sm()
 727                                                    .child(
 728                                                        Label::new(extension_provides_label(
 729                                                            *provides,
 730                                                        ))
 731                                                        .size(LabelSize::XSmall),
 732                                                    )
 733                                            })
 734                                            .collect::<Vec<_>>(),
 735                                    ),
 736                                )
 737                            }),
 738                    )
 739                    .child(
 740                        h_flex()
 741                            .gap_2()
 742                            .justify_between()
 743                            .children(buttons.upgrade)
 744                            .children(buttons.configure)
 745                            .child(buttons.install_or_uninstall),
 746                    ),
 747            )
 748            .child(
 749                h_flex()
 750                    .gap_2()
 751                    .justify_between()
 752                    .child(
 753                        Label::new(format!(
 754                            "{}: {}",
 755                            if extension.manifest.authors.len() > 1 {
 756                                "Authors"
 757                            } else {
 758                                "Author"
 759                            },
 760                            extension.manifest.authors.join(", ")
 761                        ))
 762                        .size(LabelSize::Small)
 763                        .color(Color::Muted)
 764                        .truncate(),
 765                    )
 766                    .child(
 767                        Label::new(format!(
 768                            "Downloads: {}",
 769                            extension.download_count.to_formatted_string(&Locale::en)
 770                        ))
 771                        .size(LabelSize::Small),
 772                    ),
 773            )
 774            .child(
 775                h_flex()
 776                    .gap_2()
 777                    .justify_between()
 778                    .children(extension.manifest.description.as_ref().map(|description| {
 779                        Label::new(description.clone())
 780                            .size(LabelSize::Small)
 781                            .color(Color::Default)
 782                            .truncate()
 783                    }))
 784                    .child(
 785                        h_flex()
 786                            .gap_2()
 787                            .child(
 788                                IconButton::new(
 789                                    SharedString::from(format!("repository-{}", extension.id)),
 790                                    IconName::Github,
 791                                )
 792                                .icon_color(Color::Accent)
 793                                .icon_size(IconSize::Small)
 794                                .on_click(cx.listener({
 795                                    let repository_url = repository_url.clone();
 796                                    move |_, _, _, cx| {
 797                                        cx.open_url(&repository_url);
 798                                    }
 799                                }))
 800                                .tooltip(Tooltip::text(repository_url.clone())),
 801                            )
 802                            .child(
 803                                PopoverMenu::new(SharedString::from(format!(
 804                                    "more-{}",
 805                                    extension.id
 806                                )))
 807                                .trigger(
 808                                    IconButton::new(
 809                                        SharedString::from(format!("more-{}", extension.id)),
 810                                        IconName::Ellipsis,
 811                                    )
 812                                    .icon_color(Color::Accent)
 813                                    .icon_size(IconSize::Small),
 814                                )
 815                                .menu(move |window, cx| {
 816                                    Some(Self::render_remote_extension_context_menu(
 817                                        &this,
 818                                        extension_id.clone(),
 819                                        authors.clone(),
 820                                        window,
 821                                        cx,
 822                                    ))
 823                                }),
 824                            ),
 825                    ),
 826            )
 827    }
 828
 829    fn render_remote_extension_context_menu(
 830        this: &Entity<Self>,
 831        extension_id: Arc<str>,
 832        authors: Vec<String>,
 833        window: &mut Window,
 834        cx: &mut App,
 835    ) -> Entity<ContextMenu> {
 836        let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
 837            context_menu
 838                .entry(
 839                    "Install Another Version...",
 840                    None,
 841                    window.handler_for(this, {
 842                        let extension_id = extension_id.clone();
 843                        move |this, window, cx| {
 844                            this.show_extension_version_list(extension_id.clone(), window, cx)
 845                        }
 846                    }),
 847                )
 848                .entry("Copy Extension ID", None, {
 849                    let extension_id = extension_id.clone();
 850                    move |_, cx| {
 851                        cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
 852                    }
 853                })
 854                .entry("Copy Author Info", None, {
 855                    let authors = authors.clone();
 856                    move |_, cx| {
 857                        cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
 858                    }
 859                })
 860        });
 861
 862        context_menu
 863    }
 864
 865    fn show_extension_version_list(
 866        &mut self,
 867        extension_id: Arc<str>,
 868        window: &mut Window,
 869        cx: &mut Context<Self>,
 870    ) {
 871        let Some(workspace) = self.workspace.upgrade() else {
 872            return;
 873        };
 874
 875        cx.spawn_in(window, async move |this, cx| {
 876            let extension_versions_task = this.update(cx, |_, cx| {
 877                let extension_store = ExtensionStore::global(cx);
 878
 879                extension_store.update(cx, |store, cx| {
 880                    store.fetch_extension_versions(&extension_id, cx)
 881                })
 882            })?;
 883
 884            let extension_versions = extension_versions_task.await?;
 885
 886            workspace.update_in(cx, |workspace, window, cx| {
 887                let fs = workspace.project().read(cx).fs().clone();
 888                workspace.toggle_modal(window, cx, |window, cx| {
 889                    let delegate = ExtensionVersionSelectorDelegate::new(
 890                        fs,
 891                        cx.entity().downgrade(),
 892                        extension_versions,
 893                    );
 894
 895                    ExtensionVersionSelector::new(delegate, window, cx)
 896                });
 897            })?;
 898
 899            anyhow::Ok(())
 900        })
 901        .detach_and_log_err(cx);
 902    }
 903
 904    fn buttons_for_entry(
 905        &self,
 906        extension: &ExtensionMetadata,
 907        status: &ExtensionStatus,
 908        has_dev_extension: bool,
 909        cx: &mut Context<Self>,
 910    ) -> ExtensionCardButtons {
 911        let is_compatible =
 912            extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
 913
 914        if has_dev_extension {
 915            // If we have a dev extension for the given extension, just treat it as uninstalled.
 916            // The button here is a placeholder, as it won't be interactable anyways.
 917            return ExtensionCardButtons {
 918                install_or_uninstall: Button::new(
 919                    SharedString::from(extension.id.clone()),
 920                    "Install",
 921                ),
 922                configure: None,
 923                upgrade: None,
 924            };
 925        }
 926
 927        let is_configurable = extension
 928            .manifest
 929            .provides
 930            .contains(&ExtensionProvides::ContextServers);
 931
 932        match status.clone() {
 933            ExtensionStatus::NotInstalled => ExtensionCardButtons {
 934                install_or_uninstall: Button::new(
 935                    SharedString::from(extension.id.clone()),
 936                    "Install",
 937                )
 938                .on_click({
 939                    let extension_id = extension.id.clone();
 940                    move |_, _, cx| {
 941                        telemetry::event!("Extension Installed");
 942                        ExtensionStore::global(cx).update(cx, |store, cx| {
 943                            store.install_latest_extension(extension_id.clone(), cx)
 944                        });
 945                    }
 946                }),
 947                configure: None,
 948                upgrade: None,
 949            },
 950            ExtensionStatus::Installing => ExtensionCardButtons {
 951                install_or_uninstall: Button::new(
 952                    SharedString::from(extension.id.clone()),
 953                    "Install",
 954                )
 955                .disabled(true),
 956                configure: None,
 957                upgrade: None,
 958            },
 959            ExtensionStatus::Upgrading => ExtensionCardButtons {
 960                install_or_uninstall: Button::new(
 961                    SharedString::from(extension.id.clone()),
 962                    "Uninstall",
 963                )
 964                .disabled(true),
 965                configure: is_configurable.then(|| {
 966                    Button::new(
 967                        SharedString::from(format!("configure-{}", extension.id)),
 968                        "Configure",
 969                    )
 970                    .disabled(true)
 971                }),
 972                upgrade: Some(
 973                    Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
 974                ),
 975            },
 976            ExtensionStatus::Installed(installed_version) => ExtensionCardButtons {
 977                install_or_uninstall: Button::new(
 978                    SharedString::from(extension.id.clone()),
 979                    "Uninstall",
 980                )
 981                .on_click({
 982                    let extension_id = extension.id.clone();
 983                    move |_, _, cx| {
 984                        telemetry::event!("Extension Uninstalled", extension_id);
 985                        ExtensionStore::global(cx).update(cx, |store, cx| {
 986                            store
 987                                .uninstall_extension(extension_id.clone(), cx)
 988                                .detach_and_log_err(cx);
 989                        });
 990                    }
 991                }),
 992                configure: is_configurable.then(|| {
 993                    Button::new(
 994                        SharedString::from(format!("configure-{}", extension.id)),
 995                        "Configure",
 996                    )
 997                    .on_click({
 998                        let extension_id = extension.id.clone();
 999                        move |_, _, cx| {
1000                            if let Some(manifest) = ExtensionStore::global(cx)
1001                                .read(cx)
1002                                .extension_manifest_for_id(&extension_id)
1003                                .cloned()
1004                            {
1005                                if let Some(events) = extension::ExtensionEvents::try_global(cx) {
1006                                    events.update(cx, |this, cx| {
1007                                        this.emit(
1008                                            extension::Event::ConfigureExtensionRequested(manifest),
1009                                            cx,
1010                                        )
1011                                    });
1012                                }
1013                            }
1014                        }
1015                    })
1016                }),
1017                upgrade: if installed_version == extension.manifest.version {
1018                    None
1019                } else {
1020                    Some(
1021                        Button::new(SharedString::from(extension.id.clone()), "Upgrade")
1022                            .when(!is_compatible, |upgrade_button| {
1023                                upgrade_button.disabled(true).tooltip({
1024                                    let version = extension.manifest.version.clone();
1025                                    move |_, cx| {
1026                                        Tooltip::simple(
1027                                            format!(
1028                                                "v{version} is not compatible with this version of Zed.",
1029                                            ),
1030                                             cx,
1031                                        )
1032                                    }
1033                                })
1034                            })
1035                            .disabled(!is_compatible)
1036                            .on_click({
1037                                let extension_id = extension.id.clone();
1038                                let version = extension.manifest.version.clone();
1039                                move |_, _, cx| {
1040                                    telemetry::event!("Extension Installed", extension_id, version);
1041                                    ExtensionStore::global(cx).update(cx, |store, cx| {
1042                                        store
1043                                            .upgrade_extension(
1044                                                extension_id.clone(),
1045                                                version.clone(),
1046                                                cx,
1047                                            )
1048                                            .detach_and_log_err(cx)
1049                                    });
1050                                }
1051                            }),
1052                    )
1053                },
1054            },
1055            ExtensionStatus::Removing => ExtensionCardButtons {
1056                install_or_uninstall: Button::new(
1057                    SharedString::from(extension.id.clone()),
1058                    "Uninstall",
1059                )
1060                .disabled(true),
1061                configure: is_configurable.then(|| {
1062                    Button::new(
1063                        SharedString::from(format!("configure-{}", extension.id)),
1064                        "Configure",
1065                    )
1066                    .disabled(true)
1067                }),
1068                upgrade: None,
1069            },
1070        }
1071    }
1072
1073    fn render_search(&self, cx: &mut Context<Self>) -> Div {
1074        let mut key_context = KeyContext::new_with_defaults();
1075        key_context.add("BufferSearchBar");
1076
1077        let editor_border = if self.query_contains_error {
1078            Color::Error.color(cx)
1079        } else {
1080            cx.theme().colors().border
1081        };
1082
1083        h_flex()
1084            .key_context(key_context)
1085            .h_8()
1086            .flex_1()
1087            .min_w(rems_from_px(384.))
1088            .pl_1p5()
1089            .pr_2()
1090            .py_1()
1091            .gap_2()
1092            .border_1()
1093            .border_color(editor_border)
1094            .rounded_lg()
1095            .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
1096            .child(self.render_text_input(&self.query_editor, cx))
1097    }
1098
1099    fn render_text_input(
1100        &self,
1101        editor: &Entity<Editor>,
1102        cx: &mut Context<Self>,
1103    ) -> impl IntoElement {
1104        let settings = ThemeSettings::get_global(cx);
1105        let text_style = TextStyle {
1106            color: if editor.read(cx).read_only(cx) {
1107                cx.theme().colors().text_disabled
1108            } else {
1109                cx.theme().colors().text
1110            },
1111            font_family: settings.ui_font.family.clone(),
1112            font_features: settings.ui_font.features.clone(),
1113            font_fallbacks: settings.ui_font.fallbacks.clone(),
1114            font_size: rems(0.875).into(),
1115            font_weight: settings.ui_font.weight,
1116            line_height: relative(1.3),
1117            ..Default::default()
1118        };
1119
1120        EditorElement::new(
1121            editor,
1122            EditorStyle {
1123                background: cx.theme().colors().editor_background,
1124                local_player: cx.theme().players().local(),
1125                text: text_style,
1126                ..Default::default()
1127            },
1128        )
1129    }
1130
1131    fn on_query_change(
1132        &mut self,
1133        _: Entity<Editor>,
1134        event: &editor::EditorEvent,
1135        cx: &mut Context<Self>,
1136    ) {
1137        if let editor::EditorEvent::Edited { .. } = event {
1138            self.query_contains_error = false;
1139            self.refresh_search(cx);
1140        }
1141    }
1142
1143    fn refresh_search(&mut self, cx: &mut Context<Self>) {
1144        self.fetch_extensions_debounced(
1145            Some(Box::new(|this, cx| {
1146                this.scroll_to_top(cx);
1147            })),
1148            cx,
1149        );
1150        self.refresh_feature_upsells(cx);
1151    }
1152
1153    pub fn change_provides_filter(
1154        &mut self,
1155        provides_filter: Option<ExtensionProvides>,
1156        cx: &mut Context<Self>,
1157    ) {
1158        self.provides_filter = provides_filter;
1159        self.refresh_search(cx);
1160    }
1161
1162    fn fetch_extensions_debounced(
1163        &mut self,
1164        on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
1165        cx: &mut Context<ExtensionsPage>,
1166    ) {
1167        self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1168            let search = this
1169                .update(cx, |this, cx| this.search_query(cx))
1170                .ok()
1171                .flatten();
1172
1173            // Only debounce the fetching of extensions if we have a search
1174            // query.
1175            //
1176            // If the search was just cleared then we can just reload the list
1177            // of extensions without a debounce, which allows us to avoid seeing
1178            // an intermittent flash of a "no extensions" state.
1179            if search.is_some() {
1180                cx.background_executor()
1181                    .timer(Duration::from_millis(250))
1182                    .await;
1183            };
1184
1185            this.update(cx, |this, cx| {
1186                this.fetch_extensions(
1187                    search,
1188                    Some(BTreeSet::from_iter(this.provides_filter)),
1189                    on_complete,
1190                    cx,
1191                );
1192            })
1193            .ok();
1194        }));
1195    }
1196
1197    pub fn search_query(&self, cx: &mut App) -> Option<String> {
1198        let search = self.query_editor.read(cx).text(cx);
1199        if search.trim().is_empty() {
1200            None
1201        } else {
1202            Some(search)
1203        }
1204    }
1205
1206    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1207        let has_search = self.search_query(cx).is_some();
1208
1209        let message = if self.is_fetching_extensions {
1210            "Loading extensions..."
1211        } else {
1212            match self.filter {
1213                ExtensionFilter::All => {
1214                    if has_search {
1215                        "No extensions that match your search."
1216                    } else {
1217                        "No extensions."
1218                    }
1219                }
1220                ExtensionFilter::Installed => {
1221                    if has_search {
1222                        "No installed extensions that match your search."
1223                    } else {
1224                        "No installed extensions."
1225                    }
1226                }
1227                ExtensionFilter::NotInstalled => {
1228                    if has_search {
1229                        "No not installed extensions that match your search."
1230                    } else {
1231                        "No not installed extensions."
1232                    }
1233                }
1234            }
1235        };
1236
1237        Label::new(message)
1238    }
1239
1240    fn update_settings<T: Settings>(
1241        &mut self,
1242        selection: &ToggleState,
1243
1244        cx: &mut Context<Self>,
1245        callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
1246    ) {
1247        if let Some(workspace) = self.workspace.upgrade() {
1248            let fs = workspace.read(cx).app_state().fs.clone();
1249            let selection = *selection;
1250            settings::update_settings_file::<T>(fs, cx, move |settings, _| {
1251                let value = match selection {
1252                    ToggleState::Unselected => false,
1253                    ToggleState::Selected => true,
1254                    _ => return,
1255                };
1256
1257                callback(settings, value)
1258            });
1259        }
1260    }
1261
1262    fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1263        let Some(search) = self.search_query(cx) else {
1264            self.upsells.clear();
1265            return;
1266        };
1267
1268        let search = search.to_lowercase();
1269        let search_terms = search
1270            .split_whitespace()
1271            .map(|term| term.trim())
1272            .collect::<Vec<_>>();
1273
1274        for (feature, keywords) in keywords_by_feature() {
1275            if keywords
1276                .iter()
1277                .any(|keyword| search_terms.contains(keyword))
1278            {
1279                self.upsells.insert(*feature);
1280            } else {
1281                self.upsells.remove(feature);
1282            }
1283        }
1284    }
1285
1286    fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1287        let upsells_count = self.upsells.len();
1288
1289        v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| {
1290            let upsell = match feature {
1291                Feature::Git => FeatureUpsell::new(
1292                    "Zed comes with basic Git support. More Git features are coming in the future.",
1293                )
1294                .docs_url("https://zed.dev/docs/git"),
1295                Feature::OpenIn => FeatureUpsell::new(
1296                    "Zed supports linking to a source line on GitHub and others.",
1297                )
1298                .docs_url("https://zed.dev/docs/git#git-integrations"),
1299                Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!")
1300                    .docs_url("https://zed.dev/docs/vim")
1301                    .child(CheckboxWithLabel::new(
1302                        "enable-vim",
1303                        Label::new("Enable vim mode"),
1304                        if VimModeSetting::get_global(cx).0 {
1305                            ui::ToggleState::Selected
1306                        } else {
1307                            ui::ToggleState::Unselected
1308                        },
1309                        cx.listener(move |this, selection, _, cx| {
1310                            telemetry::event!("Vim Mode Toggled", source = "Feature Upsell");
1311                            this.update_settings::<VimModeSetting>(
1312                                selection,
1313                                cx,
1314                                |setting, value| *setting = Some(value),
1315                            );
1316                        }),
1317                    )),
1318                Feature::LanguageBash => FeatureUpsell::new("Shell support is built-in to Zed!")
1319                    .docs_url("https://zed.dev/docs/languages/bash"),
1320                Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!")
1321                    .docs_url("https://zed.dev/docs/languages/c"),
1322                Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!")
1323                    .docs_url("https://zed.dev/docs/languages/cpp"),
1324                Feature::LanguageGo => FeatureUpsell::new("Go support is built-in to Zed!")
1325                    .docs_url("https://zed.dev/docs/languages/go"),
1326                Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!")
1327                    .docs_url("https://zed.dev/docs/languages/python"),
1328                Feature::LanguageReact => FeatureUpsell::new("React support is built-in to Zed!")
1329                    .docs_url("https://zed.dev/docs/languages/typescript"),
1330                Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!")
1331                    .docs_url("https://zed.dev/docs/languages/rust"),
1332                Feature::LanguageTypescript => {
1333                    FeatureUpsell::new("Typescript support is built-in to Zed!")
1334                        .docs_url("https://zed.dev/docs/languages/typescript")
1335                }
1336            };
1337
1338            upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
1339        }))
1340    }
1341}
1342
1343impl Render for ExtensionsPage {
1344    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1345        v_flex()
1346            .size_full()
1347            .bg(cx.theme().colors().editor_background)
1348            .child(
1349                v_flex()
1350                    .gap_4()
1351                    .pt_4()
1352                    .px_4()
1353                    .bg(cx.theme().colors().editor_background)
1354                    .child(
1355                        h_flex()
1356                            .w_full()
1357                            .gap_2()
1358                            .justify_between()
1359                            .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1360                            .child(
1361                                Button::new("install-dev-extension", "Install Dev Extension")
1362                                    .style(ButtonStyle::Filled)
1363                                    .size(ButtonSize::Large)
1364                                    .on_click(|_event, window, cx| {
1365                                        window.dispatch_action(Box::new(InstallDevExtension), cx)
1366                                    }),
1367                            ),
1368                    )
1369                    .child(
1370                        h_flex()
1371                            .w_full()
1372                            .gap_4()
1373                            .flex_wrap()
1374                            .child(self.render_search(cx))
1375                            .child(
1376                                h_flex()
1377                                    .child(
1378                                        ToggleButton::new("filter-all", "All")
1379                                            .style(ButtonStyle::Filled)
1380                                            .size(ButtonSize::Large)
1381                                            .toggle_state(self.filter == ExtensionFilter::All)
1382                                            .on_click(cx.listener(|this, _event, _, cx| {
1383                                                this.filter = ExtensionFilter::All;
1384                                                this.filter_extension_entries(cx);
1385                                                this.scroll_to_top(cx);
1386                                            }))
1387                                            .tooltip(move |_, cx| {
1388                                                Tooltip::simple("Show all extensions", cx)
1389                                            })
1390                                            .first(),
1391                                    )
1392                                    .child(
1393                                        ToggleButton::new("filter-installed", "Installed")
1394                                            .style(ButtonStyle::Filled)
1395                                            .size(ButtonSize::Large)
1396                                            .toggle_state(self.filter == ExtensionFilter::Installed)
1397                                            .on_click(cx.listener(|this, _event, _, cx| {
1398                                                this.filter = ExtensionFilter::Installed;
1399                                                this.filter_extension_entries(cx);
1400                                                this.scroll_to_top(cx);
1401                                            }))
1402                                            .tooltip(move |_, cx| {
1403                                                Tooltip::simple("Show installed extensions", cx)
1404                                            })
1405                                            .middle(),
1406                                    )
1407                                    .child(
1408                                        ToggleButton::new("filter-not-installed", "Not Installed")
1409                                            .style(ButtonStyle::Filled)
1410                                            .size(ButtonSize::Large)
1411                                            .toggle_state(
1412                                                self.filter == ExtensionFilter::NotInstalled,
1413                                            )
1414                                            .on_click(cx.listener(|this, _event, _, cx| {
1415                                                this.filter = ExtensionFilter::NotInstalled;
1416                                                this.filter_extension_entries(cx);
1417                                                this.scroll_to_top(cx);
1418                                            }))
1419                                            .tooltip(move |_, cx| {
1420                                                Tooltip::simple("Show not installed extensions", cx)
1421                                            })
1422                                            .last(),
1423                                    ),
1424                            ),
1425                    ),
1426            )
1427            .child(
1428                h_flex()
1429                    .id("filter-row")
1430                    .gap_2()
1431                    .py_2p5()
1432                    .px_4()
1433                    .border_b_1()
1434                    .border_color(cx.theme().colors().border_variant)
1435                    .overflow_x_scroll()
1436                    .child(
1437                        Button::new("filter-all-categories", "All")
1438                            .when(self.provides_filter.is_none(), |button| {
1439                                button.style(ButtonStyle::Filled)
1440                            })
1441                            .when(self.provides_filter.is_some(), |button| {
1442                                button.style(ButtonStyle::Subtle)
1443                            })
1444                            .toggle_state(self.provides_filter.is_none())
1445                            .on_click(cx.listener(|this, _event, _, cx| {
1446                                this.change_provides_filter(None, cx);
1447                            })),
1448                    )
1449                    .children(ExtensionProvides::iter().map(|provides| {
1450                        let label = extension_provides_label(provides);
1451                        Button::new(
1452                            SharedString::from(format!("filter-category-{}", label)),
1453                            label,
1454                        )
1455                        .style(if self.provides_filter == Some(provides) {
1456                            ButtonStyle::Filled
1457                        } else {
1458                            ButtonStyle::Subtle
1459                        })
1460                        .toggle_state(self.provides_filter == Some(provides))
1461                        .on_click({
1462                            cx.listener(move |this, _event, _, cx| {
1463                                this.change_provides_filter(Some(provides), cx);
1464                            })
1465                        })
1466                    })),
1467            )
1468            .child(self.render_feature_upsells(cx))
1469            .child(
1470                v_flex()
1471                    .pl_4()
1472                    .pr_6()
1473                    .size_full()
1474                    .overflow_y_hidden()
1475                    .map(|this| {
1476                        let mut count = self.filtered_remote_extension_indices.len();
1477                        if self.filter.include_dev_extensions() {
1478                            count += self.dev_extension_entries.len();
1479                        }
1480
1481                        if count == 0 {
1482                            return this.py_4().child(self.render_empty_state(cx));
1483                        }
1484
1485                        let scroll_handle = self.list.clone();
1486                        this.child(
1487                            uniform_list("entries", count, cx.processor(Self::render_extensions))
1488                                .flex_grow()
1489                                .pb_4()
1490                                .track_scroll(scroll_handle),
1491                        )
1492                        .child(
1493                            div()
1494                                .absolute()
1495                                .right_1()
1496                                .top_0()
1497                                .bottom_0()
1498                                .w(px(12.))
1499                                .children(Scrollbar::vertical(self.scrollbar_state.clone())),
1500                        )
1501                    }),
1502            )
1503    }
1504}
1505
1506impl EventEmitter<ItemEvent> for ExtensionsPage {}
1507
1508impl Focusable for ExtensionsPage {
1509    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1510        self.query_editor.read(cx).focus_handle(cx)
1511    }
1512}
1513
1514impl Item for ExtensionsPage {
1515    type Event = ItemEvent;
1516
1517    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1518        "Extensions".into()
1519    }
1520
1521    fn telemetry_event_text(&self) -> Option<&'static str> {
1522        Some("Extensions Page Opened")
1523    }
1524
1525    fn show_toolbar(&self) -> bool {
1526        false
1527    }
1528
1529    fn clone_on_split(
1530        &self,
1531        _workspace_id: Option<WorkspaceId>,
1532        _window: &mut Window,
1533        _: &mut Context<Self>,
1534    ) -> Option<Entity<Self>> {
1535        None
1536    }
1537
1538    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1539        f(*event)
1540    }
1541}