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