extensions_ui.rs

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