Add an extensions installation view (#7689)

Carlos Lopez , Max Brunsfeld , Marshall , Carlos , Marshall Bowers , and Max created

This PR adds a view for installing extensions within Zed.

My subtasks:

- [X] Page Extensions and assign in App Menu
- [X] List extensions 
- [X] Button to Install/Uninstall
- [x] Search Input to search in extensions registry API
- [x] Get Extensions from API
- [x] Action install to download extension and copy in /extensions
folder
- [x] Action uninstall to remove from /extensions folder
- [x] Filtering
- [x] Better UI Design

Open to collab!

Release Notes:

- Added an extension installation view. Open it using the `zed:
extensions` action in the command palette
([#7096](https://github.com/zed-industries/zed/issues/7096)).

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
Co-authored-by: Marshall <marshall@zed.dev>
Co-authored-by: Carlos <foxkdev@gmail.com>
Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
Co-authored-by: Max <max@zed.dev>

Change summary

Cargo.lock                                   |  33 +
Cargo.toml                                   |   3 
crates/extension/Cargo.toml                  |   6 
crates/extension/src/extension_store.rs      | 203 ++++++++++
crates/extension/src/extension_store_test.rs |  25 +
crates/extensions_ui/Cargo.toml              |  38 +
crates/extensions_ui/LICENSE-GPL             |   1 
crates/extensions_ui/src/extensions_ui.rs    | 422 +++++++++++++++++++++
crates/zed/Cargo.toml                        |   3 
crates/zed/src/app_menus.rs                  |   1 
crates/zed/src/main.rs                       |   9 
crates/zed/src/zed.rs                        |   2 
12 files changed, 735 insertions(+), 11 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2675,20 +2675,52 @@ name = "extension"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "async-compression",
+ "async-tar",
+ "client",
  "collections",
  "fs",
  "futures 0.3.28",
  "gpui",
  "language",
+ "log",
  "parking_lot 0.11.2",
  "schemars",
  "serde",
  "serde_json",
+ "settings",
  "theme",
  "toml",
  "util",
 ]
 
+[[package]]
+name = "extensions_ui"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-compression",
+ "async-tar",
+ "client",
+ "db",
+ "editor",
+ "extension",
+ "fs",
+ "futures 0.3.28",
+ "fuzzy",
+ "gpui",
+ "log",
+ "picker",
+ "project",
+ "serde",
+ "serde_json",
+ "settings",
+ "theme",
+ "ui",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "fallible-iterator"
 version = "0.2.0"
@@ -10792,6 +10824,7 @@ dependencies = [
  "editor",
  "env_logger",
  "extension",
+ "extensions_ui",
  "feature_flags",
  "feedback",
  "file_finder",

Cargo.toml 🔗

@@ -22,6 +22,7 @@ members = [
     "crates/diagnostics",
     "crates/editor",
     "crates/extension",
+    "crates/extensions_ui",
     "crates/feature_flags",
     "crates/feedback",
     "crates/file_finder",
@@ -113,6 +114,7 @@ db = { path = "crates/db" }
 diagnostics = { path = "crates/diagnostics" }
 editor = { path = "crates/editor" }
 extension = { path = "crates/extension" }
+extensions_ui = { path = "crates/extensions_ui" }
 feature_flags = { path = "crates/feature_flags" }
 feedback = { path = "crates/feedback" }
 file_finder = { path = "crates/file_finder" }
@@ -177,6 +179,7 @@ zed_actions = { path = "crates/zed_actions" }
 
 anyhow = "1.0.57"
 async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
+async-tar = "0.4.2"
 async-trait = "0.1"
 chrono = { version = "0.4", features = ["serde"] }
 ctor = "0.2.6"

crates/extension/Cargo.toml 🔗

@@ -14,20 +14,26 @@ path = "src/extension_json_schemas.rs"
 
 [dependencies]
 anyhow.workspace = true
+async-compression.workspace = true
+async-tar.workspace = true
+client.workspace = true
 collections.workspace = true
 fs.workspace = true
 futures.workspace = true
 gpui.workspace = true
 language.workspace = true
+log.workspace = true
 parking_lot.workspace = true
 schemars.workspace = true
 serde.workspace = true
 serde_json.workspace = true
+settings.workspace = true
 theme.workspace = true
 toml.workspace = true
 util.workspace = true
 
 [dev-dependencies]
+client = { workspace = true, features = ["test-support"] }
 fs = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }

crates/extension/src/extension_store.rs 🔗

@@ -1,13 +1,18 @@
-use anyhow::{Context as _, Result};
-use collections::HashMap;
-use fs::Fs;
+use anyhow::{anyhow, bail, Context as _, Result};
+use async_compression::futures::bufread::GzipDecoder;
+use async_tar::Archive;
+use client::ClientSettings;
+use collections::{HashMap, HashSet};
+use fs::{Fs, RemoveOptions};
 use futures::StreamExt as _;
+use futures::{io::BufReader, AsyncReadExt as _};
 use gpui::{actions, AppContext, Context, Global, Model, ModelContext, Task};
 use language::{
     LanguageConfig, LanguageMatcher, LanguageQueries, LanguageRegistry, QUERY_FILENAME_PREFIXES,
 };
 use parking_lot::RwLock;
 use serde::{Deserialize, Serialize};
+use settings::Settings as _;
 use std::{
     ffi::OsStr,
     path::{Path, PathBuf},
@@ -15,15 +20,43 @@ use std::{
     time::Duration,
 };
 use theme::{ThemeRegistry, ThemeSettings};
-use util::{paths::EXTENSIONS_DIR, ResultExt};
+use util::http::AsyncBody;
+use util::{http::HttpClient, paths::EXTENSIONS_DIR, ResultExt};
 
 #[cfg(test)]
 mod extension_store_test;
 
+#[derive(Deserialize)]
+pub struct ExtensionsApiResponse {
+    pub data: Vec<Extension>,
+}
+
+#[derive(Deserialize)]
+pub struct Extension {
+    pub id: Arc<str>,
+    pub version: Arc<str>,
+    pub name: String,
+    pub description: Option<String>,
+    pub authors: Vec<String>,
+    pub repository: String,
+}
+
+#[derive(Clone)]
+pub enum ExtensionStatus {
+    NotInstalled,
+    Installing,
+    Upgrading,
+    Installed(Arc<str>),
+    Removing,
+}
+
 pub struct ExtensionStore {
     manifest: Arc<RwLock<Manifest>>,
     fs: Arc<dyn Fs>,
+    http_client: Arc<dyn HttpClient>,
     extensions_dir: PathBuf,
+    extensions_being_installed: HashSet<Arc<str>>,
+    extensions_being_uninstalled: HashSet<Arc<str>>,
     manifest_path: PathBuf,
     language_registry: Arc<LanguageRegistry>,
     theme_registry: Arc<ThemeRegistry>,
@@ -36,6 +69,7 @@ impl Global for GlobalExtensionStore {}
 
 #[derive(Deserialize, Serialize, Default)]
 pub struct Manifest {
+    pub extensions: HashMap<Arc<str>, Arc<str>>,
     pub grammars: HashMap<Arc<str>, GrammarManifestEntry>,
     pub languages: HashMap<Arc<str>, LanguageManifestEntry>,
     pub themes: HashMap<String, ThemeManifestEntry>,
@@ -65,6 +99,7 @@ actions!(zed, [ReloadExtensions]);
 
 pub fn init(
     fs: Arc<fs::RealFs>,
+    http_client: Arc<dyn HttpClient>,
     language_registry: Arc<LanguageRegistry>,
     theme_registry: Arc<ThemeRegistry>,
     cx: &mut AppContext,
@@ -73,6 +108,7 @@ pub fn init(
         ExtensionStore::new(
             EXTENSIONS_DIR.clone(),
             fs.clone(),
+            http_client.clone(),
             language_registry.clone(),
             theme_registry,
             cx,
@@ -90,9 +126,14 @@ pub fn init(
 }
 
 impl ExtensionStore {
+    pub fn global(cx: &AppContext) -> Model<Self> {
+        cx.global::<GlobalExtensionStore>().0.clone()
+    }
+
     pub fn new(
         extensions_dir: PathBuf,
         fs: Arc<dyn Fs>,
+        http_client: Arc<dyn HttpClient>,
         language_registry: Arc<LanguageRegistry>,
         theme_registry: Arc<ThemeRegistry>,
         cx: &mut ModelContext<Self>,
@@ -101,7 +142,10 @@ impl ExtensionStore {
             manifest: Default::default(),
             extensions_dir: extensions_dir.join("installed"),
             manifest_path: extensions_dir.join("manifest.json"),
+            extensions_being_installed: Default::default(),
+            extensions_being_uninstalled: Default::default(),
             fs,
+            http_client,
             language_registry,
             theme_registry,
             _watch_extensions_dir: [Task::ready(()), Task::ready(())],
@@ -140,6 +184,132 @@ impl ExtensionStore {
         }
     }
 
+    pub fn extensions_dir(&self) -> PathBuf {
+        self.extensions_dir.clone()
+    }
+
+    pub fn extension_status(&self, extension_id: &str) -> ExtensionStatus {
+        let is_uninstalling = self.extensions_being_uninstalled.contains(extension_id);
+        if is_uninstalling {
+            return ExtensionStatus::Removing;
+        }
+
+        let installed_version = self.manifest.read().extensions.get(extension_id).cloned();
+        let is_installing = self.extensions_being_installed.contains(extension_id);
+        match (installed_version, is_installing) {
+            (Some(_), true) => ExtensionStatus::Upgrading,
+            (Some(version), false) => ExtensionStatus::Installed(version.clone()),
+            (None, true) => ExtensionStatus::Installing,
+            (None, false) => ExtensionStatus::NotInstalled,
+        }
+    }
+
+    pub fn fetch_extensions(
+        &self,
+        search: Option<&str>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Vec<Extension>>> {
+        let url = format!(
+            "{}/{}{query}",
+            ClientSettings::get_global(cx).server_url,
+            "api/extensions",
+            query = search
+                .map(|search| format!("?filter={search}"))
+                .unwrap_or_default()
+        );
+        let http_client = self.http_client.clone();
+        cx.spawn(move |_, _| async move {
+            let mut response = http_client.get(&url, AsyncBody::empty(), true).await?;
+
+            let mut body = Vec::new();
+            response
+                .body_mut()
+                .read_to_end(&mut body)
+                .await
+                .context("error reading extensions")?;
+
+            if response.status().is_client_error() {
+                let text = String::from_utf8_lossy(body.as_slice());
+                bail!(
+                    "status error {}, response: {text:?}",
+                    response.status().as_u16()
+                );
+            }
+
+            let response: ExtensionsApiResponse = serde_json::from_slice(&body)?;
+
+            Ok(response.data)
+        })
+    }
+
+    pub fn install_extension(
+        &mut self,
+        extension_id: Arc<str>,
+        version: Arc<str>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        log::info!("installing extension {extension_id} {version}");
+        let url = format!(
+            "{}/api/extensions/{extension_id}/{version}/download",
+            ClientSettings::get_global(cx).server_url
+        );
+
+        let extensions_dir = self.extensions_dir();
+        let http_client = self.http_client.clone();
+
+        self.extensions_being_installed.insert(extension_id.clone());
+
+        cx.spawn(move |this, mut cx| async move {
+            let mut response = http_client
+                .get(&url, Default::default(), true)
+                .await
+                .map_err(|err| anyhow!("error downloading extension: {}", err))?;
+            let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
+            let archive = Archive::new(decompressed_bytes);
+            archive
+                .unpack(extensions_dir.join(extension_id.as_ref()))
+                .await?;
+
+            this.update(&mut cx, |store, cx| {
+                store
+                    .extensions_being_installed
+                    .remove(extension_id.as_ref());
+                store.reload(cx)
+            })?
+            .await
+        })
+    }
+
+    pub fn uninstall_extension(
+        &mut self,
+        extension_id: Arc<str>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        let extensions_dir = self.extensions_dir();
+        let fs = self.fs.clone();
+
+        self.extensions_being_uninstalled
+            .insert(extension_id.clone());
+
+        cx.spawn(move |this, mut cx| async move {
+            fs.remove_dir(
+                &extensions_dir.join(extension_id.as_ref()),
+                RemoveOptions {
+                    recursive: true,
+                    ignore_if_not_exists: true,
+                },
+            )
+            .await?;
+
+            this.update(&mut cx, |this, cx| {
+                this.extensions_being_uninstalled
+                    .remove(extension_id.as_ref());
+                this.reload(cx)
+            })?
+            .await
+        })
+    }
+
     fn manifest_updated(&mut self, manifest: Manifest, cx: &mut ModelContext<Self>) {
         self.language_registry
             .register_wasm_grammars(manifest.grammars.iter().map(|(grammar_name, grammar)| {
@@ -235,11 +405,13 @@ impl ExtensionStore {
                 language_registry.reload_languages(&changed_languages, &changed_grammars);
 
                 for theme_path in &changed_themes {
-                    theme_registry
-                        .load_user_theme(&theme_path, fs.clone())
-                        .await
-                        .context("failed to load user theme")
-                        .log_err();
+                    if fs.is_file(&theme_path).await {
+                        theme_registry
+                            .load_user_theme(&theme_path, fs.clone())
+                            .await
+                            .context("failed to load user theme")
+                            .log_err();
+                    }
                 }
 
                 if !changed_themes.is_empty() {
@@ -284,6 +456,19 @@ impl ExtensionStore {
                             continue;
                         };
 
+                        #[derive(Deserialize)]
+                        struct ExtensionJson {
+                            pub version: String,
+                        }
+
+                        let extension_json_path = extension_dir.join("extension.json");
+                        let extension_json: ExtensionJson =
+                            serde_json::from_str(&fs.load(&extension_json_path).await?)?;
+
+                        manifest
+                            .extensions
+                            .insert(extension_name.into(), extension_json.version.into());
+
                         if let Ok(mut grammar_paths) =
                             fs.read_dir(&extension_dir.join("grammars")).await
                         {

crates/extension/src/extension_store_test.rs 🔗

@@ -7,16 +7,23 @@ use language::{LanguageMatcher, LanguageRegistry};
 use serde_json::json;
 use std::{path::PathBuf, sync::Arc};
 use theme::ThemeRegistry;
+use util::http::FakeHttpClient;
 
 #[gpui::test]
 async fn test_extension_store(cx: &mut TestAppContext) {
     let fs = FakeFs::new(cx.executor());
+    let http_client = FakeHttpClient::with_200_response();
 
     fs.insert_tree(
         "/the-extension-dir",
         json!({
             "installed": {
                 "zed-monokai": {
+                    "extension.json": r#"{
+                        "id": "zed-monokai",
+                        "name": "Zed Monokai",
+                        "version": "2.0.0"
+                    }"#,
                     "themes": {
                         "monokai.json": r#"{
                             "name": "Monokai",
@@ -53,6 +60,11 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                     }
                 },
                 "zed-ruby": {
+                    "extension.json": r#"{
+                        "id": "zed-ruby",
+                        "name": "Zed Ruby",
+                        "version": "1.0.0"
+                    }"#,
                     "grammars": {
                         "ruby.wasm": "",
                         "embedded_template.wasm": "",
@@ -82,6 +94,12 @@ async fn test_extension_store(cx: &mut TestAppContext) {
     .await;
 
     let mut expected_manifest = Manifest {
+        extensions: [
+            ("zed-ruby".into(), "1.0.0".into()),
+            ("zed-monokai".into(), "2.0.0".into()),
+        ]
+        .into_iter()
+        .collect(),
         grammars: [
             (
                 "embedded_template".into(),
@@ -169,6 +187,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
         ExtensionStore::new(
             PathBuf::from("/the-extension-dir"),
             fs.clone(),
+            http_client.clone(),
             language_registry.clone(),
             theme_registry.clone(),
             cx,
@@ -201,6 +220,11 @@ async fn test_extension_store(cx: &mut TestAppContext) {
     fs.insert_tree(
         "/the-extension-dir/installed/zed-gruvbox",
         json!({
+            "extension.json": r#"{
+                "id": "zed-gruvbox",
+                "name": "Zed Gruvbox",
+                "version": "1.0.0"
+            }"#,
             "themes": {
                 "gruvbox.json": r#"{
                     "name": "Gruvbox",
@@ -260,6 +284,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
         ExtensionStore::new(
             PathBuf::from("/the-extension-dir"),
             fs.clone(),
+            http_client.clone(),
             language_registry.clone(),
             theme_registry.clone(),
             cx,

crates/extensions_ui/Cargo.toml 🔗

@@ -0,0 +1,38 @@
+[package]
+name = "extensions_ui"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "GPL-3.0-or-later"
+
+[lib]
+path = "src/extensions_ui.rs"
+
+[features]
+test-support = []
+
+[dependencies]
+anyhow.workspace = true
+async-compression.workspace = true
+async-tar.workspace = true
+client.workspace = true
+db.workspace = true
+editor.workspace = true
+extension.workspace = true
+fs.workspace = true
+futures.workspace = true
+fuzzy.workspace = true
+gpui.workspace = true
+log.workspace = true
+picker.workspace = true
+project.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+settings.workspace = true
+theme.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace.workspace = true
+
+[dev-dependencies]
+editor = { workspace = true, features = ["test-support"] }

crates/extensions_ui/src/extensions_ui.rs 🔗

@@ -0,0 +1,422 @@
+use client::telemetry::Telemetry;
+use editor::{Editor, EditorElement, EditorStyle};
+use extension::{Extension, ExtensionStatus, ExtensionStore};
+use fs::Fs;
+use gpui::{
+    actions, uniform_list, AnyElement, AppContext, EventEmitter, FocusableView, FontStyle,
+    FontWeight, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle,
+    UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WhiteSpace, WindowContext,
+};
+use settings::Settings;
+use std::time::Duration;
+use std::{ops::Range, sync::Arc};
+use theme::ThemeSettings;
+use ui::prelude::*;
+
+use workspace::{
+    item::{Item, ItemEvent},
+    Workspace, WorkspaceId,
+};
+
+actions!(zed, [Extensions]);
+
+pub fn init(cx: &mut AppContext) {
+    cx.observe_new_views(move |workspace: &mut Workspace, _cx| {
+        workspace.register_action(move |workspace, _: &Extensions, cx| {
+            let extensions_page = ExtensionsPage::new(workspace, cx);
+            workspace.add_item(Box::new(extensions_page), cx)
+        });
+    })
+    .detach();
+}
+
+pub struct ExtensionsPage {
+    workspace: WeakView<Workspace>,
+    fs: Arc<dyn Fs>,
+    list: UniformListScrollHandle,
+    telemetry: Arc<Telemetry>,
+    extensions_entries: Vec<Extension>,
+    query_editor: View<Editor>,
+    query_contains_error: bool,
+    extension_fetch_task: Option<Task<()>>,
+}
+
+impl Render for ExtensionsPage {
+    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
+        h_flex()
+            .full()
+            .bg(cx.theme().colors().editor_background)
+            .child(
+                v_flex()
+                    .full()
+                    .p_4()
+                    .child(
+                        h_flex()
+                            .w_full()
+                            .child(Headline::new("Extensions").size(HeadlineSize::XLarge)),
+                    )
+                    .child(h_flex().w_56().my_4().child(self.render_search(cx)))
+                    .child(
+                        h_flex().flex_col().items_start().full().child(
+                            uniform_list::<_, Div, _>(
+                                cx.view().clone(),
+                                "entries",
+                                self.extensions_entries.len(),
+                                Self::render_extensions,
+                            )
+                            .size_full()
+                            .track_scroll(self.list.clone()),
+                        ),
+                    ),
+            )
+    }
+}
+
+impl ExtensionsPage {
+    pub fn new(workspace: &Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
+        let extensions_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
+            let query_editor = cx.new_view(|cx| Editor::single_line(cx));
+            cx.subscribe(&query_editor, Self::on_query_change).detach();
+
+            let mut this = Self {
+                fs: workspace.project().read(cx).fs().clone(),
+                workspace: workspace.weak_handle(),
+                list: UniformListScrollHandle::new(),
+                telemetry: workspace.client().telemetry().clone(),
+                extensions_entries: Vec::new(),
+                query_contains_error: false,
+                extension_fetch_task: None,
+                query_editor,
+            };
+            this.fetch_extensions(None, cx);
+            this
+        });
+        extensions_panel
+    }
+
+    fn install_extension(
+        &self,
+        extension_id: Arc<str>,
+        version: Arc<str>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let install = ExtensionStore::global(cx).update(cx, |store, cx| {
+            store.install_extension(extension_id, version, cx)
+        });
+        cx.spawn(move |this, mut cx| async move {
+            install.await?;
+            this.update(&mut cx, |_, cx| cx.notify())
+        })
+        .detach_and_log_err(cx);
+        cx.notify();
+    }
+
+    fn uninstall_extension(&self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) {
+        let install = ExtensionStore::global(cx)
+            .update(cx, |store, cx| store.uninstall_extension(extension_id, cx));
+        cx.spawn(move |this, mut cx| async move {
+            install.await?;
+            this.update(&mut cx, |_, cx| cx.notify())
+        })
+        .detach_and_log_err(cx);
+        cx.notify();
+    }
+
+    fn fetch_extensions(&mut self, search: Option<&str>, cx: &mut ViewContext<Self>) {
+        let extensions =
+            ExtensionStore::global(cx).update(cx, |store, cx| store.fetch_extensions(search, cx));
+
+        cx.spawn(move |this, mut cx| async move {
+            let extensions = extensions.await?;
+            this.update(&mut cx, |this, cx| {
+                this.extensions_entries = extensions;
+                cx.notify();
+            })
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn render_extensions(&mut self, range: Range<usize>, cx: &mut ViewContext<Self>) -> Vec<Div> {
+        self.extensions_entries[range]
+            .iter()
+            .map(|extension| self.render_entry(extension, cx))
+            .collect()
+    }
+
+    fn render_entry(&self, extension: &Extension, cx: &mut ViewContext<Self>) -> Div {
+        let status = ExtensionStore::global(cx)
+            .read(cx)
+            .extension_status(&extension.id);
+
+        let upgrade_button = match status.clone() {
+            ExtensionStatus::NotInstalled
+            | ExtensionStatus::Installing
+            | ExtensionStatus::Removing => None,
+            ExtensionStatus::Installed(installed_version) => {
+                if installed_version != extension.version {
+                    Some(
+                        Button::new(
+                            SharedString::from(format!("upgrade-{}", extension.id)),
+                            "Upgrade",
+                        )
+                        .on_click(cx.listener({
+                            let extension_id = extension.id.clone();
+                            let version = extension.version.clone();
+                            move |this, _, cx| {
+                                this.telemetry
+                                    .report_app_event("extensions: install extension".to_string());
+                                this.install_extension(extension_id.clone(), version.clone(), cx);
+                            }
+                        }))
+                        .color(Color::Accent),
+                    )
+                } else {
+                    None
+                }
+            }
+            ExtensionStatus::Upgrading => Some(
+                Button::new(
+                    SharedString::from(format!("upgrade-{}", extension.id)),
+                    "Upgrade",
+                )
+                .color(Color::Accent)
+                .disabled(true),
+            ),
+        };
+
+        let install_or_uninstall_button = match status {
+            ExtensionStatus::NotInstalled | ExtensionStatus::Installing => {
+                Button::new(SharedString::from(extension.id.clone()), "Install")
+                    .on_click(cx.listener({
+                        let extension_id = extension.id.clone();
+                        let version = extension.version.clone();
+                        move |this, _, cx| {
+                            this.telemetry
+                                .report_app_event("extensions: install extension".to_string());
+                            this.install_extension(extension_id.clone(), version.clone(), cx);
+                        }
+                    }))
+                    .disabled(matches!(status, ExtensionStatus::Installing))
+            }
+            ExtensionStatus::Installed(_)
+            | ExtensionStatus::Upgrading
+            | ExtensionStatus::Removing => {
+                Button::new(SharedString::from(extension.id.clone()), "Uninstall")
+                    .on_click(cx.listener({
+                        let extension_id = extension.id.clone();
+                        move |this, _, cx| {
+                            this.telemetry
+                                .report_app_event("extensions: uninstall extension".to_string());
+                            this.uninstall_extension(extension_id.clone(), cx);
+                        }
+                    }))
+                    .disabled(matches!(
+                        status,
+                        ExtensionStatus::Upgrading | ExtensionStatus::Removing
+                    ))
+            }
+        }
+        .color(Color::Accent);
+
+        div().w_full().child(
+            v_flex()
+                .w_full()
+                .p_3()
+                .mt_4()
+                .gap_2()
+                .bg(cx.theme().colors().elevated_surface_background)
+                .border_1()
+                .border_color(cx.theme().colors().border)
+                .rounded_md()
+                .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()
+                                .children(upgrade_button)
+                                .child(install_or_uninstall_button),
+                        ),
+                )
+                .child(
+                    h_flex().justify_between().child(
+                        Label::new(format!(
+                            "{}: {}",
+                            if extension.authors.len() > 1 {
+                                "Authors"
+                            } else {
+                                "Author"
+                            },
+                            extension.authors.join(", ")
+                        ))
+                        .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)
+                        })),
+                ),
+        )
+    }
+
+    fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
+        let mut key_context = KeyContext::default();
+        key_context.add("BufferSearchBar");
+
+        let editor_border = if self.query_contains_error {
+            Color::Error.color(cx)
+        } else {
+            cx.theme().colors().border
+        };
+
+        h_flex()
+            .w_full()
+            .gap_2()
+            .key_context(key_context)
+            // .capture_action(cx.listener(Self::tab))
+            // .on_action(cx.listener(Self::dismiss))
+            .child(
+                h_flex()
+                    .flex_1()
+                    .px_2()
+                    .py_1()
+                    .gap_2()
+                    .border_1()
+                    .border_color(editor_border)
+                    .min_w(rems(384. / 16.))
+                    .rounded_lg()
+                    .child(Icon::new(IconName::MagnifyingGlass))
+                    .child(self.render_text_input(&self.query_editor, cx)),
+            )
+    }
+
+    fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
+        let settings = ThemeSettings::get_global(cx);
+        let text_style = TextStyle {
+            color: if editor.read(cx).read_only(cx) {
+                cx.theme().colors().text_disabled
+            } else {
+                cx.theme().colors().text
+            },
+            font_family: settings.ui_font.family.clone(),
+            font_features: settings.ui_font.features,
+            font_size: rems(0.875).into(),
+            font_weight: FontWeight::NORMAL,
+            font_style: FontStyle::Normal,
+            line_height: relative(1.3).into(),
+            background_color: None,
+            underline: None,
+            strikethrough: None,
+            white_space: WhiteSpace::Normal,
+        };
+
+        EditorElement::new(
+            &editor,
+            EditorStyle {
+                background: cx.theme().colors().editor_background,
+                local_player: cx.theme().players().local(),
+                text: text_style,
+                ..Default::default()
+            },
+        )
+    }
+
+    fn on_query_change(
+        &mut self,
+        _: View<Editor>,
+        event: &editor::EditorEvent,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if let editor::EditorEvent::Edited = event {
+            self.query_contains_error = false;
+            self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
+                cx.background_executor()
+                    .timer(Duration::from_millis(250))
+                    .await;
+                this.update(&mut cx, |this, cx| {
+                    this.fetch_extensions(this.search_query(cx).as_deref(), cx);
+                })
+                .ok();
+            }));
+        }
+    }
+
+    pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
+        let search = self.query_editor.read(cx).text(cx);
+        if search.trim().is_empty() {
+            None
+        } else {
+            Some(search)
+        }
+    }
+}
+
+impl EventEmitter<ItemEvent> for ExtensionsPage {}
+
+impl FocusableView for ExtensionsPage {
+    fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
+        self.query_editor.read(cx).focus_handle(cx)
+    }
+}
+
+impl Item for ExtensionsPage {
+    type Event = ItemEvent;
+
+    fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
+        Label::new("Extensions")
+            .color(if selected {
+                Color::Default
+            } else {
+                Color::Muted
+            })
+            .into_any_element()
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        Some("extensions page")
+    }
+
+    fn show_toolbar(&self) -> bool {
+        false
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: WorkspaceId,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<View<Self>> {
+        Some(cx.new_view(|_| ExtensionsPage {
+            fs: self.fs.clone(),
+            workspace: self.workspace.clone(),
+            list: UniformListScrollHandle::new(),
+            telemetry: self.telemetry.clone(),
+            extensions_entries: Default::default(),
+            query_editor: self.query_editor.clone(),
+            query_contains_error: false,
+            extension_fetch_task: None,
+        }))
+    }
+
+    fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
+        f(*event)
+    }
+}

crates/zed/Cargo.toml 🔗

@@ -23,7 +23,7 @@ assets.workspace = true
 assistant.workspace = true
 async-compression.workspace = true
 async-recursion = "0.3"
-async-tar = "0.4.2"
+async-tar.workspace = true
 async-trait.workspace = true
 audio.workspace = true
 auto_update.workspace = true
@@ -45,6 +45,7 @@ diagnostics.workspace = true
 editor.workspace = true
 env_logger.workspace = true
 extension.workspace = true
+extensions_ui.workspace = true
 feature_flags.workspace = true
 feedback.workspace = true
 file_finder.workspace = true

crates/zed/src/app_menus.rs 🔗

@@ -21,6 +21,7 @@ pub fn app_menus() -> Vec<Menu<'static>> {
                         MenuItem::action("Select Theme", theme_selector::Toggle),
                     ],
                 }),
+                MenuItem::action("Extensions", extensions_ui::Extensions),
                 MenuItem::action("Install CLI", install_cli::Install),
                 MenuItem::separator(),
                 MenuItem::action("Hide Zed", super::Hide),

crates/zed/src/main.rs 🔗

@@ -173,7 +173,13 @@ fn main() {
         );
         assistant::init(cx);
 
-        extension::init(fs.clone(), languages.clone(), ThemeRegistry::global(cx), cx);
+        extension::init(
+            fs.clone(),
+            http.clone(),
+            languages.clone(),
+            ThemeRegistry::global(cx),
+            cx,
+        );
 
         load_user_themes_in_background(fs.clone(), cx);
         #[cfg(target_os = "macos")]
@@ -254,6 +260,7 @@ fn main() {
         feedback::init(cx);
         markdown_preview::init(cx);
         welcome::init(cx);
+        extensions_ui::init(cx);
 
         cx.set_menus(app_menus());
         initialize_workspace(app_state.clone(), cx);

crates/zed/src/zed.rs 🔗

@@ -2396,6 +2396,7 @@ mod tests {
                 .unwrap()
         }
     }
+
     fn init_keymap_test(cx: &mut TestAppContext) -> Arc<AppState> {
         cx.update(|cx| {
             let app_state = AppState::test(cx);
@@ -2409,6 +2410,7 @@ mod tests {
             app_state
         })
     }
+
     #[gpui::test]
     async fn test_base_keymap(cx: &mut gpui::TestAppContext) {
         let executor = cx.executor();