Add a command for building and installing a locally-developed Zed extension (#8781)

Max Brunsfeld , Marshall , and Marshall Bowers created

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 <marshall@zed.dev>
Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>

Change summary

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               | 796 +++++++-----
crates/extension/src/extension_store_test.rs          | 182 +-
crates/extensions_ui/Cargo.toml                       |   4 
crates/extensions_ui/src/components.rs                |   3 
crates/extensions_ui/src/components/extension_card.rs |  40 
crates/extensions_ui/src/extensions_ui.rs             | 696 +++++++----
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, 1,573 insertions(+), 700 deletions(-)

Detailed changes

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"

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]

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

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

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<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>,
+}

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<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(

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();
-
-    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);

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]

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<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),
+        )
+    }
+}

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<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()

crates/fs/src/fs.rs 🔗

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

crates/gpui/src/executor.rs 🔗

@@ -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 {

crates/gpui/src/platform/test/dispatcher.rs 🔗

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

crates/project_core/src/worktree_tests.rs 🔗

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

extensions/gleam/Cargo.toml 🔗

@@ -14,5 +14,3 @@ zed_extension_api = { path = "../../crates/extension_api" }
 [lib]
 path = "src/gleam.rs"
 crate-type = ["cdylib"]
-
-[package.metadata.component]

extensions/gleam/src/bindings.rs 🔗

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