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