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 client::{ExtensionMetadata, ExtensionProvides};
  10use collections::{BTreeMap, BTreeSet};
  11use editor::{Editor, EditorElement, EditorStyle};
  12use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore};
  13use feature_flags::FeatureFlagAppExt as _;
  14use fuzzy::{match_strings, StringMatchCandidate};
  15use gpui::{
  16    actions, uniform_list, Action, App, ClipboardItem, Context, Entity, EventEmitter, Flatten,
  17    Focusable, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle,
  18    UniformListScrollHandle, WeakEntity, Window,
  19};
  20use num_format::{Locale, ToFormattedString};
  21use project::DirectoryLister;
  22use release_channel::ReleaseChannel;
  23use settings::Settings;
  24use theme::ThemeSettings;
  25use ui::{prelude::*, CheckboxWithLabel, ContextMenu, PopoverMenu, ToggleButton, Tooltip};
  26use vim_mode_setting::VimModeSetting;
  27use workspace::{
  28    item::{Item, ItemEvent},
  29    Workspace, WorkspaceId,
  30};
  31
  32use crate::components::{ExtensionCard, FeatureUpsell};
  33use crate::extension_version_selector::{
  34    ExtensionVersionSelector, ExtensionVersionSelectorDelegate,
  35};
  36
  37actions!(zed, [InstallDevExtension]);
  38
  39pub fn init(cx: &mut App) {
  40    cx.observe_new(move |workspace: &mut Workspace, window, cx| {
  41        let Some(window) = window else {
  42            return;
  43        };
  44        workspace
  45            .register_action(move |workspace, _: &zed_actions::Extensions, window, cx| {
  46                let existing = workspace
  47                    .active_pane()
  48                    .read(cx)
  49                    .items()
  50                    .find_map(|item| item.downcast::<ExtensionsPage>());
  51
  52                if let Some(existing) = existing {
  53                    workspace.activate_item(&existing, true, true, window, cx);
  54                } else {
  55                    let extensions_page = ExtensionsPage::new(workspace, window, cx);
  56                    workspace.add_item_to_active_pane(
  57                        Box::new(extensions_page),
  58                        None,
  59                        true,
  60                        window,
  61                        cx,
  62                    )
  63                }
  64            })
  65            .register_action(move |workspace, _: &InstallDevExtension, window, cx| {
  66                let store = ExtensionStore::global(cx);
  67                let prompt = workspace.prompt_for_open_path(
  68                    gpui::PathPromptOptions {
  69                        files: false,
  70                        directories: true,
  71                        multiple: false,
  72                    },
  73                    DirectoryLister::Local(workspace.app_state().fs.clone()),
  74                    window,
  75                    cx,
  76                );
  77
  78                let workspace_handle = cx.entity().downgrade();
  79                window
  80                    .spawn(cx, |mut cx| async move {
  81                        let extension_path =
  82                            match Flatten::flatten(prompt.await.map_err(|e| e.into())) {
  83                                Ok(Some(mut paths)) => paths.pop()?,
  84                                Ok(None) => return None,
  85                                Err(err) => {
  86                                    workspace_handle
  87                                        .update(&mut cx, |workspace, cx| {
  88                                            workspace.show_portal_error(err.to_string(), cx);
  89                                        })
  90                                        .ok();
  91                                    return None;
  92                                }
  93                            };
  94
  95                        let install_task = store
  96                            .update(&mut cx, |store, cx| {
  97                                store.install_dev_extension(extension_path, cx)
  98                            })
  99                            .ok()?;
 100
 101                        match install_task.await {
 102                            Ok(_) => {}
 103                            Err(err) => {
 104                                workspace_handle
 105                                    .update(&mut cx, |workspace, cx| {
 106                                        workspace.show_error(
 107                                            &err.context("failed to install dev extension"),
 108                                            cx,
 109                                        );
 110                                    })
 111                                    .ok();
 112                            }
 113                        }
 114
 115                        Some(())
 116                    })
 117                    .detach();
 118            });
 119
 120        cx.subscribe_in(workspace.project(), window, |_, _, event, window, cx| {
 121            if let project::Event::LanguageNotFound(buffer) = event {
 122                extension_suggest::suggest(buffer.clone(), window, cx);
 123            }
 124        })
 125        .detach();
 126    })
 127    .detach();
 128}
 129
 130#[derive(Clone)]
 131pub enum ExtensionStatus {
 132    NotInstalled,
 133    Installing,
 134    Upgrading,
 135    Installed(Arc<str>),
 136    Removing,
 137}
 138
 139#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 140enum ExtensionFilter {
 141    All,
 142    Installed,
 143    NotInstalled,
 144}
 145
 146impl ExtensionFilter {
 147    pub fn include_dev_extensions(&self) -> bool {
 148        match self {
 149            Self::All | Self::Installed => true,
 150            Self::NotInstalled => false,
 151        }
 152    }
 153}
 154
 155#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 156enum Feature {
 157    Git,
 158    OpenIn,
 159    Vim,
 160    LanguageBash,
 161    LanguageC,
 162    LanguageCpp,
 163    LanguageGo,
 164    LanguagePython,
 165    LanguageReact,
 166    LanguageRust,
 167    LanguageTypescript,
 168}
 169
 170fn keywords_by_feature() -> &'static BTreeMap<Feature, Vec<&'static str>> {
 171    static KEYWORDS_BY_FEATURE: OnceLock<BTreeMap<Feature, Vec<&'static str>>> = OnceLock::new();
 172    KEYWORDS_BY_FEATURE.get_or_init(|| {
 173        BTreeMap::from_iter([
 174            (Feature::Git, vec!["git"]),
 175            (
 176                Feature::OpenIn,
 177                vec![
 178                    "github",
 179                    "gitlab",
 180                    "bitbucket",
 181                    "codeberg",
 182                    "sourcehut",
 183                    "permalink",
 184                    "link",
 185                    "open in",
 186                ],
 187            ),
 188            (Feature::Vim, vec!["vim"]),
 189            (Feature::LanguageBash, vec!["sh", "bash"]),
 190            (Feature::LanguageC, vec!["c", "clang"]),
 191            (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]),
 192            (Feature::LanguageGo, vec!["go", "golang"]),
 193            (Feature::LanguagePython, vec!["python", "py"]),
 194            (Feature::LanguageReact, vec!["react"]),
 195            (Feature::LanguageRust, vec!["rust", "rs"]),
 196            (
 197                Feature::LanguageTypescript,
 198                vec!["type", "typescript", "ts"],
 199            ),
 200        ])
 201    })
 202}
 203
 204pub struct ExtensionsPage {
 205    workspace: WeakEntity<Workspace>,
 206    list: UniformListScrollHandle,
 207    is_fetching_extensions: bool,
 208    filter: ExtensionFilter,
 209    remote_extension_entries: Vec<ExtensionMetadata>,
 210    dev_extension_entries: Vec<Arc<ExtensionManifest>>,
 211    filtered_remote_extension_indices: Vec<usize>,
 212    query_editor: Entity<Editor>,
 213    query_contains_error: bool,
 214    provides_filter: Option<ExtensionProvides>,
 215    _subscriptions: [gpui::Subscription; 2],
 216    extension_fetch_task: Option<Task<()>>,
 217    upsells: BTreeSet<Feature>,
 218}
 219
 220impl ExtensionsPage {
 221    pub fn new(
 222        workspace: &Workspace,
 223        window: &mut Window,
 224        cx: &mut Context<Workspace>,
 225    ) -> Entity<Self> {
 226        cx.new(|cx| {
 227            let store = ExtensionStore::global(cx);
 228            let workspace_handle = workspace.weak_handle();
 229            let subscriptions = [
 230                cx.observe(&store, |_: &mut Self, _, cx| cx.notify()),
 231                cx.subscribe_in(
 232                    &store,
 233                    window,
 234                    move |this, _, event, window, cx| match event {
 235                        extension_host::Event::ExtensionsUpdated => {
 236                            this.fetch_extensions_debounced(cx)
 237                        }
 238                        extension_host::Event::ExtensionInstalled(extension_id) => this
 239                            .on_extension_installed(
 240                                workspace_handle.clone(),
 241                                extension_id,
 242                                window,
 243                                cx,
 244                            ),
 245                        _ => {}
 246                    },
 247                ),
 248            ];
 249
 250            let query_editor = cx.new(|cx| {
 251                let mut input = Editor::single_line(window, cx);
 252                input.set_placeholder_text("Search extensions...", cx);
 253                input
 254            });
 255            cx.subscribe(&query_editor, Self::on_query_change).detach();
 256
 257            let mut this = Self {
 258                workspace: workspace.weak_handle(),
 259                list: UniformListScrollHandle::new(),
 260                is_fetching_extensions: false,
 261                filter: ExtensionFilter::All,
 262                dev_extension_entries: Vec::new(),
 263                filtered_remote_extension_indices: Vec::new(),
 264                remote_extension_entries: Vec::new(),
 265                query_contains_error: false,
 266                provides_filter: None,
 267                extension_fetch_task: None,
 268                _subscriptions: subscriptions,
 269                query_editor,
 270                upsells: BTreeSet::default(),
 271            };
 272            this.fetch_extensions(None, None, cx);
 273            this
 274        })
 275    }
 276
 277    fn on_extension_installed(
 278        &mut self,
 279        workspace: WeakEntity<Workspace>,
 280        extension_id: &str,
 281        window: &mut Window,
 282        cx: &mut Context<Self>,
 283    ) {
 284        let extension_store = ExtensionStore::global(cx).read(cx);
 285        let themes = extension_store
 286            .extension_themes(extension_id)
 287            .map(|name| name.to_string())
 288            .collect::<Vec<_>>();
 289        if !themes.is_empty() {
 290            workspace
 291                .update(cx, |_workspace, cx| {
 292                    window.dispatch_action(
 293                        zed_actions::theme_selector::Toggle {
 294                            themes_filter: Some(themes),
 295                        }
 296                        .boxed_clone(),
 297                        cx,
 298                    );
 299                })
 300                .ok();
 301            return;
 302        }
 303
 304        let icon_themes = extension_store
 305            .extension_icon_themes(extension_id)
 306            .map(|name| name.to_string())
 307            .collect::<Vec<_>>();
 308        if !icon_themes.is_empty() {
 309            workspace
 310                .update(cx, |_workspace, cx| {
 311                    window.dispatch_action(
 312                        zed_actions::icon_theme_selector::Toggle {
 313                            themes_filter: Some(icon_themes),
 314                        }
 315                        .boxed_clone(),
 316                        cx,
 317                    );
 318                })
 319                .ok();
 320        }
 321    }
 322
 323    /// Returns whether a dev extension currently exists for the extension with the given ID.
 324    fn dev_extension_exists(extension_id: &str, cx: &mut Context<Self>) -> bool {
 325        let extension_store = ExtensionStore::global(cx).read(cx);
 326
 327        extension_store
 328            .dev_extensions()
 329            .any(|dev_extension| dev_extension.id.as_ref() == extension_id)
 330    }
 331
 332    fn extension_status(extension_id: &str, cx: &mut Context<Self>) -> ExtensionStatus {
 333        let extension_store = ExtensionStore::global(cx).read(cx);
 334
 335        match extension_store.outstanding_operations().get(extension_id) {
 336            Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
 337            Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
 338            Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
 339            None => match extension_store.installed_extensions().get(extension_id) {
 340                Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
 341                None => ExtensionStatus::NotInstalled,
 342            },
 343        }
 344    }
 345
 346    fn filter_extension_entries(&mut self, cx: &mut Context<Self>) {
 347        self.filtered_remote_extension_indices.clear();
 348        self.filtered_remote_extension_indices.extend(
 349            self.remote_extension_entries
 350                .iter()
 351                .enumerate()
 352                .filter(|(_, extension)| match self.filter {
 353                    ExtensionFilter::All => true,
 354                    ExtensionFilter::Installed => {
 355                        let status = Self::extension_status(&extension.id, cx);
 356                        matches!(status, ExtensionStatus::Installed(_))
 357                    }
 358                    ExtensionFilter::NotInstalled => {
 359                        let status = Self::extension_status(&extension.id, cx);
 360
 361                        matches!(status, ExtensionStatus::NotInstalled)
 362                    }
 363                })
 364                .map(|(ix, _)| ix),
 365        );
 366        cx.notify();
 367    }
 368
 369    fn fetch_extensions(
 370        &mut self,
 371        search: Option<String>,
 372        provides_filter: Option<BTreeSet<ExtensionProvides>>,
 373        cx: &mut Context<Self>,
 374    ) {
 375        self.is_fetching_extensions = true;
 376        cx.notify();
 377
 378        let extension_store = ExtensionStore::global(cx);
 379
 380        let dev_extensions = extension_store.update(cx, |store, _| {
 381            store.dev_extensions().cloned().collect::<Vec<_>>()
 382        });
 383
 384        let remote_extensions = extension_store.update(cx, |store, cx| {
 385            store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
 386        });
 387
 388        cx.spawn(move |this, mut cx| async move {
 389            let dev_extensions = if let Some(search) = search {
 390                let match_candidates = dev_extensions
 391                    .iter()
 392                    .enumerate()
 393                    .map(|(ix, manifest)| StringMatchCandidate::new(ix, &manifest.name))
 394                    .collect::<Vec<_>>();
 395
 396                let matches = match_strings(
 397                    &match_candidates,
 398                    &search,
 399                    false,
 400                    match_candidates.len(),
 401                    &Default::default(),
 402                    cx.background_executor().clone(),
 403                )
 404                .await;
 405                matches
 406                    .into_iter()
 407                    .map(|mat| dev_extensions[mat.candidate_id].clone())
 408                    .collect()
 409            } else {
 410                dev_extensions
 411            };
 412
 413            let fetch_result = remote_extensions.await;
 414            this.update(&mut cx, |this, cx| {
 415                cx.notify();
 416                this.dev_extension_entries = dev_extensions;
 417                this.is_fetching_extensions = false;
 418                this.remote_extension_entries = fetch_result?;
 419                this.filter_extension_entries(cx);
 420                anyhow::Ok(())
 421            })?
 422        })
 423        .detach_and_log_err(cx);
 424    }
 425
 426    fn render_extensions(
 427        &mut self,
 428        range: Range<usize>,
 429        _: &mut Window,
 430        cx: &mut Context<Self>,
 431    ) -> Vec<ExtensionCard> {
 432        let dev_extension_entries_len = if self.filter.include_dev_extensions() {
 433            self.dev_extension_entries.len()
 434        } else {
 435            0
 436        };
 437        range
 438            .map(|ix| {
 439                if ix < dev_extension_entries_len {
 440                    let extension = &self.dev_extension_entries[ix];
 441                    self.render_dev_extension(extension, cx)
 442                } else {
 443                    let extension_ix =
 444                        self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
 445                    let extension = &self.remote_extension_entries[extension_ix];
 446                    self.render_remote_extension(extension, cx)
 447                }
 448            })
 449            .collect()
 450    }
 451
 452    fn render_dev_extension(
 453        &self,
 454        extension: &ExtensionManifest,
 455        cx: &mut Context<Self>,
 456    ) -> ExtensionCard {
 457        let status = Self::extension_status(&extension.id, cx);
 458
 459        let repository_url = extension.repository.clone();
 460
 461        ExtensionCard::new()
 462            .child(
 463                h_flex()
 464                    .justify_between()
 465                    .child(
 466                        h_flex()
 467                            .gap_2()
 468                            .items_end()
 469                            .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
 470                            .child(
 471                                Headline::new(format!("v{}", extension.version))
 472                                    .size(HeadlineSize::XSmall),
 473                            ),
 474                    )
 475                    .child(
 476                        h_flex()
 477                            .gap_2()
 478                            .justify_between()
 479                            .child(
 480                                Button::new(
 481                                    SharedString::from(format!("rebuild-{}", extension.id)),
 482                                    "Rebuild",
 483                                )
 484                                .on_click({
 485                                    let extension_id = extension.id.clone();
 486                                    move |_, _, cx| {
 487                                        ExtensionStore::global(cx).update(cx, |store, cx| {
 488                                            store.rebuild_dev_extension(extension_id.clone(), cx)
 489                                        });
 490                                    }
 491                                })
 492                                .color(Color::Accent)
 493                                .disabled(matches!(status, ExtensionStatus::Upgrading)),
 494                            )
 495                            .child(
 496                                Button::new(SharedString::from(extension.id.clone()), "Uninstall")
 497                                    .on_click({
 498                                        let extension_id = extension.id.clone();
 499                                        move |_, _, cx| {
 500                                            ExtensionStore::global(cx).update(cx, |store, cx| {
 501                                                store.uninstall_extension(extension_id.clone(), cx)
 502                                            });
 503                                        }
 504                                    })
 505                                    .color(Color::Accent)
 506                                    .disabled(matches!(status, ExtensionStatus::Removing)),
 507                            ),
 508                    ),
 509            )
 510            .child(
 511                h_flex()
 512                    .gap_2()
 513                    .justify_between()
 514                    .child(
 515                        Label::new(format!(
 516                            "{}: {}",
 517                            if extension.authors.len() > 1 {
 518                                "Authors"
 519                            } else {
 520                                "Author"
 521                            },
 522                            extension.authors.join(", ")
 523                        ))
 524                        .size(LabelSize::Small)
 525                        .truncate(),
 526                    )
 527                    .child(Label::new("<>").size(LabelSize::Small)),
 528            )
 529            .child(
 530                h_flex()
 531                    .gap_2()
 532                    .justify_between()
 533                    .children(extension.description.as_ref().map(|description| {
 534                        Label::new(description.clone())
 535                            .size(LabelSize::Small)
 536                            .color(Color::Default)
 537                            .truncate()
 538                    }))
 539                    .children(repository_url.map(|repository_url| {
 540                        IconButton::new(
 541                            SharedString::from(format!("repository-{}", extension.id)),
 542                            IconName::Github,
 543                        )
 544                        .icon_color(Color::Accent)
 545                        .icon_size(IconSize::Small)
 546                        .style(ButtonStyle::Filled)
 547                        .on_click(cx.listener({
 548                            let repository_url = repository_url.clone();
 549                            move |_, _, _, cx| {
 550                                cx.open_url(&repository_url);
 551                            }
 552                        }))
 553                        .tooltip(Tooltip::text(repository_url.clone()))
 554                    })),
 555            )
 556    }
 557
 558    fn render_remote_extension(
 559        &self,
 560        extension: &ExtensionMetadata,
 561        cx: &mut Context<Self>,
 562    ) -> ExtensionCard {
 563        let this = cx.entity().clone();
 564        let status = Self::extension_status(&extension.id, cx);
 565        let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
 566
 567        let extension_id = extension.id.clone();
 568        let (install_or_uninstall_button, upgrade_button) =
 569            self.buttons_for_entry(extension, &status, has_dev_extension, cx);
 570        let version = extension.manifest.version.clone();
 571        let repository_url = extension.manifest.repository.clone();
 572
 573        let installed_version = match status {
 574            ExtensionStatus::Installed(installed_version) => Some(installed_version),
 575            _ => None,
 576        };
 577
 578        ExtensionCard::new()
 579            .overridden_by_dev_extension(has_dev_extension)
 580            .child(
 581                h_flex()
 582                    .justify_between()
 583                    .child(
 584                        h_flex()
 585                            .gap_2()
 586                            .child(
 587                                Headline::new(extension.manifest.name.clone())
 588                                    .size(HeadlineSize::Medium),
 589                            )
 590                            .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
 591                            .children(
 592                                installed_version
 593                                    .filter(|installed_version| *installed_version != version)
 594                                    .map(|installed_version| {
 595                                        Headline::new(format!("(v{installed_version} installed)",))
 596                                            .size(HeadlineSize::XSmall)
 597                                    }),
 598                            )
 599                            .map(|parent| {
 600                                if extension.manifest.provides.is_empty() {
 601                                    return parent;
 602                                }
 603
 604                                parent.child(
 605                                    h_flex().gap_2().children(
 606                                        extension
 607                                            .manifest
 608                                            .provides
 609                                            .iter()
 610                                            .map(|provides| {
 611                                                let label = match provides {
 612                                                    ExtensionProvides::Themes => "Themes",
 613                                                    ExtensionProvides::IconThemes => "Icon Themes",
 614                                                    ExtensionProvides::Languages => "Languages",
 615                                                    ExtensionProvides::Grammars => "Grammars",
 616                                                    ExtensionProvides::LanguageServers => {
 617                                                        "Language Servers"
 618                                                    }
 619                                                    ExtensionProvides::ContextServers => {
 620                                                        "Context Servers"
 621                                                    }
 622                                                    ExtensionProvides::SlashCommands => {
 623                                                        "Slash Commands"
 624                                                    }
 625                                                    ExtensionProvides::IndexedDocsProviders => {
 626                                                        "Indexed Docs Providers"
 627                                                    }
 628                                                    ExtensionProvides::Snippets => "Snippets",
 629                                                };
 630                                                div()
 631                                                    .bg(cx.theme().colors().element_background)
 632                                                    .px_0p5()
 633                                                    .border_1()
 634                                                    .border_color(cx.theme().colors().border)
 635                                                    .rounded_sm()
 636                                                    .child(
 637                                                        Label::new(label).size(LabelSize::XSmall),
 638                                                    )
 639                                            })
 640                                            .collect::<Vec<_>>(),
 641                                    ),
 642                                )
 643                            }),
 644                    )
 645                    .child(
 646                        h_flex()
 647                            .gap_2()
 648                            .justify_between()
 649                            .children(upgrade_button)
 650                            .child(install_or_uninstall_button),
 651                    ),
 652            )
 653            .child(
 654                h_flex()
 655                    .gap_2()
 656                    .justify_between()
 657                    .child(
 658                        Label::new(format!(
 659                            "{}: {}",
 660                            if extension.manifest.authors.len() > 1 {
 661                                "Authors"
 662                            } else {
 663                                "Author"
 664                            },
 665                            extension.manifest.authors.join(", ")
 666                        ))
 667                        .size(LabelSize::Small)
 668                        .truncate(),
 669                    )
 670                    .child(
 671                        Label::new(format!(
 672                            "Downloads: {}",
 673                            extension.download_count.to_formatted_string(&Locale::en)
 674                        ))
 675                        .size(LabelSize::Small),
 676                    ),
 677            )
 678            .child(
 679                h_flex()
 680                    .gap_2()
 681                    .justify_between()
 682                    .children(extension.manifest.description.as_ref().map(|description| {
 683                        Label::new(description.clone())
 684                            .size(LabelSize::Small)
 685                            .color(Color::Default)
 686                            .truncate()
 687                    }))
 688                    .child(
 689                        h_flex()
 690                            .gap_2()
 691                            .child(
 692                                IconButton::new(
 693                                    SharedString::from(format!("repository-{}", extension.id)),
 694                                    IconName::Github,
 695                                )
 696                                .icon_color(Color::Accent)
 697                                .icon_size(IconSize::Small)
 698                                .style(ButtonStyle::Filled)
 699                                .on_click(cx.listener({
 700                                    let repository_url = repository_url.clone();
 701                                    move |_, _, _, cx| {
 702                                        cx.open_url(&repository_url);
 703                                    }
 704                                }))
 705                                .tooltip(Tooltip::text(repository_url.clone())),
 706                            )
 707                            .child(
 708                                PopoverMenu::new(SharedString::from(format!(
 709                                    "more-{}",
 710                                    extension.id
 711                                )))
 712                                .trigger(
 713                                    IconButton::new(
 714                                        SharedString::from(format!("more-{}", extension.id)),
 715                                        IconName::Ellipsis,
 716                                    )
 717                                    .icon_color(Color::Accent)
 718                                    .icon_size(IconSize::Small)
 719                                    .style(ButtonStyle::Filled),
 720                                )
 721                                .menu(move |window, cx| {
 722                                    Some(Self::render_remote_extension_context_menu(
 723                                        &this,
 724                                        extension_id.clone(),
 725                                        window,
 726                                        cx,
 727                                    ))
 728                                }),
 729                            ),
 730                    ),
 731            )
 732    }
 733
 734    fn render_remote_extension_context_menu(
 735        this: &Entity<Self>,
 736        extension_id: Arc<str>,
 737        window: &mut Window,
 738        cx: &mut App,
 739    ) -> Entity<ContextMenu> {
 740        let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
 741            context_menu
 742                .entry(
 743                    "Install Another Version...",
 744                    None,
 745                    window.handler_for(this, {
 746                        let extension_id = extension_id.clone();
 747                        move |this, window, cx| {
 748                            this.show_extension_version_list(extension_id.clone(), window, cx)
 749                        }
 750                    }),
 751                )
 752                .entry("Copy Extension ID", None, {
 753                    let extension_id = extension_id.clone();
 754                    move |_, cx| {
 755                        cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
 756                    }
 757                })
 758        });
 759
 760        context_menu
 761    }
 762
 763    fn show_extension_version_list(
 764        &mut self,
 765        extension_id: Arc<str>,
 766        window: &mut Window,
 767        cx: &mut Context<Self>,
 768    ) {
 769        let Some(workspace) = self.workspace.upgrade() else {
 770            return;
 771        };
 772
 773        cx.spawn_in(window, move |this, mut cx| async move {
 774            let extension_versions_task = this.update(&mut cx, |_, cx| {
 775                let extension_store = ExtensionStore::global(cx);
 776
 777                extension_store.update(cx, |store, cx| {
 778                    store.fetch_extension_versions(&extension_id, cx)
 779                })
 780            })?;
 781
 782            let extension_versions = extension_versions_task.await?;
 783
 784            workspace.update_in(&mut cx, |workspace, window, cx| {
 785                let fs = workspace.project().read(cx).fs().clone();
 786                workspace.toggle_modal(window, cx, |window, cx| {
 787                    let delegate = ExtensionVersionSelectorDelegate::new(
 788                        fs,
 789                        cx.entity().downgrade(),
 790                        extension_versions,
 791                    );
 792
 793                    ExtensionVersionSelector::new(delegate, window, cx)
 794                });
 795            })?;
 796
 797            anyhow::Ok(())
 798        })
 799        .detach_and_log_err(cx);
 800    }
 801
 802    fn buttons_for_entry(
 803        &self,
 804        extension: &ExtensionMetadata,
 805        status: &ExtensionStatus,
 806        has_dev_extension: bool,
 807        cx: &mut Context<Self>,
 808    ) -> (Button, Option<Button>) {
 809        let is_compatible =
 810            extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
 811
 812        if has_dev_extension {
 813            // If we have a dev extension for the given extension, just treat it as uninstalled.
 814            // The button here is a placeholder, as it won't be interactable anyways.
 815            return (
 816                Button::new(SharedString::from(extension.id.clone()), "Install"),
 817                None,
 818            );
 819        }
 820
 821        match status.clone() {
 822            ExtensionStatus::NotInstalled => (
 823                Button::new(SharedString::from(extension.id.clone()), "Install").on_click({
 824                    let extension_id = extension.id.clone();
 825                    move |_, _, cx| {
 826                        telemetry::event!("Extension Installed");
 827                        ExtensionStore::global(cx).update(cx, |store, cx| {
 828                            store.install_latest_extension(extension_id.clone(), cx)
 829                        });
 830                    }
 831                }),
 832                None,
 833            ),
 834            ExtensionStatus::Installing => (
 835                Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
 836                None,
 837            ),
 838            ExtensionStatus::Upgrading => (
 839                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
 840                Some(
 841                    Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
 842                ),
 843            ),
 844            ExtensionStatus::Installed(installed_version) => (
 845                Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click({
 846                    let extension_id = extension.id.clone();
 847                    move |_, _, cx| {
 848                        telemetry::event!("Extension Uninstalled", extension_id);
 849                        ExtensionStore::global(cx).update(cx, |store, cx| {
 850                            store.uninstall_extension(extension_id.clone(), cx)
 851                        });
 852                    }
 853                }),
 854                if installed_version == extension.manifest.version {
 855                    None
 856                } else {
 857                    Some(
 858                        Button::new(SharedString::from(extension.id.clone()), "Upgrade")
 859                            .when(!is_compatible, |upgrade_button| {
 860                                upgrade_button.disabled(true).tooltip({
 861                                    let version = extension.manifest.version.clone();
 862                                    move |_, cx| {
 863                                        Tooltip::simple(
 864                                            format!(
 865                                                "v{version} is not compatible with this version of Zed.",
 866                                            ),
 867                                             cx,
 868                                        )
 869                                    }
 870                                })
 871                            })
 872                            .disabled(!is_compatible)
 873                            .on_click({
 874                                let extension_id = extension.id.clone();
 875                                let version = extension.manifest.version.clone();
 876                                move |_, _, cx| {
 877                                    telemetry::event!("Extension Installed", extension_id, version);
 878                                    ExtensionStore::global(cx).update(cx, |store, cx| {
 879                                        store
 880                                            .upgrade_extension(
 881                                                extension_id.clone(),
 882                                                version.clone(),
 883                                                cx,
 884                                            )
 885                                            .detach_and_log_err(cx)
 886                                    });
 887                                }
 888                            }),
 889                    )
 890                },
 891            ),
 892            ExtensionStatus::Removing => (
 893                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
 894                None,
 895            ),
 896        }
 897    }
 898
 899    fn render_search(&self, cx: &mut Context<Self>) -> Div {
 900        let mut key_context = KeyContext::new_with_defaults();
 901        key_context.add("BufferSearchBar");
 902
 903        let editor_border = if self.query_contains_error {
 904            Color::Error.color(cx)
 905        } else {
 906            cx.theme().colors().border
 907        };
 908
 909        h_flex().w_full().gap_2().key_context(key_context).child(
 910            h_flex()
 911                .flex_1()
 912                .px_2()
 913                .py_1()
 914                .gap_2()
 915                .border_1()
 916                .border_color(editor_border)
 917                .min_w(rems_from_px(384.))
 918                .rounded_lg()
 919                .child(Icon::new(IconName::MagnifyingGlass))
 920                .child(self.render_text_input(&self.query_editor, cx)),
 921        )
 922    }
 923
 924    fn render_text_input(
 925        &self,
 926        editor: &Entity<Editor>,
 927        cx: &mut Context<Self>,
 928    ) -> impl IntoElement {
 929        let settings = ThemeSettings::get_global(cx);
 930        let text_style = TextStyle {
 931            color: if editor.read(cx).read_only(cx) {
 932                cx.theme().colors().text_disabled
 933            } else {
 934                cx.theme().colors().text
 935            },
 936            font_family: settings.ui_font.family.clone(),
 937            font_features: settings.ui_font.features.clone(),
 938            font_fallbacks: settings.ui_font.fallbacks.clone(),
 939            font_size: rems(0.875).into(),
 940            font_weight: settings.ui_font.weight,
 941            line_height: relative(1.3),
 942            ..Default::default()
 943        };
 944
 945        EditorElement::new(
 946            editor,
 947            EditorStyle {
 948                background: cx.theme().colors().editor_background,
 949                local_player: cx.theme().players().local(),
 950                text: text_style,
 951                ..Default::default()
 952            },
 953        )
 954    }
 955
 956    fn on_query_change(
 957        &mut self,
 958        _: Entity<Editor>,
 959        event: &editor::EditorEvent,
 960        cx: &mut Context<Self>,
 961    ) {
 962        if let editor::EditorEvent::Edited { .. } = event {
 963            self.query_contains_error = false;
 964            self.refresh_search(cx);
 965        }
 966    }
 967
 968    fn refresh_search(&mut self, cx: &mut Context<Self>) {
 969        self.fetch_extensions_debounced(cx);
 970        self.refresh_feature_upsells(cx);
 971    }
 972
 973    fn fetch_extensions_debounced(&mut self, cx: &mut Context<ExtensionsPage>) {
 974        self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
 975            let search = this
 976                .update(&mut cx, |this, cx| this.search_query(cx))
 977                .ok()
 978                .flatten();
 979
 980            // Only debounce the fetching of extensions if we have a search
 981            // query.
 982            //
 983            // If the search was just cleared then we can just reload the list
 984            // of extensions without a debounce, which allows us to avoid seeing
 985            // an intermittent flash of a "no extensions" state.
 986            if search.is_some() {
 987                cx.background_executor()
 988                    .timer(Duration::from_millis(250))
 989                    .await;
 990            };
 991
 992            this.update(&mut cx, |this, cx| {
 993                this.fetch_extensions(search, Some(BTreeSet::from_iter(this.provides_filter)), cx);
 994            })
 995            .ok();
 996        }));
 997    }
 998
 999    pub fn search_query(&self, cx: &mut App) -> Option<String> {
1000        let search = self.query_editor.read(cx).text(cx);
1001        if search.trim().is_empty() {
1002            None
1003        } else {
1004            Some(search)
1005        }
1006    }
1007
1008    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1009        let has_search = self.search_query(cx).is_some();
1010
1011        let message = if self.is_fetching_extensions {
1012            "Loading extensions..."
1013        } else {
1014            match self.filter {
1015                ExtensionFilter::All => {
1016                    if has_search {
1017                        "No extensions that match your search."
1018                    } else {
1019                        "No extensions."
1020                    }
1021                }
1022                ExtensionFilter::Installed => {
1023                    if has_search {
1024                        "No installed extensions that match your search."
1025                    } else {
1026                        "No installed extensions."
1027                    }
1028                }
1029                ExtensionFilter::NotInstalled => {
1030                    if has_search {
1031                        "No not installed extensions that match your search."
1032                    } else {
1033                        "No not installed extensions."
1034                    }
1035                }
1036            }
1037        };
1038
1039        Label::new(message)
1040    }
1041
1042    fn update_settings<T: Settings>(
1043        &mut self,
1044        selection: &ToggleState,
1045
1046        cx: &mut Context<Self>,
1047        callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
1048    ) {
1049        if let Some(workspace) = self.workspace.upgrade() {
1050            let fs = workspace.read(cx).app_state().fs.clone();
1051            let selection = *selection;
1052            settings::update_settings_file::<T>(fs, cx, move |settings, _| {
1053                let value = match selection {
1054                    ToggleState::Unselected => false,
1055                    ToggleState::Selected => true,
1056                    _ => return,
1057                };
1058
1059                callback(settings, value)
1060            });
1061        }
1062    }
1063
1064    fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1065        let Some(search) = self.search_query(cx) else {
1066            self.upsells.clear();
1067            return;
1068        };
1069
1070        let search = search.to_lowercase();
1071        let search_terms = search
1072            .split_whitespace()
1073            .map(|term| term.trim())
1074            .collect::<Vec<_>>();
1075
1076        for (feature, keywords) in keywords_by_feature() {
1077            if keywords
1078                .iter()
1079                .any(|keyword| search_terms.contains(keyword))
1080            {
1081                self.upsells.insert(*feature);
1082            } else {
1083                self.upsells.remove(feature);
1084            }
1085        }
1086    }
1087
1088    fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1089        let upsells_count = self.upsells.len();
1090
1091        v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| {
1092            let upsell = match feature {
1093                Feature::Git => FeatureUpsell::new(
1094                    "Zed comes with basic Git support. More Git features are coming in the future.",
1095                )
1096                .docs_url("https://zed.dev/docs/git"),
1097                Feature::OpenIn => FeatureUpsell::new(
1098                    "Zed supports linking to a source line on GitHub and others.",
1099                )
1100                .docs_url("https://zed.dev/docs/git#git-integrations"),
1101                Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!")
1102                    .docs_url("https://zed.dev/docs/vim")
1103                    .child(CheckboxWithLabel::new(
1104                        "enable-vim",
1105                        Label::new("Enable vim mode"),
1106                        if VimModeSetting::get_global(cx).0 {
1107                            ui::ToggleState::Selected
1108                        } else {
1109                            ui::ToggleState::Unselected
1110                        },
1111                        cx.listener(move |this, selection, _, cx| {
1112                            telemetry::event!("Vim Mode Toggled", source = "Feature Upsell");
1113                            this.update_settings::<VimModeSetting>(
1114                                selection,
1115                                cx,
1116                                |setting, value| *setting = Some(value),
1117                            );
1118                        }),
1119                    )),
1120                Feature::LanguageBash => FeatureUpsell::new("Shell support is built-in to Zed!")
1121                    .docs_url("https://zed.dev/docs/languages/bash"),
1122                Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!")
1123                    .docs_url("https://zed.dev/docs/languages/c"),
1124                Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!")
1125                    .docs_url("https://zed.dev/docs/languages/cpp"),
1126                Feature::LanguageGo => FeatureUpsell::new("Go support is built-in to Zed!")
1127                    .docs_url("https://zed.dev/docs/languages/go"),
1128                Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!")
1129                    .docs_url("https://zed.dev/docs/languages/python"),
1130                Feature::LanguageReact => FeatureUpsell::new("React support is built-in to Zed!")
1131                    .docs_url("https://zed.dev/docs/languages/typescript"),
1132                Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!")
1133                    .docs_url("https://zed.dev/docs/languages/rust"),
1134                Feature::LanguageTypescript => {
1135                    FeatureUpsell::new("Typescript support is built-in to Zed!")
1136                        .docs_url("https://zed.dev/docs/languages/typescript")
1137                }
1138            };
1139
1140            upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
1141        }))
1142    }
1143}
1144
1145impl Render for ExtensionsPage {
1146    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1147        v_flex()
1148            .size_full()
1149            .bg(cx.theme().colors().editor_background)
1150            .child(
1151                v_flex()
1152                    .gap_4()
1153                    .p_4()
1154                    .border_b_1()
1155                    .border_color(cx.theme().colors().border)
1156                    .bg(cx.theme().colors().editor_background)
1157                    .child(
1158                        h_flex()
1159                            .w_full()
1160                            .gap_2()
1161                            .justify_between()
1162                            .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1163                            .child(
1164                                Button::new("install-dev-extension", "Install Dev Extension")
1165                                    .style(ButtonStyle::Filled)
1166                                    .size(ButtonSize::Large)
1167                                    .on_click(|_event, window, cx| {
1168                                        window.dispatch_action(Box::new(InstallDevExtension), cx)
1169                                    }),
1170                            ),
1171                    )
1172                    .child(
1173                        h_flex()
1174                            .w_full()
1175                            .gap_2()
1176                            .justify_between()
1177                            .child(
1178                                h_flex()
1179                                    .gap_2()
1180                                    .child(self.render_search(cx))
1181                                    .map(|parent| {
1182                                        // Note: Staff-only until this gets design input.
1183                                        if !cx.is_staff() {
1184                                            return parent;
1185                                        }
1186
1187                                        parent.child(CheckboxWithLabel::new(
1188                                            "icon-themes-filter",
1189                                            Label::new("Icon themes"),
1190                                            match self.provides_filter {
1191                                                Some(ExtensionProvides::IconThemes) => {
1192                                                    ToggleState::Selected
1193                                                }
1194                                                _ => ToggleState::Unselected,
1195                                            },
1196                                            cx.listener(|this, checked, _window, cx| {
1197                                                match checked {
1198                                                    ToggleState::Unselected
1199                                                    | ToggleState::Indeterminate => {
1200                                                        this.provides_filter = None
1201                                                    }
1202                                                    ToggleState::Selected => {
1203                                                        this.provides_filter =
1204                                                            Some(ExtensionProvides::IconThemes)
1205                                                    }
1206                                                };
1207                                                this.refresh_search(cx);
1208                                            }),
1209                                        ))
1210                                    }),
1211                            )
1212                            .child(
1213                                h_flex()
1214                                    .child(
1215                                        ToggleButton::new("filter-all", "All")
1216                                            .style(ButtonStyle::Filled)
1217                                            .size(ButtonSize::Large)
1218                                            .toggle_state(self.filter == ExtensionFilter::All)
1219                                            .on_click(cx.listener(|this, _event, _, cx| {
1220                                                this.filter = ExtensionFilter::All;
1221                                                this.filter_extension_entries(cx);
1222                                            }))
1223                                            .tooltip(move |_, cx| {
1224                                                Tooltip::simple("Show all extensions", cx)
1225                                            })
1226                                            .first(),
1227                                    )
1228                                    .child(
1229                                        ToggleButton::new("filter-installed", "Installed")
1230                                            .style(ButtonStyle::Filled)
1231                                            .size(ButtonSize::Large)
1232                                            .toggle_state(self.filter == ExtensionFilter::Installed)
1233                                            .on_click(cx.listener(|this, _event, _, cx| {
1234                                                this.filter = ExtensionFilter::Installed;
1235                                                this.filter_extension_entries(cx);
1236                                            }))
1237                                            .tooltip(move |_, cx| {
1238                                                Tooltip::simple("Show installed extensions", cx)
1239                                            })
1240                                            .middle(),
1241                                    )
1242                                    .child(
1243                                        ToggleButton::new("filter-not-installed", "Not Installed")
1244                                            .style(ButtonStyle::Filled)
1245                                            .size(ButtonSize::Large)
1246                                            .toggle_state(
1247                                                self.filter == ExtensionFilter::NotInstalled,
1248                                            )
1249                                            .on_click(cx.listener(|this, _event, _, cx| {
1250                                                this.filter = ExtensionFilter::NotInstalled;
1251                                                this.filter_extension_entries(cx);
1252                                            }))
1253                                            .tooltip(move |_, cx| {
1254                                                Tooltip::simple("Show not installed extensions", cx)
1255                                            })
1256                                            .last(),
1257                                    ),
1258                            ),
1259                    ),
1260            )
1261            .child(self.render_feature_upsells(cx))
1262            .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
1263                let mut count = self.filtered_remote_extension_indices.len();
1264                if self.filter.include_dev_extensions() {
1265                    count += self.dev_extension_entries.len();
1266                }
1267
1268                if count == 0 {
1269                    return this.py_4().child(self.render_empty_state(cx));
1270                }
1271
1272                let extensions_page = cx.entity().clone();
1273                let scroll_handle = self.list.clone();
1274                this.child(
1275                    uniform_list(extensions_page, "entries", count, Self::render_extensions)
1276                        .flex_grow()
1277                        .pb_4()
1278                        .track_scroll(scroll_handle),
1279                )
1280            }))
1281    }
1282}
1283
1284impl EventEmitter<ItemEvent> for ExtensionsPage {}
1285
1286impl Focusable for ExtensionsPage {
1287    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1288        self.query_editor.read(cx).focus_handle(cx)
1289    }
1290}
1291
1292impl Item for ExtensionsPage {
1293    type Event = ItemEvent;
1294
1295    fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option<SharedString> {
1296        Some("Extensions".into())
1297    }
1298
1299    fn telemetry_event_text(&self) -> Option<&'static str> {
1300        Some("Extensions Page Opened")
1301    }
1302
1303    fn show_toolbar(&self) -> bool {
1304        false
1305    }
1306
1307    fn clone_on_split(
1308        &self,
1309        _workspace_id: Option<WorkspaceId>,
1310        _window: &mut Window,
1311        _: &mut Context<Self>,
1312    ) -> Option<Entity<Self>> {
1313        None
1314    }
1315
1316    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1317        f(*event)
1318    }
1319}