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