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