mirror of
https://github.com/zed-industries/zed.git
synced 2024-11-07 20:39:04 +03:00
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:
parent
e273198ada
commit
675ae24964
26
Cargo.lock
generated
26
Cargo.lock
generated
@ -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"
|
||||||
|
@ -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]
|
||||||
|
@ -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"] }
|
||||||
|
375
crates/extension/src/build_extension.rs
Normal file
375
crates/extension/src/build_extension.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
72
crates/extension/src/extension_manifest.rs
Normal file
72
crates/extension/src/extension_manifest.rs
Normal 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
@ -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);
|
||||||
|
@ -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]
|
||||||
|
3
crates/extensions_ui/src/components.rs
Normal file
3
crates/extensions_ui/src/components.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mod extension_card;
|
||||||
|
|
||||||
|
pub use extension_card::*;
|
40
crates/extensions_ui/src/components/extension_card.rs
Normal file
40
crates/extensions_ui/src/components/extension_card.rs
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
||||||
|
@ -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())
|
||||||
|
@ -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 {
|
||||||
|
@ -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());
|
||||||
}
|
}
|
||||||
|
@ -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
1
extensions/gleam/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
grammars
|
@ -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]
|
|
||||||
|
@ -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() {}
|
|
Loading…
Reference in New Issue
Block a user