From 2ae1a472e49584fc83307e06502b50922276b685 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 15 Jul 2024 17:10:01 -0400 Subject: [PATCH] Upsell built-in features on the extensions page (#14516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR extends the extensions page with support for upselling built-in Zed features when certain keywords are searched for. This should help inform users about features that Zed has out-of-the-box when they go looking for them as extensions. For example, when someone searches "vim": Screenshot 2024-07-15 at 4 58 44 PM Here are more examples of what the upsells can look like: Screenshot 2024-07-15 at 4 54 39 PM Release Notes: - Added banners for built-in Zed features when corresponding keywords are used in the extension search. --- Cargo.lock | 2 + crates/extensions_ui/Cargo.toml | 2 + crates/extensions_ui/src/components.rs | 2 + .../src/components/feature_upsell.rs | 72 ++++++++++ crates/extensions_ui/src/extensions_ui.rs | 134 ++++++++++++++++-- crates/gpui_macros/src/styles.rs | 2 +- 6 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 crates/extensions_ui/src/components/feature_upsell.rs diff --git a/Cargo.lock b/Cargo.lock index 5224e111f3..472ac875e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3987,6 +3987,7 @@ version = "0.1.0" dependencies = [ "anyhow", "client", + "collections", "db", "editor", "extension", @@ -4006,6 +4007,7 @@ dependencies = [ "theme_selector", "ui", "util", + "vim", "workspace", ] diff --git a/crates/extensions_ui/Cargo.toml b/crates/extensions_ui/Cargo.toml index 3fd04fca4e..28f0fcb7ad 100644 --- a/crates/extensions_ui/Cargo.toml +++ b/crates/extensions_ui/Cargo.toml @@ -17,6 +17,7 @@ test-support = [] [dependencies] anyhow.workspace = true client.workspace = true +collections.workspace = true db.workspace = true editor.workspace = true extension.workspace = true @@ -36,6 +37,7 @@ theme.workspace = true theme_selector.workspace = true ui.workspace = true util.workspace = true +vim.workspace = true workspace.workspace = true [dev-dependencies] diff --git a/crates/extensions_ui/src/components.rs b/crates/extensions_ui/src/components.rs index bf11abd679..957980e49f 100644 --- a/crates/extensions_ui/src/components.rs +++ b/crates/extensions_ui/src/components.rs @@ -1,3 +1,5 @@ mod extension_card; +mod feature_upsell; pub use extension_card::*; +pub use feature_upsell::*; diff --git a/crates/extensions_ui/src/components/feature_upsell.rs b/crates/extensions_ui/src/components/feature_upsell.rs new file mode 100644 index 0000000000..38ee867b66 --- /dev/null +++ b/crates/extensions_ui/src/components/feature_upsell.rs @@ -0,0 +1,72 @@ +use gpui::{AnyElement, Div, StyleRefinement}; +use smallvec::SmallVec; +use ui::{prelude::*, ButtonLike}; + +#[derive(IntoElement)] +pub struct FeatureUpsell { + base: Div, + text: SharedString, + docs_url: Option, + children: SmallVec<[AnyElement; 2]>, +} + +impl FeatureUpsell { + pub fn new(text: impl Into) -> Self { + Self { + base: h_flex(), + text: text.into(), + docs_url: None, + children: SmallVec::new(), + } + } + + pub fn docs_url(mut self, docs_url: impl Into) -> Self { + self.docs_url = Some(docs_url.into()); + self + } +} + +impl ParentElement for FeatureUpsell { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} + +// Style methods. +impl FeatureUpsell { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } + + gpui::border_style_methods!({ + visibility: pub + }); +} + +impl RenderOnce for FeatureUpsell { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + self.base + .p_4() + .justify_between() + .border_color(cx.theme().colors().border) + .child(v_flex().overflow_hidden().child(Label::new(self.text))) + .child(h_flex().gap_2().children(self.children).when_some( + self.docs_url, + |el, docs_url| { + el.child( + ButtonLike::new("open_docs") + .child( + h_flex() + .gap_2() + .child(Label::new("View docs")) + .child(Icon::new(IconName::ArrowUpRight)), + ) + .on_click({ + let docs_url = docs_url.clone(); + move |_event, cx| cx.open_url(&docs_url) + }), + ) + }, + )) + } +} diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index e67f69901b..d77dc9e11a 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -2,12 +2,14 @@ mod components; mod extension_suggest; mod extension_version_selector; -use crate::components::ExtensionCard; -use crate::extension_version_selector::{ - ExtensionVersionSelector, ExtensionVersionSelectorDelegate, -}; +use std::ops::DerefMut; +use std::sync::OnceLock; +use std::time::Duration; +use std::{ops::Range, sync::Arc}; + use client::telemetry::Telemetry; use client::ExtensionMetadata; +use collections::{BTreeMap, BTreeSet}; use editor::{Editor, EditorElement, EditorStyle}; use extension::{ExtensionManifest, ExtensionOperation, ExtensionStore}; use fuzzy::{match_strings, StringMatchCandidate}; @@ -19,17 +21,20 @@ use gpui::{ 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::{prelude::*, ContextMenu, PopoverMenu, ToggleButton, Tooltip}; +use ui::{prelude::*, CheckboxWithLabel, ContextMenu, PopoverMenu, ToggleButton, Tooltip}; +use vim::VimModeSetting; use workspace::item::TabContentParams; use workspace::{ item::{Item, ItemEvent}, Workspace, WorkspaceId, }; +use crate::components::{ExtensionCard, FeatureUpsell}; +use crate::extension_version_selector::{ + ExtensionVersionSelector, ExtensionVersionSelectorDelegate, +}; + actions!(zed, [Extensions, InstallDevExtension]); pub fn init(cx: &mut AppContext) { @@ -122,6 +127,30 @@ impl ExtensionFilter { } } +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +enum Feature { + Git, + Vim, + LanguageC, + LanguageCpp, + LanguagePython, + LanguageRust, +} + +fn keywords_by_feature() -> &'static BTreeMap> { + static KEYWORDS_BY_FEATURE: OnceLock>> = OnceLock::new(); + KEYWORDS_BY_FEATURE.get_or_init(|| { + BTreeMap::from_iter([ + (Feature::Git, vec!["git"]), + (Feature::Vim, vec!["vim"]), + (Feature::LanguageC, vec!["c", "clang"]), + (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]), + (Feature::LanguagePython, vec!["python", "py"]), + (Feature::LanguageRust, vec!["rust", "rs"]), + ]) + }) +} + pub struct ExtensionsPage { workspace: WeakView, list: UniformListScrollHandle, @@ -135,6 +164,7 @@ pub struct ExtensionsPage { query_contains_error: bool, _subscriptions: [gpui::Subscription; 2], extension_fetch_task: Option>, + upsells: BTreeSet, } impl ExtensionsPage { @@ -173,6 +203,7 @@ impl ExtensionsPage { extension_fetch_task: None, _subscriptions: subscriptions, query_editor, + upsells: BTreeSet::default(), }; this.fetch_extensions(None, cx); this @@ -792,6 +823,7 @@ impl ExtensionsPage { if let editor::EditorEvent::Edited { .. } = event { self.query_contains_error = false; self.fetch_extensions_debounced(cx); + self.refresh_feature_upsells(cx); } } @@ -863,6 +895,91 @@ impl ExtensionsPage { Label::new(message) } + + fn update_settings( + &mut self, + selection: &Selection, + cx: &mut ViewContext, + callback: impl 'static + Send + Fn(&mut T::FileContent, bool), + ) { + if let Some(workspace) = self.workspace.upgrade() { + let fs = workspace.read(cx).app_state().fs.clone(); + let selection = *selection; + settings::update_settings_file::(fs, cx, move |settings| { + let value = match selection { + Selection::Unselected => false, + Selection::Selected => true, + _ => return, + }; + + callback(settings, value) + }); + } + } + + fn refresh_feature_upsells(&mut self, cx: &mut ViewContext) { + let Some(search) = self.search_query(cx) else { + self.upsells.clear(); + return; + }; + + let search = search.to_lowercase(); + let search_terms = search + .split_whitespace() + .map(|term| term.trim()) + .collect::>(); + + for (feature, keywords) in keywords_by_feature() { + if keywords + .iter() + .any(|keyword| search_terms.contains(keyword)) + { + self.upsells.insert(*feature); + } else { + self.upsells.remove(&feature); + } + } + } + + fn render_feature_upsells(&self, cx: &mut ViewContext) -> impl IntoElement { + let upsells_count = self.upsells.len(); + + v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| { + let upsell = match feature { + Feature::Git => FeatureUpsell::new("Zed comes with basic Git support for diffs and branches. More Git features are coming in the future."), + Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!") + .docs_url("https://zed.dev/docs/vim") + .child(CheckboxWithLabel::new( + "enable-vim", + Label::new("Enable vim mode"), + if VimModeSetting::get_global(cx).0 { + ui::Selection::Selected + } else { + ui::Selection::Unselected + }, + cx.listener(move |this, selection, cx| { + this.telemetry + .report_app_event("extensions: toggle vim".to_string()); + this.update_settings::( + selection, + cx, + |setting, value| *setting = Some(value), + ); + }), + )), + Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!") + .docs_url("https://zed.dev/docs/languages/c"), + Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!") + .docs_url("https://zed.dev/docs/languages/cpp"), + Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!") + .docs_url("https://zed.dev/docs/languages/python"), + Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!") + .docs_url("https://zed.dev/docs/languages/rust"), + }; + + upsell.when(ix < upsells_count, |upsell| upsell.border_b_1()) + })) + } } impl Render for ExtensionsPage { @@ -945,6 +1062,7 @@ impl Render for ExtensionsPage { ), ), ) + .child(self.render_feature_upsells(cx)) .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| { let mut count = self.filtered_remote_extension_indices.len(); if self.filter.include_dev_extensions() { diff --git a/crates/gpui_macros/src/styles.rs b/crates/gpui_macros/src/styles.rs index afbc0e1f26..b7e488f929 100644 --- a/crates/gpui_macros/src/styles.rs +++ b/crates/gpui_macros/src/styles.rs @@ -353,7 +353,7 @@ pub fn border_style_methods(input: TokenStream) -> TokenStream { /// Sets the border color of the element. #visibility fn border_color(mut self, border_color: C) -> Self where - C: Into, + C: Into, Self: Sized, { self.style().border_color = Some(border_color.into());