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