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.weak_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                                    this.upgrade().map(|this| {
 893                                        Self::render_remote_extension_context_menu(
 894                                            &this,
 895                                            extension_id.clone(),
 896                                            authors.clone(),
 897                                            window,
 898                                            cx,
 899                                        )
 900                                    })
 901                                }),
 902                            ),
 903                    ),
 904            )
 905    }
 906
 907    fn render_remote_extension_context_menu(
 908        this: &Entity<Self>,
 909        extension_id: Arc<str>,
 910        authors: Vec<String>,
 911        window: &mut Window,
 912        cx: &mut App,
 913    ) -> Entity<ContextMenu> {
 914        ContextMenu::build(window, cx, |context_menu, window, _| {
 915            context_menu
 916                .entry(
 917                    "Install Another Version...",
 918                    None,
 919                    window.handler_for(this, {
 920                        let extension_id = extension_id.clone();
 921                        move |this, window, cx| {
 922                            this.show_extension_version_list(extension_id.clone(), window, cx)
 923                        }
 924                    }),
 925                )
 926                .entry("Copy Extension ID", None, {
 927                    let extension_id = extension_id.clone();
 928                    move |_, cx| {
 929                        cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
 930                    }
 931                })
 932                .entry("Copy Author Info", None, {
 933                    let authors = authors.clone();
 934                    move |_, cx| {
 935                        cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", ")));
 936                    }
 937                })
 938        })
 939    }
 940
 941    fn show_extension_version_list(
 942        &mut self,
 943        extension_id: Arc<str>,
 944        window: &mut Window,
 945        cx: &mut Context<Self>,
 946    ) {
 947        let Some(workspace) = self.workspace.upgrade() else {
 948            return;
 949        };
 950
 951        cx.spawn_in(window, async move |this, cx| {
 952            let extension_versions_task = this.update(cx, |_, cx| {
 953                let extension_store = ExtensionStore::global(cx);
 954
 955                extension_store.update(cx, |store, cx| {
 956                    store.fetch_extension_versions(&extension_id, cx)
 957                })
 958            })?;
 959
 960            let extension_versions = extension_versions_task.await?;
 961
 962            workspace.update_in(cx, |workspace, window, cx| {
 963                let fs = workspace.project().read(cx).fs().clone();
 964                workspace.toggle_modal(window, cx, |window, cx| {
 965                    let delegate = ExtensionVersionSelectorDelegate::new(
 966                        fs,
 967                        cx.entity().downgrade(),
 968                        extension_versions,
 969                    );
 970
 971                    ExtensionVersionSelector::new(delegate, window, cx)
 972                });
 973            })?;
 974
 975            anyhow::Ok(())
 976        })
 977        .detach_and_log_err(cx);
 978    }
 979
 980    fn buttons_for_entry(
 981        &self,
 982        extension: &ExtensionMetadata,
 983        status: &ExtensionStatus,
 984        has_dev_extension: bool,
 985        cx: &mut Context<Self>,
 986    ) -> ExtensionCardButtons {
 987        let is_compatible =
 988            extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
 989
 990        if has_dev_extension {
 991            // If we have a dev extension for the given extension, just treat it as uninstalled.
 992            // The button here is a placeholder, as it won't be interactable anyways.
 993            return ExtensionCardButtons {
 994                install_or_uninstall: Button::new(
 995                    SharedString::from(extension.id.clone()),
 996                    "Install",
 997                ),
 998                configure: None,
 999                upgrade: None,
1000            };
1001        }
1002
1003        let is_configurable = extension
1004            .manifest
1005            .provides
1006            .contains(&ExtensionProvides::ContextServers);
1007
1008        match status.clone() {
1009            ExtensionStatus::NotInstalled => ExtensionCardButtons {
1010                install_or_uninstall: Button::new(
1011                    SharedString::from(extension.id.clone()),
1012                    "Install",
1013                )
1014                .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1015                .icon(IconName::Download)
1016                .icon_size(IconSize::Small)
1017                .icon_color(Color::Muted)
1018                .icon_position(IconPosition::Start)
1019                .on_click({
1020                    let extension_id = extension.id.clone();
1021                    move |_, _, cx| {
1022                        telemetry::event!("Extension Installed");
1023                        ExtensionStore::global(cx).update(cx, |store, cx| {
1024                            store.install_latest_extension(extension_id.clone(), cx)
1025                        });
1026                    }
1027                }),
1028                configure: None,
1029                upgrade: None,
1030            },
1031            ExtensionStatus::Installing => ExtensionCardButtons {
1032                install_or_uninstall: Button::new(
1033                    SharedString::from(extension.id.clone()),
1034                    "Install",
1035                )
1036                .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1037                .icon(IconName::Download)
1038                .icon_size(IconSize::Small)
1039                .icon_color(Color::Muted)
1040                .icon_position(IconPosition::Start)
1041                .disabled(true),
1042                configure: None,
1043                upgrade: None,
1044            },
1045            ExtensionStatus::Upgrading => ExtensionCardButtons {
1046                install_or_uninstall: Button::new(
1047                    SharedString::from(extension.id.clone()),
1048                    "Uninstall",
1049                )
1050                .style(ButtonStyle::OutlinedGhost)
1051                .disabled(true),
1052                configure: is_configurable.then(|| {
1053                    Button::new(
1054                        SharedString::from(format!("configure-{}", extension.id)),
1055                        "Configure",
1056                    )
1057                    .disabled(true)
1058                }),
1059                upgrade: Some(
1060                    Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
1061                ),
1062            },
1063            ExtensionStatus::Installed(installed_version) => ExtensionCardButtons {
1064                install_or_uninstall: Button::new(
1065                    SharedString::from(extension.id.clone()),
1066                    "Uninstall",
1067                )
1068                .style(ButtonStyle::OutlinedGhost)
1069                .on_click({
1070                    let extension_id = extension.id.clone();
1071                    move |_, _, cx| {
1072                        telemetry::event!("Extension Uninstalled", extension_id);
1073                        ExtensionStore::global(cx).update(cx, |store, cx| {
1074                            store
1075                                .uninstall_extension(extension_id.clone(), cx)
1076                                .detach_and_log_err(cx);
1077                        });
1078                    }
1079                }),
1080                configure: is_configurable.then(|| {
1081                    Button::new(
1082                        SharedString::from(format!("configure-{}", extension.id)),
1083                        "Configure",
1084                    )
1085                    .style(ButtonStyle::OutlinedGhost)
1086                    .on_click({
1087                        let extension_id = extension.id.clone();
1088                        move |_, _, cx| {
1089                            if let Some(manifest) = ExtensionStore::global(cx)
1090                                .read(cx)
1091                                .extension_manifest_for_id(&extension_id)
1092                                .cloned()
1093                                && let Some(events) = extension::ExtensionEvents::try_global(cx)
1094                            {
1095                                events.update(cx, |this, cx| {
1096                                    this.emit(
1097                                        extension::Event::ConfigureExtensionRequested(manifest),
1098                                        cx,
1099                                    )
1100                                });
1101                            }
1102                        }
1103                    })
1104                }),
1105                upgrade: if installed_version == extension.manifest.version {
1106                    None
1107                } else {
1108                    Some(
1109                        Button::new(SharedString::from(extension.id.clone()), "Upgrade")
1110                          .style(ButtonStyle::Tinted(ui::TintColor::Accent))
1111                            .when(!is_compatible, |upgrade_button| {
1112                                upgrade_button.disabled(true).tooltip({
1113                                    let version = extension.manifest.version.clone();
1114                                    move |_, cx| {
1115                                        Tooltip::simple(
1116                                            format!(
1117                                                "v{version} is not compatible with this version of Zed.",
1118                                            ),
1119                                             cx,
1120                                        )
1121                                    }
1122                                })
1123                            })
1124                            .disabled(!is_compatible)
1125                            .on_click({
1126                                let extension_id = extension.id.clone();
1127                                let version = extension.manifest.version.clone();
1128                                move |_, _, cx| {
1129                                    telemetry::event!("Extension Installed", extension_id, version);
1130                                    ExtensionStore::global(cx).update(cx, |store, cx| {
1131                                        store
1132                                            .upgrade_extension(
1133                                                extension_id.clone(),
1134                                                version.clone(),
1135                                                cx,
1136                                            )
1137                                            .detach_and_log_err(cx)
1138                                    });
1139                                }
1140                            }),
1141                    )
1142                },
1143            },
1144            ExtensionStatus::Removing => ExtensionCardButtons {
1145                install_or_uninstall: Button::new(
1146                    SharedString::from(extension.id.clone()),
1147                    "Uninstall",
1148                )
1149                .style(ButtonStyle::OutlinedGhost)
1150                .disabled(true),
1151                configure: is_configurable.then(|| {
1152                    Button::new(
1153                        SharedString::from(format!("configure-{}", extension.id)),
1154                        "Configure",
1155                    )
1156                    .disabled(true)
1157                }),
1158                upgrade: None,
1159            },
1160        }
1161    }
1162
1163    fn render_search(&self, cx: &mut Context<Self>) -> Div {
1164        let mut key_context = KeyContext::new_with_defaults();
1165        key_context.add("BufferSearchBar");
1166
1167        let editor_border = if self.query_contains_error {
1168            Color::Error.color(cx)
1169        } else {
1170            cx.theme().colors().border
1171        };
1172
1173        h_flex()
1174            .key_context(key_context)
1175            .h_8()
1176            .min_w(rems_from_px(384.))
1177            .flex_1()
1178            .pl_1p5()
1179            .pr_2()
1180            .gap_2()
1181            .border_1()
1182            .border_color(editor_border)
1183            .rounded_md()
1184            .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
1185            .child(self.render_text_input(&self.query_editor, cx))
1186    }
1187
1188    fn render_text_input(
1189        &self,
1190        editor: &Entity<Editor>,
1191        cx: &mut Context<Self>,
1192    ) -> impl IntoElement {
1193        let settings = ThemeSettings::get_global(cx);
1194        let text_style = TextStyle {
1195            color: if editor.read(cx).read_only(cx) {
1196                cx.theme().colors().text_disabled
1197            } else {
1198                cx.theme().colors().text
1199            },
1200            font_family: settings.ui_font.family.clone(),
1201            font_features: settings.ui_font.features.clone(),
1202            font_fallbacks: settings.ui_font.fallbacks.clone(),
1203            font_size: rems(0.875).into(),
1204            font_weight: settings.ui_font.weight,
1205            line_height: relative(1.3),
1206            ..Default::default()
1207        };
1208
1209        EditorElement::new(
1210            editor,
1211            EditorStyle {
1212                background: cx.theme().colors().editor_background,
1213                local_player: cx.theme().players().local(),
1214                text: text_style,
1215                ..Default::default()
1216            },
1217        )
1218    }
1219
1220    fn on_query_change(
1221        &mut self,
1222        _: Entity<Editor>,
1223        event: &editor::EditorEvent,
1224        cx: &mut Context<Self>,
1225    ) {
1226        if let editor::EditorEvent::Edited { .. } = event {
1227            self.query_contains_error = false;
1228            self.refresh_search(cx);
1229        }
1230    }
1231
1232    fn refresh_search(&mut self, cx: &mut Context<Self>) {
1233        self.fetch_extensions_debounced(
1234            Some(Box::new(|this, cx| {
1235                this.scroll_to_top(cx);
1236            })),
1237            cx,
1238        );
1239        self.refresh_feature_upsells(cx);
1240    }
1241
1242    pub fn focus_extension(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
1243        self.query_editor.update(cx, |editor, cx| {
1244            editor.set_text(format!("id:{id}"), window, cx)
1245        });
1246        self.refresh_search(cx);
1247    }
1248
1249    pub fn change_provides_filter(
1250        &mut self,
1251        provides_filter: Option<ExtensionProvides>,
1252        cx: &mut Context<Self>,
1253    ) {
1254        self.provides_filter = provides_filter;
1255        self.refresh_search(cx);
1256    }
1257
1258    fn fetch_extensions_debounced(
1259        &mut self,
1260        on_complete: Option<Box<dyn FnOnce(&mut Self, &mut Context<Self>) + Send>>,
1261        cx: &mut Context<ExtensionsPage>,
1262    ) {
1263        self.extension_fetch_task = Some(cx.spawn(async move |this, cx| {
1264            let search = this
1265                .update(cx, |this, cx| this.search_query(cx))
1266                .ok()
1267                .flatten();
1268
1269            // Only debounce the fetching of extensions if we have a search
1270            // query.
1271            //
1272            // If the search was just cleared then we can just reload the list
1273            // of extensions without a debounce, which allows us to avoid seeing
1274            // an intermittent flash of a "no extensions" state.
1275            if search.is_some() {
1276                cx.background_executor()
1277                    .timer(Duration::from_millis(250))
1278                    .await;
1279            };
1280
1281            this.update(cx, |this, cx| {
1282                this.fetch_extensions(
1283                    search,
1284                    Some(BTreeSet::from_iter(this.provides_filter)),
1285                    on_complete,
1286                    cx,
1287                );
1288            })
1289            .ok();
1290        }));
1291    }
1292
1293    pub fn search_query(&self, cx: &mut App) -> Option<String> {
1294        let search = self.query_editor.read(cx).text(cx);
1295        if search.trim().is_empty() {
1296            None
1297        } else {
1298            Some(search)
1299        }
1300    }
1301
1302    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1303        let has_search = self.search_query(cx).is_some();
1304
1305        let message = if self.is_fetching_extensions {
1306            "Loading extensions…"
1307        } else if self.fetch_failed {
1308            "Failed to load extensions. Please check your connection and try again."
1309        } else {
1310            match self.filter {
1311                ExtensionFilter::All => {
1312                    if has_search {
1313                        "No extensions that match your search."
1314                    } else {
1315                        "No extensions."
1316                    }
1317                }
1318                ExtensionFilter::Installed => {
1319                    if has_search {
1320                        "No installed extensions that match your search."
1321                    } else {
1322                        "No installed extensions."
1323                    }
1324                }
1325                ExtensionFilter::NotInstalled => {
1326                    if has_search {
1327                        "No not installed extensions that match your search."
1328                    } else {
1329                        "No not installed extensions."
1330                    }
1331                }
1332            }
1333        };
1334
1335        h_flex()
1336            .py_4()
1337            .gap_1p5()
1338            .when(self.fetch_failed, |this| {
1339                this.child(
1340                    Icon::new(IconName::Warning)
1341                        .size(IconSize::Small)
1342                        .color(Color::Warning),
1343                )
1344            })
1345            .child(Label::new(message))
1346    }
1347
1348    fn update_settings(
1349        &mut self,
1350        selection: &ToggleState,
1351
1352        cx: &mut Context<Self>,
1353        callback: impl 'static + Send + Fn(&mut SettingsContent, bool),
1354    ) {
1355        if let Some(workspace) = self.workspace.upgrade() {
1356            let fs = workspace.read(cx).app_state().fs.clone();
1357            let selection = *selection;
1358            settings::update_settings_file(fs, cx, move |settings, _| {
1359                let value = match selection {
1360                    ToggleState::Unselected => false,
1361                    ToggleState::Selected => true,
1362                    _ => return,
1363                };
1364
1365                callback(settings, value)
1366            });
1367        }
1368    }
1369
1370    fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1371        let Some(search) = self.search_query(cx) else {
1372            self.upsells.clear();
1373            return;
1374        };
1375
1376        if let Some(id) = search.strip_prefix("id:") {
1377            self.upsells.clear();
1378
1379            let upsell = match id.to_lowercase().as_str() {
1380                "ruff" => Some(Feature::ExtensionRuff),
1381                "basedpyright" => Some(Feature::ExtensionBasedpyright),
1382                "ty" => Some(Feature::ExtensionTy),
1383                _ => None,
1384            };
1385
1386            if let Some(upsell) = upsell {
1387                self.upsells.insert(upsell);
1388            }
1389
1390            return;
1391        }
1392
1393        let search = search.to_lowercase();
1394        let search_terms = search
1395            .split_whitespace()
1396            .map(|term| term.trim())
1397            .collect::<Vec<_>>();
1398
1399        for (feature, keywords) in keywords_by_feature() {
1400            if keywords
1401                .iter()
1402                .any(|keyword| search_terms.contains(keyword))
1403            {
1404                self.upsells.insert(*feature);
1405            } else {
1406                self.upsells.remove(feature);
1407            }
1408        }
1409    }
1410
1411    fn render_feature_upsell_banner(
1412        &self,
1413        label: SharedString,
1414        docs_url: SharedString,
1415        vim: bool,
1416        cx: &mut Context<Self>,
1417    ) -> impl IntoElement {
1418        let docs_url_button = Button::new("open_docs", "View Documentation")
1419            .icon(IconName::ArrowUpRight)
1420            .icon_size(IconSize::Small)
1421            .icon_position(IconPosition::End)
1422            .on_click({
1423                move |_event, _window, cx| {
1424                    telemetry::event!(
1425                        "Documentation Viewed",
1426                        source = "Feature Upsell",
1427                        url = docs_url,
1428                    );
1429                    cx.open_url(&docs_url)
1430                }
1431            });
1432
1433        div()
1434            .pt_4()
1435            .px_4()
1436            .child(
1437                Banner::new()
1438                    .severity(Severity::Success)
1439                    .child(Label::new(label).mt_0p5())
1440                    .map(|this| {
1441                        if vim {
1442                            this.action_slot(
1443                                h_flex()
1444                                    .gap_1()
1445                                    .child(docs_url_button)
1446                                    .child(Divider::vertical().color(ui::DividerColor::Border))
1447                                    .child(
1448                                        h_flex()
1449                                            .pl_1()
1450                                            .gap_1()
1451                                            .child(Label::new("Enable Vim mode"))
1452                                            .child(
1453                                                Switch::new(
1454                                                    "enable-vim",
1455                                                    if VimModeSetting::get_global(cx).0 {
1456                                                        ui::ToggleState::Selected
1457                                                    } else {
1458                                                        ui::ToggleState::Unselected
1459                                                    },
1460                                                )
1461                                                .on_click(cx.listener(
1462                                                    move |this, selection, _, cx| {
1463                                                        telemetry::event!(
1464                                                            "Vim Mode Toggled",
1465                                                            source = "Feature Upsell"
1466                                                        );
1467                                                        this.update_settings(
1468                                                            selection,
1469                                                            cx,
1470                                                            |setting, value| {
1471                                                                setting.vim_mode = Some(value)
1472                                                            },
1473                                                        );
1474                                                    },
1475                                                ))
1476                                                .color(ui::SwitchColor::Accent),
1477                                            ),
1478                                    ),
1479                            )
1480                        } else {
1481                            this.action_slot(docs_url_button)
1482                        }
1483                    }),
1484            )
1485            .into_any_element()
1486    }
1487
1488    fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1489        let mut container = v_flex();
1490
1491        for feature in &self.upsells {
1492            let banner = match feature {
1493                Feature::AgentClaude => self.render_feature_upsell_banner(
1494                    "Claude Code support is built-in to Zed!".into(),
1495                    "https://zed.dev/docs/ai/external-agents#claude-code".into(),
1496                    false,
1497                    cx,
1498                ),
1499                Feature::AgentCodex => self.render_feature_upsell_banner(
1500                    "Codex CLI support is built-in to Zed!".into(),
1501                    "https://zed.dev/docs/ai/external-agents#codex-cli".into(),
1502                    false,
1503                    cx,
1504                ),
1505                Feature::AgentGemini => self.render_feature_upsell_banner(
1506                    "Gemini CLI support is built-in to Zed!".into(),
1507                    "https://zed.dev/docs/ai/external-agents#gemini-cli".into(),
1508                    false,
1509                    cx,
1510                ),
1511                Feature::ExtensionBasedpyright => self.render_feature_upsell_banner(
1512                    "Basedpyright (Python language server) support is built-in to Zed!".into(),
1513                    "https://zed.dev/docs/languages/python#basedpyright".into(),
1514                    false,
1515                    cx,
1516                ),
1517                Feature::ExtensionRuff => self.render_feature_upsell_banner(
1518                    "Ruff (linter for Python) support is built-in to Zed!".into(),
1519                    "https://zed.dev/docs/languages/python#code-formatting--linting".into(),
1520                    false,
1521                    cx,
1522                ),
1523                Feature::ExtensionTailwind => self.render_feature_upsell_banner(
1524                    "Tailwind CSS support is built-in to Zed!".into(),
1525                    "https://zed.dev/docs/languages/tailwindcss".into(),
1526                    false,
1527                    cx,
1528                ),
1529                Feature::ExtensionTy => self.render_feature_upsell_banner(
1530                    "Ty (Python language server) support is built-in to Zed!".into(),
1531                    "https://zed.dev/docs/languages/python".into(),
1532                    false,
1533                    cx,
1534                ),
1535                Feature::Git => self.render_feature_upsell_banner(
1536                    "Zed comes with basic Git support—more features are coming in the future."
1537                        .into(),
1538                    "https://zed.dev/docs/git".into(),
1539                    false,
1540                    cx,
1541                ),
1542                Feature::LanguageBash => self.render_feature_upsell_banner(
1543                    "Shell support is built-in to Zed!".into(),
1544                    "https://zed.dev/docs/languages/bash".into(),
1545                    false,
1546                    cx,
1547                ),
1548                Feature::LanguageC => self.render_feature_upsell_banner(
1549                    "C support is built-in to Zed!".into(),
1550                    "https://zed.dev/docs/languages/c".into(),
1551                    false,
1552                    cx,
1553                ),
1554                Feature::LanguageCpp => self.render_feature_upsell_banner(
1555                    "C++ support is built-in to Zed!".into(),
1556                    "https://zed.dev/docs/languages/cpp".into(),
1557                    false,
1558                    cx,
1559                ),
1560                Feature::LanguageGo => self.render_feature_upsell_banner(
1561                    "Go support is built-in to Zed!".into(),
1562                    "https://zed.dev/docs/languages/go".into(),
1563                    false,
1564                    cx,
1565                ),
1566                Feature::LanguagePython => self.render_feature_upsell_banner(
1567                    "Python support is built-in to Zed!".into(),
1568                    "https://zed.dev/docs/languages/python".into(),
1569                    false,
1570                    cx,
1571                ),
1572                Feature::LanguageReact => self.render_feature_upsell_banner(
1573                    "React support is built-in to Zed!".into(),
1574                    "https://zed.dev/docs/languages/typescript".into(),
1575                    false,
1576                    cx,
1577                ),
1578                Feature::LanguageRust => self.render_feature_upsell_banner(
1579                    "Rust support is built-in to Zed!".into(),
1580                    "https://zed.dev/docs/languages/rust".into(),
1581                    false,
1582                    cx,
1583                ),
1584                Feature::LanguageTypescript => self.render_feature_upsell_banner(
1585                    "Typescript support is built-in to Zed!".into(),
1586                    "https://zed.dev/docs/languages/typescript".into(),
1587                    false,
1588                    cx,
1589                ),
1590                Feature::OpenIn => self.render_feature_upsell_banner(
1591                    "Zed supports linking to a source line on GitHub and others.".into(),
1592                    "https://zed.dev/docs/git#git-integrations".into(),
1593                    false,
1594                    cx,
1595                ),
1596                Feature::Vim => self.render_feature_upsell_banner(
1597                    "Vim support is built-in to Zed!".into(),
1598                    "https://zed.dev/docs/vim".into(),
1599                    true,
1600                    cx,
1601                ),
1602            };
1603            container = container.child(banner);
1604        }
1605
1606        container
1607    }
1608}
1609
1610impl Render for ExtensionsPage {
1611    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1612        v_flex()
1613            .size_full()
1614            .bg(cx.theme().colors().editor_background)
1615            .child(
1616                v_flex()
1617                    .gap_4()
1618                    .pt_4()
1619                    .px_4()
1620                    .bg(cx.theme().colors().editor_background)
1621                    .child(
1622                        h_flex()
1623                            .w_full()
1624                            .gap_1p5()
1625                            .justify_between()
1626                            .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1627                            .child(
1628                                Button::new("install-dev-extension", "Install Dev Extension")
1629                                    .style(ButtonStyle::Outlined)
1630                                    .size(ButtonSize::Medium)
1631                                    .on_click(|_event, window, cx| {
1632                                        window.dispatch_action(Box::new(InstallDevExtension), cx)
1633                                    }),
1634                            ),
1635                    )
1636                    .child(
1637                        h_flex()
1638                            .w_full()
1639                            .flex_wrap()
1640                            .gap_2()
1641                            .child(self.render_search(cx))
1642                            .child(
1643                                div().child(
1644                                    ToggleButtonGroup::single_row(
1645                                        "filter-buttons",
1646                                        [
1647                                            ToggleButtonSimple::new(
1648                                                "All",
1649                                                cx.listener(|this, _event, _, cx| {
1650                                                    this.filter = ExtensionFilter::All;
1651                                                    this.filter_extension_entries(cx);
1652                                                    this.scroll_to_top(cx);
1653                                                }),
1654                                            ),
1655                                            ToggleButtonSimple::new(
1656                                                "Installed",
1657                                                cx.listener(|this, _event, _, cx| {
1658                                                    this.filter = ExtensionFilter::Installed;
1659                                                    this.filter_extension_entries(cx);
1660                                                    this.scroll_to_top(cx);
1661                                                }),
1662                                            ),
1663                                            ToggleButtonSimple::new(
1664                                                "Not Installed",
1665                                                cx.listener(|this, _event, _, cx| {
1666                                                    this.filter = ExtensionFilter::NotInstalled;
1667                                                    this.filter_extension_entries(cx);
1668                                                    this.scroll_to_top(cx);
1669                                                }),
1670                                            ),
1671                                        ],
1672                                    )
1673                                    .style(ToggleButtonGroupStyle::Outlined)
1674                                    .size(ToggleButtonGroupSize::Custom(rems_from_px(30.))) // Perfectly matches the input
1675                                    .label_size(LabelSize::Default)
1676                                    .auto_width()
1677                                    .selected_index(match self.filter {
1678                                        ExtensionFilter::All => 0,
1679                                        ExtensionFilter::Installed => 1,
1680                                        ExtensionFilter::NotInstalled => 2,
1681                                    })
1682                                    .into_any_element(),
1683                                ),
1684                            ),
1685                    ),
1686            )
1687            .child(
1688                h_flex()
1689                    .id("filter-row")
1690                    .gap_2()
1691                    .py_2p5()
1692                    .px_4()
1693                    .border_b_1()
1694                    .border_color(cx.theme().colors().border_variant)
1695                    .overflow_x_scroll()
1696                    .child(
1697                        Button::new("filter-all-categories", "All")
1698                            .when(self.provides_filter.is_none(), |button| {
1699                                button.style(ButtonStyle::Filled)
1700                            })
1701                            .when(self.provides_filter.is_some(), |button| {
1702                                button.style(ButtonStyle::Subtle)
1703                            })
1704                            .toggle_state(self.provides_filter.is_none())
1705                            .on_click(cx.listener(|this, _event, _, cx| {
1706                                this.change_provides_filter(None, cx);
1707                            })),
1708                    )
1709                    .children(ExtensionProvides::iter().filter_map(|provides| {
1710                        match provides {
1711                            ExtensionProvides::SlashCommands
1712                            | ExtensionProvides::IndexedDocsProviders => return None,
1713                            _ => {}
1714                        }
1715
1716                        let label = extension_provides_label(provides);
1717                        let button_id = SharedString::from(format!("filter-category-{}", label));
1718
1719                        Some(
1720                            Button::new(button_id, label)
1721                                .style(if self.provides_filter == Some(provides) {
1722                                    ButtonStyle::Filled
1723                                } else {
1724                                    ButtonStyle::Subtle
1725                                })
1726                                .toggle_state(self.provides_filter == Some(provides))
1727                                .on_click({
1728                                    cx.listener(move |this, _event, _, cx| {
1729                                        this.change_provides_filter(Some(provides), cx);
1730                                    })
1731                                }),
1732                        )
1733                    })),
1734            )
1735            .child(self.render_feature_upsells(cx))
1736            .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
1737                let mut count = self.filtered_remote_extension_indices.len();
1738                if self.filter.include_dev_extensions() {
1739                    count += self.dev_extension_entries.len();
1740                }
1741
1742                if count == 0 {
1743                    this.child(self.render_empty_state(cx)).into_any_element()
1744                } else {
1745                    let scroll_handle = &self.list;
1746                    this.child(
1747                        uniform_list("entries", count, cx.processor(Self::render_extensions))
1748                            .flex_grow()
1749                            .pb_4()
1750                            .track_scroll(scroll_handle),
1751                    )
1752                    .vertical_scrollbar_for(scroll_handle, window, cx)
1753                    .into_any_element()
1754                }
1755            }))
1756    }
1757}
1758
1759impl EventEmitter<ItemEvent> for ExtensionsPage {}
1760
1761impl Focusable for ExtensionsPage {
1762    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1763        self.query_editor.read(cx).focus_handle(cx)
1764    }
1765}
1766
1767impl Item for ExtensionsPage {
1768    type Event = ItemEvent;
1769
1770    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1771        "Extensions".into()
1772    }
1773
1774    fn telemetry_event_text(&self) -> Option<&'static str> {
1775        Some("Extensions Page Opened")
1776    }
1777
1778    fn show_toolbar(&self) -> bool {
1779        false
1780    }
1781
1782    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1783        f(*event)
1784    }
1785}