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