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