Add an extensions installation view (#7689)

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>
This commit is contained in:
Carlos Lopez 2024-02-13 20:09:02 +01:00 committed by GitHub
parent 33f713a8ab
commit fecb5a82f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 735 additions and 11 deletions

View file

@ -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"] }

View file

@ -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
{

View file

@ -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,