Detailed changes
@@ -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",
@@ -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"
@@ -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,
@@ -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"] }
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -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)
+ }
+}
@@ -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
@@ -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),
@@ -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);
@@ -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();