Upsell built-in features on the extensions page (#14516)

Marshall Bowers created

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":

<img width="1341" alt="Screenshot 2024-07-15 at 4 58 44 PM"
src="https://github.com/user-attachments/assets/b256d07a-559a-43c2-b491-3eca5bff436e">

Here are more examples of what the upsells can look like:

<img width="1341" alt="Screenshot 2024-07-15 at 4 54 39 PM"
src="https://github.com/user-attachments/assets/1f453132-ac14-4884-afc4-7c12db47ad1d">

Release Notes:

- Added banners for built-in Zed features when corresponding keywords
are used in the extension search.

Change summary

Cargo.lock                                            |   2 
crates/extensions_ui/Cargo.toml                       |   2 
crates/extensions_ui/src/components.rs                |   2 
crates/extensions_ui/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(-)

Detailed changes

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",
 ]
 

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]

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<SharedString>,
+    children: SmallVec<[AnyElement; 2]>,
+}
+
+impl FeatureUpsell {
+    pub fn new(text: impl Into<SharedString>) -> Self {
+        Self {
+            base: h_flex(),
+            text: text.into(),
+            docs_url: None,
+            children: SmallVec::new(),
+        }
+    }
+
+    pub fn docs_url(mut self, docs_url: impl Into<SharedString>) -> Self {
+        self.docs_url = Some(docs_url.into());
+        self
+    }
+}
+
+impl ParentElement for FeatureUpsell {
+    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
+        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)
+                            }),
+                    )
+                },
+            ))
+    }
+}

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<Feature, Vec<&'static str>> {
+    static KEYWORDS_BY_FEATURE: OnceLock<BTreeMap<Feature, Vec<&'static str>>> = 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<Workspace>,
     list: UniformListScrollHandle,
@@ -135,6 +164,7 @@ pub struct ExtensionsPage {
     query_contains_error: bool,
     _subscriptions: [gpui::Subscription; 2],
     extension_fetch_task: Option<Task<()>>,
+    upsells: BTreeSet<Feature>,
 }
 
 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<T: Settings>(
+        &mut self,
+        selection: &Selection,
+        cx: &mut ViewContext<Self>,
+        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::<T>(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<Self>) {
+        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::<Vec<_>>();
+
+        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<Self>) -> 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::<VimModeSetting>(
+                                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() {

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<C>(mut self, border_color: C) -> Self
         where
-            C: Into<Hsla>,
+            C: Into<gpui::Hsla>,
             Self: Sized,
         {
             self.style().border_color = Some(border_color.into());