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:
parent
33f713a8ab
commit
fecb5a82f1
12 changed files with 735 additions and 11 deletions
|
@ -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"] }
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue