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

This PR adds an `zed: Install Local Extension` action, which lets you
select a path to a folder containing a Zed extension, and install that .
When you select a directory, the extension will be compiled (both the
Tree-sitter grammars and the Rust code for the extension itself) and
installed as a Zed extension, using a symlink.

### Details

A few dependencies are needed to build an extension:
* The Rust `wasm32-wasi` target. This is automatically installed if
needed via `rustup`.
* A wasi-preview1 adapter WASM module, for building WASM components with
Rust. This is automatically downloaded if needed from a `wasmtime`
GitHub release
* For building Tree-sitter parsers, a distribution of `wasi-sdk`. This
is automatically downloaded if needed from a `wasi-sdk` GitHub release.

The downloaded artifacts are cached in a support directory called
`Zed/extensions/build`.

### Tasks

UX

* [x] Show local extensions in the Extensions view
* [x] Provide a button for recompiling a linked extension
* [x] Make this action discoverable by adding a button for it on the
Extensions view
* [ ] Surface errors (don't just write them to the Zed log)

Packaging

* [ ] Create a separate executable that performs the extension
compilation. We'll switch the packaging system in our
[extensions](https://github.com/zed-industries/extensions) repo to use
this binary, so that there is one canonical definition of how to
build/package an extensions.

### Release Notes:

- N/A

---------

Co-authored-by: Marshall <marshall@zed.dev>
Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
This commit is contained in:
Max Brunsfeld 2024-03-06 15:35:22 -08:00 committed by GitHub
parent e273198ada
commit 675ae24964
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1662 additions and 763 deletions

26
Cargo.lock generated
View File

@ -3543,19 +3543,24 @@ dependencies = [
"wasmparser", "wasmparser",
"wasmtime", "wasmtime",
"wasmtime-wasi", "wasmtime-wasi",
"wit-component 0.20.3",
] ]
[[package]] [[package]]
name = "extensions_ui" name = "extensions_ui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"client", "client",
"editor", "editor",
"extension", "extension",
"fuzzy",
"gpui", "gpui",
"settings", "settings",
"smallvec",
"theme", "theme",
"ui", "ui",
"util",
"workspace", "workspace",
] ]
@ -12426,7 +12431,7 @@ dependencies = [
"heck 0.4.1", "heck 0.4.1",
"wasm-metadata", "wasm-metadata",
"wit-bindgen-core", "wit-bindgen-core",
"wit-component", "wit-component 0.21.0",
] ]
[[package]] [[package]]
@ -12443,6 +12448,25 @@ dependencies = [
"wit-bindgen-rust", "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]] [[package]]
name = "wit-component" name = "wit-component"
version = "0.21.0" version = "0.21.0"

View File

@ -317,6 +317,7 @@ wasmparser = "0.121"
wasmtime = "18.0" wasmtime = "18.0"
wasmtime-wasi = "18.0" wasmtime-wasi = "18.0"
which = "6.0.0" which = "6.0.0"
wit-component = "0.20"
sys-locale = "0.3.1" sys-locale = "0.3.1"
[workspace.dependencies.windows] [workspace.dependencies.windows]

View File

@ -39,6 +39,7 @@ util.workspace = true
wasmtime = { workspace = true, features = ["async"] } wasmtime = { workspace = true, features = ["async"] }
wasmtime-wasi.workspace = true wasmtime-wasi.workspace = true
wasmparser.workspace = true wasmparser.workspace = true
wit-component.workspace = true
[dev-dependencies] [dev-dependencies]
fs = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] }

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
use crate::{ use crate::{
ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry, ExtensionManifest, build_extension::{CompileExtensionOptions, ExtensionBuilder},
ExtensionStore, GrammarManifestEntry, ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry, ExtensionIndexThemeEntry,
ExtensionManifest, ExtensionStore, GrammarManifestEntry, RELOAD_DEBOUNCE_DURATION,
}; };
use async_compression::futures::bufread::GzipEncoder; use async_compression::futures::bufread::GzipEncoder;
use collections::BTreeMap; use collections::BTreeMap;
@ -21,7 +22,7 @@ use std::{
sync::Arc, sync::Arc,
}; };
use theme::ThemeRegistry; use theme::ThemeRegistry;
use util::http::{FakeHttpClient, Response}; use util::http::{self, FakeHttpClient, Response};
#[gpui::test] #[gpui::test]
async fn test_extension_store(cx: &mut TestAppContext) { async fn test_extension_store(cx: &mut TestAppContext) {
@ -131,45 +132,49 @@ async fn test_extension_store(cx: &mut TestAppContext) {
extensions: [ extensions: [
( (
"zed-ruby".into(), "zed-ruby".into(),
ExtensionManifest { ExtensionIndexEntry {
id: "zed-ruby".into(), manifest: Arc::new(ExtensionManifest {
name: "Zed Ruby".into(), id: "zed-ruby".into(),
version: "1.0.0".into(), name: "Zed Ruby".into(),
description: None, version: "1.0.0".into(),
authors: Vec::new(), description: None,
repository: None, authors: Vec::new(),
themes: Default::default(), repository: None,
lib: Default::default(), themes: Default::default(),
languages: vec!["languages/erb".into(), "languages/ruby".into()], lib: Default::default(),
grammars: [ languages: vec!["languages/erb".into(), "languages/ruby".into()],
("embedded_template".into(), GrammarManifestEntry::default()), grammars: [
("ruby".into(), GrammarManifestEntry::default()), ("embedded_template".into(), GrammarManifestEntry::default()),
] ("ruby".into(), GrammarManifestEntry::default()),
.into_iter() ]
.collect(), .into_iter()
language_servers: BTreeMap::default(), .collect(),
} language_servers: BTreeMap::default(),
.into(), }),
dev: false,
},
), ),
( (
"zed-monokai".into(), "zed-monokai".into(),
ExtensionManifest { ExtensionIndexEntry {
id: "zed-monokai".into(), manifest: Arc::new(ExtensionManifest {
name: "Zed Monokai".into(), id: "zed-monokai".into(),
version: "2.0.0".into(), name: "Zed Monokai".into(),
description: None, version: "2.0.0".into(),
authors: vec![], description: None,
repository: None, authors: vec![],
themes: vec![ repository: None,
"themes/monokai-pro.json".into(), themes: vec![
"themes/monokai.json".into(), "themes/monokai-pro.json".into(),
], "themes/monokai.json".into(),
lib: Default::default(), ],
languages: Default::default(), lib: Default::default(),
grammars: BTreeMap::default(), languages: Default::default(),
language_servers: BTreeMap::default(), grammars: BTreeMap::default(),
} language_servers: BTreeMap::default(),
.into(), }),
dev: false,
},
), ),
] ]
.into_iter() .into_iter()
@ -205,28 +210,28 @@ async fn test_extension_store(cx: &mut TestAppContext) {
themes: [ themes: [
( (
"Monokai Dark".into(), "Monokai Dark".into(),
ExtensionIndexEntry { ExtensionIndexThemeEntry {
extension: "zed-monokai".into(), extension: "zed-monokai".into(),
path: "themes/monokai.json".into(), path: "themes/monokai.json".into(),
}, },
), ),
( (
"Monokai Light".into(), "Monokai Light".into(),
ExtensionIndexEntry { ExtensionIndexThemeEntry {
extension: "zed-monokai".into(), extension: "zed-monokai".into(),
path: "themes/monokai.json".into(), path: "themes/monokai.json".into(),
}, },
), ),
( (
"Monokai Pro Dark".into(), "Monokai Pro Dark".into(),
ExtensionIndexEntry { ExtensionIndexThemeEntry {
extension: "zed-monokai".into(), extension: "zed-monokai".into(),
path: "themes/monokai-pro.json".into(), path: "themes/monokai-pro.json".into(),
}, },
), ),
( (
"Monokai Pro Light".into(), "Monokai Pro Light".into(),
ExtensionIndexEntry { ExtensionIndexThemeEntry {
extension: "zed-monokai".into(), extension: "zed-monokai".into(),
path: "themes/monokai-pro.json".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, _| { store.read_with(cx, |store, _| {
let index = &store.extension_index; let index = &store.extension_index;
assert_eq!(index.extensions, expected_index.extensions); assert_eq!(index.extensions, expected_index.extensions);
@ -305,32 +310,34 @@ async fn test_extension_store(cx: &mut TestAppContext) {
expected_index.extensions.insert( expected_index.extensions.insert(
"zed-gruvbox".into(), "zed-gruvbox".into(),
ExtensionManifest { ExtensionIndexEntry {
id: "zed-gruvbox".into(), manifest: Arc::new(ExtensionManifest {
name: "Zed Gruvbox".into(), id: "zed-gruvbox".into(),
version: "1.0.0".into(), name: "Zed Gruvbox".into(),
description: None, version: "1.0.0".into(),
authors: vec![], description: None,
repository: None, authors: vec![],
themes: vec!["themes/gruvbox.json".into()], repository: None,
lib: Default::default(), themes: vec!["themes/gruvbox.json".into()],
languages: Default::default(), lib: Default::default(),
grammars: BTreeMap::default(), languages: Default::default(),
language_servers: BTreeMap::default(), grammars: BTreeMap::default(),
} language_servers: BTreeMap::default(),
.into(), }),
dev: false,
},
); );
expected_index.themes.insert( expected_index.themes.insert(
"Gruvbox".into(), "Gruvbox".into(),
ExtensionIndexEntry { ExtensionIndexThemeEntry {
extension: "zed-gruvbox".into(), extension: "zed-gruvbox".into(),
path: "themes/gruvbox.json".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, _| { store.read_with(cx, |store, _| {
let index = &store.extension_index; let index = &store.extension_index;
assert_eq!(index.extensions, expected_index.extensions); 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) 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.extensions.remove("zed-ruby");
expected_index.languages.remove("Ruby"); expected_index.languages.remove("Ruby");
expected_index.languages.remove("ERB"); 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) { async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
init_test(cx); init_test(cx);
let gleam_extension_dir = PathBuf::from_iter([ let root_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
env!("CARGO_MANIFEST_DIR"), .parent()
"..", .unwrap()
"..", .parent()
"extensions", .unwrap();
"gleam", let cache_dir = root_dir.join("target");
]) let gleam_extension_dir = root_dir.join("extensions").join("gleam");
.canonicalize()
.unwrap();
compile_extension("zed_gleam", &gleam_extension_dir); cx.executor().allow_parking();
ExtensionBuilder::new(cache_dir, http::client())
.compile_extension(
&gleam_extension_dir,
CompileExtensionOptions { release: false },
)
.await
.unwrap();
cx.executor().forbid_parking();
let fs = FakeFs::new(cx.executor()); let fs = FakeFs::new(cx.executor());
fs.insert_tree("/the-extension-dir", json!({ "installed": {} })) 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"); 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) { fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| { cx.update(|cx| {
let store = SettingsStore::test(cx); let store = SettingsStore::test(cx);

View File

@ -15,13 +15,17 @@ path = "src/extensions_ui.rs"
test-support = [] test-support = []
[dependencies] [dependencies]
anyhow.workspace = true
client.workspace = true client.workspace = true
editor.workspace = true editor.workspace = true
extension.workspace = true extension.workspace = true
fuzzy.workspace = true
gpui.workspace = true gpui.workspace = true
settings.workspace = true settings.workspace = true
smallvec.workspace = true
theme.workspace = true theme.workspace = true
ui.workspace = true ui.workspace = true
util.workspace = true
workspace.workspace = true workspace.workspace = true
[dev-dependencies] [dev-dependencies]

View File

@ -0,0 +1,3 @@
mod extension_card;
pub use extension_card::*;

View File

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

View File

@ -1,6 +1,10 @@
mod components;
use crate::components::ExtensionCard;
use client::telemetry::Telemetry; use client::telemetry::Telemetry;
use editor::{Editor, EditorElement, EditorStyle}; use editor::{Editor, EditorElement, EditorStyle};
use extension::{ExtensionApiResponse, ExtensionStatus, ExtensionStore}; use extension::{ExtensionApiResponse, ExtensionManifest, ExtensionStatus, ExtensionStore};
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{ use gpui::{
actions, canvas, uniform_list, AnyElement, AppContext, AvailableSpace, EventEmitter, actions, canvas, uniform_list, AnyElement, AppContext, AvailableSpace, EventEmitter,
FocusableView, FontStyle, FontWeight, InteractiveElement, KeyContext, ParentElement, Render, FocusableView, FontStyle, FontWeight, InteractiveElement, KeyContext, ParentElement, Render,
@ -8,24 +12,46 @@ use gpui::{
WindowContext, WindowContext,
}; };
use settings::Settings; use settings::Settings;
use std::ops::DerefMut;
use std::time::Duration; use std::time::Duration;
use std::{ops::Range, sync::Arc}; use std::{ops::Range, sync::Arc};
use theme::ThemeSettings; use theme::ThemeSettings;
use ui::{prelude::*, ToggleButton, Tooltip}; use ui::{prelude::*, ToggleButton, Tooltip};
use util::ResultExt as _;
use workspace::{ use workspace::{
item::{Item, ItemEvent}, item::{Item, ItemEvent},
Workspace, WorkspaceId, Workspace, WorkspaceId,
}; };
actions!(zed, [Extensions]); actions!(zed, [Extensions, InstallDevExtension]);
pub fn init(cx: &mut AppContext) { pub fn init(cx: &mut AppContext) {
cx.observe_new_views(move |workspace: &mut Workspace, _cx| { cx.observe_new_views(move |workspace: &mut Workspace, _cx| {
workspace.register_action(move |workspace, _: &Extensions, cx| { workspace
let extensions_page = ExtensionsPage::new(workspace, cx); .register_action(move |workspace, _: &Extensions, cx| {
workspace.add_item_to_active_pane(Box::new(extensions_page), 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(); .detach();
} }
@ -37,15 +63,26 @@ enum ExtensionFilter {
NotInstalled, NotInstalled,
} }
impl ExtensionFilter {
pub fn include_dev_extensions(&self) -> bool {
match self {
Self::All | Self::Installed => true,
Self::NotInstalled => false,
}
}
}
pub struct ExtensionsPage { pub struct ExtensionsPage {
list: UniformListScrollHandle, list: UniformListScrollHandle,
telemetry: Arc<Telemetry>, telemetry: Arc<Telemetry>,
is_fetching_extensions: bool, is_fetching_extensions: bool,
filter: ExtensionFilter, 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_editor: View<Editor>,
query_contains_error: bool, query_contains_error: bool,
_subscription: gpui::Subscription, _subscriptions: [gpui::Subscription; 2],
extension_fetch_task: Option<Task<()>>, extension_fetch_task: Option<Task<()>>,
} }
@ -53,7 +90,14 @@ impl ExtensionsPage {
pub fn new(workspace: &Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> { pub fn new(workspace: &Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
cx.new_view(|cx: &mut ViewContext<Self>| { cx.new_view(|cx: &mut ViewContext<Self>| {
let store = ExtensionStore::global(cx); 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 query_editor = cx.new_view(|cx| {
let mut input = Editor::single_line(cx); let mut input = Editor::single_line(cx);
@ -67,10 +111,12 @@ impl ExtensionsPage {
telemetry: workspace.client().telemetry().clone(), telemetry: workspace.client().telemetry().clone(),
is_fetching_extensions: false, is_fetching_extensions: false,
filter: ExtensionFilter::All, 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, query_contains_error: false,
extension_fetch_task: None, extension_fetch_task: None,
_subscription: subscription, _subscriptions: subscriptions,
query_editor, query_editor,
}; };
this.fetch_extensions(None, cx); 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); let extension_store = ExtensionStore::global(cx).read(cx);
self.extension_entries self.filtered_remote_extension_indices.clear();
.iter() self.filtered_remote_extension_indices.extend(
.filter(|extension| match self.filter { self.remote_extension_entries
ExtensionFilter::All => true, .iter()
ExtensionFilter::Installed => { .enumerate()
let status = extension_store.extension_status(&extension.id); .filter(|(_, extension)| match self.filter {
ExtensionFilter::All => true,
ExtensionFilter::Installed => {
let status = extension_store.extension_status(&extension.id);
matches!(status, ExtensionStatus::Installed(_))
}
ExtensionFilter::NotInstalled => {
let status = extension_store.extension_status(&extension.id);
matches!(status, ExtensionStatus::Installed(_)) matches!(status, ExtensionStatus::NotInstalled)
} }
ExtensionFilter::NotInstalled => { })
let status = extension_store.extension_status(&extension.id); .map(|(ix, _)| ix),
);
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(); cx.notify();
} }
fn uninstall_extension(&self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) { fn fetch_extensions(&mut self, search: Option<String>, cx: &mut ViewContext<Self>) {
ExtensionStore::global(cx)
.update(cx, |store, cx| store.uninstall_extension(extension_id, cx));
cx.notify();
}
fn fetch_extensions(&mut self, search: Option<&str>, cx: &mut ViewContext<Self>) {
self.is_fetching_extensions = true; self.is_fetching_extensions = true;
cx.notify(); cx.notify();
let extensions = let extension_store = ExtensionStore::global(cx);
ExtensionStore::global(cx).update(cx, |store, cx| store.fetch_extensions(search, 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 { cx.spawn(move |this, mut cx| async move {
let fetch_result = extensions.await; let dev_extensions = if let Some(search) = search {
match fetch_result { let match_candidates = dev_extensions
Ok(extensions) => this.update(&mut cx, |this, cx| { .iter()
this.extension_entries = extensions; .enumerate()
this.is_fetching_extensions = false; .map(|(ix, manifest)| StringMatchCandidate {
cx.notify(); id: ix,
}), string: manifest.name.clone(),
Err(err) => { char_bag: manifest.name.as_str().into(),
this.update(&mut cx, |this, cx| {
this.is_fetching_extensions = false;
cx.notify();
}) })
.ok(); .collect::<Vec<_>>();
Err(err) let matches = match_strings(
} &match_candidates,
} &search,
false,
match_candidates.len(),
&Default::default(),
cx.background_executor().clone(),
)
.await;
matches
.into_iter()
.map(|mat| dev_extensions[mat.candidate_id].clone())
.collect()
} else {
dev_extensions
};
let fetch_result = remote_extensions.await;
this.update(&mut cx, |this, cx| {
cx.notify();
this.dev_extension_entries = dev_extensions;
this.is_fetching_extensions = false;
this.remote_extension_entries = fetch_result?;
this.filter_extension_entries(cx);
anyhow::Ok(())
})?
}) })
.detach_and_log_err(cx); .detach_and_log_err(cx);
} }
fn render_extensions(&mut self, range: Range<usize>, cx: &mut ViewContext<Self>) -> Vec<Div> { fn render_extensions(
self.filtered_extension_entries(cx)[range] &mut self,
.iter() range: Range<usize>,
.map(|extension| self.render_entry(extension, cx)) 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() .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) let status = ExtensionStore::global(cx)
.read(cx) .read(cx)
.extension_status(&extension.id); .extension_status(&extension.id);
let upgrade_button = match status.clone() { let repository_url = extension.repository.clone();
ExtensionStatus::NotInstalled
| ExtensionStatus::Installing ExtensionCard::new()
| ExtensionStatus::Removing => None, .child(
ExtensionStatus::Installed(installed_version) => { h_flex()
if installed_version != extension.version { .justify_between()
Some( .child(
Button::new( h_flex()
SharedString::from(format!("upgrade-{}", extension.id)), .gap_2()
"Upgrade", .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({ .on_click(cx.listener({
let extension_id = extension.id.clone(); let repository_url = repository_url.clone();
let version = extension.version.clone(); move |_, _, cx| {
move |this, _, cx| { cx.open_url(&repository_url);
this.telemetry
.report_app_event("extensions: install extension".to_string());
this.install_extension(extension_id.clone(), version.clone(), cx);
} }
})) }))
.color(Color::Accent), .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx))
) })),
} 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"
},
)
.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 repository_url = extension.repository.clone();
let tooltip_text = Tooltip::text(repository_url.clone(), cx);
div().w_full().child( ExtensionCard::new()
v_flex() .child(
.w_full() h_flex()
.h(rems(7.)) .justify_between()
.p_3() .child(
.mt_4() h_flex()
.gap_2() .gap_2()
.bg(cx.theme().colors().elevated_surface_background) .items_end()
.border_1() .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium))
.border_color(cx.theme().colors().border) .child(
.rounded_md() Headline::new(format!("v{}", extension.version))
.child( .size(HeadlineSize::XSmall),
h_flex() ),
.justify_between() )
.child( .child(
h_flex() h_flex()
.gap_2() .gap_2()
.items_end() .justify_between()
.child( .children(upgrade_button)
Headline::new(extension.name.clone()) .child(install_or_uninstall_button),
.size(HeadlineSize::Medium), ),
) )
.child( .child(
Headline::new(format!("v{}", extension.version)) h_flex()
.size(HeadlineSize::XSmall), .justify_between()
), .child(
) Label::new(format!(
.child( "{}: {}",
h_flex() if extension.authors.len() > 1 {
.gap_2() "Authors"
.justify_between() } else {
.children(upgrade_button) "Author"
.child(install_or_uninstall_button), },
), extension.authors.join(", ")
) ))
.child( .size(LabelSize::Small),
h_flex() )
.justify_between() .child(
.child( Label::new(format!("Downloads: {}", extension.download_count))
Label::new(format!(
"{}: {}",
if extension.authors.len() > 1 {
"Authors"
} else {
"Author"
},
extension.authors.join(", ")
))
.size(LabelSize::Small), .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( .icon_color(Color::Accent)
Label::new(format!("Downloads: {}", extension.download_count)) .icon_size(IconSize::Small)
.size(LabelSize::Small), .style(ButtonStyle::Filled)
), .on_click(cx.listener({
) let repository_url = repository_url.clone();
.child( move |_, _, cx| {
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| {
cx.open_url(&repository_url); 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 { fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
@ -394,32 +564,36 @@ impl ExtensionsPage {
) { ) {
if let editor::EditorEvent::Edited = event { if let editor::EditorEvent::Edited = event {
self.query_contains_error = false; self.query_contains_error = false;
self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move { self.fetch_extensions_debounced(cx);
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();
}));
} }
} }
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> { pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
let search = self.query_editor.read(cx).text(cx); let search = self.query_editor.read(cx).text(cx);
if search.trim().is_empty() { if search.trim().is_empty() {
@ -479,7 +653,17 @@ impl Render for ExtensionsPage {
.child( .child(
h_flex() h_flex()
.w_full() .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( .child(
h_flex() h_flex()
@ -494,8 +678,9 @@ impl Render for ExtensionsPage {
.style(ButtonStyle::Filled) .style(ButtonStyle::Filled)
.size(ButtonSize::Large) .size(ButtonSize::Large)
.selected(self.filter == ExtensionFilter::All) .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 = ExtensionFilter::All;
this.filter_extension_entries(cx);
})) }))
.tooltip(move |cx| { .tooltip(move |cx| {
Tooltip::text("Show all extensions", cx) Tooltip::text("Show all extensions", cx)
@ -507,8 +692,9 @@ impl Render for ExtensionsPage {
.style(ButtonStyle::Filled) .style(ButtonStyle::Filled)
.size(ButtonSize::Large) .size(ButtonSize::Large)
.selected(self.filter == ExtensionFilter::Installed) .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 = ExtensionFilter::Installed;
this.filter_extension_entries(cx);
})) }))
.tooltip(move |cx| { .tooltip(move |cx| {
Tooltip::text("Show installed extensions", cx) Tooltip::text("Show installed extensions", cx)
@ -520,8 +706,9 @@ impl Render for ExtensionsPage {
.style(ButtonStyle::Filled) .style(ButtonStyle::Filled)
.size(ButtonSize::Large) .size(ButtonSize::Large)
.selected(self.filter == ExtensionFilter::NotInstalled) .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 = ExtensionFilter::NotInstalled;
this.filter_extension_entries(cx);
})) }))
.tooltip(move |cx| { .tooltip(move |cx| {
Tooltip::text("Show not installed extensions", 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| { .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| {
let entries = self.filtered_extension_entries(cx); let mut count = self.filtered_remote_extension_indices.len();
if entries.is_empty() { 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)); return this.py_4().child(self.render_empty_state(cx));
} }
@ -541,12 +732,11 @@ impl Render for ExtensionsPage {
canvas({ canvas({
let view = cx.view().clone(); let view = cx.view().clone();
let scroll_handle = self.list.clone(); let scroll_handle = self.list.clone();
let item_count = entries.len();
move |bounds, cx| { move |bounds, cx| {
uniform_list::<_, Div, _>( uniform_list::<_, ExtensionCard, _>(
view, view,
"entries", "entries",
item_count, count,
Self::render_extensions, Self::render_extensions,
) )
.size_full() .size_full()

View File

@ -43,6 +43,7 @@ use std::ffi::OsStr;
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait Fs: Send + Sync { pub trait Fs: Send + Sync {
async fn create_dir(&self, path: &Path) -> Result<()>; 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(&self, path: &Path, options: CreateOptions) -> Result<()>;
async fn create_file_with( async fn create_file_with(
&self, &self,
@ -124,6 +125,16 @@ impl Fs for RealFs {
Ok(smol::fs::create_dir_all(path).await?) 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<()> { async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()> {
let mut open_options = smol::fs::OpenOptions::new(); let mut open_options = smol::fs::OpenOptions::new();
open_options.write(true).create(true); open_options.write(true).create(true);
@ -994,6 +1005,25 @@ impl Fs for FakeFs {
Ok(()) 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( async fn create_file_with(
&self, &self,
path: &Path, path: &Path,
@ -1503,8 +1533,9 @@ mod tests {
] ]
); );
fs.insert_symlink("/root/dir2/link-to-dir3", "./dir3".into()) fs.create_symlink("/root/dir2/link-to-dir3".as_ref(), "./dir3".into())
.await; .await
.unwrap();
assert_eq!( assert_eq!(
fs.canonicalize("/root/dir2/link-to-dir3".as_ref()) fs.canonicalize("/root/dir2/link-to-dir3".as_ref())

View File

@ -348,6 +348,12 @@ impl BackgroundExecutor {
self.dispatcher.as_test().unwrap().allow_parking(); 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 /// in tests, returns the rng used by the dispatcher and seeded by the `SEED` environment variable
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn rng(&self) -> StdRng { pub fn rng(&self) -> StdRng {

View File

@ -128,6 +128,10 @@ impl TestDispatcher {
self.state.lock().allow_parking = true self.state.lock().allow_parking = true
} }
pub fn forbid_parking(&self) {
self.state.lock().allow_parking = false
}
pub fn start_waiting(&self) { pub fn start_waiting(&self) {
self.state.lock().waiting_backtrace = Some(Backtrace::new_unresolved()); self.state.lock().waiting_backtrace = Some(Backtrace::new_unresolved());
} }

View File

@ -207,8 +207,12 @@ async fn test_circular_symlinks(cx: &mut TestAppContext) {
}), }),
) )
.await; .await;
fs.insert_symlink("/root/lib/a/lib", "..".into()).await; fs.create_symlink("/root/lib/a/lib".as_ref(), "..".into())
fs.insert_symlink("/root/lib/b/lib", "..".into()).await; .await
.unwrap();
fs.create_symlink("/root/lib/b/lib".as_ref(), "..".into())
.await
.unwrap();
let tree = Worktree::local( let tree = Worktree::local(
build_client(cx), build_client(cx),
@ -303,10 +307,12 @@ async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) {
.await; .await;
// These symlinks point to directories outside of the worktree's root, dir1. // These symlinks point to directories outside of the worktree's root, dir1.
fs.insert_symlink("/root/dir1/deps/dep-dir2", "../../dir2".into()) fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into())
.await; .await
fs.insert_symlink("/root/dir1/deps/dep-dir3", "../../dir3".into()) .unwrap();
.await; fs.create_symlink("/root/dir1/deps/dep-dir3".as_ref(), "../../dir3".into())
.await
.unwrap();
let tree = Worktree::local( let tree = Worktree::local(
build_client(cx), build_client(cx),

1
extensions/gleam/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
grammars

View File

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

View File

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