Add checkbox to only show installed extensions (#8208)

Marshall Bowers created

This PR adds a checkbox to the extensions view to allow filtering to
just extensions that are installed:

<img width="1408" alt="Screenshot 2024-02-22 at 12 05 40 PM"
src="https://github.com/zed-industries/zed/assets/1486634/b5e82941-53be-432e-bfe5-fec7fd0959c5">

Release Notes:

- Added a checkbox to the extensions view to only show installed
extensions.

Change summary

crates/extension/src/extension_store.rs   |  2 
crates/extensions_ui/src/extensions_ui.rs | 92 ++++++++++++++++++++----
2 files changed, 75 insertions(+), 19 deletions(-)

Detailed changes

crates/extension/src/extension_store.rs 🔗

@@ -34,7 +34,7 @@ pub struct ExtensionsApiResponse {
     pub data: Vec<Extension>,
 }
 
-#[derive(Deserialize)]
+#[derive(Clone, Deserialize)]
 pub struct Extension {
     pub id: Arc<str>,
     pub version: Arc<str>,

crates/extensions_ui/src/extensions_ui.rs 🔗

@@ -11,7 +11,7 @@ use settings::Settings;
 use std::time::Duration;
 use std::{ops::Range, sync::Arc};
 use theme::ThemeSettings;
-use ui::{prelude::*, Tooltip};
+use ui::{prelude::*, CheckboxWithLabel, Tooltip};
 
 use workspace::{
     item::{Item, ItemEvent},
@@ -34,7 +34,8 @@ pub struct ExtensionsPage {
     list: UniformListScrollHandle,
     telemetry: Arc<Telemetry>,
     is_fetching_extensions: bool,
-    extensions_entries: Vec<Extension>,
+    is_only_showing_installed_extensions: bool,
+    extension_entries: Vec<Extension>,
     query_editor: View<Editor>,
     query_contains_error: bool,
     _subscription: gpui::Subscription,
@@ -54,7 +55,8 @@ impl ExtensionsPage {
                 list: UniformListScrollHandle::new(),
                 telemetry: workspace.client().telemetry().clone(),
                 is_fetching_extensions: false,
-                extensions_entries: Vec::new(),
+                is_only_showing_installed_extensions: false,
+                extension_entries: Vec::new(),
                 query_contains_error: false,
                 extension_fetch_task: None,
                 _subscription: subscription,
@@ -65,6 +67,24 @@ impl ExtensionsPage {
         })
     }
 
+    fn filtered_extension_entries(&self, cx: &mut ViewContext<Self>) -> Vec<Extension> {
+        let extension_store = ExtensionStore::global(cx).read(cx);
+
+        self.extension_entries
+            .iter()
+            .filter(|extension| {
+                if self.is_only_showing_installed_extensions {
+                    let status = extension_store.extension_status(&extension.id);
+
+                    matches!(status, ExtensionStatus::Installed(_))
+                } else {
+                    true
+                }
+            })
+            .cloned()
+            .collect::<Vec<_>>()
+    }
+
     fn install_extension(
         &self,
         extension_id: Arc<str>,
@@ -94,7 +114,7 @@ impl ExtensionsPage {
             let fetch_result = extensions.await;
             match fetch_result {
                 Ok(extensions) => this.update(&mut cx, |this, cx| {
-                    this.extensions_entries = extensions;
+                    this.extension_entries = extensions;
                     this.is_fetching_extensions = false;
                     cx.notify();
                 }),
@@ -113,7 +133,7 @@ impl ExtensionsPage {
     }
 
     fn render_extensions(&mut self, range: Range<usize>, cx: &mut ViewContext<Self>) -> Vec<Div> {
-        self.extensions_entries[range]
+        self.filtered_extension_entries(cx)[range]
             .iter()
             .map(|extension| self.render_entry(extension, cx))
             .collect()
@@ -381,10 +401,32 @@ impl ExtensionsPage {
             Some(search)
         }
     }
+
+    fn render_empty_state(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let has_search = self.search_query(cx).is_some();
+
+        let message = if self.is_fetching_extensions {
+            "Loading extensions..."
+        } else if self.is_only_showing_installed_extensions {
+            if has_search {
+                "No installed extensions that match your search."
+            } else {
+                "No installed extensions."
+            }
+        } else {
+            if has_search {
+                "No extensions that match your search."
+            } else {
+                "No extensions."
+            }
+        };
+
+        Label::new(message)
+    }
 }
 
 impl Render for ExtensionsPage {
-    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         v_flex()
             .size_full()
             .p_4()
@@ -395,25 +437,39 @@ impl Render for ExtensionsPage {
                     .w_full()
                     .child(Headline::new("Extensions").size(HeadlineSize::XLarge)),
             )
-            .child(h_flex().w_56().child(self.render_search(cx)))
+            .child(
+                h_flex()
+                    .w_full()
+                    .gap_2()
+                    .child(h_flex().child(self.render_search(cx)))
+                    .child(CheckboxWithLabel::new(
+                        "installed",
+                        Label::new("Only show installed"),
+                        if self.is_only_showing_installed_extensions {
+                            Selection::Selected
+                        } else {
+                            Selection::Unselected
+                        },
+                        cx.listener(|this, selection, _cx| {
+                            this.is_only_showing_installed_extensions = match selection {
+                                Selection::Selected => true,
+                                Selection::Unselected => false,
+                                Selection::Indeterminate => return,
+                            }
+                        }),
+                    )),
+            )
             .child(v_flex().size_full().overflow_y_hidden().map(|this| {
-                if self.extensions_entries.is_empty() {
-                    let message = if self.is_fetching_extensions {
-                        "Loading extensions..."
-                    } else if self.search_query(cx).is_some() {
-                        "No extensions that match your search."
-                    } else {
-                        "No extensions."
-                    };
-
-                    return this.child(Label::new(message));
+                let entries = self.filtered_extension_entries(cx);
+                if entries.is_empty() {
+                    return this.child(self.render_empty_state(cx));
                 }
 
                 this.child(
                     canvas({
                         let view = cx.view().clone();
                         let scroll_handle = self.list.clone();
-                        let item_count = self.extensions_entries.len();
+                        let item_count = entries.len();
                         move |bounds, cx| {
                             uniform_list::<_, Div, _>(
                                 view,