Detailed changes
@@ -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"
@@ -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]
@@ -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"] }
@@ -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<dyn HttpClient>,
+}
+
+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<dyn HttpClient>) -> 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<str>,
+ 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<Vec<u8>> {
+ 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<PathBuf> {
+ 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)
+ }
+}
@@ -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<str>,
+
+ #[serde(default)]
+ pub description: Option<String>,
+ #[serde(default)]
+ pub repository: Option<String>,
+ #[serde(default)]
+ pub authors: Vec<String>,
+
+ #[serde(default)]
+ pub themes: BTreeMap<Arc<str>, PathBuf>,
+ #[serde(default)]
+ pub languages: BTreeMap<Arc<str>, PathBuf>,
+ #[serde(default)]
+ pub grammars: BTreeMap<Arc<str>, PathBuf>,
+}
+
+#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
+pub struct ExtensionManifest {
+ pub id: Arc<str>,
+ pub name: String,
+ pub version: Arc<str>,
+
+ #[serde(default)]
+ pub description: Option<String>,
+ #[serde(default)]
+ pub repository: Option<String>,
+ #[serde(default)]
+ pub authors: Vec<String>,
+ #[serde(default)]
+ pub lib: LibManifestEntry,
+
+ #[serde(default)]
+ pub themes: Vec<PathBuf>,
+ #[serde(default)]
+ pub languages: Vec<PathBuf>,
+ #[serde(default)]
+ pub grammars: BTreeMap<Arc<str>, GrammarManifestEntry>,
+ #[serde(default)]
+ pub language_servers: BTreeMap<LanguageServerName, LanguageServerManifestEntry>,
+}
+
+#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
+pub struct LibManifestEntry {
+ pub kind: Option<ExtensionLibraryKind>,
+}
+
+#[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<str>,
+}
@@ -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<str>,
-
- #[serde(default)]
- pub description: Option<String>,
- #[serde(default)]
- pub repository: Option<String>,
- #[serde(default)]
- pub authors: Vec<String>,
-
- #[serde(default)]
- pub themes: BTreeMap<Arc<str>, PathBuf>,
- #[serde(default)]
- pub languages: BTreeMap<Arc<str>, PathBuf>,
- #[serde(default)]
- pub grammars: BTreeMap<Arc<str>, PathBuf>,
-}
-
-#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
-pub struct ExtensionManifest {
- pub id: Arc<str>,
- pub name: String,
- pub version: Arc<str>,
-
- #[serde(default)]
- pub description: Option<String>,
- #[serde(default)]
- pub repository: Option<String>,
- #[serde(default)]
- pub authors: Vec<String>,
- #[serde(default)]
- pub lib: LibManifestEntry,
-
- #[serde(default)]
- pub themes: Vec<PathBuf>,
- #[serde(default)]
- pub languages: Vec<PathBuf>,
- #[serde(default)]
- pub grammars: BTreeMap<Arc<str>, GrammarManifestEntry>,
- #[serde(default)]
- pub language_servers: BTreeMap<LanguageServerName, LanguageServerManifestEntry>,
-}
-
-#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
-pub struct LibManifestEntry {
- path: Option<PathBuf>,
-}
-
-#[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<str>,
+pub struct ExtensionStore {
+ builder: Arc<ExtensionBuilder>,
+ extension_index: ExtensionIndex,
+ fs: Arc<dyn Fs>,
+ http_client: Arc<HttpClientWithUrl>,
+ reload_tx: UnboundedSender<Option<Arc<str>>>,
+ reload_complete_senders: Vec<oneshot::Sender<()>>,
+ installed_dir: PathBuf,
+ outstanding_operations: HashMap<Arc<str>, ExtensionOperation>,
+ index_path: PathBuf,
+ language_registry: Arc<LanguageRegistry>,
+ theme_registry: Arc<ThemeRegistry>,
+ modified_extensions: HashSet<Arc<str>>,
+ wasm_host: Arc<WasmHost>,
+ wasm_extensions: Vec<(Arc<ExtensionManifest>, WasmExtension)>,
+ tasks: Vec<Task<()>>,
}
#[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<dyn Fs>,
- http_client: Arc<HttpClientWithUrl>,
- extensions_dir: PathBuf,
- extensions_being_installed: HashSet<Arc<str>>,
- extensions_being_uninstalled: HashSet<Arc<str>>,
- manifest_path: PathBuf,
- language_registry: Arc<LanguageRegistry>,
- theme_registry: Arc<ThemeRegistry>,
- modified_extensions: HashSet<Arc<str>>,
- wasm_host: Arc<WasmHost>,
- wasm_extensions: Vec<(Arc<ExtensionManifest>, WasmExtension)>,
- reload_task: Option<Task<Option<()>>>,
- needs_reload: bool,
- _watch_extensions_dir: [Task<()>; 2],
+#[derive(Copy, Clone)]
+pub enum Event {
+ ExtensionsUpdated,
}
+impl EventEmitter<Event> for ExtensionStore {}
+
struct GlobalExtensionStore(Model<ExtensionStore>);
impl Global for GlobalExtensionStore {}
#[derive(Debug, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct ExtensionIndex {
- pub extensions: BTreeMap<Arc<str>, Arc<ExtensionManifest>>,
- pub themes: BTreeMap<Arc<str>, ExtensionIndexEntry>,
+ pub extensions: BTreeMap<Arc<str>, ExtensionIndexEntry>,
+ pub themes: BTreeMap<Arc<str>, ExtensionIndexThemeEntry>,
pub languages: BTreeMap<Arc<str>, ExtensionIndexLanguageEntry>,
}
-#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)]
+#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct ExtensionIndexEntry {
+ manifest: Arc<ExtensionManifest>,
+ dev: bool,
+}
+
+#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)]
+pub struct ExtensionIndexThemeEntry {
extension: Arc<str>,
path: PathBuf,
}
@@ -203,7 +159,7 @@ pub fn init(
cx.on_action(|_: &ReloadExtensions, cx| {
let store = cx.global::<GlobalExtensionStore>().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<ThemeRegistry>,
cx: &mut ModelContext<Self>,
) -> 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<Self>) {
- 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);
+ }
+
+ // 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();
+ }
+ }
+ }
+ }
+ }
+ }));
- if should_reload {
- self.reload(cx)
+ this
+ }
+
+ fn reload(&mut self, modified_extension: Option<Arc<str>>) -> impl Future<Output = ()> {
+ 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<Item = &Arc<ExtensionManifest>> {
+ 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<str>,
version: Arc<str>,
cx: &mut ModelContext<Self>,
+ ) {
+ self.install_or_upgrade_extension(extension_id, version, ExtensionOperation::Install, cx)
+ }
+
+ pub fn upgrade_extension(
+ &mut self,
+ extension_id: Arc<str>,
+ version: Arc<str>,
+ cx: &mut ModelContext<Self>,
+ ) {
+ self.install_or_upgrade_extension(extension_id, version, ExtensionOperation::Upgrade, cx)
+ }
+
+ fn install_or_upgrade_extension(
+ &mut self,
+ extension_id: Arc<str>,
+ version: Arc<str>,
+ operation: ExtensionOperation,
+ cx: &mut ModelContext<Self>,
) {
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<Self>,
+ ) {
+ 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<str>, cx: &mut ModelContext<Self>) {
+ 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<Self>,
- ) -> Task<Result<()>> {
- fn diff<'a, T, I1, I2>(
- old_keys: I1,
- new_keys: I2,
- modified_keys: &HashSet<Arc<str>>,
- ) -> (Vec<Arc<str>>, Vec<Arc<str>>)
- where
- T: PartialEq,
- I1: Iterator<Item = (&'a Arc<str>, T)>,
- I2: Iterator<Item = (&'a Arc<str>, 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::<Vec<_>>();
- 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::<Vec<_>>();
-
- 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::<Vec<_>>();
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<Self>) -> [Task<()>; 2] {
+ fn rebuild_extension_index(&self, cx: &mut ModelContext<Self>) -> Task<ExtensionIndex> {
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 {
+ let work_dir = self.wasm_host.work_dir.clone();
+ 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();
+
+ 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;
};
-
- 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();
- }
- }
+ Self::add_extension_to_index(fs.clone(), extension_dir, &mut index)
+ .await
+ .log_err();
}
}
- });
- 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;
- }
+ 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();
}
- });
-
- [events_task, reload_task]
- }
-
- fn reload(&mut self, cx: &mut ModelContext<Self>) {
- if self.reload_task.is_some() {
- self.needs_reload = true;
- return;
- }
-
- 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();
-
- 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();
- }
-
- this.update(&mut cx, |this, cx| {
- this.reload_task.take();
- if this.needs_reload {
- this.reload(cx);
- }
- })
- }
- .log_err()
- }));
+ log::info!("rebuilt extension index in {:?}", start_time.elapsed());
+ index
+ })
}
async fn add_extension_to_index(
@@ -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();
-
- compile_extension("zed_gleam", &gleam_extension_dir);
+ 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");
+
+ 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);
@@ -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]
@@ -0,0 +1,3 @@
+mod extension_card;
+
+pub use extension_card::*;
@@ -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<Item = AnyElement>) {
+ 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),
+ )
+ }
+}
@@ -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<Telemetry>,
is_fetching_extensions: bool,
filter: ExtensionFilter,
- extension_entries: Vec<ExtensionApiResponse>,
+ remote_extension_entries: Vec<ExtensionApiResponse>,
+ dev_extension_entries: Vec<Arc<ExtensionManifest>>,
+ filtered_remote_extension_indices: Vec<usize>,
query_editor: View<Editor>,
query_contains_error: bool,
- _subscription: gpui::Subscription,
+ _subscriptions: [gpui::Subscription; 2],
extension_fetch_task: Option<Task<()>>,
}
@@ -53,7 +90,14 @@ impl ExtensionsPage {
pub fn new(workspace: &Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
cx.new_view(|cx: &mut ViewContext<Self>| {
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<Self>) -> Vec<ExtensionApiResponse> {
+ fn filter_extension_entries(&mut self, cx: &mut ViewContext<Self>) {
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);
-
- matches!(status, ExtensionStatus::Installed(_))
- }
- ExtensionFilter::NotInstalled => {
- let status = extension_store.extension_status(&extension.id);
-
- matches!(status, ExtensionStatus::NotInstalled)
- }
- })
- .cloned()
- .collect::<Vec<_>>()
- }
-
- fn install_extension(
- &self,
- extension_id: Arc<str>,
- version: Arc<str>,
- cx: &mut ViewContext<Self>,
- ) {
- ExtensionStore::global(cx).update(cx, |store, cx| {
- store.install_extension(extension_id, version, cx)
- });
- cx.notify();
- }
+ 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);
- fn uninstall_extension(&self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) {
- ExtensionStore::global(cx)
- .update(cx, |store, cx| store.uninstall_extension(extension_id, cx));
+ matches!(status, ExtensionStatus::NotInstalled)
+ }
+ })
+ .map(|(ix, _)| ix),
+ );
cx.notify();
}
- fn fetch_extensions(&mut self, search: Option<&str>, cx: &mut ViewContext<Self>) {
+ fn fetch_extensions(&mut self, search: Option<String>, cx: &mut ViewContext<Self>) {
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::<Vec<_>>()
+ });
+
+ 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::<Vec<_>>();
+
+ 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
+ };
- Err(err)
- }
- }
+ 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<usize>, cx: &mut ViewContext<Self>) -> Vec<Div> {
- self.filtered_extension_entries(cx)[range]
- .iter()
- .map(|extension| self.render_entry(extension, cx))
+ fn render_extensions(
+ &mut self,
+ range: Range<usize>,
+ cx: &mut ViewContext<Self>,
+ ) -> Vec<ExtensionCard> {
+ 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<Self>) -> Div {
+ fn render_dev_extension(
+ &self,
+ extension: &ExtensionManifest,
+ cx: &mut ViewContext<Self>,
+ ) -> 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"
- },
- )
- .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"
- },
+ .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx))
+ })),
)
- .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<Self>,
+ ) -> 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<Self>,
+ ) -> (Button, Option<Button>) {
+ match status.clone() {
+ ExtensionStatus::NotInstalled => (
+ 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());
+ ExtensionStore::global(cx).update(cx, |store, cx| {
+ store.install_extension(extension_id.clone(), version.clone(), cx)
+ });
+ }
+ }),
),
- )
+ None,
+ ),
+ ExtensionStatus::Installing => (
+ Button::new(SharedString::from(extension.id.clone()), "Install").disabled(true),
+ None,
+ ),
+ ExtensionStatus::Upgrading => (
+ Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
+ Some(
+ Button::new(SharedString::from(extension.id.clone()), "Upgrade").disabled(true),
+ ),
+ ),
+ ExtensionStatus::Installed(installed_version) => (
+ 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());
+ ExtensionStore::global(cx).update(cx, |store, cx| {
+ store.uninstall_extension(extension_id.clone(), cx)
+ });
+ }
+ }),
+ ),
+ if installed_version == extension.version {
+ None
+ } else {
+ Some(
+ Button::new(SharedString::from(extension.id.clone()), "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(),
+ );
+ ExtensionStore::global(cx).update(cx, |store, cx| {
+ store.upgrade_extension(
+ extension_id.clone(),
+ version.clone(),
+ cx,
+ )
+ });
+ }
+ }),
+ ),
+ )
+ },
+ ),
+ ExtensionStatus::Removing => (
+ Button::new(SharedString::from(extension.id.clone()), "Uninstall").disabled(true),
+ None,
+ ),
+ }
}
fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
@@ -394,32 +564,36 @@ impl ExtensionsPage {
) {
if let editor::EditorEvent::Edited = event {
self.query_contains_error = false;
- self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
- let search = this
- .update(&mut cx, |this, cx| this.search_query(cx))
- .ok()
- .flatten();
-
- // Only debounce the fetching of extensions if we have a search
- // query.
- //
- // If the search was just cleared then we can just reload the list
- // of extensions without a debounce, which allows us to avoid seeing
- // an intermittent flash of a "no extensions" state.
- if let Some(_) = search {
- cx.background_executor()
- .timer(Duration::from_millis(250))
- .await;
- };
-
- this.update(&mut cx, |this, cx| {
- this.fetch_extensions(search.as_deref(), cx);
- })
- .ok();
- }));
+ self.fetch_extensions_debounced(cx);
}
}
+ fn fetch_extensions_debounced(&mut self, cx: &mut ViewContext<'_, ExtensionsPage>) {
+ self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
+ let search = this
+ .update(&mut cx, |this, cx| this.search_query(cx))
+ .ok()
+ .flatten();
+
+ // Only debounce the fetching of extensions if we have a search
+ // query.
+ //
+ // If the search was just cleared then we can just reload the list
+ // of extensions without a debounce, which allows us to avoid seeing
+ // an intermittent flash of a "no extensions" state.
+ if let Some(_) = search {
+ cx.background_executor()
+ .timer(Duration::from_millis(250))
+ .await;
+ };
+
+ this.update(&mut cx, |this, cx| {
+ this.fetch_extensions(search, cx);
+ })
+ .ok();
+ }));
+ }
+
pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
let search = self.query_editor.read(cx).text(cx);
if search.trim().is_empty() {
@@ -479,7 +653,17 @@ impl Render for ExtensionsPage {
.child(
h_flex()
.w_full()
- .child(Headline::new("Extensions").size(HeadlineSize::XLarge)),
+ .gap_2()
+ .justify_between()
+ .child(Headline::new("Extensions").size(HeadlineSize::XLarge))
+ .child(
+ Button::new("add-dev-extension", "Add Dev Extension")
+ .style(ButtonStyle::Filled)
+ .size(ButtonSize::Large)
+ .on_click(|_event, cx| {
+ cx.dispatch_action(Box::new(InstallDevExtension))
+ }),
+ ),
)
.child(
h_flex()
@@ -494,8 +678,9 @@ impl Render for ExtensionsPage {
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.selected(self.filter == ExtensionFilter::All)
- .on_click(cx.listener(|this, _event, _cx| {
+ .on_click(cx.listener(|this, _event, cx| {
this.filter = ExtensionFilter::All;
+ this.filter_extension_entries(cx);
}))
.tooltip(move |cx| {
Tooltip::text("Show all extensions", cx)
@@ -507,8 +692,9 @@ impl Render for ExtensionsPage {
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.selected(self.filter == ExtensionFilter::Installed)
- .on_click(cx.listener(|this, _event, _cx| {
+ .on_click(cx.listener(|this, _event, cx| {
this.filter = ExtensionFilter::Installed;
+ this.filter_extension_entries(cx);
}))
.tooltip(move |cx| {
Tooltip::text("Show installed extensions", cx)
@@ -520,8 +706,9 @@ impl Render for ExtensionsPage {
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.selected(self.filter == ExtensionFilter::NotInstalled)
- .on_click(cx.listener(|this, _event, _cx| {
+ .on_click(cx.listener(|this, _event, cx| {
this.filter = ExtensionFilter::NotInstalled;
+ this.filter_extension_entries(cx);
}))
.tooltip(move |cx| {
Tooltip::text("Show not installed extensions", cx)
@@ -532,8 +719,12 @@ impl Render for ExtensionsPage {
),
)
.child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
- let entries = self.filtered_extension_entries(cx);
- if entries.is_empty() {
+ let mut count = self.filtered_remote_extension_indices.len();
+ if self.filter.include_dev_extensions() {
+ count += self.dev_extension_entries.len();
+ }
+
+ if count == 0 {
return this.py_4().child(self.render_empty_state(cx));
}
@@ -541,12 +732,11 @@ impl Render for ExtensionsPage {
canvas({
let view = cx.view().clone();
let scroll_handle = self.list.clone();
- let item_count = entries.len();
move |bounds, cx| {
- uniform_list::<_, Div, _>(
+ uniform_list::<_, ExtensionCard, _>(
view,
"entries",
- item_count,
+ count,
Self::render_extensions,
)
.size_full()
@@ -43,6 +43,7 @@ use std::ffi::OsStr;
#[async_trait::async_trait]
pub trait Fs: Send + Sync {
async fn create_dir(&self, path: &Path) -> Result<()>;
+ async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()>;
async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()>;
async fn create_file_with(
&self,
@@ -124,6 +125,16 @@ impl Fs for RealFs {
Ok(smol::fs::create_dir_all(path).await?)
}
+ async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()> {
+ #[cfg(target_family = "unix")]
+ smol::fs::unix::symlink(target, path).await?;
+
+ #[cfg(target_family = "windows")]
+ Err(anyhow!("not supported yet on windows"))?;
+
+ Ok(())
+ }
+
async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()> {
let mut open_options = smol::fs::OpenOptions::new();
open_options.write(true).create(true);
@@ -994,6 +1005,25 @@ impl Fs for FakeFs {
Ok(())
}
+ async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()> {
+ let mut state = self.state.lock();
+ let file = Arc::new(Mutex::new(FakeFsEntry::Symlink { target }));
+ state
+ .write_path(path.as_ref(), move |e| match e {
+ btree_map::Entry::Vacant(e) => {
+ e.insert(file);
+ Ok(())
+ }
+ btree_map::Entry::Occupied(mut e) => {
+ *e.get_mut() = file;
+ Ok(())
+ }
+ })
+ .unwrap();
+ state.emit_event(&[path]);
+ Ok(())
+ }
+
async fn create_file_with(
&self,
path: &Path,
@@ -1503,8 +1533,9 @@ mod tests {
]
);
- fs.insert_symlink("/root/dir2/link-to-dir3", "./dir3".into())
- .await;
+ fs.create_symlink("/root/dir2/link-to-dir3".as_ref(), "./dir3".into())
+ .await
+ .unwrap();
assert_eq!(
fs.canonicalize("/root/dir2/link-to-dir3".as_ref())
@@ -348,6 +348,12 @@ impl BackgroundExecutor {
self.dispatcher.as_test().unwrap().allow_parking();
}
+ /// undoes the effect of [`allow_parking`].
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn forbid_parking(&self) {
+ self.dispatcher.as_test().unwrap().forbid_parking();
+ }
+
/// in tests, returns the rng used by the dispatcher and seeded by the `SEED` environment variable
#[cfg(any(test, feature = "test-support"))]
pub fn rng(&self) -> StdRng {
@@ -128,6 +128,10 @@ impl TestDispatcher {
self.state.lock().allow_parking = true
}
+ pub fn forbid_parking(&self) {
+ self.state.lock().allow_parking = false
+ }
+
pub fn start_waiting(&self) {
self.state.lock().waiting_backtrace = Some(Backtrace::new_unresolved());
}
@@ -207,8 +207,12 @@ async fn test_circular_symlinks(cx: &mut TestAppContext) {
}),
)
.await;
- fs.insert_symlink("/root/lib/a/lib", "..".into()).await;
- fs.insert_symlink("/root/lib/b/lib", "..".into()).await;
+ fs.create_symlink("/root/lib/a/lib".as_ref(), "..".into())
+ .await
+ .unwrap();
+ fs.create_symlink("/root/lib/b/lib".as_ref(), "..".into())
+ .await
+ .unwrap();
let tree = Worktree::local(
build_client(cx),
@@ -303,10 +307,12 @@ async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) {
.await;
// These symlinks point to directories outside of the worktree's root, dir1.
- fs.insert_symlink("/root/dir1/deps/dep-dir2", "../../dir2".into())
- .await;
- fs.insert_symlink("/root/dir1/deps/dep-dir3", "../../dir3".into())
- .await;
+ fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into())
+ .await
+ .unwrap();
+ fs.create_symlink("/root/dir1/deps/dep-dir3".as_ref(), "../../dir3".into())
+ .await
+ .unwrap();
let tree = Worktree::local(
build_client(cx),
@@ -0,0 +1 @@
+grammars
@@ -14,5 +14,3 @@ zed_extension_api = { path = "../../crates/extension_api" }
[lib]
path = "src/gleam.rs"
crate-type = ["cdylib"]
-
-[package.metadata.component]
@@ -1,11 +0,0 @@
-// Generated by `wit-bindgen` 0.16.0. DO NOT EDIT!
-
-#[cfg(target_arch = "wasm32")]
-#[link_section = "component-type:zed_gleam"]
-#[doc(hidden)]
-pub static __WIT_BINDGEN_COMPONENT_TYPE: [u8; 169] = [3, 0, 9, 122, 101, 100, 95, 103, 108, 101, 97, 109, 0, 97, 115, 109, 13, 0, 1, 0, 7, 40, 1, 65, 2, 1, 65, 0, 4, 1, 29, 99, 111, 109, 112, 111, 110, 101, 110, 116, 58, 122, 101, 100, 95, 103, 108, 101, 97, 109, 47, 122, 101, 100, 95, 103, 108, 101, 97, 109, 4, 0, 11, 15, 1, 0, 9, 122, 101, 100, 95, 103, 108, 101, 97, 109, 3, 0, 0, 0, 16, 12, 112, 97, 99, 107, 97, 103, 101, 45, 100, 111, 99, 115, 0, 123, 125, 0, 70, 9, 112, 114, 111, 100, 117, 99, 101, 114, 115, 1, 12, 112, 114, 111, 99, 101, 115, 115, 101, 100, 45, 98, 121, 2, 13, 119, 105, 116, 45, 99, 111, 109, 112, 111, 110, 101, 110, 116, 6, 48, 46, 49, 56, 46, 50, 16, 119, 105, 116, 45, 98, 105, 110, 100, 103, 101, 110, 45, 114, 117, 115, 116, 6, 48, 46, 49, 54, 46, 48];
-
-#[inline(never)]
-#[doc(hidden)]
-#[cfg(target_arch = "wasm32")]
-pub fn __link_section() {}