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