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