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;
  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                            .items_end()
 579                            .child(
 580                                Headline::new(extension.manifest.name.clone())
 581                                    .size(HeadlineSize::Medium),
 582                            )
 583                            .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
 584                            .children(
 585                                installed_version
 586                                    .filter(|installed_version| *installed_version != version)
 587                                    .map(|installed_version| {
 588                                        Headline::new(format!("(v{installed_version} installed)",))
 589                                            .size(HeadlineSize::XSmall)
 590                                    }),
 591                            ),
 592                    )
 593                    .child(
 594                        h_flex()
 595                            .gap_2()
 596                            .justify_between()
 597                            .children(upgrade_button)
 598                            .child(install_or_uninstall_button),
 599                    ),
 600            )
 601            .child(
 602                h_flex()
 603                    .gap_2()
 604                    .justify_between()
 605                    .child(
 606                        Label::new(format!(
 607                            "{}: {}",
 608                            if extension.manifest.authors.len() > 1 {
 609                                "Authors"
 610                            } else {
 611                                "Author"
 612                            },
 613                            extension.manifest.authors.join(", ")
 614                        ))
 615                        .size(LabelSize::Small)
 616                        .text_ellipsis(),
 617                    )
 618                    .child(
 619                        Label::new(format!(
 620                            "Downloads: {}",
 621                            extension.download_count.to_formatted_string(&Locale::en)
 622                        ))
 623                        .size(LabelSize::Small),
 624                    ),
 625            )
 626            .child(
 627                h_flex()
 628                    .gap_2()
 629                    .justify_between()
 630                    .children(extension.manifest.description.as_ref().map(|description| {
 631                        Label::new(description.clone())
 632                            .size(LabelSize::Small)
 633                            .color(Color::Default)
 634                            .text_ellipsis()
 635                    }))
 636                    .child(
 637                        h_flex()
 638                            .gap_2()
 639                            .child(
 640                                IconButton::new(
 641                                    SharedString::from(format!("repository-{}", extension.id)),
 642                                    IconName::Github,
 643                                )
 644                                .icon_color(Color::Accent)
 645                                .icon_size(IconSize::Small)
 646                                .style(ButtonStyle::Filled)
 647                                .on_click(cx.listener({
 648                                    let repository_url = repository_url.clone();
 649                                    move |_, _, _, cx| {
 650                                        cx.open_url(&repository_url);
 651                                    }
 652                                }))
 653                                .tooltip(Tooltip::text(repository_url.clone())),
 654                            )
 655                            .child(
 656                                PopoverMenu::new(SharedString::from(format!(
 657                                    "more-{}",
 658                                    extension.id
 659                                )))
 660                                .trigger(
 661                                    IconButton::new(
 662                                        SharedString::from(format!("more-{}", extension.id)),
 663                                        IconName::Ellipsis,
 664                                    )
 665                                    .icon_color(Color::Accent)
 666                                    .icon_size(IconSize::Small)
 667                                    .style(ButtonStyle::Filled),
 668                                )
 669                                .menu(move |window, cx| {
 670                                    Some(Self::render_remote_extension_context_menu(
 671                                        &this,
 672                                        extension_id.clone(),
 673                                        window,
 674                                        cx,
 675                                    ))
 676                                }),
 677                            ),
 678                    ),
 679            )
 680    }
 681
 682    fn render_remote_extension_context_menu(
 683        this: &Entity<Self>,
 684        extension_id: Arc<str>,
 685        window: &mut Window,
 686        cx: &mut App,
 687    ) -> Entity<ContextMenu> {
 688        let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
 689            context_menu
 690                .entry(
 691                    "Install Another Version...",
 692                    None,
 693                    window.handler_for(this, {
 694                        let extension_id = extension_id.clone();
 695                        move |this, window, cx| {
 696                            this.show_extension_version_list(extension_id.clone(), window, cx)
 697                        }
 698                    }),
 699                )
 700                .entry("Copy Extension ID", None, {
 701                    let extension_id = extension_id.clone();
 702                    move |_, cx| {
 703                        cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
 704                    }
 705                })
 706        });
 707
 708        context_menu
 709    }
 710
 711    fn show_extension_version_list(
 712        &mut self,
 713        extension_id: Arc<str>,
 714        window: &mut Window,
 715        cx: &mut Context<Self>,
 716    ) {
 717        let Some(workspace) = self.workspace.upgrade() else {
 718            return;
 719        };
 720
 721        cx.spawn_in(window, move |this, mut cx| async move {
 722            let extension_versions_task = this.update(&mut cx, |_, cx| {
 723                let extension_store = ExtensionStore::global(cx);
 724
 725                extension_store.update(cx, |store, cx| {
 726                    store.fetch_extension_versions(&extension_id, cx)
 727                })
 728            })?;
 729
 730            let extension_versions = extension_versions_task.await?;
 731
 732            workspace.update_in(&mut cx, |workspace, window, cx| {
 733                let fs = workspace.project().read(cx).fs().clone();
 734                workspace.toggle_modal(window, cx, |window, cx| {
 735                    let delegate = ExtensionVersionSelectorDelegate::new(
 736                        fs,
 737                        cx.entity().downgrade(),
 738                        extension_versions,
 739                    );
 740
 741                    ExtensionVersionSelector::new(delegate, window, cx)
 742                });
 743            })?;
 744
 745            anyhow::Ok(())
 746        })
 747        .detach_and_log_err(cx);
 748    }
 749
 750    fn buttons_for_entry(
 751        &self,
 752        extension: &ExtensionMetadata,
 753        status: &ExtensionStatus,
 754        has_dev_extension: bool,
 755        cx: &mut Context<Self>,
 756    ) -> (Button, Option<Button>) {
 757        let is_compatible =
 758            extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
 759
 760        if has_dev_extension {
 761            // If we have a dev extension for the given extension, just treat it as uninstalled.
 762            // The button here is a placeholder, as it won't be interactable anyways.
 763            return (
 764                Button::new(SharedString::from(extension.id.clone()), "Install"),
 765                None,
 766            );
 767        }
 768
 769        match status.clone() {
 770            ExtensionStatus::NotInstalled => (
 771                Button::new(SharedString::from(extension.id.clone()), "Install").on_click({
 772                    let extension_id = extension.id.clone();
 773                    move |_, _, cx| {
 774                        telemetry::event!("Extension Installed");
 775                        ExtensionStore::global(cx).update(cx, |store, cx| {
 776                            store.install_latest_extension(extension_id.clone(), cx)
 777                        });
 778                    }
 779                }),
 780                None,
 781            ),
 782            ExtensionStatus::Installing => (
 783                Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
 784                None,
 785            ),
 786            ExtensionStatus::Upgrading => (
 787                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
 788                Some(
 789                    Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
 790                ),
 791            ),
 792            ExtensionStatus::Installed(installed_version) => (
 793                Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click({
 794                    let extension_id = extension.id.clone();
 795                    move |_, _, cx| {
 796                        telemetry::event!("Extension Uninstalled", extension_id);
 797                        ExtensionStore::global(cx).update(cx, |store, cx| {
 798                            store.uninstall_extension(extension_id.clone(), cx)
 799                        });
 800                    }
 801                }),
 802                if installed_version == extension.manifest.version {
 803                    None
 804                } else {
 805                    Some(
 806                        Button::new(SharedString::from(extension.id.clone()), "Upgrade")
 807                            .when(!is_compatible, |upgrade_button| {
 808                                upgrade_button.disabled(true).tooltip({
 809                                    let version = extension.manifest.version.clone();
 810                                    move |_, cx| {
 811                                        Tooltip::simple(
 812                                            format!(
 813                                                "v{version} is not compatible with this version of Zed.",
 814                                            ),
 815                                             cx,
 816                                        )
 817                                    }
 818                                })
 819                            })
 820                            .disabled(!is_compatible)
 821                            .on_click({
 822                                let extension_id = extension.id.clone();
 823                                let version = extension.manifest.version.clone();
 824                                move |_, _, cx| {
 825                                    telemetry::event!("Extension Installed", extension_id, version);
 826                                    ExtensionStore::global(cx).update(cx, |store, cx| {
 827                                        store
 828                                            .upgrade_extension(
 829                                                extension_id.clone(),
 830                                                version.clone(),
 831                                                cx,
 832                                            )
 833                                            .detach_and_log_err(cx)
 834                                    });
 835                                }
 836                            }),
 837                    )
 838                },
 839            ),
 840            ExtensionStatus::Removing => (
 841                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
 842                None,
 843            ),
 844        }
 845    }
 846
 847    fn render_search(&self, cx: &mut Context<Self>) -> Div {
 848        let mut key_context = KeyContext::new_with_defaults();
 849        key_context.add("BufferSearchBar");
 850
 851        let editor_border = if self.query_contains_error {
 852            Color::Error.color(cx)
 853        } else {
 854            cx.theme().colors().border
 855        };
 856
 857        h_flex().w_full().gap_2().key_context(key_context).child(
 858            h_flex()
 859                .flex_1()
 860                .px_2()
 861                .py_1()
 862                .gap_2()
 863                .border_1()
 864                .border_color(editor_border)
 865                .min_w(rems_from_px(384.))
 866                .rounded_lg()
 867                .child(Icon::new(IconName::MagnifyingGlass))
 868                .child(self.render_text_input(&self.query_editor, cx)),
 869        )
 870    }
 871
 872    fn render_text_input(
 873        &self,
 874        editor: &Entity<Editor>,
 875        cx: &mut Context<Self>,
 876    ) -> impl IntoElement {
 877        let settings = ThemeSettings::get_global(cx);
 878        let text_style = TextStyle {
 879            color: if editor.read(cx).read_only(cx) {
 880                cx.theme().colors().text_disabled
 881            } else {
 882                cx.theme().colors().text
 883            },
 884            font_family: settings.ui_font.family.clone(),
 885            font_features: settings.ui_font.features.clone(),
 886            font_fallbacks: settings.ui_font.fallbacks.clone(),
 887            font_size: rems(0.875).into(),
 888            font_weight: settings.ui_font.weight,
 889            line_height: relative(1.3),
 890            ..Default::default()
 891        };
 892
 893        EditorElement::new(
 894            editor,
 895            EditorStyle {
 896                background: cx.theme().colors().editor_background,
 897                local_player: cx.theme().players().local(),
 898                text: text_style,
 899                ..Default::default()
 900            },
 901        )
 902    }
 903
 904    fn on_query_change(
 905        &mut self,
 906        _: Entity<Editor>,
 907        event: &editor::EditorEvent,
 908        cx: &mut Context<Self>,
 909    ) {
 910        if let editor::EditorEvent::Edited { .. } = event {
 911            self.query_contains_error = false;
 912            self.fetch_extensions_debounced(cx);
 913            self.refresh_feature_upsells(cx);
 914        }
 915    }
 916
 917    fn fetch_extensions_debounced(&mut self, cx: &mut Context<ExtensionsPage>) {
 918        self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
 919            let search = this
 920                .update(&mut cx, |this, cx| this.search_query(cx))
 921                .ok()
 922                .flatten();
 923
 924            // Only debounce the fetching of extensions if we have a search
 925            // query.
 926            //
 927            // If the search was just cleared then we can just reload the list
 928            // of extensions without a debounce, which allows us to avoid seeing
 929            // an intermittent flash of a "no extensions" state.
 930            if search.is_some() {
 931                cx.background_executor()
 932                    .timer(Duration::from_millis(250))
 933                    .await;
 934            };
 935
 936            this.update(&mut cx, |this, cx| {
 937                this.fetch_extensions(search, cx);
 938            })
 939            .ok();
 940        }));
 941    }
 942
 943    pub fn search_query(&self, cx: &mut App) -> Option<String> {
 944        let search = self.query_editor.read(cx).text(cx);
 945        if search.trim().is_empty() {
 946            None
 947        } else {
 948            Some(search)
 949        }
 950    }
 951
 952    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
 953        let has_search = self.search_query(cx).is_some();
 954
 955        let message = if self.is_fetching_extensions {
 956            "Loading extensions..."
 957        } else {
 958            match self.filter {
 959                ExtensionFilter::All => {
 960                    if has_search {
 961                        "No extensions that match your search."
 962                    } else {
 963                        "No extensions."
 964                    }
 965                }
 966                ExtensionFilter::Installed => {
 967                    if has_search {
 968                        "No installed extensions that match your search."
 969                    } else {
 970                        "No installed extensions."
 971                    }
 972                }
 973                ExtensionFilter::NotInstalled => {
 974                    if has_search {
 975                        "No not installed extensions that match your search."
 976                    } else {
 977                        "No not installed extensions."
 978                    }
 979                }
 980            }
 981        };
 982
 983        Label::new(message)
 984    }
 985
 986    fn update_settings<T: Settings>(
 987        &mut self,
 988        selection: &ToggleState,
 989
 990        cx: &mut Context<Self>,
 991        callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
 992    ) {
 993        if let Some(workspace) = self.workspace.upgrade() {
 994            let fs = workspace.read(cx).app_state().fs.clone();
 995            let selection = *selection;
 996            settings::update_settings_file::<T>(fs, cx, move |settings, _| {
 997                let value = match selection {
 998                    ToggleState::Unselected => false,
 999                    ToggleState::Selected => true,
1000                    _ => return,
1001                };
1002
1003                callback(settings, value)
1004            });
1005        }
1006    }
1007
1008    fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
1009        let Some(search) = self.search_query(cx) else {
1010            self.upsells.clear();
1011            return;
1012        };
1013
1014        let search = search.to_lowercase();
1015        let search_terms = search
1016            .split_whitespace()
1017            .map(|term| term.trim())
1018            .collect::<Vec<_>>();
1019
1020        for (feature, keywords) in keywords_by_feature() {
1021            if keywords
1022                .iter()
1023                .any(|keyword| search_terms.contains(keyword))
1024            {
1025                self.upsells.insert(*feature);
1026            } else {
1027                self.upsells.remove(feature);
1028            }
1029        }
1030    }
1031
1032    fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1033        let upsells_count = self.upsells.len();
1034
1035        v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| {
1036            let upsell = match feature {
1037                Feature::Git => FeatureUpsell::new(
1038                    "Zed comes with basic Git support. More Git features are coming in the future.",
1039                )
1040                .docs_url("https://zed.dev/docs/git"),
1041                Feature::OpenIn => FeatureUpsell::new(
1042                    "Zed supports linking to a source line on GitHub and others.",
1043                )
1044                .docs_url("https://zed.dev/docs/git#git-integrations"),
1045                Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!")
1046                    .docs_url("https://zed.dev/docs/vim")
1047                    .child(CheckboxWithLabel::new(
1048                        "enable-vim",
1049                        Label::new("Enable vim mode"),
1050                        if VimModeSetting::get_global(cx).0 {
1051                            ui::ToggleState::Selected
1052                        } else {
1053                            ui::ToggleState::Unselected
1054                        },
1055                        cx.listener(move |this, selection, _, cx| {
1056                            telemetry::event!("Vim Mode Toggled", source = "Feature Upsell");
1057                            this.update_settings::<VimModeSetting>(
1058                                selection,
1059                                cx,
1060                                |setting, value| *setting = Some(value),
1061                            );
1062                        }),
1063                    )),
1064                Feature::LanguageBash => FeatureUpsell::new("Shell support is built-in to Zed!")
1065                    .docs_url("https://zed.dev/docs/languages/bash"),
1066                Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!")
1067                    .docs_url("https://zed.dev/docs/languages/c"),
1068                Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!")
1069                    .docs_url("https://zed.dev/docs/languages/cpp"),
1070                Feature::LanguageGo => FeatureUpsell::new("Go support is built-in to Zed!")
1071                    .docs_url("https://zed.dev/docs/languages/go"),
1072                Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!")
1073                    .docs_url("https://zed.dev/docs/languages/python"),
1074                Feature::LanguageReact => FeatureUpsell::new("React support is built-in to Zed!")
1075                    .docs_url("https://zed.dev/docs/languages/typescript"),
1076                Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!")
1077                    .docs_url("https://zed.dev/docs/languages/rust"),
1078                Feature::LanguageTypescript => {
1079                    FeatureUpsell::new("Typescript support is built-in to Zed!")
1080                        .docs_url("https://zed.dev/docs/languages/typescript")
1081                }
1082            };
1083
1084            upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
1085        }))
1086    }
1087}
1088
1089impl Render for ExtensionsPage {
1090    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1091        v_flex()
1092            .size_full()
1093            .bg(cx.theme().colors().editor_background)
1094            .child(
1095                v_flex()
1096                    .gap_4()
1097                    .p_4()
1098                    .border_b_1()
1099                    .border_color(cx.theme().colors().border)
1100                    .bg(cx.theme().colors().editor_background)
1101                    .child(
1102                        h_flex()
1103                            .w_full()
1104                            .gap_2()
1105                            .justify_between()
1106                            .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1107                            .child(
1108                                Button::new("install-dev-extension", "Install Dev Extension")
1109                                    .style(ButtonStyle::Filled)
1110                                    .size(ButtonSize::Large)
1111                                    .on_click(|_event, window, cx| {
1112                                        window.dispatch_action(Box::new(InstallDevExtension), cx)
1113                                    }),
1114                            ),
1115                    )
1116                    .child(
1117                        h_flex()
1118                            .w_full()
1119                            .gap_2()
1120                            .justify_between()
1121                            .child(h_flex().child(self.render_search(cx)))
1122                            .child(
1123                                h_flex()
1124                                    .child(
1125                                        ToggleButton::new("filter-all", "All")
1126                                            .style(ButtonStyle::Filled)
1127                                            .size(ButtonSize::Large)
1128                                            .toggle_state(self.filter == ExtensionFilter::All)
1129                                            .on_click(cx.listener(|this, _event, _, cx| {
1130                                                this.filter = ExtensionFilter::All;
1131                                                this.filter_extension_entries(cx);
1132                                            }))
1133                                            .tooltip(move |_, cx| {
1134                                                Tooltip::simple("Show all extensions", cx)
1135                                            })
1136                                            .first(),
1137                                    )
1138                                    .child(
1139                                        ToggleButton::new("filter-installed", "Installed")
1140                                            .style(ButtonStyle::Filled)
1141                                            .size(ButtonSize::Large)
1142                                            .toggle_state(self.filter == ExtensionFilter::Installed)
1143                                            .on_click(cx.listener(|this, _event, _, cx| {
1144                                                this.filter = ExtensionFilter::Installed;
1145                                                this.filter_extension_entries(cx);
1146                                            }))
1147                                            .tooltip(move |_, cx| {
1148                                                Tooltip::simple("Show installed extensions", cx)
1149                                            })
1150                                            .middle(),
1151                                    )
1152                                    .child(
1153                                        ToggleButton::new("filter-not-installed", "Not Installed")
1154                                            .style(ButtonStyle::Filled)
1155                                            .size(ButtonSize::Large)
1156                                            .toggle_state(
1157                                                self.filter == ExtensionFilter::NotInstalled,
1158                                            )
1159                                            .on_click(cx.listener(|this, _event, _, cx| {
1160                                                this.filter = ExtensionFilter::NotInstalled;
1161                                                this.filter_extension_entries(cx);
1162                                            }))
1163                                            .tooltip(move |_, cx| {
1164                                                Tooltip::simple("Show not installed extensions", cx)
1165                                            })
1166                                            .last(),
1167                                    ),
1168                            ),
1169                    ),
1170            )
1171            .child(self.render_feature_upsells(cx))
1172            .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
1173                let mut count = self.filtered_remote_extension_indices.len();
1174                if self.filter.include_dev_extensions() {
1175                    count += self.dev_extension_entries.len();
1176                }
1177
1178                if count == 0 {
1179                    return this.py_4().child(self.render_empty_state(cx));
1180                }
1181
1182                let extensions_page = cx.entity().clone();
1183                let scroll_handle = self.list.clone();
1184                this.child(
1185                    uniform_list(extensions_page, "entries", count, Self::render_extensions)
1186                        .flex_grow()
1187                        .pb_4()
1188                        .track_scroll(scroll_handle),
1189                )
1190            }))
1191    }
1192}
1193
1194impl EventEmitter<ItemEvent> for ExtensionsPage {}
1195
1196impl Focusable for ExtensionsPage {
1197    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1198        self.query_editor.read(cx).focus_handle(cx)
1199    }
1200}
1201
1202impl Item for ExtensionsPage {
1203    type Event = ItemEvent;
1204
1205    fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option<SharedString> {
1206        Some("Extensions".into())
1207    }
1208
1209    fn telemetry_event_text(&self) -> Option<&'static str> {
1210        Some("extensions page")
1211    }
1212
1213    fn show_toolbar(&self) -> bool {
1214        false
1215    }
1216
1217    fn clone_on_split(
1218        &self,
1219        _workspace_id: Option<WorkspaceId>,
1220        _window: &mut Window,
1221        _: &mut Context<Self>,
1222    ) -> Option<Entity<Self>> {
1223        None
1224    }
1225
1226    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1227        f(*event)
1228    }
1229}