extensions_ui.rs

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