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