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

33
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@ -1,13 +1,18 @@
use anyhow::{Context as _, Result}; use anyhow::{anyhow, bail, Context as _, Result};
use collections::HashMap; use async_compression::futures::bufread::GzipDecoder;
use fs::Fs; use async_tar::Archive;
use client::ClientSettings;
use collections::{HashMap, HashSet};
use fs::{Fs, RemoveOptions};
use futures::StreamExt as _; use futures::StreamExt as _;
use futures::{io::BufReader, AsyncReadExt as _};
use gpui::{actions, AppContext, Context, Global, Model, ModelContext, Task}; use gpui::{actions, AppContext, Context, Global, Model, ModelContext, Task};
use language::{ use language::{
LanguageConfig, LanguageMatcher, LanguageQueries, LanguageRegistry, QUERY_FILENAME_PREFIXES, LanguageConfig, LanguageMatcher, LanguageQueries, LanguageRegistry, QUERY_FILENAME_PREFIXES,
}; };
use parking_lot::RwLock; use parking_lot::RwLock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::Settings as _;
use std::{ use std::{
ffi::OsStr, ffi::OsStr,
path::{Path, PathBuf}, path::{Path, PathBuf},
@ -15,15 +20,43 @@ use std::{
time::Duration, time::Duration,
}; };
use theme::{ThemeRegistry, ThemeSettings}; use theme::{ThemeRegistry, ThemeSettings};
use util::{paths::EXTENSIONS_DIR, ResultExt}; use util::http::AsyncBody;
use util::{http::HttpClient, paths::EXTENSIONS_DIR, ResultExt};
#[cfg(test)] #[cfg(test)]
mod extension_store_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 { pub struct ExtensionStore {
manifest: Arc<RwLock<Manifest>>, manifest: Arc<RwLock<Manifest>>,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
http_client: Arc<dyn HttpClient>,
extensions_dir: PathBuf, extensions_dir: PathBuf,
extensions_being_installed: HashSet<Arc<str>>,
extensions_being_uninstalled: HashSet<Arc<str>>,
manifest_path: PathBuf, manifest_path: PathBuf,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
theme_registry: Arc<ThemeRegistry>, theme_registry: Arc<ThemeRegistry>,
@ -36,6 +69,7 @@ impl Global for GlobalExtensionStore {}
#[derive(Deserialize, Serialize, Default)] #[derive(Deserialize, Serialize, Default)]
pub struct Manifest { pub struct Manifest {
pub extensions: HashMap<Arc<str>, Arc<str>>,
pub grammars: HashMap<Arc<str>, GrammarManifestEntry>, pub grammars: HashMap<Arc<str>, GrammarManifestEntry>,
pub languages: HashMap<Arc<str>, LanguageManifestEntry>, pub languages: HashMap<Arc<str>, LanguageManifestEntry>,
pub themes: HashMap<String, ThemeManifestEntry>, pub themes: HashMap<String, ThemeManifestEntry>,
@ -65,6 +99,7 @@ actions!(zed, [ReloadExtensions]);
pub fn init( pub fn init(
fs: Arc<fs::RealFs>, fs: Arc<fs::RealFs>,
http_client: Arc<dyn HttpClient>,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
theme_registry: Arc<ThemeRegistry>, theme_registry: Arc<ThemeRegistry>,
cx: &mut AppContext, cx: &mut AppContext,
@ -73,6 +108,7 @@ pub fn init(
ExtensionStore::new( ExtensionStore::new(
EXTENSIONS_DIR.clone(), EXTENSIONS_DIR.clone(),
fs.clone(), fs.clone(),
http_client.clone(),
language_registry.clone(), language_registry.clone(),
theme_registry, theme_registry,
cx, cx,
@ -90,9 +126,14 @@ pub fn init(
} }
impl ExtensionStore { impl ExtensionStore {
pub fn global(cx: &AppContext) -> Model<Self> {
cx.global::<GlobalExtensionStore>().0.clone()
}
pub fn new( pub fn new(
extensions_dir: PathBuf, extensions_dir: PathBuf,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
http_client: Arc<dyn HttpClient>,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
theme_registry: Arc<ThemeRegistry>, theme_registry: Arc<ThemeRegistry>,
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
@ -101,7 +142,10 @@ impl ExtensionStore {
manifest: Default::default(), manifest: Default::default(),
extensions_dir: extensions_dir.join("installed"), extensions_dir: extensions_dir.join("installed"),
manifest_path: extensions_dir.join("manifest.json"), manifest_path: extensions_dir.join("manifest.json"),
extensions_being_installed: Default::default(),
extensions_being_uninstalled: Default::default(),
fs, fs,
http_client,
language_registry, language_registry,
theme_registry, theme_registry,
_watch_extensions_dir: [Task::ready(()), Task::ready(())], _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>) { fn manifest_updated(&mut self, manifest: Manifest, cx: &mut ModelContext<Self>) {
self.language_registry self.language_registry
.register_wasm_grammars(manifest.grammars.iter().map(|(grammar_name, grammar)| { .register_wasm_grammars(manifest.grammars.iter().map(|(grammar_name, grammar)| {
@ -235,11 +405,13 @@ impl ExtensionStore {
language_registry.reload_languages(&changed_languages, &changed_grammars); language_registry.reload_languages(&changed_languages, &changed_grammars);
for theme_path in &changed_themes { for theme_path in &changed_themes {
theme_registry if fs.is_file(&theme_path).await {
.load_user_theme(&theme_path, fs.clone()) theme_registry
.await .load_user_theme(&theme_path, fs.clone())
.context("failed to load user theme") .await
.log_err(); .context("failed to load user theme")
.log_err();
}
} }
if !changed_themes.is_empty() { if !changed_themes.is_empty() {
@ -284,6 +456,19 @@ impl ExtensionStore {
continue; 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) = if let Ok(mut grammar_paths) =
fs.read_dir(&extension_dir.join("grammars")).await fs.read_dir(&extension_dir.join("grammars")).await
{ {

View File

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

View File

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

View File

@ -0,0 +1 @@
../../LICENSE-GPL

View File

@ -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)
}
}

View File

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

View File

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

View File

@ -173,7 +173,13 @@ fn main() {
); );
assistant::init(cx); 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); load_user_themes_in_background(fs.clone(), cx);
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@ -254,6 +260,7 @@ fn main() {
feedback::init(cx); feedback::init(cx);
markdown_preview::init(cx); markdown_preview::init(cx);
welcome::init(cx); welcome::init(cx);
extensions_ui::init(cx);
cx.set_menus(app_menus()); cx.set_menus(app_menus());
initialize_workspace(app_state.clone(), cx); initialize_workspace(app_state.clone(), cx);

View File

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