diff --git a/Cargo.lock b/Cargo.lock index 8d12fda537..7f73fcb189 100644 --- a/Cargo.lock +++ b/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", diff --git a/Cargo.toml b/Cargo.toml index 02c52bd077..313038aa80 100644 --- a/Cargo.toml +++ b/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" diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml index 597bbf8f72..32ed59d2a3 100644 --- a/crates/extension/Cargo.toml +++ b/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"] } diff --git a/crates/extension/src/extension_store.rs b/crates/extension/src/extension_store.rs index acad322478..844f57b8b8 100644 --- a/crates/extension/src/extension_store.rs +++ b/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, +} + +#[derive(Deserialize)] +pub struct Extension { + pub id: Arc, + pub version: Arc, + pub name: String, + pub description: Option, + pub authors: Vec, + pub repository: String, +} + +#[derive(Clone)] +pub enum ExtensionStatus { + NotInstalled, + Installing, + Upgrading, + Installed(Arc), + Removing, +} + pub struct ExtensionStore { manifest: Arc>, fs: Arc, + http_client: Arc, extensions_dir: PathBuf, + extensions_being_installed: HashSet>, + extensions_being_uninstalled: HashSet>, manifest_path: PathBuf, language_registry: Arc, theme_registry: Arc, @@ -36,6 +69,7 @@ impl Global for GlobalExtensionStore {} #[derive(Deserialize, Serialize, Default)] pub struct Manifest { + pub extensions: HashMap, Arc>, pub grammars: HashMap, GrammarManifestEntry>, pub languages: HashMap, LanguageManifestEntry>, pub themes: HashMap, @@ -65,6 +99,7 @@ actions!(zed, [ReloadExtensions]); pub fn init( fs: Arc, + http_client: Arc, language_registry: Arc, theme_registry: Arc, 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 { + cx.global::().0.clone() + } + pub fn new( extensions_dir: PathBuf, fs: Arc, + http_client: Arc, language_registry: Arc, theme_registry: Arc, cx: &mut ModelContext, @@ -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, + ) -> Task>> { + 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, + version: Arc, + cx: &mut ModelContext, + ) -> Task> { + 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, + cx: &mut ModelContext, + ) -> Task> { + 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.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 { diff --git a/crates/extension/src/extension_store_test.rs b/crates/extension/src/extension_store_test.rs index 758111b286..42a54bfd86 100644 --- a/crates/extension/src/extension_store_test.rs +++ b/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, diff --git a/crates/extensions_ui/Cargo.toml b/crates/extensions_ui/Cargo.toml new file mode 100644 index 0000000000..04af02a4c9 --- /dev/null +++ b/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"] } diff --git a/crates/extensions_ui/LICENSE-GPL b/crates/extensions_ui/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/extensions_ui/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs new file mode 100644 index 0000000000..d84df83ac0 --- /dev/null +++ b/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, + fs: Arc, + list: UniformListScrollHandle, + telemetry: Arc, + extensions_entries: Vec, + query_editor: View, + query_contains_error: bool, + extension_fetch_task: Option>, +} + +impl Render for ExtensionsPage { + fn render(&mut self, cx: &mut gpui::ViewContext) -> 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) -> View { + let extensions_panel = cx.new_view(|cx: &mut ViewContext| { + 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, + version: Arc, + cx: &mut ViewContext, + ) { + 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, cx: &mut ViewContext) { + 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) { + 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, cx: &mut ViewContext) -> Vec
{ + self.extensions_entries[range] + .iter() + .map(|extension| self.render_entry(extension, cx)) + .collect() + } + + fn render_entry(&self, extension: &Extension, cx: &mut ViewContext) -> 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) -> 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, cx: &ViewContext) -> 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, + event: &editor::EditorEvent, + cx: &mut ViewContext, + ) { + 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 { + let search = self.query_editor.read(cx).text(cx); + if search.trim().is_empty() { + None + } else { + Some(search) + } + } +} + +impl EventEmitter 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, 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, + ) -> Option> { + 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) + } +} diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index bddbee4aac..49bf56dcc1 100644 --- a/crates/zed/Cargo.toml +++ b/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 diff --git a/crates/zed/src/app_menus.rs b/crates/zed/src/app_menus.rs index a3f2145827..15cc17620b 100644 --- a/crates/zed/src/app_menus.rs +++ b/crates/zed/src/app_menus.rs @@ -21,6 +21,7 @@ pub fn app_menus() -> Vec> { 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), diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index de4004be32..b90c43b809 100644 --- a/crates/zed/src/main.rs +++ b/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); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index aaaf2ed913..b30d2148f4 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -2396,6 +2396,7 @@ mod tests { .unwrap() } } + fn init_keymap_test(cx: &mut TestAppContext) -> Arc { 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();