extensions_ui.rs

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