From 675ae24964625a500d0ef1a3f2f71649c210b189 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 6 Mar 2024 15:35:22 -0800 Subject: [PATCH] Add a command for building and installing a locally-developed Zed extension (#8781) This PR adds an `zed: Install Local Extension` action, which lets you select a path to a folder containing a Zed extension, and install that . When you select a directory, the extension will be compiled (both the Tree-sitter grammars and the Rust code for the extension itself) and installed as a Zed extension, using a symlink. ### Details A few dependencies are needed to build an extension: * The Rust `wasm32-wasi` target. This is automatically installed if needed via `rustup`. * A wasi-preview1 adapter WASM module, for building WASM components with Rust. This is automatically downloaded if needed from a `wasmtime` GitHub release * For building Tree-sitter parsers, a distribution of `wasi-sdk`. This is automatically downloaded if needed from a `wasi-sdk` GitHub release. The downloaded artifacts are cached in a support directory called `Zed/extensions/build`. ### Tasks UX * [x] Show local extensions in the Extensions view * [x] Provide a button for recompiling a linked extension * [x] Make this action discoverable by adding a button for it on the Extensions view * [ ] Surface errors (don't just write them to the Zed log) Packaging * [ ] Create a separate executable that performs the extension compilation. We'll switch the packaging system in our [extensions](https://github.com/zed-industries/extensions) repo to use this binary, so that there is one canonical definition of how to build/package an extensions. ### Release Notes: - N/A --------- Co-authored-by: Marshall Co-authored-by: Marshall Bowers --- Cargo.lock | 26 +- Cargo.toml | 1 + crates/extension/Cargo.toml | 1 + crates/extension/src/build_extension.rs | 375 +++++++ crates/extension/src/extension_manifest.rs | 72 ++ crates/extension/src/extension_store.rs | 952 ++++++++++-------- crates/extension/src/extension_store_test.rs | 180 ++-- crates/extensions_ui/Cargo.toml | 4 + crates/extensions_ui/src/components.rs | 3 + .../src/components/extension_card.rs | 40 + crates/extensions_ui/src/extensions_ui.rs | 694 ++++++++----- crates/fs/src/fs.rs | 35 +- crates/gpui/src/executor.rs | 6 + crates/gpui/src/platform/test/dispatcher.rs | 4 + crates/project_core/src/worktree_tests.rs | 18 +- extensions/gleam/.gitignore | 1 + extensions/gleam/Cargo.toml | 2 - extensions/gleam/src/bindings.rs | 11 - 18 files changed, 1662 insertions(+), 763 deletions(-) create mode 100644 crates/extension/src/build_extension.rs create mode 100644 crates/extension/src/extension_manifest.rs create mode 100644 crates/extensions_ui/src/components.rs create mode 100644 crates/extensions_ui/src/components/extension_card.rs create mode 100644 extensions/gleam/.gitignore delete mode 100644 extensions/gleam/src/bindings.rs diff --git a/Cargo.lock b/Cargo.lock index 98f15f8d72..b8b4bcab21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3543,19 +3543,24 @@ dependencies = [ "wasmparser", "wasmtime", "wasmtime-wasi", + "wit-component 0.20.3", ] [[package]] name = "extensions_ui" version = "0.1.0" dependencies = [ + "anyhow", "client", "editor", "extension", + "fuzzy", "gpui", "settings", + "smallvec", "theme", "ui", + "util", "workspace", ] @@ -12426,7 +12431,7 @@ dependencies = [ "heck 0.4.1", "wasm-metadata", "wit-bindgen-core", - "wit-component", + "wit-component 0.21.0", ] [[package]] @@ -12443,6 +12448,25 @@ dependencies = [ "wit-bindgen-rust", ] +[[package]] +name = "wit-component" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4436190e87b4e539807bcdcf5b817e79d2e29e16bc5ddb6445413fe3d1f5716" +dependencies = [ + "anyhow", + "bitflags 2.4.2", + "indexmap 2.0.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.41.2", + "wasm-metadata", + "wasmparser", + "wit-parser 0.13.2", +] + [[package]] name = "wit-component" version = "0.21.0" diff --git a/Cargo.toml b/Cargo.toml index 846411eb5f..11f4b7b2dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -317,6 +317,7 @@ wasmparser = "0.121" wasmtime = "18.0" wasmtime-wasi = "18.0" which = "6.0.0" +wit-component = "0.20" sys-locale = "0.3.1" [workspace.dependencies.windows] diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml index d5ceeeff4b..e5375f2cec 100644 --- a/crates/extension/Cargo.toml +++ b/crates/extension/Cargo.toml @@ -39,6 +39,7 @@ util.workspace = true wasmtime = { workspace = true, features = ["async"] } wasmtime-wasi.workspace = true wasmparser.workspace = true +wit-component.workspace = true [dev-dependencies] fs = { workspace = true, features = ["test-support"] } diff --git a/crates/extension/src/build_extension.rs b/crates/extension/src/build_extension.rs new file mode 100644 index 0000000000..3cbdf7e7a4 --- /dev/null +++ b/crates/extension/src/build_extension.rs @@ -0,0 +1,375 @@ +use crate::ExtensionManifest; +use crate::{extension_manifest::ExtensionLibraryKind, GrammarManifestEntry}; +use anyhow::{anyhow, bail, Context as _, Result}; +use async_compression::futures::bufread::GzipDecoder; +use async_tar::Archive; +use futures::io::BufReader; +use futures::AsyncReadExt; +use serde::Deserialize; +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::Arc, +}; +use util::http::{AsyncBody, HttpClient}; +use wit_component::ComponentEncoder; + +/// Currently, we compile with Rust's `wasm32-wasi` target, which works with WASI `preview1`. +/// But the WASM component model is based on WASI `preview2`. So we need an 'adapter' WASM +/// module, which implements the `preview1` interface in terms of `preview2`. +/// +/// Once Rust 1.78 is released, there will be a `wasm32-wasip2` target available, so we will +/// not need the adapter anymore. +const RUST_TARGET: &str = "wasm32-wasi"; +const WASI_ADAPTER_URL: &str = + "https://github.com/bytecodealliance/wasmtime/releases/download/v18.0.2/wasi_snapshot_preview1.reactor.wasm"; + +/// Compiling Tree-sitter parsers from C to WASM requires Clang 17, and a WASM build of libc +/// and clang's runtime library. The `wasi-sdk` provides these binaries. +/// +/// Once Clang 17 and its wasm target are available via system package managers, we won't need +/// to download this. +const WASI_SDK_URL: &str = "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-21/"; +const WASI_SDK_ASSET_NAME: Option<&str> = if cfg!(target_os = "macos") { + Some("wasi-sdk-21.0-macos.tar.gz") +} else if cfg!(target_os = "linux") { + Some("wasi-sdk-21.0-linux.tar.gz") +} else { + None +}; + +pub struct ExtensionBuilder { + cache_dir: PathBuf, + pub http: Arc, +} + +pub struct CompileExtensionOptions { + pub release: bool, +} + +#[derive(Deserialize)] +struct CargoToml { + package: CargoTomlPackage, +} + +#[derive(Deserialize)] +struct CargoTomlPackage { + name: String, +} + +impl ExtensionBuilder { + pub fn new(cache_dir: PathBuf, http: Arc) -> Self { + Self { cache_dir, http } + } + + pub async fn compile_extension( + &self, + extension_dir: &Path, + options: CompileExtensionOptions, + ) -> Result<()> { + fs::create_dir_all(&self.cache_dir)?; + let extension_toml_path = extension_dir.join("extension.toml"); + let extension_toml_content = fs::read_to_string(&extension_toml_path)?; + let extension_toml: ExtensionManifest = toml::from_str(&extension_toml_content)?; + + let cargo_toml_path = extension_dir.join("Cargo.toml"); + if extension_toml.lib.kind == Some(ExtensionLibraryKind::Rust) + || fs::metadata(&cargo_toml_path)?.is_file() + { + self.compile_rust_extension(extension_dir, options).await?; + } + + for (grammar_name, grammar_metadata) in extension_toml.grammars { + self.compile_grammar(extension_dir, grammar_name, grammar_metadata) + .await?; + } + + log::info!("finished compiling extension {}", extension_dir.display()); + Ok(()) + } + + async fn compile_rust_extension( + &self, + extension_dir: &Path, + options: CompileExtensionOptions, + ) -> Result<(), anyhow::Error> { + self.install_rust_wasm_target_if_needed()?; + let adapter_bytes = self.install_wasi_preview1_adapter_if_needed().await?; + + let cargo_toml_content = fs::read_to_string(&extension_dir.join("Cargo.toml"))?; + let cargo_toml: CargoToml = toml::from_str(&cargo_toml_content)?; + + log::info!("compiling rust extension {}", extension_dir.display()); + let output = Command::new("cargo") + .args(["build", "--target", RUST_TARGET]) + .args(options.release.then_some("--release")) + .arg("--target-dir") + .arg(extension_dir.join("target")) + .current_dir(&extension_dir) + .output() + .context("failed to run `cargo`")?; + if !output.status.success() { + bail!( + "failed to build extension {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let mut wasm_path = PathBuf::from(extension_dir); + wasm_path.extend([ + "target", + RUST_TARGET, + if options.release { "release" } else { "debug" }, + cargo_toml.package.name.as_str(), + ]); + wasm_path.set_extension("wasm"); + + let wasm_bytes = fs::read(&wasm_path) + .with_context(|| format!("failed to read output module `{}`", wasm_path.display()))?; + + let encoder = ComponentEncoder::default() + .module(&wasm_bytes)? + .adapter("wasi_snapshot_preview1", &adapter_bytes) + .context("failed to load adapter module")? + .validate(true); + + let component_bytes = encoder + .encode() + .context("failed to encode wasm component")?; + + fs::write(extension_dir.join("extension.wasm"), &component_bytes) + .context("failed to write extension.wasm")?; + + Ok(()) + } + + async fn compile_grammar( + &self, + extension_dir: &Path, + grammar_name: Arc, + grammar_metadata: GrammarManifestEntry, + ) -> Result<()> { + let clang_path = self.install_wasi_sdk_if_needed().await?; + + let mut grammar_repo_dir = extension_dir.to_path_buf(); + grammar_repo_dir.extend(["grammars", grammar_name.as_ref()]); + + let mut grammar_wasm_path = grammar_repo_dir.clone(); + grammar_wasm_path.set_extension("wasm"); + + log::info!("checking out {grammar_name} parser"); + self.checkout_repo( + &grammar_repo_dir, + &grammar_metadata.repository, + &grammar_metadata.rev, + )?; + + let src_path = grammar_repo_dir.join("src"); + let parser_path = src_path.join("parser.c"); + let scanner_path = src_path.join("scanner.c"); + + log::info!("compiling {grammar_name} parser"); + let clang_output = Command::new(&clang_path) + .args(["-fPIC", "-shared", "-Os"]) + .arg(format!("-Wl,--export=tree_sitter_{grammar_name}")) + .arg("-o") + .arg(&grammar_wasm_path) + .arg("-I") + .arg(&src_path) + .arg(&parser_path) + .args(scanner_path.exists().then_some(scanner_path)) + .output() + .context("failed to run clang")?; + if !clang_output.status.success() { + bail!( + "failed to compile {} parser with clang: {}", + grammar_name, + String::from_utf8_lossy(&clang_output.stderr), + ); + } + + Ok(()) + } + + fn checkout_repo(&self, directory: &Path, url: &str, rev: &str) -> Result<()> { + let git_dir = directory.join(".git"); + + if directory.exists() { + let remotes_output = Command::new("git") + .arg("--git-dir") + .arg(&git_dir) + .args(["remote", "-v"]) + .output()?; + let has_remote = remotes_output.status.success() + && String::from_utf8_lossy(&remotes_output.stdout) + .lines() + .any(|line| { + let mut parts = line.split(|c: char| c.is_whitespace()); + parts.next() == Some("origin") && parts.any(|part| part == url) + }); + if !has_remote { + bail!( + "grammar directory '{}' already exists, but is not a git clone of '{}'", + directory.display(), + url + ); + } + } else { + fs::create_dir_all(&directory).with_context(|| { + format!("failed to create grammar directory {}", directory.display(),) + })?; + let init_output = Command::new("git") + .arg("init") + .current_dir(&directory) + .output()?; + if !init_output.status.success() { + bail!( + "failed to run `git init` in directory '{}'", + directory.display() + ); + } + + let remote_add_output = Command::new("git") + .arg("--git-dir") + .arg(&git_dir) + .args(["remote", "add", "origin", url]) + .output() + .context("failed to execute `git remote add`")?; + if !remote_add_output.status.success() { + bail!( + "failed to add remote {url} for git repository {}", + git_dir.display() + ); + } + } + + let fetch_output = Command::new("git") + .arg("--git-dir") + .arg(&git_dir) + .args(["fetch", "--depth", "1", "origin", &rev]) + .output() + .context("failed to execute `git fetch`")?; + if !fetch_output.status.success() { + bail!( + "failed to fetch revision {} in directory '{}'", + rev, + directory.display() + ); + } + + let checkout_output = Command::new("git") + .arg("--git-dir") + .arg(&git_dir) + .args(["checkout", &rev]) + .current_dir(&directory) + .output() + .context("failed to execute `git checkout`")?; + if !checkout_output.status.success() { + bail!( + "failed to checkout revision {} in directory '{}'", + rev, + directory.display() + ); + } + + Ok(()) + } + + fn install_rust_wasm_target_if_needed(&self) -> Result<()> { + let rustc_output = Command::new("rustc") + .arg("--print") + .arg("sysroot") + .output() + .context("failed to run rustc")?; + if !rustc_output.status.success() { + bail!( + "failed to retrieve rust sysroot: {}", + String::from_utf8_lossy(&rustc_output.stderr) + ); + } + + let sysroot = PathBuf::from(String::from_utf8(rustc_output.stdout)?.trim()); + if sysroot.join("lib/rustlib").join(RUST_TARGET).exists() { + return Ok(()); + } + + let output = Command::new("rustup") + .args(["target", "add", RUST_TARGET]) + .stderr(Stdio::inherit()) + .stdout(Stdio::inherit()) + .output() + .context("failed to run `rustup target add`")?; + if !output.status.success() { + bail!("failed to install the `{RUST_TARGET}` target"); + } + + Ok(()) + } + + async fn install_wasi_preview1_adapter_if_needed(&self) -> Result> { + let cache_path = self.cache_dir.join("wasi_snapshot_preview1.reactor.wasm"); + if let Ok(content) = fs::read(&cache_path) { + if wasmparser::Parser::is_core_wasm(&content) { + return Ok(content); + } + } + + fs::remove_file(&cache_path).ok(); + + log::info!("downloading wasi adapter module"); + let mut response = self + .http + .get(WASI_ADAPTER_URL, AsyncBody::default(), true) + .await?; + + let mut content = Vec::new(); + let mut body = BufReader::new(response.body_mut()); + body.read_to_end(&mut content).await?; + + fs::write(&cache_path, &content) + .with_context(|| format!("failed to save file {}", cache_path.display()))?; + + if !wasmparser::Parser::is_core_wasm(&content) { + bail!("downloaded wasi adapter is invalid"); + } + Ok(content) + } + + async fn install_wasi_sdk_if_needed(&self) -> Result { + let url = if let Some(asset_name) = WASI_SDK_ASSET_NAME { + format!("{WASI_SDK_URL}/{asset_name}") + } else { + bail!("wasi-sdk is not available for platform {}", env::consts::OS); + }; + + let wasi_sdk_dir = self.cache_dir.join("wasi-sdk"); + let mut clang_path = wasi_sdk_dir.clone(); + clang_path.extend(["bin", "clang-17"]); + + if fs::metadata(&clang_path).map_or(false, |metadata| metadata.is_file()) { + return Ok(clang_path); + } + + fs::remove_dir_all(&wasi_sdk_dir).ok(); + + let mut response = self.http.get(&url, AsyncBody::default(), true).await?; + + let mut tar_out_dir = wasi_sdk_dir.clone(); + tar_out_dir.set_extension(".output"); + let body = BufReader::new(response.body_mut()); + let body = GzipDecoder::new(body); + let tar = Archive::new(body); + tar.unpack(&tar_out_dir).await?; + + let inner_dir = fs::read_dir(&tar_out_dir)? + .next() + .ok_or_else(|| anyhow!("no content"))? + .context("failed to read contents of extracted wasi archive directory")? + .path(); + fs::rename(&inner_dir, &wasi_sdk_dir).context("failed to move extracted wasi dir")?; + fs::remove_dir_all(&tar_out_dir).ok(); + + Ok(clang_path) + } +} diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs new file mode 100644 index 0000000000..f237e2ad7b --- /dev/null +++ b/crates/extension/src/extension_manifest.rs @@ -0,0 +1,72 @@ +use collections::BTreeMap; +use language::LanguageServerName; +use serde::{Deserialize, Serialize}; +use std::{path::PathBuf, sync::Arc}; + +/// This is the old version of the extension manifest, from when it was `extension.json`. +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct OldExtensionManifest { + pub name: String, + pub version: Arc, + + #[serde(default)] + pub description: Option, + #[serde(default)] + pub repository: Option, + #[serde(default)] + pub authors: Vec, + + #[serde(default)] + pub themes: BTreeMap, PathBuf>, + #[serde(default)] + pub languages: BTreeMap, PathBuf>, + #[serde(default)] + pub grammars: BTreeMap, PathBuf>, +} + +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct ExtensionManifest { + pub id: Arc, + pub name: String, + pub version: Arc, + + #[serde(default)] + pub description: Option, + #[serde(default)] + pub repository: Option, + #[serde(default)] + pub authors: Vec, + #[serde(default)] + pub lib: LibManifestEntry, + + #[serde(default)] + pub themes: Vec, + #[serde(default)] + pub languages: Vec, + #[serde(default)] + pub grammars: BTreeMap, GrammarManifestEntry>, + #[serde(default)] + pub language_servers: BTreeMap, +} + +#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct LibManifestEntry { + pub kind: Option, +} + +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub enum ExtensionLibraryKind { + Rust, +} + +#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct GrammarManifestEntry { + pub repository: String, + #[serde(alias = "commit")] + pub rev: String, +} + +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct LanguageServerManifestEntry { + pub language: Arc, +} diff --git a/crates/extension/src/extension_store.rs b/crates/extension/src/extension_store.rs index e80ac6f5a4..01dffb653c 100644 --- a/crates/extension/src/extension_store.rs +++ b/crates/extension/src/extension_store.rs @@ -1,19 +1,30 @@ +mod build_extension; mod extension_lsp_adapter; +mod extension_manifest; mod wasm_host; #[cfg(test)] mod extension_store_test; +use crate::{extension_lsp_adapter::ExtensionLspAdapter, wasm_host::wit}; use anyhow::{anyhow, bail, Context as _, Result}; use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; -use collections::{BTreeMap, HashSet}; +use build_extension::{CompileExtensionOptions, ExtensionBuilder}; +use collections::{hash_map, BTreeMap, HashMap, HashSet}; +use extension_manifest::ExtensionLibraryKind; use fs::{Fs, RemoveOptions}; -use futures::{channel::mpsc::unbounded, io::BufReader, AsyncReadExt as _, StreamExt as _}; -use gpui::{actions, AppContext, Context, Global, Model, ModelContext, Task}; +use futures::{ + channel::{ + mpsc::{unbounded, UnboundedSender}, + oneshot, + }, + io::BufReader, + select_biased, AsyncReadExt as _, Future, FutureExt as _, StreamExt as _, +}; +use gpui::{actions, AppContext, Context, EventEmitter, Global, Model, ModelContext, Task}; use language::{ - LanguageConfig, LanguageMatcher, LanguageQueries, LanguageRegistry, LanguageServerName, - QUERY_FILENAME_PREFIXES, + LanguageConfig, LanguageMatcher, LanguageQueries, LanguageRegistry, QUERY_FILENAME_PREFIXES, }; use node_runtime::NodeRuntime; use serde::{Deserialize, Serialize}; @@ -22,17 +33,20 @@ use std::{ ffi::OsStr, path::{self, Path, PathBuf}, sync::Arc, - time::Duration, + time::{Duration, Instant}, }; use theme::{ThemeRegistry, ThemeSettings}; use util::{ http::{AsyncBody, HttpClient, HttpClientWithUrl}, paths::EXTENSIONS_DIR, - ResultExt, TryFutureExt, + ResultExt, }; use wasm_host::{WasmExtension, WasmHost}; -use crate::{extension_lsp_adapter::ExtensionLspAdapter, wasm_host::wit}; +pub use extension_manifest::{ExtensionManifest, GrammarManifestEntry, OldExtensionManifest}; + +const RELOAD_DEBOUNCE_DURATION: Duration = Duration::from_millis(200); +const FS_WATCH_LATENCY: Duration = Duration::from_millis(100); #[derive(Deserialize)] pub struct ExtensionsApiResponse { @@ -50,67 +64,22 @@ pub struct ExtensionApiResponse { pub download_count: usize, } -/// This is the old version of the extension manifest, from when it was `extension.json`. -#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] -pub struct OldExtensionManifest { - pub name: String, - pub version: Arc, - - #[serde(default)] - pub description: Option, - #[serde(default)] - pub repository: Option, - #[serde(default)] - pub authors: Vec, - - #[serde(default)] - pub themes: BTreeMap, PathBuf>, - #[serde(default)] - pub languages: BTreeMap, PathBuf>, - #[serde(default)] - pub grammars: BTreeMap, PathBuf>, -} - -#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] -pub struct ExtensionManifest { - pub id: Arc, - pub name: String, - pub version: Arc, - - #[serde(default)] - pub description: Option, - #[serde(default)] - pub repository: Option, - #[serde(default)] - pub authors: Vec, - #[serde(default)] - pub lib: LibManifestEntry, - - #[serde(default)] - pub themes: Vec, - #[serde(default)] - pub languages: Vec, - #[serde(default)] - pub grammars: BTreeMap, GrammarManifestEntry>, - #[serde(default)] - pub language_servers: BTreeMap, -} - -#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)] -pub struct LibManifestEntry { - path: Option, -} - -#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)] -pub struct GrammarManifestEntry { - repository: String, - #[serde(alias = "commit")] - rev: String, -} - -#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] -pub struct LanguageServerManifestEntry { - language: Arc, +pub struct ExtensionStore { + builder: Arc, + extension_index: ExtensionIndex, + fs: Arc, + http_client: Arc, + reload_tx: UnboundedSender>>, + reload_complete_senders: Vec>, + installed_dir: PathBuf, + outstanding_operations: HashMap, ExtensionOperation>, + index_path: PathBuf, + language_registry: Arc, + theme_registry: Arc, + modified_extensions: HashSet>, + wasm_host: Arc, + wasm_extensions: Vec<(Arc, WasmExtension)>, + tasks: Vec>, } #[derive(Clone)] @@ -122,51 +91,38 @@ pub enum ExtensionStatus { Removing, } -impl ExtensionStatus { - pub fn is_installing(&self) -> bool { - matches!(self, Self::Installing) - } - - pub fn is_upgrading(&self) -> bool { - matches!(self, Self::Upgrading) - } - - pub fn is_removing(&self) -> bool { - matches!(self, Self::Removing) - } +enum ExtensionOperation { + Upgrade, + Install, + Remove, } -pub struct ExtensionStore { - extension_index: ExtensionIndex, - 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, - modified_extensions: HashSet>, - wasm_host: Arc, - wasm_extensions: Vec<(Arc, WasmExtension)>, - reload_task: Option>>, - needs_reload: bool, - _watch_extensions_dir: [Task<()>; 2], +#[derive(Copy, Clone)] +pub enum Event { + ExtensionsUpdated, } +impl EventEmitter for ExtensionStore {} + struct GlobalExtensionStore(Model); impl Global for GlobalExtensionStore {} #[derive(Debug, Deserialize, Serialize, Default, PartialEq, Eq)] pub struct ExtensionIndex { - pub extensions: BTreeMap, Arc>, - pub themes: BTreeMap, ExtensionIndexEntry>, + pub extensions: BTreeMap, ExtensionIndexEntry>, + pub themes: BTreeMap, ExtensionIndexThemeEntry>, pub languages: BTreeMap, ExtensionIndexLanguageEntry>, } -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] pub struct ExtensionIndexEntry { + manifest: Arc, + dev: bool, +} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] +pub struct ExtensionIndexThemeEntry { extension: Arc, path: PathBuf, } @@ -203,7 +159,7 @@ pub fn init( cx.on_action(|_: &ReloadExtensions, cx| { let store = cx.global::().0.clone(); - store.update(cx, |store, cx| store.reload(cx)) + store.update(cx, |store, _| drop(store.reload(None))); }); cx.set_global(GlobalExtensionStore(store)); @@ -223,86 +179,172 @@ impl ExtensionStore { theme_registry: Arc, cx: &mut ModelContext, ) -> Self { + let work_dir = extensions_dir.join("work"); + let build_dir = extensions_dir.join("build"); + let installed_dir = extensions_dir.join("installed"); + let index_path = extensions_dir.join("index.json"); + + let (reload_tx, mut reload_rx) = unbounded(); let mut this = Self { extension_index: 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(), - reload_task: None, + installed_dir, + index_path, + builder: Arc::new(ExtensionBuilder::new(build_dir, http_client.clone())), + outstanding_operations: Default::default(), + modified_extensions: Default::default(), + reload_complete_senders: Vec::new(), wasm_host: WasmHost::new( fs.clone(), http_client.clone(), node_runtime, language_registry.clone(), - extensions_dir.join("work"), + work_dir, ), wasm_extensions: Vec::new(), - needs_reload: false, - modified_extensions: Default::default(), fs, http_client, language_registry, theme_registry, - _watch_extensions_dir: [Task::ready(()), Task::ready(())], + reload_tx, + tasks: Vec::new(), }; - this._watch_extensions_dir = this.watch_extensions_dir(cx); - this.load(cx); - this - } - pub fn load(&mut self, cx: &mut ModelContext) { - let (manifest_content, manifest_metadata, extensions_metadata) = + // The extensions store maintains an index file, which contains a complete + // list of the installed extensions and the resources that they provide. + // This index is loaded synchronously on startup. + let (index_content, index_metadata, extensions_metadata) = cx.background_executor().block(async { futures::join!( - self.fs.load(&self.manifest_path), - self.fs.metadata(&self.manifest_path), - self.fs.metadata(&self.extensions_dir), + this.fs.load(&this.index_path), + this.fs.metadata(&this.index_path), + this.fs.metadata(&this.installed_dir), ) }); - if let Some(manifest_content) = manifest_content.log_err() { - if let Some(manifest) = serde_json::from_str(&manifest_content).log_err() { - // TODO: don't detach - self.extensions_updated(manifest, cx).detach(); + // Normally, there is no need to rebuild the index. But if the index file + // is invalid or is out-of-date according to the filesystem mtimes, then + // it must be asynchronously rebuilt. + let mut extension_index = ExtensionIndex::default(); + let mut extension_index_needs_rebuild = true; + if let Some(index_content) = index_content.log_err() { + if let Some(index) = serde_json::from_str(&index_content).log_err() { + extension_index = index; + if let (Ok(Some(index_metadata)), Ok(Some(extensions_metadata))) = + (index_metadata, extensions_metadata) + { + if index_metadata.mtime > extensions_metadata.mtime { + extension_index_needs_rebuild = false; + } + } } } - let should_reload = if let (Ok(Some(manifest_metadata)), Ok(Some(extensions_metadata))) = - (manifest_metadata, extensions_metadata) - { - extensions_metadata.mtime > manifest_metadata.mtime - } else { - true - }; + // Immediately load all of the extensions in the initial manifest. If the + // index needs to be rebuild, then enqueue + let load_initial_extensions = this.extensions_updated(extension_index, cx); + if extension_index_needs_rebuild { + let _ = this.reload(None); + } - if should_reload { - self.reload(cx) + // Perform all extension loading in a single task to ensure that we + // never attempt to simultaneously load/unload extensions from multiple + // parallel tasks. + this.tasks.push(cx.spawn(|this, mut cx| { + async move { + load_initial_extensions.await; + + let mut debounce_timer = cx + .background_executor() + .timer(RELOAD_DEBOUNCE_DURATION) + .fuse(); + loop { + select_biased! { + _ = debounce_timer => { + let index = this + .update(&mut cx, |this, cx| this.rebuild_extension_index(cx))? + .await; + this.update(&mut cx, |this, cx| this.extensions_updated(index, cx))? + .await; + } + extension_id = reload_rx.next() => { + let Some(extension_id) = extension_id else { break; }; + this.update(&mut cx, |this, _| { + this.modified_extensions.extend(extension_id); + })?; + debounce_timer = cx.background_executor() + .timer(RELOAD_DEBOUNCE_DURATION) + .fuse(); + } + } + } + + anyhow::Ok(()) + } + .map(drop) + })); + + // Watch the installed extensions directory for changes. Whenever changes are + // detected, rebuild the extension index, and load/unload any extensions that + // have been added, removed, or modified. + this.tasks.push(cx.background_executor().spawn({ + let fs = this.fs.clone(); + let reload_tx = this.reload_tx.clone(); + let installed_dir = this.installed_dir.clone(); + async move { + let mut events = fs.watch(&installed_dir, FS_WATCH_LATENCY).await; + while let Some(events) = events.next().await { + for event in events { + let Ok(event_path) = event.path.strip_prefix(&installed_dir) else { + continue; + }; + + if let Some(path::Component::Normal(extension_dir_name)) = + event_path.components().next() + { + if let Some(extension_id) = extension_dir_name.to_str() { + reload_tx.unbounded_send(Some(extension_id.into())).ok(); + } + } + } + } + } + })); + + this + } + + fn reload(&mut self, modified_extension: Option>) -> impl Future { + let (tx, rx) = oneshot::channel(); + self.reload_complete_senders.push(tx); + self.reload_tx + .unbounded_send(modified_extension) + .expect("reload task exited"); + async move { + rx.await.ok(); } } - pub fn extensions_dir(&self) -> PathBuf { - self.extensions_dir.clone() + fn extensions_dir(&self) -> PathBuf { + self.installed_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; + match self.outstanding_operations.get(extension_id) { + Some(ExtensionOperation::Install) => ExtensionStatus::Installing, + Some(ExtensionOperation::Remove) => ExtensionStatus::Removing, + Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading, + None => match self.extension_index.extensions.get(extension_id) { + Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()), + None => ExtensionStatus::NotInstalled, + }, } + } - let installed_version = self - .extension_index + pub fn dev_extensions(&self) -> impl Iterator> { + self.extension_index .extensions - .get(extension_id) - .map(|manifest| manifest.version.clone()); - 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), - (None, true) => ExtensionStatus::Installing, - (None, false) => ExtensionStatus::NotInstalled, - } + .values() + .filter_map(|extension| extension.dev.then_some(&extension.manifest)) } pub fn fetch_extensions( @@ -346,6 +388,25 @@ impl ExtensionStore { extension_id: Arc, version: Arc, cx: &mut ModelContext, + ) { + self.install_or_upgrade_extension(extension_id, version, ExtensionOperation::Install, cx) + } + + pub fn upgrade_extension( + &mut self, + extension_id: Arc, + version: Arc, + cx: &mut ModelContext, + ) { + self.install_or_upgrade_extension(extension_id, version, ExtensionOperation::Upgrade, cx) + } + + fn install_or_upgrade_extension( + &mut self, + extension_id: Arc, + version: Arc, + operation: ExtensionOperation, + cx: &mut ModelContext, ) { log::info!("installing extension {extension_id} {version}"); let url = self @@ -355,9 +416,25 @@ impl ExtensionStore { let extensions_dir = self.extensions_dir(); let http_client = self.http_client.clone(); - self.extensions_being_installed.insert(extension_id.clone()); + match self.outstanding_operations.entry(extension_id.clone()) { + hash_map::Entry::Occupied(_) => return, + hash_map::Entry::Vacant(e) => e.insert(operation), + }; cx.spawn(move |this, mut cx| async move { + let _finish = util::defer({ + let this = this.clone(); + let mut cx = cx.clone(); + let extension_id = extension_id.clone(); + move || { + this.update(&mut cx, |this, cx| { + this.outstanding_operations.remove(extension_id.as_ref()); + cx.notify(); + }) + .ok(); + } + }); + let mut response = http_client .get(&url, Default::default(), true) .await @@ -367,12 +444,9 @@ impl ExtensionStore { archive .unpack(extensions_dir.join(extension_id.as_ref())) .await?; - - this.update(&mut cx, |this, cx| { - this.extensions_being_installed - .remove(extension_id.as_ref()); - this.reload(cx) - }) + this.update(&mut cx, |this, _| this.reload(Some(extension_id)))? + .await; + anyhow::Ok(()) }) .detach_and_log_err(cx); } @@ -381,10 +455,25 @@ impl ExtensionStore { let extensions_dir = self.extensions_dir(); let fs = self.fs.clone(); - self.extensions_being_uninstalled - .insert(extension_id.clone()); + match self.outstanding_operations.entry(extension_id.clone()) { + hash_map::Entry::Occupied(_) => return, + hash_map::Entry::Vacant(e) => e.insert(ExtensionOperation::Remove), + }; cx.spawn(move |this, mut cx| async move { + let _finish = util::defer({ + let this = this.clone(); + let mut cx = cx.clone(); + let extension_id = extension_id.clone(); + move || { + this.update(&mut cx, |this, cx| { + this.outstanding_operations.remove(extension_id.as_ref()); + cx.notify(); + }) + .ok(); + } + }); + fs.remove_dir( &extensions_dir.join(extension_id.as_ref()), RemoveOptions { @@ -394,11 +483,120 @@ impl ExtensionStore { ) .await?; + this.update(&mut cx, |this, _| this.reload(None))?.await; + anyhow::Ok(()) + }) + .detach_and_log_err(cx) + } + + pub fn install_dev_extension( + &mut self, + extension_source_path: PathBuf, + cx: &mut ModelContext, + ) { + let extensions_dir = self.extensions_dir(); + let fs = self.fs.clone(); + let builder = self.builder.clone(); + + cx.spawn(move |this, mut cx| async move { + let extension_manifest = + Self::load_extension_manifest(fs.clone(), &extension_source_path).await?; + let extension_id = extension_manifest.id.clone(); + + if !this.update(&mut cx, |this, cx| { + match this.outstanding_operations.entry(extension_id.clone()) { + hash_map::Entry::Occupied(_) => return false, + hash_map::Entry::Vacant(e) => e.insert(ExtensionOperation::Remove), + }; + cx.notify(); + true + })? { + return Ok(()); + } + + let _finish = util::defer({ + let this = this.clone(); + let mut cx = cx.clone(); + let extension_id = extension_id.clone(); + move || { + this.update(&mut cx, |this, cx| { + this.outstanding_operations.remove(extension_id.as_ref()); + cx.notify(); + }) + .ok(); + } + }); + + cx.background_executor() + .spawn({ + let extension_source_path = extension_source_path.clone(); + async move { + builder + .compile_extension( + &extension_source_path, + CompileExtensionOptions { release: true }, + ) + .await + } + }) + .await?; + + let output_path = &extensions_dir.join(extension_id.as_ref()); + if let Some(metadata) = fs.metadata(&output_path).await? { + if metadata.is_symlink { + fs.remove_file( + &output_path, + RemoveOptions { + recursive: false, + ignore_if_not_exists: true, + }, + ) + .await?; + } else { + bail!("extension {extension_id} is already installed"); + } + } + + fs.create_symlink(output_path, extension_source_path) + .await?; + + this.update(&mut cx, |this, _| this.reload(Some(extension_id)))? + .await; + Ok(()) + }) + .detach_and_log_err(cx) + } + + pub fn rebuild_dev_extension(&mut self, extension_id: Arc, cx: &mut ModelContext) { + let path = self.installed_dir.join(extension_id.as_ref()); + let builder = self.builder.clone(); + + match self.outstanding_operations.entry(extension_id.clone()) { + hash_map::Entry::Occupied(_) => return, + hash_map::Entry::Vacant(e) => e.insert(ExtensionOperation::Upgrade), + }; + + cx.notify(); + let compile = cx.background_executor().spawn(async move { + builder + .compile_extension(&path, CompileExtensionOptions { release: true }) + .await + }); + + cx.spawn(|this, mut cx| async move { + let result = compile.await; + this.update(&mut cx, |this, cx| { - this.extensions_being_uninstalled - .remove(extension_id.as_ref()); - this.reload(cx) - }) + this.outstanding_operations.remove(&extension_id); + cx.notify(); + })?; + + if result.is_ok() { + this.update(&mut cx, |this, _| this.reload(Some(extension_id)))? + .await; + } + + result }) .detach_and_log_err(cx) } @@ -413,57 +611,63 @@ impl ExtensionStore { &mut self, new_index: ExtensionIndex, cx: &mut ModelContext, - ) -> Task> { - fn diff<'a, T, I1, I2>( - old_keys: I1, - new_keys: I2, - modified_keys: &HashSet>, - ) -> (Vec>, Vec>) - where - T: PartialEq, - I1: Iterator, T)>, - I2: Iterator, T)>, + ) -> Task<()> { + let old_index = &self.extension_index; + + // Determine which extensions need to be loaded and unloaded, based + // on the changes to the manifest and the extensions that we know have been + // modified. + let mut extensions_to_unload = Vec::default(); + let mut extensions_to_load = Vec::default(); { - let mut removed_keys = Vec::default(); - let mut added_keys = Vec::default(); - let mut old_keys = old_keys.peekable(); - let mut new_keys = new_keys.peekable(); + let mut old_keys = old_index.extensions.iter().peekable(); + let mut new_keys = new_index.extensions.iter().peekable(); loop { match (old_keys.peek(), new_keys.peek()) { - (None, None) => return (removed_keys, added_keys), + (None, None) => break, (None, Some(_)) => { - added_keys.push(new_keys.next().unwrap().0.clone()); + extensions_to_load.push(new_keys.next().unwrap().0.clone()); } (Some(_), None) => { - removed_keys.push(old_keys.next().unwrap().0.clone()); + extensions_to_unload.push(old_keys.next().unwrap().0.clone()); } (Some((old_key, _)), Some((new_key, _))) => match old_key.cmp(&new_key) { Ordering::Equal => { let (old_key, old_value) = old_keys.next().unwrap(); let (new_key, new_value) = new_keys.next().unwrap(); - if old_value != new_value || modified_keys.contains(old_key) { - removed_keys.push(old_key.clone()); - added_keys.push(new_key.clone()); + if old_value != new_value || self.modified_extensions.contains(old_key) + { + extensions_to_unload.push(old_key.clone()); + extensions_to_load.push(new_key.clone()); } } Ordering::Less => { - removed_keys.push(old_keys.next().unwrap().0.clone()); + extensions_to_unload.push(old_keys.next().unwrap().0.clone()); } Ordering::Greater => { - added_keys.push(new_keys.next().unwrap().0.clone()); + extensions_to_load.push(new_keys.next().unwrap().0.clone()); } }, } } + self.modified_extensions.clear(); } - let old_index = &self.extension_index; - let (extensions_to_unload, extensions_to_load) = diff( - old_index.extensions.iter(), - new_index.extensions.iter(), - &self.modified_extensions, + if extensions_to_load.is_empty() && extensions_to_unload.is_empty() { + return Task::ready(()); + } + + let reload_count = extensions_to_unload + .iter() + .filter(|id| extensions_to_load.contains(id)) + .count(); + + log::info!( + "extensions updated. loading {}, reloading {}, unloading {}", + extensions_to_unload.len() - reload_count, + reload_count, + extensions_to_load.len() - reload_count ); - self.modified_extensions.clear(); let themes_to_remove = old_index .themes @@ -487,31 +691,20 @@ impl ExtensionStore { } }) .collect::>(); - let empty = Default::default(); - let grammars_to_remove = extensions_to_unload - .iter() - .flat_map(|extension_id| { - old_index - .extensions - .get(extension_id) - .map_or(&empty, |extension| &extension.grammars) - .keys() - .cloned() - }) - .collect::>(); - - self.wasm_extensions - .retain(|(extension, _)| !extensions_to_unload.contains(&extension.id)); - + let mut grammars_to_remove = Vec::new(); for extension_id in &extensions_to_unload { - if let Some(extension) = old_index.extensions.get(extension_id) { - for (language_server_name, config) in extension.language_servers.iter() { - self.language_registry - .remove_lsp_adapter(config.language.as_ref(), language_server_name); - } + let Some(extension) = old_index.extensions.get(extension_id) else { + continue; + }; + grammars_to_remove.extend(extension.manifest.grammars.keys().cloned()); + for (language_server_name, config) in extension.manifest.language_servers.iter() { + self.language_registry + .remove_lsp_adapter(config.language.as_ref(), language_server_name); } } + self.wasm_extensions + .retain(|(extension, _)| !extensions_to_unload.contains(&extension.id)); self.theme_registry.remove_user_themes(&themes_to_remove); self.language_registry .remove_languages(&languages_to_remove, &grammars_to_remove); @@ -528,15 +721,15 @@ impl ExtensionStore { continue; }; - grammars_to_add.extend(extension.grammars.keys().map(|grammar_name| { - let mut grammar_path = self.extensions_dir.clone(); + grammars_to_add.extend(extension.manifest.grammars.keys().map(|grammar_name| { + let mut grammar_path = self.installed_dir.clone(); grammar_path.extend([extension_id.as_ref(), "grammars"]); grammar_path.push(grammar_name.as_ref()); grammar_path.set_extension("wasm"); (grammar_name.clone(), grammar_path) })); - themes_to_add.extend(extension.themes.iter().map(|theme_path| { - let mut path = self.extensions_dir.clone(); + themes_to_add.extend(extension.manifest.themes.iter().map(|theme_path| { + let mut path = self.installed_dir.clone(); path.extend([Path::new(extension_id.as_ref()), theme_path.as_path()]); path })); @@ -546,7 +739,7 @@ impl ExtensionStore { .register_wasm_grammars(grammars_to_add); for (language_name, language) in languages_to_add { - let mut language_path = self.extensions_dir.clone(); + let mut language_path = self.installed_dir.clone(); language_path.extend([ Path::new(language.extension.as_ref()), language.path.as_path(), @@ -567,15 +760,16 @@ impl ExtensionStore { let fs = self.fs.clone(); let wasm_host = self.wasm_host.clone(); - let root_dir = self.extensions_dir.clone(); + let root_dir = self.installed_dir.clone(); let theme_registry = self.theme_registry.clone(); - let extension_manifests = extensions_to_load + let extension_entries = extensions_to_load .iter() .filter_map(|name| new_index.extensions.get(name).cloned()) .collect::>(); self.extension_index = new_index; cx.notify(); + cx.emit(Event::ExtensionsUpdated); cx.spawn(|this, mut cx| async move { cx.background_executor() @@ -593,36 +787,51 @@ impl ExtensionStore { .await; let mut wasm_extensions = Vec::new(); - for extension_manifest in extension_manifests { - let Some(wasm_path) = &extension_manifest.lib.path else { + for extension in extension_entries { + if extension.manifest.lib.kind.is_none() { continue; }; let mut path = root_dir.clone(); - path.extend([ - Path::new(extension_manifest.id.as_ref()), - wasm_path.as_path(), - ]); - let mut wasm_file = fs + path.extend([extension.manifest.id.as_ref(), "extension.wasm"]); + let Some(mut wasm_file) = fs .open_sync(&path) .await - .context("failed to open wasm file")?; + .context("failed to open wasm file") + .log_err() + else { + continue; + }; + let mut wasm_bytes = Vec::new(); - wasm_file + if wasm_file .read_to_end(&mut wasm_bytes) - .context("failed to read wasm")?; - let wasm_extension = wasm_host + .context("failed to read wasm") + .log_err() + .is_none() + { + continue; + } + + let Some(wasm_extension) = wasm_host .load_extension( wasm_bytes, - extension_manifest.clone(), + extension.manifest.clone(), cx.background_executor().clone(), ) .await - .context("failed to load wasm extension")?; - wasm_extensions.push((extension_manifest.clone(), wasm_extension)); + .context("failed to load wasm extension") + .log_err() + else { + continue; + }; + + wasm_extensions.push((extension.manifest.clone(), wasm_extension)); } this.update(&mut cx, |this, cx| { + this.reload_complete_senders.clear(); + for (manifest, wasm_extension) in &wasm_extensions { for (language_server_name, language_server_config) in &manifest.language_servers { @@ -643,116 +852,43 @@ impl ExtensionStore { ThemeSettings::reload_current_theme(cx) }) .ok(); - Ok(()) }) } - fn watch_extensions_dir(&self, cx: &mut ModelContext) -> [Task<()>; 2] { - let fs = self.fs.clone(); - let extensions_dir = self.extensions_dir.clone(); - let (changed_extensions_tx, mut changed_extensions_rx) = unbounded(); - - let events_task = cx.background_executor().spawn(async move { - let mut events = fs.watch(&extensions_dir, Duration::from_millis(250)).await; - while let Some(events) = events.next().await { - for event in events { - let Ok(event_path) = event.path.strip_prefix(&extensions_dir) else { - continue; - }; - - if let Some(path::Component::Normal(extension_dir_name)) = - event_path.components().next() - { - if let Some(extension_id) = extension_dir_name.to_str() { - changed_extensions_tx - .unbounded_send(Arc::from(extension_id)) - .ok(); - } - } - } - } - }); - - let reload_task = cx.spawn(|this, mut cx| async move { - while let Some(changed_extension_id) = changed_extensions_rx.next().await { - if this - .update(&mut cx, |this, cx| { - this.modified_extensions.insert(changed_extension_id); - this.reload(cx); - }) - .is_err() - { - break; - } - } - }); - - [events_task, reload_task] - } - - fn reload(&mut self, cx: &mut ModelContext) { - if self.reload_task.is_some() { - self.needs_reload = true; - return; - } - + fn rebuild_extension_index(&self, cx: &mut ModelContext) -> Task { let fs = self.fs.clone(); let work_dir = self.wasm_host.work_dir.clone(); - let extensions_dir = self.extensions_dir.clone(); - let manifest_path = self.manifest_path.clone(); - self.needs_reload = false; - self.reload_task = Some(cx.spawn(|this, mut cx| { - async move { - let extension_index = cx - .background_executor() - .spawn(async move { - let mut index = ExtensionIndex::default(); + let extensions_dir = self.installed_dir.clone(); + let index_path = self.index_path.clone(); + cx.background_executor().spawn(async move { + let start_time = Instant::now(); + let mut index = ExtensionIndex::default(); - fs.create_dir(&work_dir).await.log_err(); - fs.create_dir(&extensions_dir).await.log_err(); + fs.create_dir(&work_dir).await.log_err(); + fs.create_dir(&extensions_dir).await.log_err(); - let extension_paths = fs.read_dir(&extensions_dir).await; - if let Ok(mut extension_paths) = extension_paths { - while let Some(extension_dir) = extension_paths.next().await { - let Ok(extension_dir) = extension_dir else { - continue; - }; - Self::add_extension_to_index(fs.clone(), extension_dir, &mut index) - .await - .log_err(); - } - } - - if let Ok(index_json) = serde_json::to_string_pretty(&index) { - fs.save( - &manifest_path, - &index_json.as_str().into(), - Default::default(), - ) - .await - .context("failed to save extension manifest") - .log_err(); - } - - index - }) - .await; - - if let Ok(task) = this.update(&mut cx, |this, cx| { - this.extensions_updated(extension_index, cx) - }) { - task.await.log_err(); + let extension_paths = fs.read_dir(&extensions_dir).await; + if let Ok(mut extension_paths) = extension_paths { + while let Some(extension_dir) = extension_paths.next().await { + let Ok(extension_dir) = extension_dir else { + continue; + }; + Self::add_extension_to_index(fs.clone(), extension_dir, &mut index) + .await + .log_err(); } - - this.update(&mut cx, |this, cx| { - this.reload_task.take(); - if this.needs_reload { - this.reload(cx); - } - }) } - .log_err() - })); + + if let Ok(index_json) = serde_json::to_string_pretty(&index) { + fs.save(&index_path, &index_json.as_str().into(), Default::default()) + .await + .context("failed to save extension index") + .log_err(); + } + + log::info!("rebuilt extension index in {:?}", start_time.elapsed()); + index + }) } async fn add_extension_to_index( @@ -760,60 +896,17 @@ impl ExtensionStore { extension_dir: PathBuf, index: &mut ExtensionIndex, ) -> Result<()> { - let extension_name = extension_dir - .file_name() - .and_then(OsStr::to_str) - .ok_or_else(|| anyhow!("invalid extension name"))?; + let mut extension_manifest = + Self::load_extension_manifest(fs.clone(), &extension_dir).await?; + let extension_id = extension_manifest.id.clone(); - let mut extension_manifest_path = extension_dir.join("extension.json"); - let mut extension_manifest; - if fs.is_file(&extension_manifest_path).await { - let manifest_content = fs - .load(&extension_manifest_path) - .await - .with_context(|| format!("failed to load {extension_name} extension.json"))?; - let manifest_json = serde_json::from_str::(&manifest_content) - .with_context(|| { - format!("invalid extension.json for extension {extension_name}") - })?; - - extension_manifest = ExtensionManifest { - id: extension_name.into(), - name: manifest_json.name, - version: manifest_json.version, - description: manifest_json.description, - repository: manifest_json.repository, - authors: manifest_json.authors, - lib: Default::default(), - themes: { - let mut themes = manifest_json.themes.into_values().collect::>(); - themes.sort(); - themes.dedup(); - themes - }, - languages: { - let mut languages = manifest_json.languages.into_values().collect::>(); - languages.sort(); - languages.dedup(); - languages - }, - grammars: manifest_json - .grammars - .into_keys() - .map(|grammar_name| (grammar_name, Default::default())) - .collect(), - language_servers: Default::default(), - }; - } else { - extension_manifest_path.set_extension("toml"); - let manifest_content = fs - .load(&extension_manifest_path) - .await - .with_context(|| format!("failed to load {extension_name} extension.toml"))?; - extension_manifest = ::toml::from_str(&manifest_content).with_context(|| { - format!("invalid extension.json for extension {extension_name}") - })?; - }; + // TODO: distinguish dev extensions more explicitly, by the absence + // of a checksum file that we'll create when downloading normal extensions. + let is_dev = fs + .metadata(&extension_dir) + .await? + .ok_or_else(|| anyhow!("directory does not exist"))? + .is_symlink; if let Ok(mut language_paths) = fs.read_dir(&extension_dir.join("languages")).await { while let Some(language_path) = language_paths.next().await { @@ -838,7 +931,7 @@ impl ExtensionStore { index.languages.insert( config.name.clone(), ExtensionIndexLanguageEntry { - extension: extension_name.into(), + extension: extension_id.clone(), path: relative_path, matcher: config.matcher, grammar: config.grammar, @@ -869,8 +962,8 @@ impl ExtensionStore { for theme in theme_family.themes { index.themes.insert( theme.name.into(), - ExtensionIndexEntry { - extension: extension_name.into(), + ExtensionIndexThemeEntry { + extension: extension_id.clone(), path: relative_path.clone(), }, ); @@ -878,20 +971,89 @@ impl ExtensionStore { } } - let default_extension_wasm_path = extension_dir.join("extension.wasm"); - if fs.is_file(&default_extension_wasm_path).await { + let extension_wasm_path = extension_dir.join("extension.wasm"); + if fs.is_file(&extension_wasm_path).await { extension_manifest .lib - .path - .get_or_insert(default_extension_wasm_path); + .kind + .get_or_insert(ExtensionLibraryKind::Rust); } - index - .extensions - .insert(extension_name.into(), Arc::new(extension_manifest)); + index.extensions.insert( + extension_id.clone(), + ExtensionIndexEntry { + dev: is_dev, + manifest: Arc::new(extension_manifest), + }, + ); Ok(()) } + + async fn load_extension_manifest( + fs: Arc, + extension_dir: &Path, + ) -> Result { + let extension_name = extension_dir + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| anyhow!("invalid extension name"))?; + + let mut extension_manifest_path = extension_dir.join("extension.json"); + if fs.is_file(&extension_manifest_path).await { + let manifest_content = fs + .load(&extension_manifest_path) + .await + .with_context(|| format!("failed to load {extension_name} extension.json"))?; + let manifest_json = serde_json::from_str::(&manifest_content) + .with_context(|| { + format!("invalid extension.json for extension {extension_name}") + })?; + + Ok(manifest_from_old_manifest(manifest_json, extension_name)) + } else { + extension_manifest_path.set_extension("toml"); + let manifest_content = fs + .load(&extension_manifest_path) + .await + .with_context(|| format!("failed to load {extension_name} extension.toml"))?; + toml::from_str(&manifest_content) + .with_context(|| format!("invalid extension.json for extension {extension_name}")) + } + } +} + +fn manifest_from_old_manifest( + manifest_json: OldExtensionManifest, + extension_id: &str, +) -> ExtensionManifest { + ExtensionManifest { + id: extension_id.into(), + name: manifest_json.name, + version: manifest_json.version, + description: manifest_json.description, + repository: manifest_json.repository, + authors: manifest_json.authors, + lib: Default::default(), + themes: { + let mut themes = manifest_json.themes.into_values().collect::>(); + themes.sort(); + themes.dedup(); + themes + }, + languages: { + let mut languages = manifest_json.languages.into_values().collect::>(); + languages.sort(); + languages.dedup(); + languages + }, + grammars: manifest_json + .grammars + .into_keys() + .map(|grammar_name| (grammar_name, Default::default())) + .collect(), + language_servers: Default::default(), + } } fn load_plugin_queries(root_path: &Path) -> LanguageQueries { diff --git a/crates/extension/src/extension_store_test.rs b/crates/extension/src/extension_store_test.rs index 4f72bf2f87..7ef2247883 100644 --- a/crates/extension/src/extension_store_test.rs +++ b/crates/extension/src/extension_store_test.rs @@ -1,6 +1,7 @@ use crate::{ - ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry, ExtensionManifest, - ExtensionStore, GrammarManifestEntry, + build_extension::{CompileExtensionOptions, ExtensionBuilder}, + ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry, ExtensionIndexThemeEntry, + ExtensionManifest, ExtensionStore, GrammarManifestEntry, RELOAD_DEBOUNCE_DURATION, }; use async_compression::futures::bufread::GzipEncoder; use collections::BTreeMap; @@ -21,7 +22,7 @@ use std::{ sync::Arc, }; use theme::ThemeRegistry; -use util::http::{FakeHttpClient, Response}; +use util::http::{self, FakeHttpClient, Response}; #[gpui::test] async fn test_extension_store(cx: &mut TestAppContext) { @@ -131,45 +132,49 @@ async fn test_extension_store(cx: &mut TestAppContext) { extensions: [ ( "zed-ruby".into(), - ExtensionManifest { - id: "zed-ruby".into(), - name: "Zed Ruby".into(), - version: "1.0.0".into(), - description: None, - authors: Vec::new(), - repository: None, - themes: Default::default(), - lib: Default::default(), - languages: vec!["languages/erb".into(), "languages/ruby".into()], - grammars: [ - ("embedded_template".into(), GrammarManifestEntry::default()), - ("ruby".into(), GrammarManifestEntry::default()), - ] - .into_iter() - .collect(), - language_servers: BTreeMap::default(), - } - .into(), + ExtensionIndexEntry { + manifest: Arc::new(ExtensionManifest { + id: "zed-ruby".into(), + name: "Zed Ruby".into(), + version: "1.0.0".into(), + description: None, + authors: Vec::new(), + repository: None, + themes: Default::default(), + lib: Default::default(), + languages: vec!["languages/erb".into(), "languages/ruby".into()], + grammars: [ + ("embedded_template".into(), GrammarManifestEntry::default()), + ("ruby".into(), GrammarManifestEntry::default()), + ] + .into_iter() + .collect(), + language_servers: BTreeMap::default(), + }), + dev: false, + }, ), ( "zed-monokai".into(), - ExtensionManifest { - id: "zed-monokai".into(), - name: "Zed Monokai".into(), - version: "2.0.0".into(), - description: None, - authors: vec![], - repository: None, - themes: vec![ - "themes/monokai-pro.json".into(), - "themes/monokai.json".into(), - ], - lib: Default::default(), - languages: Default::default(), - grammars: BTreeMap::default(), - language_servers: BTreeMap::default(), - } - .into(), + ExtensionIndexEntry { + manifest: Arc::new(ExtensionManifest { + id: "zed-monokai".into(), + name: "Zed Monokai".into(), + version: "2.0.0".into(), + description: None, + authors: vec![], + repository: None, + themes: vec![ + "themes/monokai-pro.json".into(), + "themes/monokai.json".into(), + ], + lib: Default::default(), + languages: Default::default(), + grammars: BTreeMap::default(), + language_servers: BTreeMap::default(), + }), + dev: false, + }, ), ] .into_iter() @@ -205,28 +210,28 @@ async fn test_extension_store(cx: &mut TestAppContext) { themes: [ ( "Monokai Dark".into(), - ExtensionIndexEntry { + ExtensionIndexThemeEntry { extension: "zed-monokai".into(), path: "themes/monokai.json".into(), }, ), ( "Monokai Light".into(), - ExtensionIndexEntry { + ExtensionIndexThemeEntry { extension: "zed-monokai".into(), path: "themes/monokai.json".into(), }, ), ( "Monokai Pro Dark".into(), - ExtensionIndexEntry { + ExtensionIndexThemeEntry { extension: "zed-monokai".into(), path: "themes/monokai-pro.json".into(), }, ), ( "Monokai Pro Light".into(), - ExtensionIndexEntry { + ExtensionIndexThemeEntry { extension: "zed-monokai".into(), path: "themes/monokai-pro.json".into(), }, @@ -252,7 +257,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { ) }); - cx.executor().run_until_parked(); + cx.executor().advance_clock(super::RELOAD_DEBOUNCE_DURATION); store.read_with(cx, |store, _| { let index = &store.extension_index; assert_eq!(index.extensions, expected_index.extensions); @@ -305,32 +310,34 @@ async fn test_extension_store(cx: &mut TestAppContext) { expected_index.extensions.insert( "zed-gruvbox".into(), - ExtensionManifest { - id: "zed-gruvbox".into(), - name: "Zed Gruvbox".into(), - version: "1.0.0".into(), - description: None, - authors: vec![], - repository: None, - themes: vec!["themes/gruvbox.json".into()], - lib: Default::default(), - languages: Default::default(), - grammars: BTreeMap::default(), - language_servers: BTreeMap::default(), - } - .into(), + ExtensionIndexEntry { + manifest: Arc::new(ExtensionManifest { + id: "zed-gruvbox".into(), + name: "Zed Gruvbox".into(), + version: "1.0.0".into(), + description: None, + authors: vec![], + repository: None, + themes: vec!["themes/gruvbox.json".into()], + lib: Default::default(), + languages: Default::default(), + grammars: BTreeMap::default(), + language_servers: BTreeMap::default(), + }), + dev: false, + }, ); expected_index.themes.insert( "Gruvbox".into(), - ExtensionIndexEntry { + ExtensionIndexThemeEntry { extension: "zed-gruvbox".into(), path: "themes/gruvbox.json".into(), }, ); - store.update(cx, |store, cx| store.reload(cx)); + let _ = store.update(cx, |store, _| store.reload(None)); - cx.executor().run_until_parked(); + cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION); store.read_with(cx, |store, _| { let index = &store.extension_index; assert_eq!(index.extensions, expected_index.extensions); @@ -400,7 +407,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { store.uninstall_extension("zed-ruby".into(), cx) }); - cx.executor().run_until_parked(); + cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION); expected_index.extensions.remove("zed-ruby"); expected_index.languages.remove("Ruby"); expected_index.languages.remove("ERB"); @@ -416,17 +423,23 @@ async fn test_extension_store(cx: &mut TestAppContext) { async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) { init_test(cx); - let gleam_extension_dir = PathBuf::from_iter([ - env!("CARGO_MANIFEST_DIR"), - "..", - "..", - "extensions", - "gleam", - ]) - .canonicalize() - .unwrap(); + let root_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + let cache_dir = root_dir.join("target"); + let gleam_extension_dir = root_dir.join("extensions").join("gleam"); - compile_extension("zed_gleam", &gleam_extension_dir); + cx.executor().allow_parking(); + ExtensionBuilder::new(cache_dir, http::client()) + .compile_extension( + &gleam_extension_dir, + CompileExtensionOptions { release: false }, + ) + .await + .unwrap(); + cx.executor().forbid_parking(); let fs = FakeFs::new(cx.executor()); fs.insert_tree("/the-extension-dir", json!({ "installed": {} })) @@ -509,7 +522,7 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) { ) }); - cx.executor().run_until_parked(); + cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION); let mut fake_servers = language_registry.fake_language_servers("Gleam"); @@ -572,27 +585,6 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) { ); } -fn compile_extension(name: &str, extension_dir_path: &Path) { - let output = std::process::Command::new("cargo") - .args(["component", "build", "--target-dir"]) - .arg(extension_dir_path.join("target")) - .current_dir(&extension_dir_path) - .output() - .unwrap(); - - assert!( - output.status.success(), - "failed to build component {}", - String::from_utf8_lossy(&output.stderr) - ); - - let mut wasm_path = PathBuf::from(extension_dir_path); - wasm_path.extend(["target", "wasm32-wasi", "debug", name]); - wasm_path.set_extension("wasm"); - - std::fs::rename(wasm_path, extension_dir_path.join("extension.wasm")).unwrap(); -} - fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let store = SettingsStore::test(cx); diff --git a/crates/extensions_ui/Cargo.toml b/crates/extensions_ui/Cargo.toml index 7c2320717f..2832ed6a13 100644 --- a/crates/extensions_ui/Cargo.toml +++ b/crates/extensions_ui/Cargo.toml @@ -15,13 +15,17 @@ path = "src/extensions_ui.rs" test-support = [] [dependencies] +anyhow.workspace = true client.workspace = true editor.workspace = true extension.workspace = true +fuzzy.workspace = true gpui.workspace = true settings.workspace = true +smallvec.workspace = true theme.workspace = true ui.workspace = true +util.workspace = true workspace.workspace = true [dev-dependencies] diff --git a/crates/extensions_ui/src/components.rs b/crates/extensions_ui/src/components.rs new file mode 100644 index 0000000000..bf11abd679 --- /dev/null +++ b/crates/extensions_ui/src/components.rs @@ -0,0 +1,3 @@ +mod extension_card; + +pub use extension_card::*; diff --git a/crates/extensions_ui/src/components/extension_card.rs b/crates/extensions_ui/src/components/extension_card.rs new file mode 100644 index 0000000000..7517f9363c --- /dev/null +++ b/crates/extensions_ui/src/components/extension_card.rs @@ -0,0 +1,40 @@ +use gpui::{prelude::*, AnyElement}; +use smallvec::SmallVec; +use ui::prelude::*; + +#[derive(IntoElement)] +pub struct ExtensionCard { + children: SmallVec<[AnyElement; 2]>, +} + +impl ExtensionCard { + pub fn new() -> Self { + Self { + children: SmallVec::new(), + } + } +} + +impl ParentElement for ExtensionCard { + fn extend(&mut self, elements: impl Iterator) { + self.children.extend(elements) + } +} + +impl RenderOnce for ExtensionCard { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + div().w_full().child( + v_flex() + .w_full() + .h(rems(7.)) + .p_3() + .mt_4() + .gap_2() + .bg(cx.theme().colors().elevated_surface_background) + .border_1() + .border_color(cx.theme().colors().border) + .rounded_md() + .children(self.children), + ) + } +} diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index cd07f8244e..759779472c 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -1,6 +1,10 @@ +mod components; + +use crate::components::ExtensionCard; use client::telemetry::Telemetry; use editor::{Editor, EditorElement, EditorStyle}; -use extension::{ExtensionApiResponse, ExtensionStatus, ExtensionStore}; +use extension::{ExtensionApiResponse, ExtensionManifest, ExtensionStatus, ExtensionStore}; +use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ actions, canvas, uniform_list, AnyElement, AppContext, AvailableSpace, EventEmitter, FocusableView, FontStyle, FontWeight, InteractiveElement, KeyContext, ParentElement, Render, @@ -8,24 +12,46 @@ use gpui::{ WindowContext, }; use settings::Settings; +use std::ops::DerefMut; use std::time::Duration; use std::{ops::Range, sync::Arc}; use theme::ThemeSettings; use ui::{prelude::*, ToggleButton, Tooltip}; - +use util::ResultExt as _; use workspace::{ item::{Item, ItemEvent}, Workspace, WorkspaceId, }; -actions!(zed, [Extensions]); +actions!(zed, [Extensions, InstallDevExtension]); 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_to_active_pane(Box::new(extensions_page), cx) - }); + workspace + .register_action(move |workspace, _: &Extensions, cx| { + let extensions_page = ExtensionsPage::new(workspace, cx); + workspace.add_item_to_active_pane(Box::new(extensions_page), cx) + }) + .register_action(move |_, _: &InstallDevExtension, cx| { + let store = ExtensionStore::global(cx); + let prompt = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + }); + + cx.deref_mut() + .spawn(|mut cx| async move { + let extension_path = prompt.await.log_err()??.pop()?; + store + .update(&mut cx, |store, cx| { + store.install_dev_extension(extension_path, cx); + }) + .ok()?; + Some(()) + }) + .detach(); + }); }) .detach(); } @@ -37,15 +63,26 @@ enum ExtensionFilter { NotInstalled, } +impl ExtensionFilter { + pub fn include_dev_extensions(&self) -> bool { + match self { + Self::All | Self::Installed => true, + Self::NotInstalled => false, + } + } +} + pub struct ExtensionsPage { list: UniformListScrollHandle, telemetry: Arc, is_fetching_extensions: bool, filter: ExtensionFilter, - extension_entries: Vec, + remote_extension_entries: Vec, + dev_extension_entries: Vec>, + filtered_remote_extension_indices: Vec, query_editor: View, query_contains_error: bool, - _subscription: gpui::Subscription, + _subscriptions: [gpui::Subscription; 2], extension_fetch_task: Option>, } @@ -53,7 +90,14 @@ impl ExtensionsPage { pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> View { cx.new_view(|cx: &mut ViewContext| { let store = ExtensionStore::global(cx); - let subscription = cx.observe(&store, |_, _, cx| cx.notify()); + let subscriptions = [ + cx.observe(&store, |_, _, cx| cx.notify()), + cx.subscribe(&store, |this, _, event, cx| match event { + extension::Event::ExtensionsUpdated => { + this.fetch_extensions_debounced(cx); + } + }), + ]; let query_editor = cx.new_view(|cx| { let mut input = Editor::single_line(cx); @@ -67,10 +111,12 @@ impl ExtensionsPage { telemetry: workspace.client().telemetry().clone(), is_fetching_extensions: false, filter: ExtensionFilter::All, - extension_entries: Vec::new(), + dev_extension_entries: Vec::new(), + filtered_remote_extension_indices: Vec::new(), + remote_extension_entries: Vec::new(), query_contains_error: false, extension_fetch_task: None, - _subscription: subscription, + _subscriptions: subscriptions, query_editor, }; this.fetch_extensions(None, cx); @@ -78,250 +124,374 @@ impl ExtensionsPage { }) } - fn filtered_extension_entries(&self, cx: &mut ViewContext) -> Vec { + fn filter_extension_entries(&mut self, cx: &mut ViewContext) { let extension_store = ExtensionStore::global(cx).read(cx); - self.extension_entries - .iter() - .filter(|extension| match self.filter { - ExtensionFilter::All => true, - ExtensionFilter::Installed => { - let status = extension_store.extension_status(&extension.id); + self.filtered_remote_extension_indices.clear(); + self.filtered_remote_extension_indices.extend( + self.remote_extension_entries + .iter() + .enumerate() + .filter(|(_, extension)| match self.filter { + ExtensionFilter::All => true, + ExtensionFilter::Installed => { + let status = extension_store.extension_status(&extension.id); + matches!(status, ExtensionStatus::Installed(_)) + } + ExtensionFilter::NotInstalled => { + let status = extension_store.extension_status(&extension.id); - matches!(status, ExtensionStatus::Installed(_)) - } - ExtensionFilter::NotInstalled => { - let status = extension_store.extension_status(&extension.id); - - matches!(status, ExtensionStatus::NotInstalled) - } - }) - .cloned() - .collect::>() - } - - fn install_extension( - &self, - extension_id: Arc, - version: Arc, - cx: &mut ViewContext, - ) { - ExtensionStore::global(cx).update(cx, |store, cx| { - store.install_extension(extension_id, version, cx) - }); + matches!(status, ExtensionStatus::NotInstalled) + } + }) + .map(|(ix, _)| ix), + ); cx.notify(); } - fn uninstall_extension(&self, extension_id: Arc, cx: &mut ViewContext) { - ExtensionStore::global(cx) - .update(cx, |store, cx| store.uninstall_extension(extension_id, cx)); - cx.notify(); - } - - fn fetch_extensions(&mut self, search: Option<&str>, cx: &mut ViewContext) { + fn fetch_extensions(&mut self, search: Option, cx: &mut ViewContext) { self.is_fetching_extensions = true; cx.notify(); - let extensions = - ExtensionStore::global(cx).update(cx, |store, cx| store.fetch_extensions(search, cx)); + let extension_store = ExtensionStore::global(cx); + + let dev_extensions = extension_store.update(cx, |store, _| { + store.dev_extensions().cloned().collect::>() + }); + + let remote_extensions = extension_store.update(cx, |store, cx| { + store.fetch_extensions(search.as_deref(), cx) + }); cx.spawn(move |this, mut cx| async move { - let fetch_result = extensions.await; - match fetch_result { - Ok(extensions) => this.update(&mut cx, |this, cx| { - this.extension_entries = extensions; - this.is_fetching_extensions = false; - cx.notify(); - }), - Err(err) => { - this.update(&mut cx, |this, cx| { - this.is_fetching_extensions = false; - cx.notify(); + let dev_extensions = if let Some(search) = search { + let match_candidates = dev_extensions + .iter() + .enumerate() + .map(|(ix, manifest)| StringMatchCandidate { + id: ix, + string: manifest.name.clone(), + char_bag: manifest.name.as_str().into(), }) - .ok(); + .collect::>(); - Err(err) - } - } + let matches = match_strings( + &match_candidates, + &search, + false, + match_candidates.len(), + &Default::default(), + cx.background_executor().clone(), + ) + .await; + matches + .into_iter() + .map(|mat| dev_extensions[mat.candidate_id].clone()) + .collect() + } else { + dev_extensions + }; + + let fetch_result = remote_extensions.await; + this.update(&mut cx, |this, cx| { + cx.notify(); + this.dev_extension_entries = dev_extensions; + this.is_fetching_extensions = false; + this.remote_extension_entries = fetch_result?; + this.filter_extension_entries(cx); + anyhow::Ok(()) + })? }) .detach_and_log_err(cx); } - fn render_extensions(&mut self, range: Range, cx: &mut ViewContext) -> Vec
{ - self.filtered_extension_entries(cx)[range] - .iter() - .map(|extension| self.render_entry(extension, cx)) + fn render_extensions( + &mut self, + range: Range, + cx: &mut ViewContext, + ) -> Vec { + let dev_extension_entries_len = if self.filter.include_dev_extensions() { + self.dev_extension_entries.len() + } else { + 0 + }; + range + .map(|ix| { + if ix < dev_extension_entries_len { + let extension = &self.dev_extension_entries[ix]; + self.render_dev_extension(extension, cx) + } else { + let extension_ix = + self.filtered_remote_extension_indices[ix - dev_extension_entries_len]; + let extension = &self.remote_extension_entries[extension_ix]; + self.render_remote_extension(extension, cx) + } + }) .collect() } - fn render_entry(&self, extension: &ExtensionApiResponse, cx: &mut ViewContext) -> Div { + fn render_dev_extension( + &self, + extension: &ExtensionManifest, + cx: &mut ViewContext, + ) -> ExtensionCard { 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", + let repository_url = extension.repository.clone(); + + ExtensionCard::new() + .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() + .child( + Button::new( + SharedString::from(format!("rebuild-{}", extension.id)), + "Rebuild", + ) + .on_click({ + let extension_id = extension.id.clone(); + move |_, cx| { + ExtensionStore::global(cx).update(cx, |store, cx| { + store.rebuild_dev_extension(extension_id.clone(), cx) + }); + } + }) + .color(Color::Accent) + .disabled(matches!(status, ExtensionStatus::Upgrading)), + ) + .child( + Button::new(SharedString::from(extension.id.clone()), "Uninstall") + .on_click({ + let extension_id = extension.id.clone(); + move |_, cx| { + ExtensionStore::global(cx).update(cx, |store, cx| { + store.uninstall_extension(extension_id.clone(), cx) + }); + } + }) + .color(Color::Accent) + .disabled(matches!(status, ExtensionStatus::Removing)), + ), + ), + ) + .child( + h_flex() + .justify_between() + .child( + Label::new(format!( + "{}: {}", + if extension.authors.len() > 1 { + "Authors" + } else { + "Author" + }, + extension.authors.join(", ") + )) + .size(LabelSize::Small), + ) + .child(Label::new("<>").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) + })) + .children(repository_url.map(|repository_url| { + IconButton::new( + SharedString::from(format!("repository-{}", extension.id)), + IconName::Github, ) + .icon_color(Color::Accent) + .icon_size(IconSize::Small) + .style(ButtonStyle::Filled) .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); + let repository_url = repository_url.clone(); + move |_, _, cx| { + cx.open_url(&repository_url); } })) - .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()), - if status.is_installing() { - "Installing..." - } else { - "Install" - }, + .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx)) + })), ) - .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(status.is_installing()), - ExtensionStatus::Installed(_) - | ExtensionStatus::Upgrading - | ExtensionStatus::Removing => Button::new( - SharedString::from(extension.id.clone()), - if status.is_upgrading() { - "Upgrading..." - } else if status.is_removing() { - "Removing..." - } else { - "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); + } + fn render_remote_extension( + &self, + extension: &ExtensionApiResponse, + cx: &mut ViewContext, + ) -> ExtensionCard { + let status = ExtensionStore::global(cx) + .read(cx) + .extension_status(&extension.id); + + let (install_or_uninstall_button, upgrade_button) = + self.buttons_for_entry(extension, &status, cx); let repository_url = extension.repository.clone(); - let tooltip_text = Tooltip::text(repository_url.clone(), cx); - div().w_full().child( - v_flex() - .w_full() - .h(rems(7.)) - .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(", ") - )) + ExtensionCard::new() + .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( + Label::new(format!("Downloads: {}", extension.download_count)) .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) + })) + .child( + IconButton::new( + SharedString::from(format!("repository-{}", extension.id)), + IconName::Github, ) - .child( - Label::new(format!("Downloads: {}", extension.download_count)) - .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) - })) - .child( - IconButton::new( - SharedString::from(format!("repository-{}", extension.id)), - IconName::Github, - ) - .icon_color(Color::Accent) - .icon_size(IconSize::Small) - .style(ButtonStyle::Filled) - .on_click(cx.listener(move |_, _, cx| { + .icon_color(Color::Accent) + .icon_size(IconSize::Small) + .style(ButtonStyle::Filled) + .on_click(cx.listener({ + let repository_url = repository_url.clone(); + move |_, _, cx| { cx.open_url(&repository_url); - })) - .tooltip(move |_| tooltip_text.clone()), - ), + } + })) + .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx)), + ), + ) + } + + fn buttons_for_entry( + &self, + extension: &ExtensionApiResponse, + status: &ExtensionStatus, + cx: &mut ViewContext, + ) -> (Button, Option