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