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        }
 299    }
 300
 301    /// Returns whether a dev extension currently exists for the extension with the given ID.
 302    fn dev_extension_exists(extension_id: &str, cx: &mut Context<Self>) -> bool {
 303        let extension_store = ExtensionStore::global(cx).read(cx);
 304
 305        extension_store
 306            .dev_extensions()
 307            .any(|dev_extension| dev_extension.id.as_ref() == extension_id)
 308    }
 309
 310    fn extension_status(extension_id: &str, cx: &mut Context<Self>) -> ExtensionStatus {
 311        let extension_store = ExtensionStore::global(cx).read(cx);
 312
 313        match extension_store.outstanding_operations().get(extension_id) {
 314            Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
 315            Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
 316            Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
 317            None => match extension_store.installed_extensions().get(extension_id) {
 318                Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
 319                None => ExtensionStatus::NotInstalled,
 320            },
 321        }
 322    }
 323
 324    fn filter_extension_entries(&mut self, cx: &mut Context<Self>) {
 325        self.filtered_remote_extension_indices.clear();
 326        self.filtered_remote_extension_indices.extend(
 327            self.remote_extension_entries
 328                .iter()
 329                .enumerate()
 330                .filter(|(_, extension)| match self.filter {
 331                    ExtensionFilter::All => true,
 332                    ExtensionFilter::Installed => {
 333                        let status = Self::extension_status(&extension.id, cx);
 334                        matches!(status, ExtensionStatus::Installed(_))
 335                    }
 336                    ExtensionFilter::NotInstalled => {
 337                        let status = Self::extension_status(&extension.id, cx);
 338
 339                        matches!(status, ExtensionStatus::NotInstalled)
 340                    }
 341                })
 342                .map(|(ix, _)| ix),
 343        );
 344        cx.notify();
 345    }
 346
 347    fn fetch_extensions(&mut self, search: Option<String>, cx: &mut Context<Self>) {
 348        self.is_fetching_extensions = true;
 349        cx.notify();
 350
 351        let extension_store = ExtensionStore::global(cx);
 352
 353        let dev_extensions = extension_store.update(cx, |store, _| {
 354            store.dev_extensions().cloned().collect::<Vec<_>>()
 355        });
 356
 357        let remote_extensions = extension_store.update(cx, |store, cx| {
 358            store.fetch_extensions(search.as_deref(), cx)
 359        });
 360
 361        cx.spawn(move |this, mut cx| async move {
 362            let dev_extensions = if let Some(search) = search {
 363                let match_candidates = dev_extensions
 364                    .iter()
 365                    .enumerate()
 366                    .map(|(ix, manifest)| StringMatchCandidate::new(ix, &manifest.name))
 367                    .collect::<Vec<_>>();
 368
 369                let matches = match_strings(
 370                    &match_candidates,
 371                    &search,
 372                    false,
 373                    match_candidates.len(),
 374                    &Default::default(),
 375                    cx.background_executor().clone(),
 376                )
 377                .await;
 378                matches
 379                    .into_iter()
 380                    .map(|mat| dev_extensions[mat.candidate_id].clone())
 381                    .collect()
 382            } else {
 383                dev_extensions
 384            };
 385
 386            let fetch_result = remote_extensions.await;
 387            this.update(&mut cx, |this, cx| {
 388                cx.notify();
 389                this.dev_extension_entries = dev_extensions;
 390                this.is_fetching_extensions = false;
 391                this.remote_extension_entries = fetch_result?;
 392                this.filter_extension_entries(cx);
 393                anyhow::Ok(())
 394            })?
 395        })
 396        .detach_and_log_err(cx);
 397    }
 398
 399    fn render_extensions(
 400        &mut self,
 401        range: Range<usize>,
 402        _: &mut Window,
 403        cx: &mut Context<Self>,
 404    ) -> Vec<ExtensionCard> {
 405        let dev_extension_entries_len = if self.filter.include_dev_extensions() {
 406            self.dev_extension_entries.len()
 407        } else {
 408            0
 409        };
 410        range
 411            .map(|ix| {
 412                if ix < dev_extension_entries_len {
 413                    let extension = &self.dev_extension_entries[ix];
 414                    self.render_dev_extension(extension, cx)
 415                } else {
 416                    let extension_ix =
 417                        self.filtered_remote_extension_indices[ix - dev_extension_entries_len];
 418                    let extension = &self.remote_extension_entries[extension_ix];
 419                    self.render_remote_extension(extension, cx)
 420                }
 421            })
 422            .collect()
 423    }
 424
 425    fn render_dev_extension(
 426        &self,
 427        extension: &ExtensionManifest,
 428        cx: &mut Context<Self>,
 429    ) -> ExtensionCard {
 430        let status = Self::extension_status(&extension.id, cx);
 431
 432        let repository_url = extension.repository.clone();
 433
 434        ExtensionCard::new()
 435            .child(
 436                h_flex()
 437                    .justify_between()
 438                    .child(
 439                        h_flex()
 440                            .gap_2()
 441                            .items_end()
 442                            .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
 443                            .child(
 444                                Headline::new(format!("v{}", extension.version))
 445                                    .size(HeadlineSize::XSmall),
 446                            ),
 447                    )
 448                    .child(
 449                        h_flex()
 450                            .gap_2()
 451                            .justify_between()
 452                            .child(
 453                                Button::new(
 454                                    SharedString::from(format!("rebuild-{}", extension.id)),
 455                                    "Rebuild",
 456                                )
 457                                .on_click({
 458                                    let extension_id = extension.id.clone();
 459                                    move |_, _, cx| {
 460                                        ExtensionStore::global(cx).update(cx, |store, cx| {
 461                                            store.rebuild_dev_extension(extension_id.clone(), cx)
 462                                        });
 463                                    }
 464                                })
 465                                .color(Color::Accent)
 466                                .disabled(matches!(status, ExtensionStatus::Upgrading)),
 467                            )
 468                            .child(
 469                                Button::new(SharedString::from(extension.id.clone()), "Uninstall")
 470                                    .on_click({
 471                                        let extension_id = extension.id.clone();
 472                                        move |_, _, cx| {
 473                                            ExtensionStore::global(cx).update(cx, |store, cx| {
 474                                                store.uninstall_extension(extension_id.clone(), cx)
 475                                            });
 476                                        }
 477                                    })
 478                                    .color(Color::Accent)
 479                                    .disabled(matches!(status, ExtensionStatus::Removing)),
 480                            ),
 481                    ),
 482            )
 483            .child(
 484                h_flex()
 485                    .gap_2()
 486                    .justify_between()
 487                    .child(
 488                        Label::new(format!(
 489                            "{}: {}",
 490                            if extension.authors.len() > 1 {
 491                                "Authors"
 492                            } else {
 493                                "Author"
 494                            },
 495                            extension.authors.join(", ")
 496                        ))
 497                        .size(LabelSize::Small)
 498                        .text_ellipsis(),
 499                    )
 500                    .child(Label::new("<>").size(LabelSize::Small)),
 501            )
 502            .child(
 503                h_flex()
 504                    .gap_2()
 505                    .justify_between()
 506                    .children(extension.description.as_ref().map(|description| {
 507                        Label::new(description.clone())
 508                            .size(LabelSize::Small)
 509                            .color(Color::Default)
 510                            .text_ellipsis()
 511                    }))
 512                    .children(repository_url.map(|repository_url| {
 513                        IconButton::new(
 514                            SharedString::from(format!("repository-{}", extension.id)),
 515                            IconName::Github,
 516                        )
 517                        .icon_color(Color::Accent)
 518                        .icon_size(IconSize::Small)
 519                        .style(ButtonStyle::Filled)
 520                        .on_click(cx.listener({
 521                            let repository_url = repository_url.clone();
 522                            move |_, _, _, cx| {
 523                                cx.open_url(&repository_url);
 524                            }
 525                        }))
 526                        .tooltip(Tooltip::text(repository_url.clone()))
 527                    })),
 528            )
 529    }
 530
 531    fn render_remote_extension(
 532        &self,
 533        extension: &ExtensionMetadata,
 534        cx: &mut Context<Self>,
 535    ) -> ExtensionCard {
 536        let this = cx.entity().clone();
 537        let status = Self::extension_status(&extension.id, cx);
 538        let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
 539
 540        let extension_id = extension.id.clone();
 541        let (install_or_uninstall_button, upgrade_button) =
 542            self.buttons_for_entry(extension, &status, has_dev_extension, cx);
 543        let version = extension.manifest.version.clone();
 544        let repository_url = extension.manifest.repository.clone();
 545
 546        let installed_version = match status {
 547            ExtensionStatus::Installed(installed_version) => Some(installed_version),
 548            _ => None,
 549        };
 550
 551        ExtensionCard::new()
 552            .overridden_by_dev_extension(has_dev_extension)
 553            .child(
 554                h_flex()
 555                    .justify_between()
 556                    .child(
 557                        h_flex()
 558                            .gap_2()
 559                            .items_end()
 560                            .child(
 561                                Headline::new(extension.manifest.name.clone())
 562                                    .size(HeadlineSize::Medium),
 563                            )
 564                            .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
 565                            .children(
 566                                installed_version
 567                                    .filter(|installed_version| *installed_version != version)
 568                                    .map(|installed_version| {
 569                                        Headline::new(format!("(v{installed_version} installed)",))
 570                                            .size(HeadlineSize::XSmall)
 571                                    }),
 572                            ),
 573                    )
 574                    .child(
 575                        h_flex()
 576                            .gap_2()
 577                            .justify_between()
 578                            .children(upgrade_button)
 579                            .child(install_or_uninstall_button),
 580                    ),
 581            )
 582            .child(
 583                h_flex()
 584                    .gap_2()
 585                    .justify_between()
 586                    .child(
 587                        Label::new(format!(
 588                            "{}: {}",
 589                            if extension.manifest.authors.len() > 1 {
 590                                "Authors"
 591                            } else {
 592                                "Author"
 593                            },
 594                            extension.manifest.authors.join(", ")
 595                        ))
 596                        .size(LabelSize::Small)
 597                        .text_ellipsis(),
 598                    )
 599                    .child(
 600                        Label::new(format!(
 601                            "Downloads: {}",
 602                            extension.download_count.to_formatted_string(&Locale::en)
 603                        ))
 604                        .size(LabelSize::Small),
 605                    ),
 606            )
 607            .child(
 608                h_flex()
 609                    .gap_2()
 610                    .justify_between()
 611                    .children(extension.manifest.description.as_ref().map(|description| {
 612                        Label::new(description.clone())
 613                            .size(LabelSize::Small)
 614                            .color(Color::Default)
 615                            .text_ellipsis()
 616                    }))
 617                    .child(
 618                        h_flex()
 619                            .gap_2()
 620                            .child(
 621                                IconButton::new(
 622                                    SharedString::from(format!("repository-{}", extension.id)),
 623                                    IconName::Github,
 624                                )
 625                                .icon_color(Color::Accent)
 626                                .icon_size(IconSize::Small)
 627                                .style(ButtonStyle::Filled)
 628                                .on_click(cx.listener({
 629                                    let repository_url = repository_url.clone();
 630                                    move |_, _, _, cx| {
 631                                        cx.open_url(&repository_url);
 632                                    }
 633                                }))
 634                                .tooltip(Tooltip::text(repository_url.clone())),
 635                            )
 636                            .child(
 637                                PopoverMenu::new(SharedString::from(format!(
 638                                    "more-{}",
 639                                    extension.id
 640                                )))
 641                                .trigger(
 642                                    IconButton::new(
 643                                        SharedString::from(format!("more-{}", extension.id)),
 644                                        IconName::Ellipsis,
 645                                    )
 646                                    .icon_color(Color::Accent)
 647                                    .icon_size(IconSize::Small)
 648                                    .style(ButtonStyle::Filled),
 649                                )
 650                                .menu(move |window, cx| {
 651                                    Some(Self::render_remote_extension_context_menu(
 652                                        &this,
 653                                        extension_id.clone(),
 654                                        window,
 655                                        cx,
 656                                    ))
 657                                }),
 658                            ),
 659                    ),
 660            )
 661    }
 662
 663    fn render_remote_extension_context_menu(
 664        this: &Entity<Self>,
 665        extension_id: Arc<str>,
 666        window: &mut Window,
 667        cx: &mut App,
 668    ) -> Entity<ContextMenu> {
 669        let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| {
 670            context_menu
 671                .entry(
 672                    "Install Another Version...",
 673                    None,
 674                    window.handler_for(this, {
 675                        let extension_id = extension_id.clone();
 676                        move |this, window, cx| {
 677                            this.show_extension_version_list(extension_id.clone(), window, cx)
 678                        }
 679                    }),
 680                )
 681                .entry("Copy Extension ID", None, {
 682                    let extension_id = extension_id.clone();
 683                    move |_, cx| {
 684                        cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string()));
 685                    }
 686                })
 687        });
 688
 689        context_menu
 690    }
 691
 692    fn show_extension_version_list(
 693        &mut self,
 694        extension_id: Arc<str>,
 695        window: &mut Window,
 696        cx: &mut Context<Self>,
 697    ) {
 698        let Some(workspace) = self.workspace.upgrade() else {
 699            return;
 700        };
 701
 702        cx.spawn_in(window, move |this, mut cx| async move {
 703            let extension_versions_task = this.update(&mut cx, |_, cx| {
 704                let extension_store = ExtensionStore::global(cx);
 705
 706                extension_store.update(cx, |store, cx| {
 707                    store.fetch_extension_versions(&extension_id, cx)
 708                })
 709            })?;
 710
 711            let extension_versions = extension_versions_task.await?;
 712
 713            workspace.update_in(&mut cx, |workspace, window, cx| {
 714                let fs = workspace.project().read(cx).fs().clone();
 715                workspace.toggle_modal(window, cx, |window, cx| {
 716                    let delegate = ExtensionVersionSelectorDelegate::new(
 717                        fs,
 718                        cx.entity().downgrade(),
 719                        extension_versions,
 720                    );
 721
 722                    ExtensionVersionSelector::new(delegate, window, cx)
 723                });
 724            })?;
 725
 726            anyhow::Ok(())
 727        })
 728        .detach_and_log_err(cx);
 729    }
 730
 731    fn buttons_for_entry(
 732        &self,
 733        extension: &ExtensionMetadata,
 734        status: &ExtensionStatus,
 735        has_dev_extension: bool,
 736        cx: &mut Context<Self>,
 737    ) -> (Button, Option<Button>) {
 738        let is_compatible =
 739            extension_host::is_version_compatible(ReleaseChannel::global(cx), extension);
 740
 741        if has_dev_extension {
 742            // If we have a dev extension for the given extension, just treat it as uninstalled.
 743            // The button here is a placeholder, as it won't be interactable anyways.
 744            return (
 745                Button::new(SharedString::from(extension.id.clone()), "Install"),
 746                None,
 747            );
 748        }
 749
 750        match status.clone() {
 751            ExtensionStatus::NotInstalled => (
 752                Button::new(SharedString::from(extension.id.clone()), "Install").on_click({
 753                    let extension_id = extension.id.clone();
 754                    move |_, _, cx| {
 755                        telemetry::event!("Extension Installed");
 756                        ExtensionStore::global(cx).update(cx, |store, cx| {
 757                            store.install_latest_extension(extension_id.clone(), cx)
 758                        });
 759                    }
 760                }),
 761                None,
 762            ),
 763            ExtensionStatus::Installing => (
 764                Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
 765                None,
 766            ),
 767            ExtensionStatus::Upgrading => (
 768                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
 769                Some(
 770                    Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
 771                ),
 772            ),
 773            ExtensionStatus::Installed(installed_version) => (
 774                Button::new(SharedString::from(extension.id.clone()), "Uninstall").on_click({
 775                    let extension_id = extension.id.clone();
 776                    move |_, _, cx| {
 777                        telemetry::event!("Extension Uninstalled", extension_id);
 778                        ExtensionStore::global(cx).update(cx, |store, cx| {
 779                            store.uninstall_extension(extension_id.clone(), cx)
 780                        });
 781                    }
 782                }),
 783                if installed_version == extension.manifest.version {
 784                    None
 785                } else {
 786                    Some(
 787                        Button::new(SharedString::from(extension.id.clone()), "Upgrade")
 788                            .when(!is_compatible, |upgrade_button| {
 789                                upgrade_button.disabled(true).tooltip({
 790                                    let version = extension.manifest.version.clone();
 791                                    move |_, cx| {
 792                                        Tooltip::simple(
 793                                            format!(
 794                                                "v{version} is not compatible with this version of Zed.",
 795                                            ),
 796                                             cx,
 797                                        )
 798                                    }
 799                                })
 800                            })
 801                            .disabled(!is_compatible)
 802                            .on_click({
 803                                let extension_id = extension.id.clone();
 804                                let version = extension.manifest.version.clone();
 805                                move |_, _, cx| {
 806                                    telemetry::event!("Extension Installed", extension_id, version);
 807                                    ExtensionStore::global(cx).update(cx, |store, cx| {
 808                                        store
 809                                            .upgrade_extension(
 810                                                extension_id.clone(),
 811                                                version.clone(),
 812                                                cx,
 813                                            )
 814                                            .detach_and_log_err(cx)
 815                                    });
 816                                }
 817                            }),
 818                    )
 819                },
 820            ),
 821            ExtensionStatus::Removing => (
 822                Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
 823                None,
 824            ),
 825        }
 826    }
 827
 828    fn render_search(&self, cx: &mut Context<Self>) -> Div {
 829        let mut key_context = KeyContext::new_with_defaults();
 830        key_context.add("BufferSearchBar");
 831
 832        let editor_border = if self.query_contains_error {
 833            Color::Error.color(cx)
 834        } else {
 835            cx.theme().colors().border
 836        };
 837
 838        h_flex().w_full().gap_2().key_context(key_context).child(
 839            h_flex()
 840                .flex_1()
 841                .px_2()
 842                .py_1()
 843                .gap_2()
 844                .border_1()
 845                .border_color(editor_border)
 846                .min_w(rems_from_px(384.))
 847                .rounded_lg()
 848                .child(Icon::new(IconName::MagnifyingGlass))
 849                .child(self.render_text_input(&self.query_editor, cx)),
 850        )
 851    }
 852
 853    fn render_text_input(
 854        &self,
 855        editor: &Entity<Editor>,
 856        cx: &mut Context<Self>,
 857    ) -> impl IntoElement {
 858        let settings = ThemeSettings::get_global(cx);
 859        let text_style = TextStyle {
 860            color: if editor.read(cx).read_only(cx) {
 861                cx.theme().colors().text_disabled
 862            } else {
 863                cx.theme().colors().text
 864            },
 865            font_family: settings.ui_font.family.clone(),
 866            font_features: settings.ui_font.features.clone(),
 867            font_fallbacks: settings.ui_font.fallbacks.clone(),
 868            font_size: rems(0.875).into(),
 869            font_weight: settings.ui_font.weight,
 870            line_height: relative(1.3),
 871            ..Default::default()
 872        };
 873
 874        EditorElement::new(
 875            editor,
 876            EditorStyle {
 877                background: cx.theme().colors().editor_background,
 878                local_player: cx.theme().players().local(),
 879                text: text_style,
 880                ..Default::default()
 881            },
 882        )
 883    }
 884
 885    fn on_query_change(
 886        &mut self,
 887        _: Entity<Editor>,
 888        event: &editor::EditorEvent,
 889        cx: &mut Context<Self>,
 890    ) {
 891        if let editor::EditorEvent::Edited { .. } = event {
 892            self.query_contains_error = false;
 893            self.fetch_extensions_debounced(cx);
 894            self.refresh_feature_upsells(cx);
 895        }
 896    }
 897
 898    fn fetch_extensions_debounced(&mut self, cx: &mut Context<ExtensionsPage>) {
 899        self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
 900            let search = this
 901                .update(&mut cx, |this, cx| this.search_query(cx))
 902                .ok()
 903                .flatten();
 904
 905            // Only debounce the fetching of extensions if we have a search
 906            // query.
 907            //
 908            // If the search was just cleared then we can just reload the list
 909            // of extensions without a debounce, which allows us to avoid seeing
 910            // an intermittent flash of a "no extensions" state.
 911            if search.is_some() {
 912                cx.background_executor()
 913                    .timer(Duration::from_millis(250))
 914                    .await;
 915            };
 916
 917            this.update(&mut cx, |this, cx| {
 918                this.fetch_extensions(search, cx);
 919            })
 920            .ok();
 921        }));
 922    }
 923
 924    pub fn search_query(&self, cx: &mut App) -> Option<String> {
 925        let search = self.query_editor.read(cx).text(cx);
 926        if search.trim().is_empty() {
 927            None
 928        } else {
 929            Some(search)
 930        }
 931    }
 932
 933    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
 934        let has_search = self.search_query(cx).is_some();
 935
 936        let message = if self.is_fetching_extensions {
 937            "Loading extensions..."
 938        } else {
 939            match self.filter {
 940                ExtensionFilter::All => {
 941                    if has_search {
 942                        "No extensions that match your search."
 943                    } else {
 944                        "No extensions."
 945                    }
 946                }
 947                ExtensionFilter::Installed => {
 948                    if has_search {
 949                        "No installed extensions that match your search."
 950                    } else {
 951                        "No installed extensions."
 952                    }
 953                }
 954                ExtensionFilter::NotInstalled => {
 955                    if has_search {
 956                        "No not installed extensions that match your search."
 957                    } else {
 958                        "No not installed extensions."
 959                    }
 960                }
 961            }
 962        };
 963
 964        Label::new(message)
 965    }
 966
 967    fn update_settings<T: Settings>(
 968        &mut self,
 969        selection: &ToggleState,
 970
 971        cx: &mut Context<Self>,
 972        callback: impl 'static + Send + Fn(&mut T::FileContent, bool),
 973    ) {
 974        if let Some(workspace) = self.workspace.upgrade() {
 975            let fs = workspace.read(cx).app_state().fs.clone();
 976            let selection = *selection;
 977            settings::update_settings_file::<T>(fs, cx, move |settings, _| {
 978                let value = match selection {
 979                    ToggleState::Unselected => false,
 980                    ToggleState::Selected => true,
 981                    _ => return,
 982                };
 983
 984                callback(settings, value)
 985            });
 986        }
 987    }
 988
 989    fn refresh_feature_upsells(&mut self, cx: &mut Context<Self>) {
 990        let Some(search) = self.search_query(cx) else {
 991            self.upsells.clear();
 992            return;
 993        };
 994
 995        let search = search.to_lowercase();
 996        let search_terms = search
 997            .split_whitespace()
 998            .map(|term| term.trim())
 999            .collect::<Vec<_>>();
1000
1001        for (feature, keywords) in keywords_by_feature() {
1002            if keywords
1003                .iter()
1004                .any(|keyword| search_terms.contains(keyword))
1005            {
1006                self.upsells.insert(*feature);
1007            } else {
1008                self.upsells.remove(feature);
1009            }
1010        }
1011    }
1012
1013    fn render_feature_upsells(&self, cx: &mut Context<Self>) -> impl IntoElement {
1014        let upsells_count = self.upsells.len();
1015
1016        v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| {
1017            let upsell = match feature {
1018                Feature::Git => FeatureUpsell::new(
1019                    "Zed comes with basic Git support. More Git features are coming in the future.",
1020                )
1021                .docs_url("https://zed.dev/docs/git"),
1022                Feature::OpenIn => FeatureUpsell::new(
1023                    "Zed supports linking to a source line on GitHub and others.",
1024                )
1025                .docs_url("https://zed.dev/docs/git#git-integrations"),
1026                Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!")
1027                    .docs_url("https://zed.dev/docs/vim")
1028                    .child(CheckboxWithLabel::new(
1029                        "enable-vim",
1030                        Label::new("Enable vim mode"),
1031                        if VimModeSetting::get_global(cx).0 {
1032                            ui::ToggleState::Selected
1033                        } else {
1034                            ui::ToggleState::Unselected
1035                        },
1036                        cx.listener(move |this, selection, _, cx| {
1037                            telemetry::event!("Vim Mode Toggled", source = "Feature Upsell");
1038                            this.update_settings::<VimModeSetting>(
1039                                selection,
1040                                cx,
1041                                |setting, value| *setting = Some(value),
1042                            );
1043                        }),
1044                    )),
1045                Feature::LanguageBash => FeatureUpsell::new("Shell support is built-in to Zed!")
1046                    .docs_url("https://zed.dev/docs/languages/bash"),
1047                Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!")
1048                    .docs_url("https://zed.dev/docs/languages/c"),
1049                Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!")
1050                    .docs_url("https://zed.dev/docs/languages/cpp"),
1051                Feature::LanguageGo => FeatureUpsell::new("Go support is built-in to Zed!")
1052                    .docs_url("https://zed.dev/docs/languages/go"),
1053                Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!")
1054                    .docs_url("https://zed.dev/docs/languages/python"),
1055                Feature::LanguageReact => FeatureUpsell::new("React support is built-in to Zed!")
1056                    .docs_url("https://zed.dev/docs/languages/typescript"),
1057                Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!")
1058                    .docs_url("https://zed.dev/docs/languages/rust"),
1059                Feature::LanguageTypescript => {
1060                    FeatureUpsell::new("Typescript support is built-in to Zed!")
1061                        .docs_url("https://zed.dev/docs/languages/typescript")
1062                }
1063            };
1064
1065            upsell.when(ix < upsells_count, |upsell| upsell.border_b_1())
1066        }))
1067    }
1068}
1069
1070impl Render for ExtensionsPage {
1071    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1072        v_flex()
1073            .size_full()
1074            .bg(cx.theme().colors().editor_background)
1075            .child(
1076                v_flex()
1077                    .gap_4()
1078                    .p_4()
1079                    .border_b_1()
1080                    .border_color(cx.theme().colors().border)
1081                    .bg(cx.theme().colors().editor_background)
1082                    .child(
1083                        h_flex()
1084                            .w_full()
1085                            .gap_2()
1086                            .justify_between()
1087                            .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
1088                            .child(
1089                                Button::new("install-dev-extension", "Install Dev Extension")
1090                                    .style(ButtonStyle::Filled)
1091                                    .size(ButtonSize::Large)
1092                                    .on_click(|_event, window, cx| {
1093                                        window.dispatch_action(Box::new(InstallDevExtension), cx)
1094                                    }),
1095                            ),
1096                    )
1097                    .child(
1098                        h_flex()
1099                            .w_full()
1100                            .gap_2()
1101                            .justify_between()
1102                            .child(h_flex().child(self.render_search(cx)))
1103                            .child(
1104                                h_flex()
1105                                    .child(
1106                                        ToggleButton::new("filter-all", "All")
1107                                            .style(ButtonStyle::Filled)
1108                                            .size(ButtonSize::Large)
1109                                            .toggle_state(self.filter == ExtensionFilter::All)
1110                                            .on_click(cx.listener(|this, _event, _, cx| {
1111                                                this.filter = ExtensionFilter::All;
1112                                                this.filter_extension_entries(cx);
1113                                            }))
1114                                            .tooltip(move |_, cx| {
1115                                                Tooltip::simple("Show all extensions", cx)
1116                                            })
1117                                            .first(),
1118                                    )
1119                                    .child(
1120                                        ToggleButton::new("filter-installed", "Installed")
1121                                            .style(ButtonStyle::Filled)
1122                                            .size(ButtonSize::Large)
1123                                            .toggle_state(self.filter == ExtensionFilter::Installed)
1124                                            .on_click(cx.listener(|this, _event, _, cx| {
1125                                                this.filter = ExtensionFilter::Installed;
1126                                                this.filter_extension_entries(cx);
1127                                            }))
1128                                            .tooltip(move |_, cx| {
1129                                                Tooltip::simple("Show installed extensions", cx)
1130                                            })
1131                                            .middle(),
1132                                    )
1133                                    .child(
1134                                        ToggleButton::new("filter-not-installed", "Not Installed")
1135                                            .style(ButtonStyle::Filled)
1136                                            .size(ButtonSize::Large)
1137                                            .toggle_state(
1138                                                self.filter == ExtensionFilter::NotInstalled,
1139                                            )
1140                                            .on_click(cx.listener(|this, _event, _, cx| {
1141                                                this.filter = ExtensionFilter::NotInstalled;
1142                                                this.filter_extension_entries(cx);
1143                                            }))
1144                                            .tooltip(move |_, cx| {
1145                                                Tooltip::simple("Show not installed extensions", cx)
1146                                            })
1147                                            .last(),
1148                                    ),
1149                            ),
1150                    ),
1151            )
1152            .child(self.render_feature_upsells(cx))
1153            .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
1154                let mut count = self.filtered_remote_extension_indices.len();
1155                if self.filter.include_dev_extensions() {
1156                    count += self.dev_extension_entries.len();
1157                }
1158
1159                if count == 0 {
1160                    return this.py_4().child(self.render_empty_state(cx));
1161                }
1162
1163                let extensions_page = cx.entity().clone();
1164                let scroll_handle = self.list.clone();
1165                this.child(
1166                    uniform_list(extensions_page, "entries", count, Self::render_extensions)
1167                        .flex_grow()
1168                        .pb_4()
1169                        .track_scroll(scroll_handle),
1170                )
1171            }))
1172    }
1173}
1174
1175impl EventEmitter<ItemEvent> for ExtensionsPage {}
1176
1177impl Focusable for ExtensionsPage {
1178    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1179        self.query_editor.read(cx).focus_handle(cx)
1180    }
1181}
1182
1183impl Item for ExtensionsPage {
1184    type Event = ItemEvent;
1185
1186    fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option<SharedString> {
1187        Some("Extensions".into())
1188    }
1189
1190    fn telemetry_event_text(&self) -> Option<&'static str> {
1191        Some("extensions page")
1192    }
1193
1194    fn show_toolbar(&self) -> bool {
1195        false
1196    }
1197
1198    fn clone_on_split(
1199        &self,
1200        _workspace_id: Option<WorkspaceId>,
1201        _window: &mut Window,
1202        _: &mut Context<Self>,
1203    ) -> Option<Entity<Self>> {
1204        None
1205    }
1206
1207    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
1208        f(*event)
1209    }
1210}