Extension refactor (#20305)

Conrad Irwin , Mikayla , Marshall Bowers , and Marshall created

This contains the main changes to the extensions crate from #20049. The
primary goal here is removing dependencies that we can't include on the
remote.


Release Notes:

- N/A

---------

Co-authored-by: Mikayla <mikayla@zed.dev>
Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
Co-authored-by: Marshall <marshall@zed.dev>

Change summary

Cargo.lock                                                    |  26 
crates/collab/src/tests/remote_editing_collaboration_tests.rs |  22 
crates/extension/src/extension_builder.rs                     |   7 
crates/extension_host/Cargo.toml                              |   8 
crates/extension_host/src/extension_host.rs                   | 318 ++--
crates/extension_host/src/wasm_host.rs                        | 102 +
crates/extension_host/src/wasm_host/wit.rs                    |   6 
crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs       |   2 
crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs       |   6 
crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs       |   6 
crates/extensions_ui/Cargo.toml                               |  22 
crates/extensions_ui/src/extension_indexed_docs_provider.rs   |   4 
crates/extensions_ui/src/extension_registration_hooks.rs      | 153 ++
crates/extensions_ui/src/extension_slash_command.rs           |   6 
crates/extensions_ui/src/extension_store_test.rs              |  60 
crates/extensions_ui/src/extensions_ui.rs                     |   8 
crates/indexed_docs/Cargo.toml                                |   1 
crates/indexed_docs/src/providers/rustdoc.rs                  |   3 
crates/indexed_docs/src/store.rs                              |   4 
crates/language/src/language_registry.rs                      |   7 
crates/languages/src/lib.rs                                   |  16 
crates/paths/src/paths.rs                                     |  16 
crates/remote/src/ssh_session.rs                              |  72 +
crates/remote_server/src/headless_project.rs                  |   4 
crates/remote_server/src/remote_editing_tests.rs              |   3 
crates/remote_server/src/unix.rs                              |   6 
crates/workspace/src/pane_group.rs                            |   2 
crates/zed/Cargo.toml                                         |   2 
crates/zed/src/main.rs                                        |  14 
crates/zed/src/reliability.rs                                 |   5 
30 files changed, 675 insertions(+), 236 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4138,7 +4138,6 @@ name = "extension_host"
 version = "0.1.0"
 dependencies = [
  "anyhow",
- "assistant_slash_command",
  "async-compression",
  "async-tar",
  "async-trait",
@@ -4151,7 +4150,6 @@ dependencies = [
  "futures 0.3.30",
  "gpui",
  "http_client",
- "indexed_docs",
  "language",
  "log",
  "lsp",
@@ -4167,16 +4165,13 @@ dependencies = [
  "serde_json",
  "serde_json_lenient",
  "settings",
- "snippet_provider",
  "task",
- "theme",
  "toml 0.8.19",
- "ui",
  "url",
  "util",
+ "wasmparser 0.215.0",
  "wasmtime",
  "wasmtime-wasi",
- "workspace",
 ]
 
 [[package]]
@@ -4184,28 +4179,44 @@ name = "extensions_ui"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "assistant_slash_command",
+ "async-compression",
+ "async-tar",
+ "async-trait",
  "client",
  "collections",
+ "ctor",
  "db",
  "editor",
+ "env_logger 0.11.5",
  "extension_host",
  "fs",
+ "futures 0.3.30",
  "fuzzy",
  "gpui",
+ "http_client",
+ "indexed_docs",
  "language",
+ "lsp",
+ "node_runtime",
  "num-format",
+ "parking_lot",
  "picker",
  "project",
  "release_channel",
+ "reqwest_client",
  "semantic_version",
  "serde",
+ "serde_json",
  "settings",
  "smallvec",
+ "snippet_provider",
  "theme",
  "theme_selector",
  "ui",
  "util",
  "vim",
+ "wasmtime-wasi",
  "workspace",
 ]
 
@@ -5822,6 +5833,7 @@ dependencies = [
  "cargo_metadata",
  "collections",
  "derive_more",
+ "extension_host",
  "fs",
  "futures 0.3.30",
  "fuzzy",
@@ -15070,6 +15082,7 @@ dependencies = [
  "ashpd",
  "assets",
  "assistant",
+ "assistant_slash_command",
  "async-watch",
  "audio",
  "auto_update",
@@ -15104,6 +15117,7 @@ dependencies = [
  "gpui",
  "http_client",
  "image_viewer",
+ "indexed_docs",
  "inline_completion_button",
  "install_cli",
  "journal",

crates/collab/src/tests/remote_editing_collaboration_tests.rs 🔗

@@ -3,7 +3,7 @@ use call::ActiveCall;
 use collections::HashSet;
 use fs::{FakeFs, Fs as _};
 use futures::StreamExt as _;
-use gpui::{BackgroundExecutor, Context as _, TestAppContext, UpdateGlobal as _};
+use gpui::{BackgroundExecutor, Context as _, SemanticVersion, TestAppContext, UpdateGlobal as _};
 use http_client::BlockedHttpClient;
 use language::{
     language_settings::{
@@ -31,6 +31,12 @@ async fn test_sharing_an_ssh_remote_project(
     server_cx: &mut TestAppContext,
 ) {
     let executor = cx_a.executor();
+    cx_a.update(|cx| {
+        release_channel::init(SemanticVersion::default(), cx);
+    });
+    server_cx.update(|cx| {
+        release_channel::init(SemanticVersion::default(), cx);
+    });
     let mut server = TestServer::start(executor.clone()).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
@@ -199,6 +205,13 @@ async fn test_ssh_collaboration_git_branches(
     cx_b.set_name("b");
     server_cx.set_name("server");
 
+    cx_a.update(|cx| {
+        release_channel::init(SemanticVersion::default(), cx);
+    });
+    server_cx.update(|cx| {
+        release_channel::init(SemanticVersion::default(), cx);
+    });
+
     let mut server = TestServer::start(executor.clone()).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
@@ -329,6 +342,13 @@ async fn test_ssh_collaboration_formatting_with_prettier(
     cx_b.set_name("b");
     server_cx.set_name("server");
 
+    cx_a.update(|cx| {
+        release_channel::init(SemanticVersion::default(), cx);
+    });
+    server_cx.update(|cx| {
+        release_channel::init(SemanticVersion::default(), cx);
+    });
+
     let mut server = TestServer::start(executor.clone()).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;

crates/extension/src/extension_builder.rs 🔗

@@ -365,12 +365,15 @@ impl ExtensionBuilder {
 
         let output = Command::new("rustup")
             .args(["target", "add", RUST_TARGET])
-            .stderr(Stdio::inherit())
+            .stderr(Stdio::piped())
             .stdout(Stdio::inherit())
             .output()
             .context("failed to run `rustup target add`")?;
         if !output.status.success() {
-            bail!("failed to install the `{RUST_TARGET}` target");
+            bail!(
+                "failed to install the `{RUST_TARGET}` target: {}",
+                String::from_utf8_lossy(&rustc_output.stderr)
+            );
         }
 
         Ok(())

crates/extension_host/Cargo.toml 🔗

@@ -14,7 +14,6 @@ doctest = false
 
 [dependencies]
 anyhow.workspace = true
-assistant_slash_command.workspace = true
 async-compression.workspace = true
 async-tar.workspace = true
 async-trait.workspace = true
@@ -25,7 +24,6 @@ fs.workspace = true
 futures.workspace = true
 gpui.workspace = true
 http_client.workspace = true
-indexed_docs.workspace = true
 language.workspace = true
 log.workspace = true
 lsp.workspace = true
@@ -39,16 +37,13 @@ serde.workspace = true
 serde_json.workspace = true
 serde_json_lenient.workspace = true
 settings.workspace = true
-snippet_provider.workspace = true
 task.workspace = true
-theme.workspace = true
 toml.workspace = true
-ui.workspace = true
 url.workspace = true
 util.workspace = true
+wasmparser.workspace = true
 wasmtime-wasi.workspace = true
 wasmtime.workspace = true
-workspace.workspace = true
 
 [dev-dependencies]
 ctor.workspace = true
@@ -59,4 +54,3 @@ language = { workspace = true, features = ["test-support"] }
 parking_lot.workspace = true
 project = { workspace = true, features = ["test-support"] }
 reqwest_client.workspace = true
-workspace = { workspace = true, features = ["test-support"] }

crates/extension_host/src/extension_host.rs 🔗

@@ -1,23 +1,15 @@
-mod extension_indexed_docs_provider;
-mod extension_lsp_adapter;
-mod extension_settings;
-mod extension_slash_command;
-mod wasm_host;
+pub mod extension_lsp_adapter;
+pub mod extension_settings;
+pub mod wasm_host;
 
-#[cfg(test)]
-mod extension_store_test;
-
-use crate::extension_indexed_docs_provider::ExtensionIndexedDocsProvider;
-use crate::extension_slash_command::ExtensionSlashCommand;
 use crate::{extension_lsp_adapter::ExtensionLspAdapter, wasm_host::wit};
 use anyhow::{anyhow, bail, Context as _, Result};
-use assistant_slash_command::SlashCommandRegistry;
 use async_compression::futures::bufread::GzipDecoder;
 use async_tar::Archive;
 use client::{telemetry::Telemetry, Client, ExtensionMetadata, GetExtensionsResponse};
 use collections::{btree_map, BTreeMap, HashSet};
 use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder};
-use extension::SchemaVersion;
+pub use extension::ExtensionManifest;
 use fs::{Fs, RemoveOptions};
 use futures::{
     channel::{
@@ -28,14 +20,13 @@ use futures::{
     select_biased, AsyncReadExt as _, Future, FutureExt as _, StreamExt as _,
 };
 use gpui::{
-    actions, AppContext, AsyncAppContext, Context, EventEmitter, Global, Model, ModelContext, Task,
-    WeakModel,
+    actions, AppContext, AsyncAppContext, Context, EventEmitter, Global, Model, ModelContext,
+    SharedString, Task, WeakModel,
 };
 use http_client::{AsyncBody, HttpClient, HttpClientWithUrl};
-use indexed_docs::{IndexedDocsRegistry, ProviderId};
 use language::{
-    LanguageConfig, LanguageMatcher, LanguageName, LanguageQueries, LanguageRegistry,
-    LoadedLanguage, QUERY_FILENAME_PREFIXES,
+    LanguageConfig, LanguageMatcher, LanguageName, LanguageQueries, LoadedLanguage,
+    QUERY_FILENAME_PREFIXES,
 };
 use node_runtime::NodeRuntime;
 use project::ContextProviderWithTasks;
@@ -43,7 +34,6 @@ use release_channel::ReleaseChannel;
 use semantic_version::SemanticVersion;
 use serde::{Deserialize, Serialize};
 use settings::Settings;
-use snippet_provider::SnippetRegistry;
 use std::ops::RangeInclusive;
 use std::str::FromStr;
 use std::{
@@ -52,20 +42,19 @@ use std::{
     sync::Arc,
     time::{Duration, Instant},
 };
-use theme::{ThemeRegistry, ThemeSettings};
 use url::Url;
-use util::{maybe, ResultExt};
+use util::ResultExt;
 use wasm_host::{
     wit::{is_supported_wasm_api_version, wasm_api_version_range},
     WasmExtension, WasmHost,
 };
 
 pub use extension::{
-    ExtensionLibraryKind, ExtensionManifest, GrammarManifestEntry, OldExtensionManifest,
+    ExtensionLibraryKind, GrammarManifestEntry, OldExtensionManifest, SchemaVersion,
 };
 pub use extension_settings::ExtensionSettings;
 
-const RELOAD_DEBOUNCE_DURATION: Duration = Duration::from_millis(200);
+pub const RELOAD_DEBOUNCE_DURATION: Duration = Duration::from_millis(200);
 const FS_WATCH_LATENCY: Duration = Duration::from_millis(100);
 
 /// The current extension [`SchemaVersion`] supported by Zed.
@@ -100,26 +89,98 @@ pub fn is_version_compatible(
     true
 }
 
+pub trait DocsDatabase: Send + Sync + 'static {
+    fn insert(&self, key: String, docs: String) -> Task<Result<()>>;
+}
+
+pub trait ExtensionRegistrationHooks: Send + Sync + 'static {
+    fn remove_user_themes(&self, _themes: Vec<SharedString>) {}
+
+    fn load_user_theme(&self, _theme_path: PathBuf, _fs: Arc<dyn Fs>) -> Task<Result<()>> {
+        Task::ready(Ok(()))
+    }
+
+    fn list_theme_names(
+        &self,
+        _theme_path: PathBuf,
+        _fs: Arc<dyn Fs>,
+    ) -> Task<Result<Vec<String>>> {
+        Task::ready(Ok(Vec::new()))
+    }
+
+    fn reload_current_theme(&self, _cx: &mut AppContext) {}
+
+    fn register_language(
+        &self,
+        _language: LanguageName,
+        _grammar: Option<Arc<str>>,
+        _matcher: language::LanguageMatcher,
+        _load: Arc<dyn Fn() -> Result<LoadedLanguage> + 'static + Send + Sync>,
+    ) {
+    }
+
+    fn register_lsp_adapter(&self, _language: LanguageName, _adapter: ExtensionLspAdapter) {}
+
+    fn remove_lsp_adapter(
+        &self,
+        _language: &LanguageName,
+        _server_name: &language::LanguageServerName,
+    ) {
+    }
+
+    fn register_wasm_grammars(&self, _grammars: Vec<(Arc<str>, PathBuf)>) {}
+
+    fn remove_languages(
+        &self,
+        _languages_to_remove: &[LanguageName],
+        _grammars_to_remove: &[Arc<str>],
+    ) {
+    }
+
+    fn register_slash_command(
+        &self,
+        _slash_command: wit::SlashCommand,
+        _extension: WasmExtension,
+        _host: Arc<WasmHost>,
+    ) {
+    }
+
+    fn register_docs_provider(
+        &self,
+        _extension: WasmExtension,
+        _host: Arc<WasmHost>,
+        _provider_id: Arc<str>,
+    ) {
+    }
+
+    fn register_snippets(&self, _path: &PathBuf, _snippet_contents: &str) -> Result<()> {
+        Ok(())
+    }
+
+    fn update_lsp_status(
+        &self,
+        _server_name: language::LanguageServerName,
+        _status: language::LanguageServerBinaryStatus,
+    ) {
+    }
+}
+
 pub struct ExtensionStore {
-    builder: Arc<ExtensionBuilder>,
-    extension_index: ExtensionIndex,
-    fs: Arc<dyn Fs>,
-    http_client: Arc<HttpClientWithUrl>,
-    telemetry: Option<Arc<Telemetry>>,
-    reload_tx: UnboundedSender<Option<Arc<str>>>,
-    reload_complete_senders: Vec<oneshot::Sender<()>>,
-    installed_dir: PathBuf,
-    outstanding_operations: BTreeMap<Arc<str>, ExtensionOperation>,
-    index_path: PathBuf,
-    language_registry: Arc<LanguageRegistry>,
-    theme_registry: Arc<ThemeRegistry>,
-    slash_command_registry: Arc<SlashCommandRegistry>,
-    indexed_docs_registry: Arc<IndexedDocsRegistry>,
-    snippet_registry: Arc<SnippetRegistry>,
-    modified_extensions: HashSet<Arc<str>>,
-    wasm_host: Arc<WasmHost>,
-    wasm_extensions: Vec<(Arc<ExtensionManifest>, WasmExtension)>,
-    tasks: Vec<Task<()>>,
+    pub registration_hooks: Arc<dyn ExtensionRegistrationHooks>,
+    pub builder: Arc<ExtensionBuilder>,
+    pub extension_index: ExtensionIndex,
+    pub fs: Arc<dyn Fs>,
+    pub http_client: Arc<HttpClientWithUrl>,
+    pub telemetry: Option<Arc<Telemetry>>,
+    pub reload_tx: UnboundedSender<Option<Arc<str>>>,
+    pub reload_complete_senders: Vec<oneshot::Sender<()>>,
+    pub installed_dir: PathBuf,
+    pub outstanding_operations: BTreeMap<Arc<str>, ExtensionOperation>,
+    pub index_path: PathBuf,
+    pub modified_extensions: HashSet<Arc<str>>,
+    pub wasm_host: Arc<WasmHost>,
+    pub wasm_extensions: Vec<(Arc<ExtensionManifest>, WasmExtension)>,
+    pub tasks: Vec<Task<()>>,
 }
 
 #[derive(Clone, Copy)]
@@ -158,26 +219,25 @@ pub struct ExtensionIndexEntry {
 
 #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)]
 pub struct ExtensionIndexThemeEntry {
-    extension: Arc<str>,
-    path: PathBuf,
+    pub extension: Arc<str>,
+    pub path: PathBuf,
 }
 
 #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)]
 pub struct ExtensionIndexLanguageEntry {
-    extension: Arc<str>,
-    path: PathBuf,
-    matcher: LanguageMatcher,
-    grammar: Option<Arc<str>>,
+    pub extension: Arc<str>,
+    pub path: PathBuf,
+    pub matcher: LanguageMatcher,
+    pub grammar: Option<Arc<str>>,
 }
 
 actions!(zed, [ReloadExtensions]);
 
 pub fn init(
+    registration_hooks: Arc<dyn ExtensionRegistrationHooks>,
     fs: Arc<dyn Fs>,
     client: Arc<Client>,
     node_runtime: NodeRuntime,
-    language_registry: Arc<LanguageRegistry>,
-    theme_registry: Arc<ThemeRegistry>,
     cx: &mut AppContext,
 ) {
     ExtensionSettings::register(cx);
@@ -186,16 +246,12 @@ pub fn init(
         ExtensionStore::new(
             paths::extensions_dir().clone(),
             None,
+            registration_hooks,
             fs,
             client.http_client().clone(),
             client.http_client().clone(),
             Some(client.telemetry().clone()),
             node_runtime,
-            language_registry,
-            theme_registry,
-            SlashCommandRegistry::global(cx),
-            IndexedDocsRegistry::global(cx),
-            SnippetRegistry::global(cx),
             cx,
         )
     });
@@ -222,16 +278,12 @@ impl ExtensionStore {
     pub fn new(
         extensions_dir: PathBuf,
         build_dir: Option<PathBuf>,
+        extension_api: Arc<dyn ExtensionRegistrationHooks>,
         fs: Arc<dyn Fs>,
         http_client: Arc<HttpClientWithUrl>,
         builder_client: Arc<dyn HttpClient>,
         telemetry: Option<Arc<Telemetry>>,
         node_runtime: NodeRuntime,
-        language_registry: Arc<LanguageRegistry>,
-        theme_registry: Arc<ThemeRegistry>,
-        slash_command_registry: Arc<SlashCommandRegistry>,
-        indexed_docs_registry: Arc<IndexedDocsRegistry>,
-        snippet_registry: Arc<SnippetRegistry>,
         cx: &mut ModelContext<Self>,
     ) -> Self {
         let work_dir = extensions_dir.join("work");
@@ -241,6 +293,7 @@ impl ExtensionStore {
 
         let (reload_tx, mut reload_rx) = unbounded();
         let mut this = Self {
+            registration_hooks: extension_api.clone(),
             extension_index: Default::default(),
             installed_dir,
             index_path,
@@ -252,7 +305,7 @@ impl ExtensionStore {
                 fs.clone(),
                 http_client.clone(),
                 node_runtime,
-                language_registry.clone(),
+                extension_api,
                 work_dir,
                 cx,
             ),
@@ -260,11 +313,6 @@ impl ExtensionStore {
             fs,
             http_client,
             telemetry,
-            language_registry,
-            theme_registry,
-            slash_command_registry,
-            indexed_docs_registry,
-            snippet_registry,
             reload_tx,
             tasks: Vec::new(),
         };
@@ -325,6 +373,7 @@ impl ExtensionStore {
             async move {
                 load_initial_extensions.await;
 
+                let mut index_changed = false;
                 let mut debounce_timer = cx
                     .background_executor()
                     .spawn(futures::future::pending())
@@ -332,17 +381,21 @@ impl ExtensionStore {
                 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;
+                            if index_changed {
+                                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;
+                                index_changed = false;
+                            }
                         }
                         extension_id = reload_rx.next() => {
                             let Some(extension_id) = extension_id else { break; };
                             this.update(&mut cx, |this, _| {
                                 this.modified_extensions.extend(extension_id);
                             })?;
+                            index_changed = true;
                             debounce_timer = cx
                                 .background_executor()
                                 .timer(RELOAD_DEBOUNCE_DURATION)
@@ -386,7 +439,7 @@ impl ExtensionStore {
         this
     }
 
-    fn reload(
+    pub fn reload(
         &mut self,
         modified_extension: Option<Arc<str>>,
         cx: &mut ModelContext<Self>,
@@ -1039,7 +1092,7 @@ impl ExtensionStore {
             grammars_to_remove.extend(extension.manifest.grammars.keys().cloned());
             for (language_server_name, config) in extension.manifest.language_servers.iter() {
                 for language in config.languages() {
-                    self.language_registry
+                    self.registration_hooks
                         .remove_lsp_adapter(&language, language_server_name);
                 }
             }
@@ -1047,8 +1100,8 @@ impl ExtensionStore {
 
         self.wasm_extensions
             .retain(|(extension, _)| !extensions_to_unload.contains(&extension.id));
-        self.theme_registry.remove_user_themes(&themes_to_remove);
-        self.language_registry
+        self.registration_hooks.remove_user_themes(themes_to_remove);
+        self.registration_hooks
             .remove_languages(&languages_to_remove, &grammars_to_remove);
 
         let languages_to_add = new_index
@@ -1083,7 +1136,7 @@ impl ExtensionStore {
             }));
         }
 
-        self.language_registry
+        self.registration_hooks
             .register_wasm_grammars(grammars_to_add);
 
         for (language_name, language) in languages_to_add {
@@ -1092,11 +1145,11 @@ impl ExtensionStore {
                 Path::new(language.extension.as_ref()),
                 language.path.as_path(),
             ]);
-            self.language_registry.register_language(
+            self.registration_hooks.register_language(
                 language_name.clone(),
                 language.grammar.clone(),
                 language.matcher.clone(),
-                move || {
+                Arc::new(move || {
                     let config = std::fs::read_to_string(language_path.join("config.toml"))?;
                     let config: LanguageConfig = ::toml::from_str(&config)?;
                     let queries = load_plugin_queries(&language_path);
@@ -1115,15 +1168,14 @@ impl ExtensionStore {
                         context_provider,
                         toolchain_provider: None,
                     })
-                },
+                }),
             );
         }
 
         let fs = self.fs.clone();
         let wasm_host = self.wasm_host.clone();
         let root_dir = self.installed_dir.clone();
-        let theme_registry = self.theme_registry.clone();
-        let snippet_registry = self.snippet_registry.clone();
+        let api = self.registration_hooks.clone();
         let extension_entries = extensions_to_load
             .iter()
             .filter_map(|name| new_index.extensions.get(name).cloned())
@@ -1138,18 +1190,14 @@ impl ExtensionStore {
                 .spawn({
                     let fs = fs.clone();
                     async move {
-                        for theme_path in &themes_to_add {
-                            theme_registry
-                                .load_user_theme(theme_path, fs.clone())
-                                .await
-                                .log_err();
+                        for theme_path in themes_to_add.into_iter() {
+                            api.load_user_theme(theme_path, fs.clone()).await.log_err();
                         }
 
                         for snippets_path in &snippets_to_add {
                             if let Some(snippets_contents) = fs.load(snippets_path).await.log_err()
                             {
-                                snippet_registry
-                                    .register_snippets(snippets_path, &snippets_contents)
+                                api.register_snippets(snippets_path, &snippets_contents)
                                     .log_err();
                             }
                         }
@@ -1163,30 +1211,13 @@ impl ExtensionStore {
                     continue;
                 };
 
-                let wasm_extension = maybe!(async {
-                    let mut path = root_dir.clone();
-                    path.extend([extension.manifest.clone().id.as_ref(), "extension.wasm"]);
-                    let mut wasm_file = fs
-                        .open_sync(&path)
-                        .await
-                        .context("failed to open wasm file")?;
-
-                    let mut wasm_bytes = Vec::new();
-                    wasm_file
-                        .read_to_end(&mut wasm_bytes)
-                        .context("failed to read wasm")?;
-
-                    wasm_host
-                        .load_extension(
-                            wasm_bytes,
-                            extension.manifest.clone().clone(),
-                            cx.background_executor().clone(),
-                        )
-                        .await
-                        .with_context(|| {
-                            format!("failed to load wasm extension {}", extension.manifest.id)
-                        })
-                })
+                let extension_path = root_dir.join(extension.manifest.id.as_ref());
+                let wasm_extension = WasmExtension::load(
+                    extension_path,
+                    &extension.manifest,
+                    wasm_host.clone(),
+                    &cx,
+                )
                 .await;
 
                 if let Some(wasm_extension) = wasm_extension.log_err() {
@@ -1205,9 +1236,9 @@ impl ExtensionStore {
                 for (manifest, wasm_extension) in &wasm_extensions {
                     for (language_server_id, language_server_config) in &manifest.language_servers {
                         for language in language_server_config.languages() {
-                            this.language_registry.register_lsp_adapter(
+                            this.registration_hooks.register_lsp_adapter(
                                 language.clone(),
-                                Arc::new(ExtensionLspAdapter {
+                                ExtensionLspAdapter {
                                     extension: wasm_extension.clone(),
                                     host: this.wasm_host.clone(),
                                     language_server_id: language_server_id.clone(),
@@ -1215,43 +1246,38 @@ impl ExtensionStore {
                                         name: language_server_id.0.to_string(),
                                         language_name: language.to_string(),
                                     },
-                                }),
+                                },
                             );
                         }
                     }
 
                     for (slash_command_name, slash_command) in &manifest.slash_commands {
-                        this.slash_command_registry.register_command(
-                            ExtensionSlashCommand {
-                                command: crate::wit::SlashCommand {
-                                    name: slash_command_name.to_string(),
-                                    description: slash_command.description.to_string(),
-                                    // We don't currently expose this as a configurable option, as it currently drives
-                                    // the `menu_text` on the `SlashCommand` trait, which is not used for slash commands
-                                    // defined in extensions, as they are not able to be added to the menu.
-                                    tooltip_text: String::new(),
-                                    requires_argument: slash_command.requires_argument,
-                                },
-                                extension: wasm_extension.clone(),
-                                host: this.wasm_host.clone(),
+                        this.registration_hooks.register_slash_command(
+                            crate::wit::SlashCommand {
+                                name: slash_command_name.to_string(),
+                                description: slash_command.description.to_string(),
+                                // We don't currently expose this as a configurable option, as it currently drives
+                                // the `menu_text` on the `SlashCommand` trait, which is not used for slash commands
+                                // defined in extensions, as they are not able to be added to the menu.
+                                tooltip_text: String::new(),
+                                requires_argument: slash_command.requires_argument,
                             },
-                            false,
+                            wasm_extension.clone(),
+                            this.wasm_host.clone(),
                         );
                     }
 
                     for (provider_id, _provider) in &manifest.indexed_docs_providers {
-                        this.indexed_docs_registry.register_provider(Box::new(
-                            ExtensionIndexedDocsProvider {
-                                extension: wasm_extension.clone(),
-                                host: this.wasm_host.clone(),
-                                id: ProviderId(provider_id.clone()),
-                            },
-                        ));
+                        this.registration_hooks.register_docs_provider(
+                            wasm_extension.clone(),
+                            this.wasm_host.clone(),
+                            provider_id.clone(),
+                        );
                     }
                 }
 
                 this.wasm_extensions.extend(wasm_extensions);
-                ThemeSettings::reload_current_theme(cx)
+                this.registration_hooks.reload_current_theme(cx);
             })
             .ok();
         })
@@ -1262,6 +1288,7 @@ impl ExtensionStore {
         let work_dir = self.wasm_host.work_dir.clone();
         let extensions_dir = self.installed_dir.clone();
         let index_path = self.index_path.clone();
+        let extension_api = self.registration_hooks.clone();
         cx.background_executor().spawn(async move {
             let start_time = Instant::now();
             let mut index = ExtensionIndex::default();
@@ -1283,9 +1310,14 @@ impl ExtensionStore {
                         continue;
                     }
 
-                    Self::add_extension_to_index(fs.clone(), extension_dir, &mut index)
-                        .await
-                        .log_err();
+                    Self::add_extension_to_index(
+                        fs.clone(),
+                        extension_dir,
+                        &mut index,
+                        extension_api.clone(),
+                    )
+                    .await
+                    .log_err();
                 }
             }
 
@@ -1305,6 +1337,7 @@ impl ExtensionStore {
         fs: Arc<dyn Fs>,
         extension_dir: PathBuf,
         index: &mut ExtensionIndex,
+        extension_api: Arc<dyn ExtensionRegistrationHooks>,
     ) -> Result<()> {
         let mut extension_manifest = ExtensionManifest::load(fs.clone(), &extension_dir).await?;
         let extension_id = extension_manifest.id.clone();
@@ -1356,7 +1389,8 @@ impl ExtensionStore {
                     continue;
                 };
 
-                let Some(theme_family) = theme::read_user_theme(&theme_path, fs.clone())
+                let Some(theme_families) = extension_api
+                    .list_theme_names(theme_path.clone(), fs.clone())
                     .await
                     .log_err()
                 else {
@@ -1368,9 +1402,9 @@ impl ExtensionStore {
                     extension_manifest.themes.push(relative_path.clone());
                 }
 
-                for theme in theme_family.themes {
+                for theme_name in theme_families {
                     index.themes.insert(
-                        theme.name.into(),
+                        theme_name.into(),
                         ExtensionIndexThemeEntry {
                             extension: extension_id.clone(),
                             path: relative_path.clone(),

crates/extension_host/src/wasm_host.rs 🔗

@@ -1,7 +1,7 @@
-pub(crate) mod wit;
+pub mod wit;
 
-use crate::ExtensionManifest;
-use anyhow::{anyhow, Context as _, Result};
+use crate::{ExtensionManifest, ExtensionRegistrationHooks};
+use anyhow::{anyhow, bail, Context as _, Result};
 use fs::{normalize_path, Fs};
 use futures::future::LocalBoxFuture;
 use futures::{
@@ -14,7 +14,6 @@ use futures::{
 };
 use gpui::{AppContext, AsyncAppContext, BackgroundExecutor, Task};
 use http_client::HttpClient;
-use language::LanguageRegistry;
 use node_runtime::NodeRuntime;
 use release_channel::ReleaseChannel;
 use semantic_version::SemanticVersion;
@@ -28,15 +27,16 @@ use wasmtime::{
 };
 use wasmtime_wasi as wasi;
 use wit::Extension;
+pub use wit::SlashCommand;
 
-pub(crate) struct WasmHost {
+pub struct WasmHost {
     engine: Engine,
     release_channel: ReleaseChannel,
     http_client: Arc<dyn HttpClient>,
     node_runtime: NodeRuntime,
-    pub(crate) language_registry: Arc<LanguageRegistry>,
+    pub registration_hooks: Arc<dyn ExtensionRegistrationHooks>,
     fs: Arc<dyn Fs>,
-    pub(crate) work_dir: PathBuf,
+    pub work_dir: PathBuf,
     _main_thread_message_task: Task<()>,
     main_thread_message_tx: mpsc::UnboundedSender<MainThreadCall>,
 }
@@ -44,16 +44,16 @@ pub(crate) struct WasmHost {
 #[derive(Clone)]
 pub struct WasmExtension {
     tx: UnboundedSender<ExtensionCall>,
-    pub(crate) manifest: Arc<ExtensionManifest>,
+    pub manifest: Arc<ExtensionManifest>,
     #[allow(unused)]
     pub zed_api_version: SemanticVersion,
 }
 
-pub(crate) struct WasmState {
+pub struct WasmState {
     manifest: Arc<ExtensionManifest>,
-    pub(crate) table: ResourceTable,
+    pub table: ResourceTable,
     ctx: wasi::WasiCtx,
-    pub(crate) host: Arc<WasmHost>,
+    pub host: Arc<WasmHost>,
 }
 
 type MainThreadCall =
@@ -81,7 +81,7 @@ impl WasmHost {
         fs: Arc<dyn Fs>,
         http_client: Arc<dyn HttpClient>,
         node_runtime: NodeRuntime,
-        language_registry: Arc<LanguageRegistry>,
+        registration_hooks: Arc<dyn ExtensionRegistrationHooks>,
         work_dir: PathBuf,
         cx: &mut AppContext,
     ) -> Arc<Self> {
@@ -97,7 +97,7 @@ impl WasmHost {
             work_dir,
             http_client,
             node_runtime,
-            language_registry,
+            registration_hooks,
             release_channel: ReleaseChannel::global(cx),
             _main_thread_message_task: task,
             main_thread_message_tx: tx,
@@ -107,13 +107,13 @@ impl WasmHost {
     pub fn load_extension(
         self: &Arc<Self>,
         wasm_bytes: Vec<u8>,
-        manifest: Arc<ExtensionManifest>,
+        manifest: &Arc<ExtensionManifest>,
         executor: BackgroundExecutor,
     ) -> Task<Result<WasmExtension>> {
         let this = self.clone();
+        let manifest = manifest.clone();
         executor.clone().spawn(async move {
-            let zed_api_version =
-                extension::parse_wasm_extension_version(&manifest.id, &wasm_bytes)?;
+            let zed_api_version = parse_wasm_extension_version(&manifest.id, &wasm_bytes)?;
 
             let component = Component::from_binary(&this.engine, &wasm_bytes)
                 .context("failed to compile wasm component")?;
@@ -151,7 +151,7 @@ impl WasmHost {
                 .detach();
 
             Ok(WasmExtension {
-                manifest,
+                manifest: manifest.clone(),
                 tx,
                 zed_api_version,
             })
@@ -198,7 +198,75 @@ impl WasmHost {
     }
 }
 
+pub fn parse_wasm_extension_version(
+    extension_id: &str,
+    wasm_bytes: &[u8],
+) -> Result<SemanticVersion> {
+    let mut version = None;
+
+    for part in wasmparser::Parser::new(0).parse_all(wasm_bytes) {
+        if let wasmparser::Payload::CustomSection(s) =
+            part.context("error parsing wasm extension")?
+        {
+            if s.name() == "zed:api-version" {
+                version = parse_wasm_extension_version_custom_section(s.data());
+                if version.is_none() {
+                    bail!(
+                        "extension {} has invalid zed:api-version section: {:?}",
+                        extension_id,
+                        s.data()
+                    );
+                }
+            }
+        }
+    }
+
+    // The reason we wait until we're done parsing all of the Wasm bytes to return the version
+    // is to work around a panic that can happen inside of Wasmtime when the bytes are invalid.
+    //
+    // By parsing the entirety of the Wasm bytes before we return, we're able to detect this problem
+    // earlier as an `Err` rather than as a panic.
+    version.ok_or_else(|| anyhow!("extension {} has no zed:api-version section", extension_id))
+}
+
+fn parse_wasm_extension_version_custom_section(data: &[u8]) -> Option<SemanticVersion> {
+    if data.len() == 6 {
+        Some(SemanticVersion::new(
+            u16::from_be_bytes([data[0], data[1]]) as _,
+            u16::from_be_bytes([data[2], data[3]]) as _,
+            u16::from_be_bytes([data[4], data[5]]) as _,
+        ))
+    } else {
+        None
+    }
+}
+
 impl WasmExtension {
+    pub async fn load(
+        extension_dir: PathBuf,
+        manifest: &Arc<ExtensionManifest>,
+        wasm_host: Arc<WasmHost>,
+        cx: &AsyncAppContext,
+    ) -> Result<Self> {
+        let path = extension_dir.join("extension.wasm");
+
+        let mut wasm_file = wasm_host
+            .fs
+            .open_sync(&path)
+            .await
+            .context("failed to open wasm file")?;
+
+        let mut wasm_bytes = Vec::new();
+        wasm_file
+            .read_to_end(&mut wasm_bytes)
+            .context("failed to read wasm")?;
+
+        wasm_host
+            .load_extension(wasm_bytes, manifest, cx.background_executor().clone())
+            .await
+            .with_context(|| format!("failed to load wasm extension {}", manifest.id))
+    }
+
     pub async fn call<T, Fn>(&self, f: Fn) -> T
     where
         T: 'static + Send,

crates/extension_host/src/wasm_host/wit.rs 🔗

@@ -3,10 +3,12 @@ mod since_v0_0_4;
 mod since_v0_0_6;
 mod since_v0_1_0;
 mod since_v0_2_0;
-use indexed_docs::IndexedDocsDatabase;
+// use indexed_docs::IndexedDocsDatabase;
 use release_channel::ReleaseChannel;
 use since_v0_2_0 as latest;
 
+use crate::DocsDatabase;
+
 use super::{wasm_engine, WasmState};
 use anyhow::{anyhow, Context, Result};
 use language::{LanguageServerName, LspAdapterDelegate};
@@ -394,7 +396,7 @@ impl Extension {
         store: &mut Store<WasmState>,
         provider: &str,
         package_name: &str,
-        database: Resource<Arc<IndexedDocsDatabase>>,
+        database: Resource<Arc<dyn DocsDatabase>>,
     ) -> Result<Result<(), String>> {
         match self {
             Extension::V020(ext) => {

crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs 🔗

@@ -1,4 +1,5 @@
 use crate::wasm_host::{wit::ToWasmtimeResult, WasmState};
+use crate::DocsDatabase;
 use ::http_client::{AsyncBody, HttpRequestExt};
 use ::settings::{Settings, WorktreeId};
 use anyhow::{anyhow, bail, Context, Result};
@@ -7,7 +8,6 @@ use async_tar::Archive;
 use async_trait::async_trait;
 use futures::{io::BufReader, FutureExt as _};
 use futures::{lock::Mutex, AsyncReadExt};
-use indexed_docs::IndexedDocsDatabase;
 use language::{
     language_settings::AllLanguageSettings, LanguageServerBinaryStatus, LspAdapterDelegate,
 };
@@ -48,7 +48,7 @@ mod settings {
 }
 
 pub type ExtensionWorktree = Arc<dyn LspAdapterDelegate>;
-pub type ExtensionKeyValueStore = Arc<IndexedDocsDatabase>;
+pub type ExtensionKeyValueStore = Arc<dyn DocsDatabase>;
 pub type ExtensionHttpResponseStream = Arc<Mutex<::http_client::Response<AsyncBody>>>;
 
 pub fn linker() -> &'static Linker<WasmState> {
@@ -512,7 +512,7 @@ impl ExtensionImports for WasmState {
         };
 
         self.host
-            .language_registry
+            .registration_hooks
             .update_lsp_status(language::LanguageServerName(server_name.into()), status);
         Ok(())
     }

crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs 🔗

@@ -1,4 +1,5 @@
 use crate::wasm_host::{wit::ToWasmtimeResult, WasmState};
+use crate::DocsDatabase;
 use ::http_client::{AsyncBody, HttpRequestExt};
 use ::settings::{Settings, WorktreeId};
 use anyhow::{anyhow, bail, Context, Result};
@@ -7,7 +8,6 @@ use async_tar::Archive;
 use async_trait::async_trait;
 use futures::{io::BufReader, FutureExt as _};
 use futures::{lock::Mutex, AsyncReadExt};
-use indexed_docs::IndexedDocsDatabase;
 use language::{
     language_settings::AllLanguageSettings, LanguageServerBinaryStatus, LspAdapterDelegate,
 };
@@ -43,7 +43,7 @@ mod settings {
 }
 
 pub type ExtensionWorktree = Arc<dyn LspAdapterDelegate>;
-pub type ExtensionKeyValueStore = Arc<IndexedDocsDatabase>;
+pub type ExtensionKeyValueStore = Arc<dyn DocsDatabase>;
 pub type ExtensionHttpResponseStream = Arc<Mutex<::http_client::Response<AsyncBody>>>;
 
 pub fn linker() -> &'static Linker<WasmState> {
@@ -459,7 +459,7 @@ impl ExtensionImports for WasmState {
         };
 
         self.host
-            .language_registry
+            .registration_hooks
             .update_lsp_status(language::LanguageServerName(server_name.into()), status);
         Ok(())
     }

crates/extensions_ui/Cargo.toml 🔗

@@ -16,14 +16,18 @@ test-support = []
 
 [dependencies]
 anyhow.workspace = true
+assistant_slash_command.workspace = true
+async-trait.workspace = true
 client.workspace = true
 collections.workspace = true
 db.workspace = true
 editor.workspace = true
 extension_host.workspace = true
 fs.workspace = true
+futures.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
+indexed_docs.workspace = true
 language.workspace = true
 num-format.workspace = true
 picker.workspace = true
@@ -33,12 +37,30 @@ semantic_version.workspace = true
 serde.workspace = true
 settings.workspace = true
 smallvec.workspace = true
+snippet_provider.workspace = true
 theme.workspace = true
 theme_selector.workspace = true
 ui.workspace = true
 util.workspace = true
 vim.workspace = true
+wasmtime-wasi.workspace = true
 workspace.workspace = true
 
 [dev-dependencies]
+async-compression.workspace = true
+async-tar.workspace = true
+ctor.workspace = true
 editor = { workspace = true, features = ["test-support"] }
+env_logger.workspace = true
+fs = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
+http_client.workspace = true
+indexed_docs.workspace = true
+language = { workspace = true, features = ["test-support"] }
+lsp.workspace = true
+node_runtime.workspace = true
+parking_lot.workspace = true
+project = { workspace = true, features = ["test-support"] }
+reqwest_client.workspace = true
+serde_json.workspace = true
+workspace = { workspace = true, features = ["test-support"] }

crates/extension_host/src/extension_indexed_docs_provider.rs → crates/extensions_ui/src/extension_indexed_docs_provider.rs 🔗

@@ -7,7 +7,7 @@ use futures::FutureExt;
 use indexed_docs::{IndexedDocsDatabase, IndexedDocsProvider, PackageName, ProviderId};
 use wasmtime_wasi::WasiView;
 
-use crate::wasm_host::{WasmExtension, WasmHost};
+use extension_host::wasm_host::{WasmExtension, WasmHost};
 
 pub struct ExtensionIndexedDocsProvider {
     pub(crate) extension: WasmExtension,
@@ -58,7 +58,7 @@ impl IndexedDocsProvider for ExtensionIndexedDocsProvider {
                 let id = self.id.clone();
                 |extension, store| {
                     async move {
-                        let database_resource = store.data_mut().table().push(database)?;
+                        let database_resource = store.data_mut().table().push(database as _)?;
                         extension
                             .call_index_docs(
                                 store,

crates/extensions_ui/src/extension_registration_hooks.rs 🔗

@@ -0,0 +1,153 @@
+use std::{path::PathBuf, sync::Arc};
+
+use anyhow::Result;
+use assistant_slash_command::SlashCommandRegistry;
+use extension_host::{extension_lsp_adapter::ExtensionLspAdapter, wasm_host};
+use fs::Fs;
+use gpui::{AppContext, BackgroundExecutor, Task};
+use indexed_docs::{IndexedDocsRegistry, ProviderId};
+use language::{LanguageRegistry, LanguageServerBinaryStatus, LoadedLanguage};
+use snippet_provider::SnippetRegistry;
+use theme::{ThemeRegistry, ThemeSettings};
+use ui::SharedString;
+
+use crate::{extension_indexed_docs_provider, extension_slash_command::ExtensionSlashCommand};
+
+pub struct ConcreteExtensionRegistrationHooks {
+    slash_command_registry: Arc<SlashCommandRegistry>,
+    theme_registry: Arc<ThemeRegistry>,
+    indexed_docs_registry: Arc<IndexedDocsRegistry>,
+    snippet_registry: Arc<SnippetRegistry>,
+    language_registry: Arc<LanguageRegistry>,
+    executor: BackgroundExecutor,
+}
+
+impl ConcreteExtensionRegistrationHooks {
+    pub fn new(
+        theme_registry: Arc<ThemeRegistry>,
+        slash_command_registry: Arc<SlashCommandRegistry>,
+        indexed_docs_registry: Arc<IndexedDocsRegistry>,
+        snippet_registry: Arc<SnippetRegistry>,
+        language_registry: Arc<LanguageRegistry>,
+        cx: &AppContext,
+    ) -> Arc<dyn extension_host::ExtensionRegistrationHooks> {
+        Arc::new(Self {
+            theme_registry,
+            slash_command_registry,
+            indexed_docs_registry,
+            snippet_registry,
+            language_registry,
+            executor: cx.background_executor().clone(),
+        })
+    }
+}
+
+impl extension_host::ExtensionRegistrationHooks for ConcreteExtensionRegistrationHooks {
+    fn remove_user_themes(&self, themes: Vec<SharedString>) {
+        self.theme_registry.remove_user_themes(&themes);
+    }
+
+    fn load_user_theme(&self, theme_path: PathBuf, fs: Arc<dyn fs::Fs>) -> Task<Result<()>> {
+        let theme_registry = self.theme_registry.clone();
+        self.executor
+            .spawn(async move { theme_registry.load_user_theme(&theme_path, fs).await })
+    }
+
+    fn register_slash_command(
+        &self,
+        command: wasm_host::SlashCommand,
+        extension: wasm_host::WasmExtension,
+        host: Arc<wasm_host::WasmHost>,
+    ) {
+        self.slash_command_registry.register_command(
+            ExtensionSlashCommand {
+                command,
+                extension,
+                host,
+            },
+            false,
+        )
+    }
+
+    fn register_docs_provider(
+        &self,
+        extension: wasm_host::WasmExtension,
+        host: Arc<wasm_host::WasmHost>,
+        provider_id: Arc<str>,
+    ) {
+        self.indexed_docs_registry.register_provider(Box::new(
+            extension_indexed_docs_provider::ExtensionIndexedDocsProvider {
+                extension,
+                host,
+                id: ProviderId(provider_id),
+            },
+        ));
+    }
+
+    fn register_snippets(&self, path: &PathBuf, snippet_contents: &str) -> Result<()> {
+        self.snippet_registry
+            .register_snippets(path, snippet_contents)
+    }
+
+    fn update_lsp_status(
+        &self,
+        server_name: language::LanguageServerName,
+        status: LanguageServerBinaryStatus,
+    ) {
+        self.language_registry
+            .update_lsp_status(server_name, status);
+    }
+
+    fn register_lsp_adapter(
+        &self,
+        language_name: language::LanguageName,
+        adapter: ExtensionLspAdapter,
+    ) {
+        self.language_registry
+            .register_lsp_adapter(language_name, Arc::new(adapter));
+    }
+
+    fn remove_lsp_adapter(
+        &self,
+        language_name: &language::LanguageName,
+        server_name: &language::LanguageServerName,
+    ) {
+        self.language_registry
+            .remove_lsp_adapter(language_name, server_name);
+    }
+
+    fn remove_languages(
+        &self,
+        languages_to_remove: &[language::LanguageName],
+        grammars_to_remove: &[Arc<str>],
+    ) {
+        self.language_registry
+            .remove_languages(&languages_to_remove, &grammars_to_remove);
+    }
+
+    fn register_wasm_grammars(&self, grammars: Vec<(Arc<str>, PathBuf)>) {
+        self.language_registry.register_wasm_grammars(grammars)
+    }
+
+    fn register_language(
+        &self,
+        language: language::LanguageName,
+        grammar: Option<Arc<str>>,
+        matcher: language::LanguageMatcher,
+        load: Arc<dyn Fn() -> Result<LoadedLanguage> + 'static + Send + Sync>,
+    ) {
+        self.language_registry
+            .register_language(language, grammar, matcher, load)
+    }
+
+    fn reload_current_theme(&self, cx: &mut AppContext) {
+        ThemeSettings::reload_current_theme(cx)
+    }
+
+    fn list_theme_names(&self, path: PathBuf, fs: Arc<dyn Fs>) -> Task<Result<Vec<String>>> {
+        self.executor.spawn(async move {
+            let themes = theme::read_user_theme(&path, fs).await?;
+            Ok(themes.themes.into_iter().map(|theme| theme.name).collect())
+        })
+    }
+}

crates/extension_host/src/extension_slash_command.rs → crates/extensions_ui/src/extension_slash_command.rs 🔗

@@ -5,20 +5,20 @@ use assistant_slash_command::{
     ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
     SlashCommandResult,
 };
-use futures::FutureExt;
+use futures::FutureExt as _;
 use gpui::{Task, WeakView, WindowContext};
 use language::{BufferSnapshot, LspAdapterDelegate};
 use ui::prelude::*;
 use wasmtime_wasi::WasiView;
 use workspace::Workspace;
 
-use crate::wasm_host::{WasmExtension, WasmHost};
+use extension_host::wasm_host::{WasmExtension, WasmHost};
 
 pub struct ExtensionSlashCommand {
     pub(crate) extension: WasmExtension,
     #[allow(unused)]
     pub(crate) host: Arc<WasmHost>,
-    pub(crate) command: crate::wit::SlashCommand,
+    pub(crate) command: extension_host::wasm_host::SlashCommand,
 }
 
 impl SlashCommand for ExtensionSlashCommand {

crates/extension_host/src/extension_store_test.rs → crates/extensions_ui/src/extension_store_test.rs 🔗

@@ -1,13 +1,13 @@
-use crate::extension_settings::ExtensionSettings;
-use crate::{
+use assistant_slash_command::SlashCommandRegistry;
+use async_compression::futures::bufread::GzipEncoder;
+use collections::BTreeMap;
+use extension_host::ExtensionSettings;
+use extension_host::SchemaVersion;
+use extension_host::{
     Event, ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry,
     ExtensionIndexThemeEntry, ExtensionManifest, ExtensionStore, GrammarManifestEntry,
     RELOAD_DEBOUNCE_DURATION,
 };
-use assistant_slash_command::SlashCommandRegistry;
-use async_compression::futures::bufread::GzipEncoder;
-use collections::BTreeMap;
-use extension::SchemaVersion;
 use fs::{FakeFs, Fs, RealFs};
 use futures::{io::BufReader, AsyncReadExt, StreamExt};
 use gpui::{Context, SemanticVersion, TestAppContext};
@@ -267,24 +267,29 @@ async fn test_extension_store(cx: &mut TestAppContext) {
     let node_runtime = NodeRuntime::unavailable();
 
     let store = cx.new_model(|cx| {
+        let extension_registration_hooks = crate::ConcreteExtensionRegistrationHooks::new(
+            theme_registry.clone(),
+            slash_command_registry.clone(),
+            indexed_docs_registry.clone(),
+            snippet_registry.clone(),
+            language_registry.clone(),
+            cx,
+        );
+
         ExtensionStore::new(
             PathBuf::from("/the-extension-dir"),
             None,
+            extension_registration_hooks,
             fs.clone(),
             http_client.clone(),
             http_client.clone(),
             None,
             node_runtime.clone(),
-            language_registry.clone(),
-            theme_registry.clone(),
-            slash_command_registry.clone(),
-            indexed_docs_registry.clone(),
-            snippet_registry.clone(),
             cx,
         )
     });
 
-    cx.executor().advance_clock(super::RELOAD_DEBOUNCE_DURATION);
+    cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
     store.read_with(cx, |store, _| {
         let index = &store.extension_index;
         assert_eq!(index.extensions, expected_index.extensions);
@@ -395,19 +400,24 @@ async fn test_extension_store(cx: &mut TestAppContext) {
     // Create new extension store, as if Zed were restarting.
     drop(store);
     let store = cx.new_model(|cx| {
+        let extension_api = crate::ConcreteExtensionRegistrationHooks::new(
+            theme_registry.clone(),
+            slash_command_registry,
+            indexed_docs_registry,
+            snippet_registry,
+            language_registry.clone(),
+            cx,
+        );
+
         ExtensionStore::new(
             PathBuf::from("/the-extension-dir"),
             None,
+            extension_api,
             fs.clone(),
             http_client.clone(),
             http_client.clone(),
             None,
             node_runtime.clone(),
-            language_registry.clone(),
-            theme_registry.clone(),
-            slash_command_registry,
-            indexed_docs_registry,
-            snippet_registry,
             cx,
         )
     });
@@ -580,19 +590,23 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
         Arc::new(ReqwestClient::user_agent(&user_agent).expect("Could not create HTTP client"));
 
     let extension_store = cx.new_model(|cx| {
+        let extension_api = crate::ConcreteExtensionRegistrationHooks::new(
+            theme_registry.clone(),
+            slash_command_registry,
+            indexed_docs_registry,
+            snippet_registry,
+            language_registry.clone(),
+            cx,
+        );
         ExtensionStore::new(
             extensions_dir.clone(),
             Some(cache_dir),
+            extension_api,
             fs.clone(),
             extension_client.clone(),
             builder_client,
             None,
             node_runtime,
-            language_registry.clone(),
-            theme_registry.clone(),
-            slash_command_registry,
-            indexed_docs_registry,
-            snippet_registry,
             cx,
         )
     });
@@ -602,7 +616,7 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
     let executor = cx.executor();
     let _task = cx.executor().spawn(async move {
         while let Some(event) = events.next().await {
-            if let crate::Event::StartedReloading = event {
+            if let extension_host::Event::StartedReloading = event {
                 executor.advance_clock(RELOAD_DEBOUNCE_DURATION);
             }
         }

crates/extensions_ui/src/extensions_ui.rs 🔗

@@ -1,7 +1,15 @@
 mod components;
+mod extension_indexed_docs_provider;
+mod extension_registration_hooks;
+mod extension_slash_command;
 mod extension_suggest;
 mod extension_version_selector;
 
+#[cfg(test)]
+mod extension_store_test;
+
+pub use extension_registration_hooks::ConcreteExtensionRegistrationHooks;
+
 use std::ops::DerefMut;
 use std::sync::OnceLock;
 use std::time::Duration;

crates/indexed_docs/Cargo.toml 🔗

@@ -30,6 +30,7 @@ paths.workspace = true
 serde.workspace = true
 strum.workspace = true
 util.workspace = true
+extension_host.workspace = true
 
 [dev-dependencies]
 indoc.workspace = true

crates/indexed_docs/src/providers/rustdoc.rs 🔗

@@ -2,6 +2,7 @@ mod item;
 mod to_markdown;
 
 use cargo_metadata::MetadataCommand;
+use extension_host::DocsDatabase;
 use futures::future::BoxFuture;
 pub use item::*;
 use parking_lot::RwLock;
@@ -208,7 +209,7 @@ impl IndexedDocsProvider for DocsDotRsProvider {
 
 async fn index_rustdoc(
     package: PackageName,
-    database: Arc<IndexedDocsDatabase>,
+    database: Arc<dyn DocsDatabase>,
     fetch_page: impl Fn(&PackageName, Option<&RustdocItem>) -> BoxFuture<'static, Result<Option<String>>>
         + Send
         + Sync,

crates/indexed_docs/src/store.rs 🔗

@@ -324,8 +324,10 @@ impl IndexedDocsDatabase {
             Ok(any)
         })
     }
+}
 
-    pub fn insert(&self, key: String, docs: String) -> Task<Result<()>> {
+impl extension_host::DocsDatabase for IndexedDocsDatabase {
+    fn insert(&self, key: String, docs: String) -> Task<Result<()>> {
         let env = self.env.clone();
         let entries = self.entries;
 

crates/language/src/language_registry.rs 🔗

@@ -288,14 +288,14 @@ impl LanguageRegistry {
             config.name.clone(),
             config.grammar.clone(),
             config.matcher.clone(),
-            move || {
+            Arc::new(move || {
                 Ok(LoadedLanguage {
                     config: config.clone(),
                     queries: Default::default(),
                     toolchain_provider: None,
                     context_provider: None,
                 })
-            },
+            }),
         )
     }
 
@@ -436,9 +436,8 @@ impl LanguageRegistry {
         name: LanguageName,
         grammar_name: Option<Arc<str>>,
         matcher: LanguageMatcher,
-        load: impl Fn() -> Result<LoadedLanguage> + 'static + Send + Sync,
+        load: Arc<dyn Fn() -> Result<LoadedLanguage> + 'static + Send + Sync>,
     ) {
-        let load = Arc::new(load);
         let state = &mut *self.state.write();
 
         for existing_language in &mut state.available_languages {

crates/languages/src/lib.rs 🔗

@@ -61,14 +61,14 @@ pub fn init(languages: Arc<LanguageRegistry>, node_runtime: NodeRuntime, cx: &mu
                 config.name.clone(),
                 config.grammar.clone(),
                 config.matcher.clone(),
-                move || {
+                Arc::new(move || {
                     Ok(LoadedLanguage {
                         config: config.clone(),
                         queries: load_queries($name),
                         context_provider: None,
                         toolchain_provider: None,
                     })
-                },
+                }),
             );
         };
         ($name:literal, $adapters:expr) => {
@@ -82,14 +82,14 @@ pub fn init(languages: Arc<LanguageRegistry>, node_runtime: NodeRuntime, cx: &mu
                 config.name.clone(),
                 config.grammar.clone(),
                 config.matcher.clone(),
-                move || {
+                Arc::new(move || {
                     Ok(LoadedLanguage {
                         config: config.clone(),
                         queries: load_queries($name),
                         context_provider: None,
                         toolchain_provider: None,
                     })
-                },
+                }),
             );
         };
         ($name:literal, $adapters:expr, $context_provider:expr) => {
@@ -103,14 +103,14 @@ pub fn init(languages: Arc<LanguageRegistry>, node_runtime: NodeRuntime, cx: &mu
                 config.name.clone(),
                 config.grammar.clone(),
                 config.matcher.clone(),
-                move || {
+                Arc::new(move || {
                     Ok(LoadedLanguage {
                         config: config.clone(),
                         queries: load_queries($name),
                         context_provider: Some(Arc::new($context_provider)),
                         toolchain_provider: None,
                     })
-                },
+                }),
             );
         };
         ($name:literal, $adapters:expr, $context_provider:expr, $toolchain_provider:expr) => {
@@ -124,14 +124,14 @@ pub fn init(languages: Arc<LanguageRegistry>, node_runtime: NodeRuntime, cx: &mu
                 config.name.clone(),
                 config.grammar.clone(),
                 config.matcher.clone(),
-                move || {
+                Arc::new(move || {
                     Ok(LoadedLanguage {
                         config: config.clone(),
                         queries: load_queries($name),
                         context_provider: Some(Arc::new($context_provider)),
                         toolchain_provider: Some($toolchain_provider),
                     })
-                },
+                }),
             );
         };
     }

crates/paths/src/paths.rs 🔗

@@ -165,6 +165,22 @@ pub fn extensions_dir() -> &'static PathBuf {
     EXTENSIONS_DIR.get_or_init(|| support_dir().join("extensions"))
 }
 
+/// Returns the path to the extensions directory.
+///
+/// This is where installed extensions are stored on a remote.
+pub fn remote_extensions_dir() -> &'static PathBuf {
+    static EXTENSIONS_DIR: OnceLock<PathBuf> = OnceLock::new();
+    EXTENSIONS_DIR.get_or_init(|| support_dir().join("remote_extensions"))
+}
+
+/// Returns the path to the extensions directory.
+///
+/// This is where installed extensions are stored on a remote.
+pub fn remote_extensions_uploads_dir() -> &'static PathBuf {
+    static UPLOAD_DIR: OnceLock<PathBuf> = OnceLock::new();
+    UPLOAD_DIR.get_or_init(|| remote_extensions_dir().join("uploads"))
+}
+
 /// Returns the path to the themes directory.
 ///
 /// This is where themes that are not provided by extensions are stored.

crates/remote/src/ssh_session.rs 🔗

@@ -990,6 +990,19 @@ impl SshRemoteClient {
             .map(|ssh_connection| ssh_connection.ssh_args())
     }
 
+    pub fn upload_directory(
+        &self,
+        src_path: PathBuf,
+        dest_path: PathBuf,
+        cx: &AppContext,
+    ) -> Task<Result<()>> {
+        let state = self.state.lock();
+        let Some(connection) = state.as_ref().and_then(|state| state.ssh_connection()) else {
+            return Task::ready(Err(anyhow!("no ssh connection")));
+        };
+        connection.upload_directory(src_path, dest_path, cx)
+    }
+
     pub fn proto_client(&self) -> AnyProtoClient {
         self.client.clone().into()
     }
@@ -1194,6 +1207,12 @@ trait RemoteConnection: Send + Sync {
         delegate: Arc<dyn SshClientDelegate>,
         cx: &mut AsyncAppContext,
     ) -> Task<Result<i32>>;
+    fn upload_directory(
+        &self,
+        src_path: PathBuf,
+        dest_path: PathBuf,
+        cx: &AppContext,
+    ) -> Task<Result<()>>;
     async fn kill(&self) -> Result<()>;
     fn has_been_killed(&self) -> bool;
     fn ssh_args(&self) -> Vec<String>;
@@ -1232,6 +1251,49 @@ impl RemoteConnection for SshRemoteConnection {
     fn connection_options(&self) -> SshConnectionOptions {
         self.socket.connection_options.clone()
     }
+
+    fn upload_directory(
+        &self,
+        src_path: PathBuf,
+        dest_path: PathBuf,
+        cx: &AppContext,
+    ) -> Task<Result<()>> {
+        let mut command = process::Command::new("scp");
+        let output = self
+            .socket
+            .ssh_options(&mut command)
+            .args(
+                self.socket
+                    .connection_options
+                    .port
+                    .map(|port| vec!["-P".to_string(), port.to_string()])
+                    .unwrap_or_default(),
+            )
+            .arg("-r")
+            .arg(&src_path)
+            .arg(format!(
+                "{}:{}",
+                self.socket.connection_options.scp_url(),
+                dest_path.display()
+            ))
+            .output();
+
+        cx.background_executor().spawn(async move {
+            let output = output.await?;
+
+            if !output.status.success() {
+                return Err(anyhow!(
+                    "failed to upload directory {} -> {}: {}",
+                    src_path.display(),
+                    dest_path.display(),
+                    String::from_utf8_lossy(&output.stderr)
+                ));
+            }
+
+            Ok(())
+        })
+    }
+
     fn start_proxy(
         &self,
         unique_identifier: String,
@@ -2286,7 +2348,7 @@ mod fake {
         },
         select_biased, FutureExt, SinkExt, StreamExt,
     };
-    use gpui::{AsyncAppContext, SemanticVersion, Task, TestAppContext};
+    use gpui::{AppContext, AsyncAppContext, SemanticVersion, Task, TestAppContext};
     use release_channel::ReleaseChannel;
     use rpc::proto::Envelope;
 
@@ -2330,6 +2392,14 @@ mod fake {
         fn ssh_args(&self) -> Vec<String> {
             Vec::new()
         }
+        fn upload_directory(
+            &self,
+            _src_path: PathBuf,
+            _dest_path: PathBuf,
+            _cx: &AppContext,
+        ) -> Task<Result<()>> {
+            unreachable!()
+        }
 
         fn connection_options(&self) -> SshConnectionOptions {
             self.connection_options.clone()

crates/remote_server/src/headless_project.rs 🔗

@@ -78,7 +78,7 @@ impl HeadlessProject {
         });
         let prettier_store = cx.new_model(|cx| {
             PrettierStore::new(
-                node_runtime,
+                node_runtime.clone(),
                 fs.clone(),
                 languages.clone(),
                 worktree_store.clone(),
@@ -124,7 +124,7 @@ impl HeadlessProject {
                 toolchain_store.clone(),
                 environment,
                 languages.clone(),
-                http_client,
+                http_client.clone(),
                 fs.clone(),
                 cx,
             );

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -1187,6 +1187,9 @@ pub async fn init_test(
     cx.update(|cx| {
         release_channel::init(SemanticVersion::default(), cx);
     });
+    server_cx.update(|cx| {
+        release_channel::init(SemanticVersion::default(), cx);
+    });
     init_logger();
 
     let (opts, ssh_server_client) = SshRemoteClient::fake_server(cx, server_cx);

crates/remote_server/src/unix.rs 🔗

@@ -14,6 +14,7 @@ use node_runtime::{NodeBinaryOptions, NodeRuntime};
 use paths::logs_dir;
 use project::project_settings::ProjectSettings;
 
+use release_channel::AppVersion;
 use remote::proxy::ProxyLaunchError;
 use remote::ssh_session::ChannelClient;
 use remote::{
@@ -377,6 +378,8 @@ fn init_paths() -> anyhow::Result<()> {
         paths::languages_dir(),
         paths::logs_dir(),
         paths::temp_dir(),
+        paths::remote_extensions_dir(),
+        paths::remote_extensions_uploads_dir(),
     ]
     .iter()
     {
@@ -418,6 +421,9 @@ pub fn execute_run(
     let git_hosting_provider_registry = Arc::new(GitHostingProviderRegistry::new());
     gpui::App::headless().run(move |cx| {
         settings::init(cx);
+        let app_version = AppVersion::init(env!("ZED_PKG_VERSION"));
+        release_channel::init(app_version, cx);
+
         HeadlessProject::init(cx);
 
         log::info!("gpui app started, initializing server");

crates/workspace/src/pane_group.rs 🔗

@@ -357,7 +357,7 @@ impl PaneAxis {
 
     pub fn load(axis: Axis, members: Vec<Member>, flexes: Option<Vec<f32>>) -> Self {
         let flexes = flexes.unwrap_or_else(|| vec![1.; members.len()]);
-        debug_assert!(members.len() == flexes.len());
+        // debug_assert!(members.len() == flexes.len());
 
         let flexes = Arc::new(Mutex::new(flexes));
         let bounding_boxes = Arc::new(Mutex::new(vec![None; members.len()]));

crates/zed/Cargo.toml 🔗

@@ -15,6 +15,7 @@ name = "zed"
 path = "src/main.rs"
 
 [dependencies]
+assistant_slash_command.workspace = true
 activity_indicator.workspace = true
 anyhow.workspace = true
 assets.workspace = true
@@ -53,6 +54,7 @@ go_to_line.workspace = true
 gpui = { workspace = true, features = ["wayland", "x11", "font-kit"] }
 http_client.workspace = true
 image_viewer.workspace = true
+indexed_docs.workspace = true
 inline_completion_button.workspace = true
 install_cli.workspace = true
 journal.workspace = true

crates/zed/src/main.rs 🔗

@@ -7,6 +7,7 @@ mod reliability;
 mod zed;
 
 use anyhow::{anyhow, Context as _, Result};
+use assistant_slash_command::SlashCommandRegistry;
 use chrono::Offset;
 use clap::{command, Parser};
 use cli::FORCE_CLI_MODE_ENV_VAR_NAME;
@@ -23,6 +24,7 @@ use gpui::{
     VisualContext,
 };
 use http_client::{read_proxy_from_env, Uri};
+use indexed_docs::IndexedDocsRegistry;
 use language::LanguageRegistry;
 use log::LevelFilter;
 use reqwest_client::ReqwestClient;
@@ -39,6 +41,7 @@ use settings::{
 };
 use simplelog::ConfigBuilder;
 use smol::process::Command;
+use snippet_provider::SnippetRegistry;
 use std::{
     env,
     fs::OpenOptions,
@@ -402,12 +405,19 @@ fn main() {
             app_state.client.telemetry().clone(),
             cx,
         );
+        let api = extensions_ui::ConcreteExtensionRegistrationHooks::new(
+            ThemeRegistry::global(cx),
+            SlashCommandRegistry::global(cx),
+            IndexedDocsRegistry::global(cx),
+            SnippetRegistry::global(cx),
+            app_state.languages.clone(),
+            cx,
+        );
         extension_host::init(
+            api,
             app_state.fs.clone(),
             app_state.client.clone(),
             app_state.node_runtime.clone(),
-            app_state.languages.clone(),
-            ThemeRegistry::global(cx),
             cx,
         );
         recent_projects::init(cx);

crates/zed/src/reliability.rs 🔗

@@ -495,10 +495,7 @@ async fn upload_panic(
 ) -> Result<bool> {
     *most_recent_panic = Some((panic.panicked_on, panic.payload.clone()));
 
-    let json_bytes = serde_json::to_vec(&PanicRequest {
-        panic: panic.clone(),
-    })
-    .unwrap();
+    let json_bytes = serde_json::to_vec(&PanicRequest { panic }).unwrap();
 
     let Some(checksum) = client::telemetry::calculate_json_checksum(&json_bytes) else {
         return Ok(false);