mod components; mod extension_suggest; mod extension_version_selector; use crate::components::ExtensionCard; use crate::extension_version_selector::{ ExtensionVersionSelector, ExtensionVersionSelectorDelegate, }; use client::telemetry::Telemetry; use client::ExtensionMetadata; use editor::{Editor, EditorElement, EditorStyle}; use extension::{ExtensionManifest, ExtensionOperation, ExtensionStore}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ actions, uniform_list, AnyElement, AppContext, EventEmitter, FocusableView, FontStyle, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WhiteSpace, WindowContext, }; use num_format::{Locale, ToFormattedString}; use release_channel::ReleaseChannel; use settings::Settings; use std::ops::DerefMut; use std::time::Duration; use std::{ops::Range, sync::Arc}; use theme::ThemeSettings; use ui::{popover_menu, prelude::*, ContextMenu, ToggleButton, Tooltip}; use util::ResultExt as _; use workspace::item::TabContentParams; use workspace::{ item::{Item, ItemEvent}, Workspace, WorkspaceId, }; actions!(zed, [Extensions, InstallDevExtension]); pub fn init(cx: &mut AppContext) { cx.observe_new_views(move |workspace: &mut Workspace, cx| { workspace .register_action(move |workspace, _: &Extensions, cx| { let existing = workspace .active_pane() .read(cx) .items() .find_map(|item| item.downcast::()); if let Some(existing) = existing { workspace.activate_item(&existing, cx); } else { let extensions_page = ExtensionsPage::new(workspace, cx); workspace.add_item_to_active_pane(Box::new(extensions_page), None, cx) } }) .register_action(move |_, _: &InstallDevExtension, cx| { let store = ExtensionStore::global(cx); let prompt = cx.prompt_for_paths(gpui::PathPromptOptions { files: false, directories: true, multiple: false, }); cx.deref_mut() .spawn(|mut cx| async move { let extension_path = prompt.await.log_err()??.pop()?; store .update(&mut cx, |store, cx| { store .install_dev_extension(extension_path, cx) .detach_and_log_err(cx) }) .ok()?; Some(()) }) .detach(); }); cx.subscribe(workspace.project(), |_, _, event, cx| match event { project::Event::LanguageNotFound(buffer) => { extension_suggest::suggest(buffer.clone(), cx); } _ => {} }) .detach(); }) .detach(); } #[derive(Clone)] pub enum ExtensionStatus { NotInstalled, Installing, Upgrading, Installed(Arc), Removing, } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] enum ExtensionFilter { All, Installed, NotInstalled, } impl ExtensionFilter { pub fn include_dev_extensions(&self) -> bool { match self { Self::All | Self::Installed => true, Self::NotInstalled => false, } } } pub struct ExtensionsPage { workspace: WeakView, list: UniformListScrollHandle, telemetry: Arc, is_fetching_extensions: bool, filter: ExtensionFilter, remote_extension_entries: Vec, dev_extension_entries: Vec>, filtered_remote_extension_indices: Vec, query_editor: View, query_contains_error: bool, _subscriptions: [gpui::Subscription; 2], extension_fetch_task: Option>, } impl ExtensionsPage { pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> View { cx.new_view(|cx: &mut ViewContext| { let store = ExtensionStore::global(cx); let workspace_handle = workspace.weak_handle(); let subscriptions = [ cx.observe(&store, |_, _, cx| cx.notify()), cx.subscribe(&store, move |this, _, event, cx| match event { extension::Event::ExtensionsUpdated => this.fetch_extensions_debounced(cx), extension::Event::ExtensionInstalled(extension_id) => { this.on_extension_installed(workspace_handle.clone(), extension_id, cx) } _ => {} }), ]; let query_editor = cx.new_view(|cx| { let mut input = Editor::single_line(cx); input.set_placeholder_text("Search extensions...", cx); input }); cx.subscribe(&query_editor, Self::on_query_change).detach(); let mut this = Self { workspace: workspace.weak_handle(), list: UniformListScrollHandle::new(), telemetry: workspace.client().telemetry().clone(), is_fetching_extensions: false, filter: ExtensionFilter::All, dev_extension_entries: Vec::new(), filtered_remote_extension_indices: Vec::new(), remote_extension_entries: Vec::new(), query_contains_error: false, extension_fetch_task: None, _subscriptions: subscriptions, query_editor, }; this.fetch_extensions(None, cx); this }) } fn on_extension_installed( &mut self, workspace: WeakView, extension_id: &str, cx: &mut ViewContext, ) { let extension_store = ExtensionStore::global(cx).read(cx); let themes = extension_store .extension_themes(extension_id) .map(|name| name.to_string()) .collect::>(); if !themes.is_empty() { workspace .update(cx, |workspace, cx| { theme_selector::toggle( workspace, &theme_selector::Toggle { themes_filter: Some(themes), }, cx, ) }) .ok(); } } /// Returns whether a dev extension currently exists for the extension with the given ID. fn dev_extension_exists(extension_id: &str, cx: &mut ViewContext) -> bool { let extension_store = ExtensionStore::global(cx).read(cx); extension_store .dev_extensions() .any(|dev_extension| dev_extension.id.as_ref() == extension_id) } fn extension_status(extension_id: &str, cx: &mut ViewContext) -> ExtensionStatus { let extension_store = ExtensionStore::global(cx).read(cx); match extension_store.outstanding_operations().get(extension_id) { Some(ExtensionOperation::Install) => ExtensionStatus::Installing, Some(ExtensionOperation::Remove) => ExtensionStatus::Removing, Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading, None => match extension_store.installed_extensions().get(extension_id) { Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()), None => ExtensionStatus::NotInstalled, }, } } fn filter_extension_entries(&mut self, cx: &mut ViewContext) { self.filtered_remote_extension_indices.clear(); self.filtered_remote_extension_indices.extend( self.remote_extension_entries .iter() .enumerate() .filter(|(_, extension)| match self.filter { ExtensionFilter::All => true, ExtensionFilter::Installed => { let status = Self::extension_status(&extension.id, cx); matches!(status, ExtensionStatus::Installed(_)) } ExtensionFilter::NotInstalled => { let status = Self::extension_status(&extension.id, cx); matches!(status, ExtensionStatus::NotInstalled) } }) .map(|(ix, _)| ix), ); cx.notify(); } fn fetch_extensions(&mut self, search: Option, cx: &mut ViewContext) { self.is_fetching_extensions = true; cx.notify(); let extension_store = ExtensionStore::global(cx); let dev_extensions = extension_store.update(cx, |store, _| { store.dev_extensions().cloned().collect::>() }); let remote_extensions = extension_store.update(cx, |store, cx| { store.fetch_extensions(search.as_deref(), cx) }); cx.spawn(move |this, mut cx| async move { let dev_extensions = if let Some(search) = search { let match_candidates = dev_extensions .iter() .enumerate() .map(|(ix, manifest)| StringMatchCandidate { id: ix, string: manifest.name.clone(), char_bag: manifest.name.as_str().into(), }) .collect::>(); let matches = match_strings( &match_candidates, &search, false, match_candidates.len(), &Default::default(), cx.background_executor().clone(), ) .await; matches .into_iter() .map(|mat| dev_extensions[mat.candidate_id].clone()) .collect() } else { dev_extensions }; let fetch_result = remote_extensions.await; this.update(&mut cx, |this, cx| { cx.notify(); this.dev_extension_entries = dev_extensions; this.is_fetching_extensions = false; this.remote_extension_entries = fetch_result?; this.filter_extension_entries(cx); anyhow::Ok(()) })? }) .detach_and_log_err(cx); } fn render_extensions( &mut self, range: Range, cx: &mut ViewContext, ) -> Vec { let dev_extension_entries_len = if self.filter.include_dev_extensions() { self.dev_extension_entries.len() } else { 0 }; range .map(|ix| { if ix < dev_extension_entries_len { let extension = &self.dev_extension_entries[ix]; self.render_dev_extension(extension, cx) } else { let extension_ix = self.filtered_remote_extension_indices[ix - dev_extension_entries_len]; let extension = &self.remote_extension_entries[extension_ix]; self.render_remote_extension(extension, cx) } }) .collect() } fn render_dev_extension( &self, extension: &ExtensionManifest, cx: &mut ViewContext, ) -> ExtensionCard { let status = Self::extension_status(&extension.id, cx); let repository_url = extension.repository.clone(); ExtensionCard::new() .child( h_flex() .justify_between() .child( h_flex() .gap_2() .items_end() .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium)) .child( Headline::new(format!("v{}", extension.version)) .size(HeadlineSize::XSmall), ), ) .child( h_flex() .gap_2() .justify_between() .child( Button::new( SharedString::from(format!("rebuild-{}", extension.id)), "Rebuild", ) .on_click({ let extension_id = extension.id.clone(); move |_, cx| { ExtensionStore::global(cx).update(cx, |store, cx| { store.rebuild_dev_extension(extension_id.clone(), cx) }); } }) .color(Color::Accent) .disabled(matches!(status, ExtensionStatus::Upgrading)), ) .child( Button::new(SharedString::from(extension.id.clone()), "Uninstall") .on_click({ let extension_id = extension.id.clone(); move |_, cx| { ExtensionStore::global(cx).update(cx, |store, cx| { store.uninstall_extension(extension_id.clone(), cx) }); } }) .color(Color::Accent) .disabled(matches!(status, ExtensionStatus::Removing)), ), ), ) .child( h_flex() .justify_between() .child( Label::new(format!( "{}: {}", if extension.authors.len() > 1 { "Authors" } else { "Author" }, extension.authors.join(", ") )) .size(LabelSize::Small), ) .child(Label::new("<>").size(LabelSize::Small)), ) .child( h_flex() .justify_between() .children(extension.description.as_ref().map(|description| { Label::new(description.clone()) .size(LabelSize::Small) .color(Color::Default) })) .children(repository_url.map(|repository_url| { IconButton::new( SharedString::from(format!("repository-{}", extension.id)), IconName::Github, ) .icon_color(Color::Accent) .icon_size(IconSize::Small) .style(ButtonStyle::Filled) .on_click(cx.listener({ let repository_url = repository_url.clone(); move |_, _, cx| { cx.open_url(&repository_url); } })) .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx)) })), ) } fn render_remote_extension( &self, extension: &ExtensionMetadata, cx: &mut ViewContext, ) -> ExtensionCard { let this = cx.view().clone(); let status = Self::extension_status(&extension.id, cx); let has_dev_extension = Self::dev_extension_exists(&extension.id, cx); let extension_id = extension.id.clone(); let (install_or_uninstall_button, upgrade_button) = self.buttons_for_entry(extension, &status, has_dev_extension, cx); let version = extension.manifest.version.clone(); let repository_url = extension.manifest.repository.clone(); let installed_version = match status { ExtensionStatus::Installed(installed_version) => Some(installed_version), _ => None, }; ExtensionCard::new() .overridden_by_dev_extension(has_dev_extension) .child( h_flex() .justify_between() .child( h_flex() .gap_2() .items_end() .child( Headline::new(extension.manifest.name.clone()) .size(HeadlineSize::Medium), ) .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall)) .children( installed_version .filter(|installed_version| *installed_version != version) .map(|installed_version| { Headline::new(format!("(v{installed_version} installed)",)) .size(HeadlineSize::XSmall) }), ), ) .child( h_flex() .gap_2() .justify_between() .children(upgrade_button) .child(install_or_uninstall_button), ), ) .child( h_flex() .justify_between() .child( Label::new(format!( "{}: {}", if extension.manifest.authors.len() > 1 { "Authors" } else { "Author" }, extension.manifest.authors.join(", ") )) .size(LabelSize::Small), ) .child( Label::new(format!( "Downloads: {}", extension.download_count.to_formatted_string(&Locale::en) )) .size(LabelSize::Small), ), ) .child( h_flex() .gap_2() .justify_between() .children(extension.manifest.description.as_ref().map(|description| { h_flex().overflow_x_hidden().child( Label::new(description.clone()) .size(LabelSize::Small) .color(Color::Default), ) })) .child( h_flex() .gap_2() .child( IconButton::new( SharedString::from(format!("repository-{}", extension.id)), IconName::Github, ) .icon_color(Color::Accent) .icon_size(IconSize::Small) .style(ButtonStyle::Filled) .on_click(cx.listener({ let repository_url = repository_url.clone(); move |_, _, cx| { cx.open_url(&repository_url); } })) .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx)), ) .child( popover_menu(SharedString::from(format!("more-{}", extension.id))) .trigger( IconButton::new( SharedString::from(format!("more-{}", extension.id)), IconName::Ellipsis, ) .icon_color(Color::Accent) .icon_size(IconSize::Small) .style(ButtonStyle::Filled), ) .menu(move |cx| { Some(Self::render_remote_extension_context_menu( &this, extension_id.clone(), cx, )) }), ), ), ) } fn render_remote_extension_context_menu( this: &View, extension_id: Arc, cx: &mut WindowContext, ) -> View { let context_menu = ContextMenu::build(cx, |context_menu, cx| { context_menu.entry( "Install Another Version...", None, cx.handler_for(&this, move |this, cx| { this.show_extension_version_list(extension_id.clone(), cx) }), ) }); context_menu } fn show_extension_version_list(&mut self, extension_id: Arc, cx: &mut ViewContext) { let Some(workspace) = self.workspace.upgrade() else { return; }; cx.spawn(move |this, mut cx| async move { let extension_versions_task = this.update(&mut cx, |_, cx| { let extension_store = ExtensionStore::global(cx); extension_store.update(cx, |store, cx| { store.fetch_extension_versions(&extension_id, cx) }) })?; let extension_versions = extension_versions_task.await?; workspace.update(&mut cx, |workspace, cx| { let fs = workspace.project().read(cx).fs().clone(); workspace.toggle_modal(cx, |cx| { let delegate = ExtensionVersionSelectorDelegate::new( fs, cx.view().downgrade(), extension_versions, ); ExtensionVersionSelector::new(delegate, cx) }); })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } fn buttons_for_entry( &self, extension: &ExtensionMetadata, status: &ExtensionStatus, has_dev_extension: bool, cx: &mut ViewContext, ) -> (Button, Option