mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b396ae6ab2 | ||
|
|
26e3ecc3a0 | ||
|
|
456b329d73 | ||
|
|
ecda2223eb | ||
|
|
b0f65769c0 | ||
|
|
9bf28dd451 | ||
|
|
3025b274f5 | ||
|
|
bdf94926ec | ||
|
|
893a549b23 | ||
|
|
3ef247c55f | ||
|
|
10a3ac92cf | ||
|
|
43c85ce8f3 | ||
|
|
ac436160b5 | ||
|
|
a3fe841e37 | ||
|
|
ed4a8b50c1 | ||
|
|
e7952ce3d8 | ||
|
|
a0595f5c12 | ||
|
|
090a1cd6df | ||
|
|
2b86dd2d9f | ||
|
|
566190e94b | ||
|
|
8869f1ce1c | ||
|
|
04fe65328e | ||
|
|
0d80e2c05a | ||
|
|
ee5d912157 | ||
|
|
4557811ef8 | ||
|
|
47f7e07a20 |
No files matched your search
Generated
+81
-39
@@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3001,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4071,7 +4071,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4094,7 +4094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4106,9 +4106,20 @@ dependencies = [
|
||||
"tree-sitter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4123,7 +4134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4132,7 +4143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4149,7 +4160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4163,7 +4174,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4181,7 +4192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4207,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4220,7 +4231,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4240,7 +4251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4252,7 +4263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4265,7 +4276,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"criterion",
|
||||
@@ -4326,7 +4337,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4344,7 +4355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4366,7 +4377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4383,7 +4394,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4439,7 +4450,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4465,7 +4476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4488,7 +4499,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ide-core",
|
||||
@@ -4504,11 +4515,12 @@ dependencies = [
|
||||
"tracing",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4521,7 +4533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4538,7 +4550,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4561,7 +4573,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4580,7 +4592,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4600,7 +4612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4613,7 +4625,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4635,7 +4647,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4650,7 +4662,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4663,7 +4675,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4676,7 +4688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4697,7 +4709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4718,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4740,7 +4752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4756,7 +4768,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6297,7 +6309,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6307,6 +6319,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"ide-agent",
|
||||
"ide-analysis",
|
||||
"ide-android",
|
||||
"ide-build",
|
||||
"ide-clipboard",
|
||||
"ide-core",
|
||||
@@ -6361,6 +6374,7 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tauri-specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
@@ -8362,7 +8376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10305,6 +10319,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.6.1"
|
||||
@@ -10825,6 +10845,28 @@ dependencies = [
|
||||
"wasmtime",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "258.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b912cfe5696dfaae736e344fc82082dbd63609abbaeca3926466c0b10d9fecc"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"leb128fmt",
|
||||
"memchr",
|
||||
"unicode-width",
|
||||
"wasm-encoder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wat"
|
||||
version = "1.258.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3069154948f5737a8bcd5c55dcdf2c2b59ca776057732e49821167bde268ec6e"
|
||||
dependencies = [
|
||||
"wast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ide-android"
|
||||
description = "Android devices and logcat for the Logcat tool window, driven through the adb CLI"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
specta.workspace = true
|
||||
which = "8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Android devices and their logs, driven through the `adb` CLI (like git is
|
||||
//! driven through its CLI).
|
||||
//!
|
||||
//! The Logcat tool window needs two things from a device: that it is there, and
|
||||
//! what it prints. Both come from `adb`, which the Android SDK installs — the
|
||||
//! IDE does not bundle it.
|
||||
//!
|
||||
//! Every function blocks; callers run them off the UI / command threads.
|
||||
|
||||
mod model;
|
||||
mod process;
|
||||
|
||||
pub use model::*;
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// `adb devices` is quick unless it starts the daemon, which can take a moment.
|
||||
const DEVICES: Duration = Duration::from_secs(20);
|
||||
/// `adb logcat -c` only truncates the buffers.
|
||||
const CLEAR: Duration = Duration::from_secs(15);
|
||||
/// How much of the buffer to show before following, so opening the window is not empty.
|
||||
const TAIL_LINES: u32 = 200;
|
||||
/// Logcat can print thousands of lines a second: batches keep the IPC channel sane,
|
||||
/// while `IDLE` makes sure a quiet stream still shows its last line promptly.
|
||||
const BATCH_IDLE: Duration = Duration::from_millis(60);
|
||||
const BATCH_MAX: usize = 300;
|
||||
|
||||
fn exe(name: &str) -> String {
|
||||
if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() }
|
||||
}
|
||||
|
||||
/// Android SDK locations, most specific first: the environment, then the usual
|
||||
/// per-platform installs (a GUI app on macOS does not see a shell's `ANDROID_HOME`).
|
||||
fn sdk_roots() -> Vec<PathBuf> {
|
||||
let mut roots: Vec<PathBuf> = ["ANDROID_HOME", "ANDROID_SDK_ROOT"]
|
||||
.iter()
|
||||
.filter_map(|key| std::env::var_os(key).map(PathBuf::from))
|
||||
.collect();
|
||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from);
|
||||
if let Some(home) = home.filter(|_| !cfg!(windows)) {
|
||||
roots.push(home.join("Library/Android/sdk"));
|
||||
roots.push(home.join("Android/Sdk"));
|
||||
}
|
||||
if cfg!(windows)
|
||||
&& let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
|
||||
{
|
||||
roots.push(local.join("Android/Sdk"));
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// Locates the adb executable once per process; `OPENIDEA_ADB` overrides.
|
||||
pub fn adb_executable() -> Option<PathBuf> {
|
||||
static ADB: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
ADB.get_or_init(|| {
|
||||
if let Some(explicit) = std::env::var_os("OPENIDEA_ADB").map(PathBuf::from).filter(|p| p.is_file()) {
|
||||
return Some(explicit);
|
||||
}
|
||||
let name = exe("adb");
|
||||
if let Some(found) =
|
||||
sdk_roots().into_iter().map(|root| root.join("platform-tools").join(&name)).find(|p| p.is_file())
|
||||
{
|
||||
return Some(found);
|
||||
}
|
||||
if let Ok(found) = which::which("adb") {
|
||||
return Some(found);
|
||||
}
|
||||
let mut candidates: Vec<PathBuf> = if cfg!(windows) {
|
||||
vec![PathBuf::from(r"C:\platform-tools\adb.exe")]
|
||||
} else {
|
||||
["/usr/local/bin/adb", "/opt/homebrew/bin/adb", "/usr/bin/adb"].iter().map(PathBuf::from).collect()
|
||||
};
|
||||
candidates.extend([
|
||||
PathBuf::from("/opt/android-sdk/platform-tools/adb"),
|
||||
PathBuf::from("/usr/lib/android-sdk/platform-tools/adb"),
|
||||
]);
|
||||
candidates.into_iter().find(|p: &PathBuf| p.is_file())
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn executable() -> IdeResult<PathBuf> {
|
||||
adb_executable().ok_or_else(|| {
|
||||
IdeError::unavailable(
|
||||
"adb was not found: install the Android SDK platform-tools, or set ANDROID_HOME (or OPENIDEA_ADB)",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The directory holding adb, for command lines that expect `adb` on `PATH`.
|
||||
pub fn platform_tools_dir() -> Option<&'static Path> {
|
||||
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
DIR.get_or_init(|| adb_executable().as_deref().and_then(Path::parent).map(Path::to_path_buf)).as_deref()
|
||||
}
|
||||
|
||||
/// Connected devices, online ones first; starting the adb daemon when needed.
|
||||
pub fn devices() -> IdeResult<Vec<AndroidDevice>> {
|
||||
let adb = executable()?;
|
||||
let mut cmd = Command::new(adb);
|
||||
cmd.args(["devices", "-l"]);
|
||||
let out = process::run(cmd, DEVICES, None)?;
|
||||
if !out.success {
|
||||
let message = out.stderr.trim();
|
||||
return Err(IdeError::unavailable(if message.is_empty() {
|
||||
"adb devices failed".to_owned()
|
||||
} else {
|
||||
format!("adb devices failed: {message}")
|
||||
}));
|
||||
}
|
||||
Ok(parse_devices(&out.stdout))
|
||||
}
|
||||
|
||||
/// Empties the device's log buffers (`adb logcat -c`).
|
||||
pub fn clear_log(serial: &str, cancel: &CancellationToken) -> IdeResult<()> {
|
||||
let adb = executable()?;
|
||||
let mut cmd = Command::new(adb);
|
||||
cmd.args(["-s", serial, "logcat", "-c"]);
|
||||
let out = process::run(cmd, CLEAR, Some(cancel))?;
|
||||
if out.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::unavailable(format!("adb logcat -c failed: {}", out.stderr.trim())))
|
||||
}
|
||||
}
|
||||
|
||||
fn command(adb: &Path, serial: &str, tail: Option<u32>) -> Command {
|
||||
let mut cmd = Command::new(adb);
|
||||
cmd.args(["-s", serial, "logcat", "-v", "threadtime"]);
|
||||
if let Some(lines) = tail {
|
||||
// Recent history first, then follow: `-T <count>` is lines, not an exit.
|
||||
cmd.arg("-T").arg(lines.to_string());
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Follows `serial`'s log until `cancel` fires, handing lines over in batches.
|
||||
/// With `clear`, the buffers are emptied first so the window starts clean.
|
||||
pub fn logcat(
|
||||
serial: &str,
|
||||
clear: bool,
|
||||
cancel: &CancellationToken,
|
||||
on_batch: &mut dyn FnMut(Vec<LogcatLine>),
|
||||
) -> IdeResult<()> {
|
||||
let adb = executable()?;
|
||||
if clear {
|
||||
clear_log(serial, cancel)?;
|
||||
}
|
||||
let mut parse = |line: &str| Some(parse_line(line));
|
||||
let (ok, stderr) = match process::stream_batched(
|
||||
command(&adb, serial, Some(TAIL_LINES)),
|
||||
cancel,
|
||||
BATCH_IDLE,
|
||||
BATCH_MAX,
|
||||
&mut parse,
|
||||
on_batch,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
// The window closed or switched devices: stopping is the expected end.
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if ok || cancel.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
// `-T` is old (API 21) but adb forwards logcat options it does not know on
|
||||
// very old devices; retry without the history rather than showing nothing.
|
||||
let unsupported = stderr.iter().any(|line| {
|
||||
line.contains("Unrecognized option") || line.contains("unknown option") || line.contains("Invalid option")
|
||||
});
|
||||
if unsupported {
|
||||
let (ok, stderr) = match process::stream_batched(
|
||||
command(&adb, serial, None),
|
||||
cancel,
|
||||
BATCH_IDLE,
|
||||
BATCH_MAX,
|
||||
&mut parse,
|
||||
on_batch,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if ok || cancel.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(failure(serial, &stderr));
|
||||
}
|
||||
Err(failure(serial, &stderr))
|
||||
}
|
||||
|
||||
fn failure(serial: &str, stderr: &[String]) -> IdeError {
|
||||
let message = stderr.join("\n");
|
||||
IdeError::unavailable(if message.trim().is_empty() {
|
||||
format!("adb logcat on {serial} stopped")
|
||||
} else {
|
||||
format!("adb logcat on {serial} stopped: {message}")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adb_candidates_are_absolute() {
|
||||
for root in sdk_roots() {
|
||||
assert!(root.is_absolute(), "{root:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executable_is_absolute_when_found() {
|
||||
if let Some(adb) = adb_executable() {
|
||||
assert!(adb.is_absolute());
|
||||
assert!(adb.is_file());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! What the Logcat tool window shows: the devices `adb` reports and the lines
|
||||
//! `adb logcat` prints, parsed from its `threadtime` format.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// One entry of `adb devices -l`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AndroidDevice {
|
||||
/// `adb -s` value: a USB serial number, `emulator-5554`, or `host:port`.
|
||||
pub serial: String,
|
||||
/// `device` (usable), `offline`, `unauthorized` or `no permissions`.
|
||||
pub state: String,
|
||||
pub model: Option<String>,
|
||||
pub product: Option<String>,
|
||||
/// The device's own name from the build (`ro.product.device`).
|
||||
pub device: Option<String>,
|
||||
pub transport_id: Option<String>,
|
||||
}
|
||||
|
||||
impl AndroidDevice {
|
||||
/// Ready for `logcat` / `install`.
|
||||
pub fn is_online(&self) -> bool {
|
||||
self.state == "device"
|
||||
}
|
||||
|
||||
/// What the picker calls it: the model when known, the serial otherwise.
|
||||
pub fn label(&self) -> &str {
|
||||
self.model.as_deref().filter(|m| !m.trim().is_empty()).unwrap_or(&self.serial)
|
||||
}
|
||||
}
|
||||
|
||||
/// The single-letter logcat priority, in increasing severity.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LogcatLevel {
|
||||
Verbose,
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
Fatal,
|
||||
/// A line logcat prints outside the message format (`--------- beginning of main`).
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl LogcatLevel {
|
||||
pub fn letter(self) -> char {
|
||||
match self {
|
||||
Self::Verbose => 'V',
|
||||
Self::Debug => 'D',
|
||||
Self::Info => 'I',
|
||||
Self::Warn => 'W',
|
||||
Self::Error => 'E',
|
||||
Self::Fatal => 'F',
|
||||
Self::Unknown => '?',
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(c: char) -> Self {
|
||||
match c.to_ascii_uppercase() {
|
||||
'V' => Self::Verbose,
|
||||
'D' => Self::Debug,
|
||||
'I' => Self::Info,
|
||||
'W' => Self::Warn,
|
||||
'E' => Self::Error,
|
||||
'F' | 'A' => Self::Fatal,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One [`LogcatLevel`]-tagged line of `adb logcat -v threadtime`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogcatLine {
|
||||
/// `MM-DD HH:MM:SS.mmm`, as logcat prints it (no year, device time zone).
|
||||
pub time: String,
|
||||
pub pid: i32,
|
||||
pub tid: i32,
|
||||
pub level: LogcatLevel,
|
||||
pub tag: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl LogcatLine {
|
||||
/// A line logcat printed outside the message format, kept so nothing is lost.
|
||||
fn unparsed(text: &str) -> Self {
|
||||
Self {
|
||||
time: String::new(),
|
||||
pid: 0,
|
||||
tid: 0,
|
||||
level: LogcatLevel::Unknown,
|
||||
tag: String::new(),
|
||||
message: text.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `adb devices -l` output; the daemon's chatter on stderr is not included.
|
||||
pub fn parse_devices(output: &str) -> Vec<AndroidDevice> {
|
||||
let mut devices: Vec<AndroidDevice> = Vec::new();
|
||||
for line in output.lines() {
|
||||
let line = line.trim_end();
|
||||
if line.trim().is_empty() || line.starts_with("List of devices") || line.starts_with('*') {
|
||||
continue;
|
||||
}
|
||||
let mut fields = line.split_whitespace();
|
||||
let (Some(serial), Some(state)) = (fields.next(), fields.next()) else {
|
||||
continue;
|
||||
};
|
||||
// "no permissions (user in plugdev group; are your udev rules wrong?)"
|
||||
// is the one state that carries spaces; the rest is a `key:value` list.
|
||||
let mut device = AndroidDevice {
|
||||
serial: serial.to_owned(),
|
||||
state: state.to_owned(),
|
||||
model: None,
|
||||
product: None,
|
||||
device: None,
|
||||
transport_id: None,
|
||||
};
|
||||
if device.state == "no" {
|
||||
device.state = "no permissions".to_owned();
|
||||
devices.push(device);
|
||||
continue;
|
||||
}
|
||||
for field in fields {
|
||||
let Some((key, value)) = field.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
match key {
|
||||
"model" => device.model = Some(value.to_owned()),
|
||||
"product" => device.product = Some(value.to_owned()),
|
||||
"device" => device.device = Some(value.to_owned()),
|
||||
"transport_id" => device.transport_id = Some(value.to_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
devices.push(device);
|
||||
}
|
||||
// Online first, then by name, so the picker's default is the device in hand.
|
||||
devices.sort_by(|a, b| b.is_online().cmp(&a.is_online()).then_with(|| a.serial.cmp(&b.serial)));
|
||||
devices
|
||||
}
|
||||
|
||||
/// Parses one `logcat -v threadtime` line:
|
||||
///
|
||||
/// ```text
|
||||
/// 11-04 12:52:39.123 3048 3059 I WindowManager: displayed
|
||||
/// ```
|
||||
///
|
||||
/// Text that does not follow the format still becomes a line, so a device that
|
||||
/// prints something unexpected does not look like a dead stream.
|
||||
pub fn parse_line(text: &str) -> LogcatLine {
|
||||
let Some(line) = parse_formatted(text) else {
|
||||
return LogcatLine::unparsed(text);
|
||||
};
|
||||
line
|
||||
}
|
||||
|
||||
fn parse_formatted(text: &str) -> Option<LogcatLine> {
|
||||
// "MM-DD HH:MM:SS.mmm" is 18 bytes; logcat pads the rest with spaces.
|
||||
let stamp = text.get(..18)?;
|
||||
if !looks_like_stamp(stamp) {
|
||||
return None;
|
||||
}
|
||||
let mut rest = text.get(18..)?;
|
||||
let pid: i32 = take_field(&mut rest)?.parse().ok()?;
|
||||
let tid: i32 = take_field(&mut rest)?.parse().ok()?;
|
||||
let level = LogcatLevel::parse(take_field(&mut rest)?.chars().next()?);
|
||||
let tail = rest.trim_start();
|
||||
// "Tag : message": logcat pads the tag to 8 columns, and the tag
|
||||
// itself never contains a colon followed by a space.
|
||||
let (tag, message) = match tail.split_once(": ") {
|
||||
Some((tag, message)) => (tag.trim_end(), message),
|
||||
None => (tail.trim_end(), ""),
|
||||
};
|
||||
Some(LogcatLine { time: stamp.to_owned(), pid, tid, level, tag: tag.to_owned(), message: message.to_owned() })
|
||||
}
|
||||
|
||||
/// Consumes the next space-separated field of `rest`, leaving the remainder.
|
||||
fn take_field<'a>(rest: &mut &'a str) -> Option<&'a str> {
|
||||
*rest = rest.trim_start();
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
let field = &rest[..end];
|
||||
*rest = &rest[end..];
|
||||
Some(field)
|
||||
}
|
||||
|
||||
fn looks_like_stamp(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
bytes.len() == 18
|
||||
&& bytes[2] == b'-'
|
||||
&& bytes[5] == b' '
|
||||
&& bytes[8] == b':'
|
||||
&& bytes[11] == b':'
|
||||
&& bytes[14] == b'.'
|
||||
&& bytes.iter().enumerate().all(|(i, b)| matches!(i, 2 | 5 | 8 | 11 | 14) || b.is_ascii_digit())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_adb_devices_with_attributes() {
|
||||
let out = "\
|
||||
List of devices attached
|
||||
HT7A1B2C3D4 device product:generic model:Generic_Phone device:generic transport_id:1
|
||||
emulator-5554 offline transport_id:2
|
||||
0123456789ABCDEF unauthorized usb:1-1 transport_id:3
|
||||
|
||||
";
|
||||
let devices = parse_devices(out);
|
||||
assert_eq!(devices.len(), 3);
|
||||
// Online first, then by serial.
|
||||
assert_eq!(devices[0].serial, "HT7A1B2C3D4");
|
||||
assert_eq!(devices[0].state, "device");
|
||||
assert_eq!(devices[0].model.as_deref(), Some("Generic_Phone"));
|
||||
assert_eq!(devices[0].transport_id.as_deref(), Some("1"));
|
||||
assert!(devices[0].is_online());
|
||||
assert_eq!(devices[1].serial, "0123456789ABCDEF");
|
||||
assert_eq!(devices[1].state, "unauthorized");
|
||||
assert_eq!(devices[1].label(), "0123456789ABCDEF");
|
||||
assert_eq!(devices[2].serial, "emulator-5554");
|
||||
assert!(!devices[2].is_online());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_adb_devices_when_the_list_is_empty() {
|
||||
assert!(parse_devices("List of devices attached\n\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_no_permissions() {
|
||||
let devices = parse_devices("ABC\tno permissions (user in plugdev group; are your udev rules wrong?)\n");
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].state, "no permissions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_threadtime_line() {
|
||||
let line = parse_line("11-04 12:52:39.123 3048 3059 I WindowManager: Displayed activity");
|
||||
assert_eq!(line.time, "11-04 12:52:39.123");
|
||||
assert_eq!(line.pid, 3048);
|
||||
assert_eq!(line.tid, 3059);
|
||||
assert_eq!(line.level, LogcatLevel::Info);
|
||||
assert_eq!(line.tag, "WindowManager");
|
||||
assert_eq!(line.message, "Displayed activity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_colons_in_the_message() {
|
||||
let line = parse_line("11-04 12:52:39.123 3048 3059 E flutter : java.lang.IllegalStateException: boom");
|
||||
assert_eq!(line.tag, "flutter");
|
||||
assert_eq!(line.level, LogcatLevel::Error);
|
||||
assert_eq!(line.message, "java.lang.IllegalStateException: boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_lines_outside_the_format() {
|
||||
let line = parse_line("--------- beginning of main");
|
||||
assert_eq!(line.level, LogcatLevel::Unknown);
|
||||
assert_eq!(line.message, "--------- beginning of main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_order_by_severity() {
|
||||
assert!(LogcatLevel::Warn > LogcatLevel::Info);
|
||||
assert!(LogcatLevel::Fatal > LogcatLevel::Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Child processes of the `adb` CLI: bounded runs, and cancellable line streams
|
||||
//! that are handed over in batches (logcat is far too chatty for one message a line).
|
||||
|
||||
use ide_core::process::terminate_tree;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::io::Read;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a cancelled child gets to exit on `SIGTERM` (and flush) before `SIGKILL`.
|
||||
const KILL_GRACE: Duration = Duration::from_millis(20);
|
||||
|
||||
pub(crate) struct Output {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
fn prepare(cmd: &mut Command) {
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
ide_core::process::prepare(ide_core::process::own_group(cmd));
|
||||
}
|
||||
|
||||
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run adb: {e}")))
|
||||
}
|
||||
|
||||
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
let _ = pipe.read_to_end(&mut buf);
|
||||
ide_core::output_encoding::decode_tool_output(&buf)
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
|
||||
/// a non-zero exit is reported in [`Output::success`], not as an error.
|
||||
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let out = child.stdout.take().map(read_all);
|
||||
let err = child.stderr.take().map(read_all);
|
||||
let deadline = Instant::now() + timeout;
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break status;
|
||||
}
|
||||
if cancel.is_some_and(CancellationToken::is_cancelled) {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::unavailable("adb did not respond in time"));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
Ok(Output {
|
||||
stdout: out.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
|
||||
stderr: err.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
|
||||
success: status.success(),
|
||||
})
|
||||
}
|
||||
|
||||
enum Event {
|
||||
Line(bool, String),
|
||||
Closed,
|
||||
}
|
||||
|
||||
fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender<Event>, stdout: bool) {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
let flush = |pending: &mut Vec<u8>| {
|
||||
if !pending.is_empty() {
|
||||
let line = ide_core::output_encoding::decode_tool_output(pending);
|
||||
pending.clear();
|
||||
let _ = tx.send(Event::Line(stdout, line.trim_end().to_string()));
|
||||
}
|
||||
};
|
||||
loop {
|
||||
match pipe.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
for &b in &buf[..n] {
|
||||
if b == b'\n' {
|
||||
flush(&mut pending);
|
||||
} else if b != b'\r' {
|
||||
pending.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut pending);
|
||||
let _ = tx.send(Event::Closed);
|
||||
});
|
||||
}
|
||||
|
||||
fn flush<T>(batch: &mut Vec<T>, on_batch: &mut dyn FnMut(Vec<T>)) {
|
||||
if !batch.is_empty() {
|
||||
on_batch(std::mem::take(batch));
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd`, parsing every stdout line with `parse` and handing the results to
|
||||
/// `on_batch` — as soon as `max` are ready, or once the oldest waiting line is `idle`
|
||||
/// old, so neither a quiet nor a steady stream holds lines back. Stderr is kept as a
|
||||
/// tail for the error message. Returns whether the child exited successfully.
|
||||
pub(crate) fn stream_batched<T>(
|
||||
mut cmd: Command,
|
||||
cancel: &CancellationToken,
|
||||
idle: Duration,
|
||||
max: usize,
|
||||
mut parse: impl FnMut(&str) -> Option<T>,
|
||||
on_batch: &mut dyn FnMut(Vec<T>),
|
||||
) -> IdeResult<(bool, Vec<String>)> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let (tx, rx) = mpsc::channel::<Event>();
|
||||
let mut open = 0;
|
||||
if let Some(out) = child.stdout.take() {
|
||||
spawn_reader(out, tx.clone(), true);
|
||||
open += 1;
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
spawn_reader(err, tx.clone(), false);
|
||||
open += 1;
|
||||
}
|
||||
drop(tx);
|
||||
let mut stderr_tail: Vec<String> = Vec::new();
|
||||
let mut batch: Vec<T> = Vec::with_capacity(max);
|
||||
// When the first line of the waiting batch arrived.
|
||||
let mut since = Instant::now();
|
||||
while open > 0 {
|
||||
if cancel.is_cancelled() {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let wait = if batch.is_empty() { idle } else { idle.saturating_sub(since.elapsed()) };
|
||||
match rx.recv_timeout(wait) {
|
||||
Ok(Event::Line(true, line)) => {
|
||||
if let Some(item) = parse(&line) {
|
||||
if batch.is_empty() {
|
||||
since = Instant::now();
|
||||
}
|
||||
batch.push(item);
|
||||
if batch.len() >= max || since.elapsed() >= idle {
|
||||
flush(&mut batch, on_batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Line(false, line)) => {
|
||||
if !line.trim().is_empty() {
|
||||
stderr_tail.push(line);
|
||||
if stderr_tail.len() > 20 {
|
||||
stderr_tail.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Closed) => open -= 1,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => flush(&mut batch, on_batch),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
flush(&mut batch, on_batch);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break status;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
Ok((status.success(), stderr_tail))
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_steady_stream_is_handed_over_while_it_runs() {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.args(["-c", "for i in $(seq 1 20); do echo $i; sleep 0.02; done"]);
|
||||
let mut sizes = Vec::new();
|
||||
let (ok, _) = stream_batched(
|
||||
cmd,
|
||||
&CancellationToken::new(),
|
||||
Duration::from_millis(60),
|
||||
300,
|
||||
|line| Some(line.to_owned()),
|
||||
&mut |batch| sizes.push(batch.len()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ok);
|
||||
assert_eq!(sizes.iter().sum::<usize>(), 20);
|
||||
assert!(sizes.len() >= 3, "lines arriving every 20 ms waited for the end: {sizes:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Drives a real device through `adb`, checking what the Logcat tool window does:
|
||||
//! that the device list parses, that the stream delivers batches, and that
|
||||
//! cancelling stops both the stream and its `adb` process.
|
||||
//!
|
||||
//! Ignored by default (needs a connected device). Run with
|
||||
//! `OPENIDEA_ADB_DEVICE=<serial> cargo test -p ide-android --test device -- --ignored --nocapture`
|
||||
//! — without the variable, the connected device is used when there is exactly one.
|
||||
|
||||
use ide_android::*;
|
||||
use ide_core::CancellationToken;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn serial() -> Option<String> {
|
||||
if let Ok(serial) = std::env::var("OPENIDEA_ADB_DEVICE") {
|
||||
return Some(serial);
|
||||
}
|
||||
let online = devices().ok()?.into_iter().filter(AndroidDevice::is_online).collect::<Vec<_>>();
|
||||
(online.len() == 1).then(|| online[0].serial.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn finds_the_adb_executable() {
|
||||
let adb = adb_executable().expect("adb (set ANDROID_HOME or OPENIDEA_ADB)");
|
||||
println!("adb: {}", adb.display());
|
||||
assert!(adb.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn lists_connected_devices() {
|
||||
let devices = devices().expect("adb devices");
|
||||
for device in &devices {
|
||||
println!("{:<16} {:<14} {} ({:?})", device.serial, device.state, device.label(), device.transport_id);
|
||||
}
|
||||
assert!(!devices.is_empty(), "no devices connected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn streams_batches_and_stops_on_cancel() {
|
||||
let serial = serial().expect("a single connected device, or OPENIDEA_ADB_DEVICE");
|
||||
let cancel = CancellationToken::new();
|
||||
let stop = cancel.clone();
|
||||
let received = Arc::new(Mutex::new(Vec::<LogcatLine>::new()));
|
||||
let sink = received.clone();
|
||||
let started = Instant::now();
|
||||
let stream = std::thread::spawn(move || {
|
||||
ide_android::logcat(&serial, false, &stop, &mut |batch| {
|
||||
sink.lock().unwrap().extend(batch);
|
||||
})
|
||||
});
|
||||
// Enough to see the tail seed and a few live lines from a device that idles.
|
||||
std::thread::sleep(Duration::from_secs(4));
|
||||
cancel.cancel();
|
||||
stream.join().unwrap().expect("logcat stops cleanly when cancelled");
|
||||
let lines = received.lock().unwrap();
|
||||
println!("{} lines in {:?}", lines.len(), started.elapsed());
|
||||
for line in lines.iter().rev().take(5) {
|
||||
println!("{:<14} {:>6} {}", line.tag, line.pid, line.message);
|
||||
}
|
||||
assert!(!lines.is_empty(), "no lines within 4s");
|
||||
assert!(lines.iter().any(|l| l.level != LogcatLevel::Unknown), "every line failed to parse");
|
||||
}
|
||||
@@ -821,13 +821,14 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
|
||||
mod tests {
|
||||
#[test]
|
||||
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
|
||||
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir_all(root.join(dir).join("app/src/main/java")).unwrap();
|
||||
std::fs::write(root.join(dir).join("settings.gradle"), "include 'app'").unwrap();
|
||||
std::fs::write(root.join(dir).join("app/build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
std::fs::write(root.join(dir).join("gradlew"), "").unwrap();
|
||||
std::fs::write(root.join(dir).join(wrapper), "").unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
@@ -842,7 +843,7 @@ mod tests {
|
||||
assert_eq!(all.steps[1].cwd, root.join("b"));
|
||||
let module = super::plan(root, &model, super::BuildKind::Rebuild, Some(&root.join("b/app")), None).unwrap();
|
||||
assert_eq!(module.steps.len(), 1);
|
||||
assert_eq!(module.steps[0].program, root.join("b/gradlew").to_string_lossy());
|
||||
assert_eq!(module.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
|
||||
assert_eq!(module.steps[0].cwd, root.join("b"));
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/jdk-b");
|
||||
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
|
||||
@@ -854,7 +855,7 @@ mod tests {
|
||||
nested.path = ":b:app".into();
|
||||
model.gradle[0].projects.push(nested);
|
||||
let nested = super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).unwrap();
|
||||
assert_eq!(nested.steps[0].program, root.join("b/gradlew").to_string_lossy());
|
||||
assert_eq!(nested.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
|
||||
assert_eq!(nested.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
|
||||
model.gradle.clear();
|
||||
assert!(super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).is_err());
|
||||
@@ -862,6 +863,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn absolute_linked_roots_build_the_project_and_external_source_module() {
|
||||
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("workspace");
|
||||
@@ -871,7 +873,7 @@ mod tests {
|
||||
std::fs::write(external.join("settings.gradle"), "include 'app'").unwrap();
|
||||
let source = external.join("app/src/main/java/App.java");
|
||||
std::fs::write(&source, "class App {}").unwrap();
|
||||
std::fs::write(external.join("gradlew"), "").unwrap();
|
||||
std::fs::write(external.join(wrapper), "").unwrap();
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
|
||||
..Default::default()
|
||||
@@ -880,7 +882,7 @@ mod tests {
|
||||
model.gradle[0].env.insert("JAVA_HOME".into(), "/external-jdk".into());
|
||||
let all = super::plan(&root, &model, super::BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(all.steps[0].cwd, external);
|
||||
assert_eq!(all.steps[0].program, external.join("gradlew").to_string_lossy());
|
||||
assert_eq!(all.steps[0].program, external.join(wrapper).to_string_lossy());
|
||||
let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap();
|
||||
assert_eq!(module.steps[0].cwd, external);
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk");
|
||||
|
||||
@@ -481,6 +481,15 @@ pub fn normalize_linked_projects(root: &Path, dirs: &[String]) -> ide_core::IdeR
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The form `normalize_linked_projects` saves `dir` in, also for a folder that no longer
|
||||
/// exists, so a path the UI sends (`C:\…` on Windows) matches the saved entry.
|
||||
pub fn linked_project_key(root: &Path, dir: &str) -> String {
|
||||
match (dunce::canonicalize(root), dunce::canonicalize(root.join(dir))) {
|
||||
(Ok(root), Ok(path)) => rel(&root, &path),
|
||||
_ => dir.replace('\\', "/"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Linked build identities without reading their scripts (also used by index hot paths).
|
||||
pub(crate) fn linked_dirs(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<String> {
|
||||
match &settings.linked_projects {
|
||||
@@ -675,6 +684,30 @@ pub(crate) fn has_war_plugin(script: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_path_from_the_ui_finds_the_saved_link() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("workspace");
|
||||
let external = temp.path().join("external");
|
||||
for dir in [root.join("a"), external.clone()] {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("settings.gradle"), "").unwrap();
|
||||
}
|
||||
let native = |p: &std::path::Path| p.to_string_lossy().into_owned();
|
||||
// Saved by linking; unlinking may send a native, absolute or non-canonical spelling.
|
||||
for (dir, sent) in [
|
||||
("a", native(&root.join("a"))),
|
||||
("a", "a".to_owned()),
|
||||
("a", native(&root.join("a").join("..").join("a"))),
|
||||
("../external", native(&external)),
|
||||
] {
|
||||
let saved = super::normalize_linked_projects(&root, &[dir.into()]).unwrap();
|
||||
assert_eq!(super::linked_project_key(&root, &sent), saved[0], "{sent}");
|
||||
}
|
||||
// A folder deleted since it was linked still matches its saved entry.
|
||||
assert_eq!(super::linked_project_key(&root, r"gone\build"), "gone/build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_linkage_preserves_default_discovery_and_can_unlink_every_build() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -80,6 +80,12 @@ pub fn load_linked(
|
||||
let repo = maven_repo.or_else(maven::default_local_repo);
|
||||
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
|
||||
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
|
||||
// The user settings' profiles can be chosen too (`-P` applies to them as well).
|
||||
for p in maven::user_settings_file(root, settings).map(|f| maven::settings_profiles(&f)).unwrap_or_default() {
|
||||
if !m.profiles.iter().any(|x| x.id == p.id) {
|
||||
m.profiles.push(p);
|
||||
}
|
||||
}
|
||||
sync::apply_maven_cache(root, cache_dir, &mut m);
|
||||
m
|
||||
});
|
||||
@@ -210,7 +216,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
gradle_external_roots(&root, &temp.path().join("cache"), &settings),
|
||||
[external.canonicalize().unwrap()]
|
||||
[dunce::canonicalize(&external).unwrap()]
|
||||
);
|
||||
settings.linked_projects = Some(vec![]);
|
||||
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
|
||||
|
||||
@@ -472,6 +472,41 @@ pub(crate) fn default_local_repo() -> Option<PathBuf> {
|
||||
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
|
||||
}
|
||||
|
||||
/// The user `settings.xml` a build reads: the configured one (`~/` and project-relative
|
||||
/// paths allowed), else `~/.m2/settings.xml`.
|
||||
pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSettings) -> Option<PathBuf> {
|
||||
let configured = settings.user_settings.trim();
|
||||
if configured.is_empty() {
|
||||
return Some(dirs::home_dir()?.join(".m2").join("settings.xml"));
|
||||
}
|
||||
let path = match configured.strip_prefix("~/") {
|
||||
Some(rest) => dirs::home_dir()?.join(rest),
|
||||
None => PathBuf::from(configured),
|
||||
};
|
||||
Some(if path.is_absolute() { path } else { root.join(path) })
|
||||
}
|
||||
|
||||
/// The profiles a `settings.xml` defines, for the Profiles list beside the POMs' own (as
|
||||
/// IDEA lists them): checked by default when `<activeProfiles>` names them or they are
|
||||
/// `activeByDefault`.
|
||||
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
|
||||
let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let active: Vec<String> =
|
||||
root.list("activeProfiles", "activeProfile").iter().map(|e| e.text.trim().to_owned()).collect();
|
||||
let mut profiles: Vec<MavenProfile> = Vec::new();
|
||||
for profile in root.list("profiles", "profile") {
|
||||
let Some(id) = profile.text_of("id") else { continue };
|
||||
if profiles.iter().any(|p| p.id == id) {
|
||||
continue;
|
||||
}
|
||||
let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
|
||||
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
|
||||
}
|
||||
profiles
|
||||
}
|
||||
|
||||
/// `<localRepository>` of a settings file, when it names one: the repository a build
|
||||
/// with `-s <file>` resolves into, which is not necessarily the user's default.
|
||||
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
|
||||
@@ -1536,6 +1571,37 @@ mod tests {
|
||||
assert_eq!(p.prefix, "checkstyle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_profiles_are_listed_with_the_active_ones_checked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("settings.xml");
|
||||
std::fs::write(
|
||||
&file,
|
||||
r#"<settings>
|
||||
<profiles>
|
||||
<profile><id>nexus</id></profile>
|
||||
<profile><id>jdk21</id><activation><activeByDefault>true</activeByDefault></activation></profile>
|
||||
<profile><id>release</id></profile>
|
||||
<profile><id>nexus</id></profile>
|
||||
<profile><activation/></profile>
|
||||
</profiles>
|
||||
<activeProfiles><activeProfile>nexus</activeProfile></activeProfiles>
|
||||
</settings>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
|
||||
assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
|
||||
assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
|
||||
|
||||
let mut s = ide_core::settings::MavenSettings::default();
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert_eq!(user_settings_file(tmp.path(), &s), Some(home.join(".m2/settings.xml")));
|
||||
s.user_settings = "conf/settings.xml".into();
|
||||
assert_eq!(user_settings_file(tmp.path(), &s), Some(tmp.path().join("conf/settings.xml")));
|
||||
s.user_settings = "~/work/settings.xml".into();
|
||||
assert_eq!(user_settings_file(tmp.path(), &s), Some(home.join("work/settings.xml")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settings_file_names_the_repository_to_read_goals_from() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod text;
|
||||
pub mod tool_path;
|
||||
pub mod version_managers;
|
||||
pub mod workflow_settings;
|
||||
pub mod wsl;
|
||||
|
||||
pub use error::{IdeError, IdeResult};
|
||||
pub use event::{EventSink, IdeEvent, NullSink, SharedSink};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Projects that live inside a WSL distro are reached from Windows through the
|
||||
//! `\\wsl.localhost\<distro>` (or legacy `\\wsl$\<distro>`) share.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The WSL launcher on Windows.
|
||||
pub const EXE: &str = "wsl.exe";
|
||||
|
||||
/// A location inside a WSL distro.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WslPath {
|
||||
pub distro: String,
|
||||
/// Absolute Linux path, e.g. `/home/me/app`.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Splits a WSL share path into distro and Linux path. Pure string work, so it
|
||||
/// accepts every spelling the UI and the OS produce: `\\wsl.localhost\X\a`,
|
||||
/// `//wsl$/X/a` and the verbatim `\\?\UNC\wsl.localhost\X\a`.
|
||||
pub fn parse(path: &Path) -> Option<WslPath> {
|
||||
let s = path.to_string_lossy().replace('\\', "/");
|
||||
let s = match s.strip_prefix("//?/UNC/") {
|
||||
Some(rest) => format!("//{rest}"),
|
||||
None => s,
|
||||
};
|
||||
let rest = s.strip_prefix("//")?;
|
||||
let (host, rest) = rest.split_once('/')?;
|
||||
if !host.eq_ignore_ascii_case("wsl.localhost") && !host.eq_ignore_ascii_case("wsl$") {
|
||||
return None;
|
||||
}
|
||||
let (distro, tail) = rest.split_once('/').unwrap_or((rest, ""));
|
||||
if distro.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(WslPath { distro: distro.to_owned(), path: format!("/{}", tail.trim_end_matches('/')) })
|
||||
}
|
||||
|
||||
/// A distro name `wsl.exe -d` can be given safely: Windows creates them from letters, digits,
|
||||
/// `.`, `_` and `-`. A leading `-` would be read as an option, so it is refused too.
|
||||
pub fn is_valid_distro(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& !name.starts_with('-')
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
||||
}
|
||||
|
||||
/// Text of `wsl.exe`'s own output: UTF-16LE (with or without BOM) for its management commands
|
||||
/// and its error messages, UTF-8 for what a command inside the distro prints.
|
||||
pub fn decode_output(raw: &[u8]) -> String {
|
||||
if raw.starts_with(&[0xff, 0xfe]) || raw.get(1) == Some(&0) {
|
||||
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
} else {
|
||||
String::from_utf8_lossy(raw).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Distro names from the output of `wsl.exe -l -q`. That output is UTF-16LE (UTF-8 when
|
||||
/// `WSL_UTF8=1`), one name per line; names that fail [`is_valid_distro`] are dropped, which also
|
||||
/// removes the localized "no installed distributions" message `wsl.exe` prints when there are none.
|
||||
pub fn parse_distro_list(raw: &[u8]) -> Vec<String> {
|
||||
let text = decode_output(raw);
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let name = line.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}');
|
||||
if is_valid_distro(name) && !names.iter().any(|n| n == name) {
|
||||
names.push(name.to_owned());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Installed WSL distros. Empty off Windows, when WSL is not installed or when `wsl.exe` fails.
|
||||
pub fn list_distros() -> Vec<String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut cmd = std::process::Command::new(EXE);
|
||||
cmd.args(["-l", "-q"]);
|
||||
crate::process::prepare(&mut cmd);
|
||||
match cmd.output() {
|
||||
Ok(out) if out.status.success() => parse_distro_list(&out.stdout),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(s: &str) -> Option<(String, String)> {
|
||||
parse(Path::new(s)).map(|w| (w.distro, w.path))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_spelling_of_the_share_is_recognised() {
|
||||
let want = Some(("Ubuntu-24.04".to_owned(), "/home/me/app".to_owned()));
|
||||
assert_eq!(p(r"\\wsl.localhost\Ubuntu-24.04\home\me\app"), want);
|
||||
assert_eq!(p("//wsl.localhost/Ubuntu-24.04/home/me/app"), want);
|
||||
assert_eq!(p(r"\\wsl$\Ubuntu-24.04\home\me\app\"), want);
|
||||
assert_eq!(p(r"\\?\UNC\WSL.LOCALHOST\Ubuntu-24.04\home\me\app"), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_root_and_foreign_paths() {
|
||||
assert_eq!(p(r"\\wsl.localhost\Debian"), Some(("Debian".to_owned(), "/".to_owned())));
|
||||
assert_eq!(p(r"\\server\share\a"), None);
|
||||
assert_eq!(p(r"\\wsl.localhost\"), None);
|
||||
assert_eq!(p("C:/Users/me"), None);
|
||||
assert_eq!(p("/home/me"), None);
|
||||
}
|
||||
|
||||
fn utf16(s: &str) -> Vec<u8> {
|
||||
std::iter::once(0xfeffu16).chain(s.encode_utf16()).flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_list_is_decoded_from_utf16_and_utf8() {
|
||||
let want = vec!["Ubuntu-24.04".to_owned(), "Debian".to_owned()];
|
||||
assert_eq!(parse_distro_list(&utf16("Ubuntu-24.04\r\nDebian\r\n\r\n")), want);
|
||||
assert_eq!(parse_distro_list(b"Ubuntu-24.04\nDebian\nDebian\n"), want);
|
||||
assert!(
|
||||
parse_distro_list(&utf16("Windows Subsystem for Linux has no installed distributions.\r\n")).is_empty()
|
||||
);
|
||||
assert!(parse_distro_list(b"").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_names_cannot_smuggle_options_or_paths() {
|
||||
for bad in ["", "-d", "--exec", "a b", "a/b", r"a\b", "a;b", "a$(x)", "a\"b"] {
|
||||
assert!(!is_valid_distro(bad), "{bad}");
|
||||
}
|
||||
assert!(is_valid_distro("Ubuntu-22.04") && is_valid_distro("docker_desktop"));
|
||||
}
|
||||
}
|
||||
@@ -302,6 +302,23 @@ pub struct DebugManager {
|
||||
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
|
||||
}
|
||||
|
||||
/// Whether the debugged project depends on the Flutter SDK, so it must go
|
||||
/// through `flutter debug_adapter` instead of the plain Dart one.
|
||||
///
|
||||
/// `dart debug_adapter` cannot start a program that imports `dart:ui`, and only
|
||||
/// the Flutter adapter answers `hotReload` / `hotRestart`. `dir` is the launch
|
||||
/// `cwd`; the program's own directory is tried first because a module inside a
|
||||
/// larger repository has its `pubspec.yaml` above the program, not above `cwd`.
|
||||
fn is_flutter_project(dir: &Path, program: Option<&Path>) -> bool {
|
||||
let mut start: Vec<&Path> = program.and_then(Path::parent).into_iter().collect();
|
||||
start.push(dir);
|
||||
start
|
||||
.iter()
|
||||
.filter_map(|d| d.ancestors().take(6).find(|a| a.join("pubspec.yaml").is_file()))
|
||||
.filter_map(|root| std::fs::read_to_string(root.join("pubspec.yaml")).ok())
|
||||
.any(|s| s.contains("sdk: flutter"))
|
||||
}
|
||||
|
||||
fn no_session(s: DebugSessionId) -> IdeError {
|
||||
IdeError::not_found(format!("no debug session {}", s.0))
|
||||
}
|
||||
@@ -570,6 +587,10 @@ impl DebugManager {
|
||||
custom_command: impl FnOnce(&str) -> Option<String>,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
let mut arguments = launch_arguments(&config)?;
|
||||
// An IDE-side marker (never sent to the adapter): the session is a Flutter
|
||||
// one regardless of what the working directory holds.
|
||||
let force_flutter =
|
||||
arguments.as_object_mut().and_then(|o| o.remove("__flutter")).and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let cwd = config
|
||||
.cwd
|
||||
.clone()
|
||||
@@ -601,6 +622,15 @@ impl DebugManager {
|
||||
spec.command = python;
|
||||
}
|
||||
}
|
||||
// Flutter: a project depending on the Flutter SDK needs `flutter debug_adapter`
|
||||
// (the Dart adapter cannot start a program importing `dart:ui`, and hot reload
|
||||
// is Flutter-only). Plain Dart projects keep the configured `dart`.
|
||||
if config.adapter == "dart" && spec.command == "dart" {
|
||||
let program = arguments.get("program").and_then(Value::as_str).map(Path::new);
|
||||
if force_flutter || is_flutter_project(&cwd, program) {
|
||||
spec.command = "flutter".into();
|
||||
}
|
||||
}
|
||||
let id = DebugSessionId::next();
|
||||
let debug_server = take_debug_server(&mut arguments)?;
|
||||
if let Some(server) = &debug_server {
|
||||
@@ -1236,6 +1266,19 @@ impl DebugManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flutter's DAP adapter: injects the changed sources into the running app.
|
||||
/// Not part of the DAP standard; adapters that do not know the request reject it.
|
||||
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
|
||||
self.session(s)?.conn.request_timeout("hotReload", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flutter's DAP adapter: restarts the app without preserving state.
|
||||
pub async fn hot_restart(&self, s: DebugSessionId) -> IdeResult<()> {
|
||||
self.session(s)?.conn.request_timeout("hotRestart", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn step(&self, s: DebugSessionId, thread_id: i64, kind: StepKind) -> IdeResult<()> {
|
||||
let command = match kind {
|
||||
StepKind::Over => "next",
|
||||
|
||||
@@ -1269,3 +1269,19 @@ async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied, "{}", err.message);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
|
||||
let root = std::env::temp_dir().join(format!("openidea-flutter-{}", std::process::id()));
|
||||
let app = root.join("app");
|
||||
let tool = root.join("tool");
|
||||
std::fs::create_dir_all(app.join("lib")).unwrap();
|
||||
std::fs::create_dir_all(tool.join("bin")).unwrap();
|
||||
std::fs::write(app.join("pubspec.yaml"), "name: app\ndependencies:\n flutter:\n sdk: flutter\n").unwrap();
|
||||
std::fs::write(tool.join("pubspec.yaml"), "name: tool\ndependencies:\n args: ^2.0.0\n").unwrap();
|
||||
assert!(is_flutter_project(&app, None));
|
||||
assert!(is_flutter_project(&root, Some(&app.join("lib/main.dart"))));
|
||||
assert!(!is_flutter_project(&tool, Some(&tool.join("bin/main.dart"))));
|
||||
assert!(!is_flutter_project(&root, None));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
@@ -79,7 +79,9 @@ impl Capability {
|
||||
Self::Secrets => "Store secrets in the system keychain".into(),
|
||||
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
|
||||
Self::InputEvents => "Observe keystrokes and editor input".into(),
|
||||
Self::Clipboard => "Read and write the clipboard from its web views".into(),
|
||||
Self::Clipboard => {
|
||||
"Copy to the clipboard from its commands, and read and write it from its web views".into()
|
||||
}
|
||||
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
|
||||
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
|
||||
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
|
||||
|
||||
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.0.0";
|
||||
pub const ENGINE_VERSION: &str = "1.1.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
|
||||
@@ -86,6 +86,8 @@ pub struct Host {
|
||||
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
|
||||
/// Shutdowns of projects closed with [`Host::close_project_detached`].
|
||||
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
|
||||
/// One open at a time per window (see [`Host::opening`]).
|
||||
open_locks: parking_lot::Mutex<HashMap<String, Arc<parking_lot::Mutex<()>>>>,
|
||||
}
|
||||
|
||||
/// How long exit waits for project shutdown before giving up.
|
||||
@@ -135,6 +137,7 @@ impl Host {
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
closing: Default::default(),
|
||||
open_locks: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -401,6 +404,13 @@ impl Host {
|
||||
self.requests.cancel(id);
|
||||
}
|
||||
|
||||
/// The lock an open of `window` holds from its check for the current project to its
|
||||
/// insert: two opens racing (a double click, a launch path and a restore) would each start
|
||||
/// an index, language servers and watchers, and the second would replace the first unclosed.
|
||||
pub(crate) fn opening(&self, window: &str) -> Arc<parking_lot::Mutex<()>> {
|
||||
self.open_locks.lock().entry(window.to_owned()).or_default().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn insert_project(&self, window: &str, project: Arc<Project>) {
|
||||
self.projects.write().insert(window.to_owned(), project);
|
||||
}
|
||||
|
||||
@@ -137,7 +137,8 @@ fn linked_gradle_paths(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
linked.retain(|path| !paths.contains(path));
|
||||
let unlinked: Vec<String> = paths.iter().map(|p| ide_build::gradle::linked_project_key(root, p)).collect();
|
||||
linked.retain(|path| !paths.contains(path) && !unlinked.contains(path));
|
||||
}
|
||||
Ok(linked)
|
||||
}
|
||||
|
||||
@@ -162,6 +162,14 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
|
||||
host.debugger.pause(session, thread_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.hot_reload(session).await
|
||||
}
|
||||
|
||||
pub async fn debug_hot_restart(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.hot_restart(session).await
|
||||
}
|
||||
|
||||
pub async fn debug_step(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
|
||||
@@ -324,6 +324,18 @@ impl HostApi for PluginApi {
|
||||
.emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body });
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
let client = from_window(client, "copy to the clipboard")?;
|
||||
self.shared.sink.emit(Some(client), PluginEvent::CopyToClipboard { plugin_id: self.id().to_owned(), text });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_url(&self, client: Option<&str>, url: String) -> Result<(), HostError> {
|
||||
let client = from_window(client, "open a URL")?;
|
||||
self.shared.sink.emit(Some(client), PluginEvent::OpenUrl { plugin_id: self.id().to_owned(), url });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn status_item(&self, id: &str, item: Option<StatusItem>) {
|
||||
let key = (self.id().to_owned(), id.to_owned());
|
||||
{
|
||||
@@ -418,6 +430,12 @@ impl HostApi for PluginApi {
|
||||
}))
|
||||
}
|
||||
|
||||
fn vcs_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, HostError> {
|
||||
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(Vec::new()) };
|
||||
let remotes = repo.remotes().map_err(failed)?;
|
||||
Ok(remotes.into_iter().map(|r| (r.name, without_credentials(&r.url))).collect())
|
||||
}
|
||||
|
||||
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError> {
|
||||
let workspace = self.shared.workspace();
|
||||
if !workspace.is_some_and(|w| w.trusted(root)) {
|
||||
@@ -812,3 +830,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The window a user action invoked the plugin from: browser tabs and clipboard writes
|
||||
/// follow what the user did, never background work (which would reach every window).
|
||||
fn from_window<'a>(client: Option<&'a str>, what: &str) -> Result<&'a str, HostError> {
|
||||
client.ok_or_else(|| HostError::Invalid(format!("can only {what} from a command the user invoked")))
|
||||
}
|
||||
|
||||
/// `url` without the `user:password@` a remote may carry (`https://user:token@host/…`):
|
||||
/// `vcs.read` must not hand out credentials. scp-style `git@host:path` has no password.
|
||||
fn without_credentials(url: &str) -> String {
|
||||
let Some((scheme, rest)) = url.split_once("://") else { return url.to_owned() };
|
||||
let authority_end = rest.find('/').unwrap_or(rest.len());
|
||||
match rest[..authority_end].rfind('@') {
|
||||
Some(at) => format!("{scheme}://{}", &rest[at + 1..]),
|
||||
None => url.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod credential_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remote_urls_lose_their_credentials() {
|
||||
for (url, expected) in [
|
||||
("https://user:[email protected]/o/r.git", "https://github.com/o/r.git"),
|
||||
("https://[email protected]/o/r", "https://gitlab.com/o/r"),
|
||||
("ssh://git@host:2222/o/r.git", "ssh://host:2222/o/r.git"),
|
||||
("https://github.com/o/r.git", "https://github.com/o/r.git"),
|
||||
("https://github.com/o/r@v1", "https://github.com/o/r@v1"),
|
||||
("[email protected]:o/r.git", "[email protected]:o/r.git"),
|
||||
("/srv/git/r.git", "/srv/git/r.git"),
|
||||
] {
|
||||
assert_eq!(without_credentials(url), expected, "{url}");
|
||||
}
|
||||
assert!(matches!(from_window(None, "open a URL"), Err(HostError::Invalid(_))));
|
||||
assert_eq!(from_window(Some("main"), "open a URL").unwrap(), "main");
|
||||
}
|
||||
}
|
||||
@@ -422,7 +422,8 @@ struct IdeDir {
|
||||
plugins: PathBuf,
|
||||
}
|
||||
|
||||
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
|
||||
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first, one
|
||||
/// per product: its newest version's (an upgrade leaves the older ones behind).
|
||||
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
|
||||
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let (config_root, data_root) = match layout {
|
||||
@@ -462,6 +463,8 @@ fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>)
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
|
||||
let mut products = std::collections::HashSet::new();
|
||||
out.retain(|ide| products.insert(ide.product.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -784,6 +787,13 @@ mod tests {
|
||||
};
|
||||
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
|
||||
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
|
||||
// An upgrade leaves the older version's folder behind: only the newest of a product counts.
|
||||
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2024.3"));
|
||||
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10"));
|
||||
let dirs = ide_dirs(Layout::Mac, home, |_| None);
|
||||
assert_eq!(dirs.len(), 1);
|
||||
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.10");
|
||||
std::fs::remove_dir(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10")).unwrap();
|
||||
let dirs = ide_dirs(Layout::Mac, home, |_| None);
|
||||
assert_eq!(dirs.len(), 1);
|
||||
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
|
||||
|
||||
@@ -115,6 +115,8 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::ViewMessage { .. }
|
||||
| PluginEvent::OpenTerminal { .. }
|
||||
| PluginEvent::OpenLocation { .. }
|
||||
| PluginEvent::CopyToClipboard { .. }
|
||||
| PluginEvent::OpenUrl { .. }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -197,6 +197,12 @@ pub enum PluginEvent {
|
||||
/// Plugin languages, or the servers they may start, changed: re-fetch
|
||||
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
|
||||
LanguagesChanged,
|
||||
/// Copy `text` to the system clipboard.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
CopyToClipboard { plugin_id: String, text: String },
|
||||
/// Open `url` (`http(s)://`) in the system browser.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenUrl { plugin_id: String, url: String },
|
||||
}
|
||||
|
||||
/// Delivers events to the UI. `client` is the window that invoked the plugin
|
||||
|
||||
@@ -77,6 +77,8 @@ impl Host {
|
||||
lap = std::time::Instant::now();
|
||||
};
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Reject a damaged manifest before replacing the currently open project.
|
||||
ide_workspace::workspace::Workspace::load(&root)?;
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
@@ -569,6 +571,8 @@ impl Host {
|
||||
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
|
||||
}
|
||||
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
if let Ok(current) = self.project(window)
|
||||
&& current.light
|
||||
{
|
||||
|
||||
@@ -251,6 +251,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "remote_disconnect"
|
||||
| "remote_connect"
|
||||
| "remote_bootstrap_list"
|
||||
| "wsl_distros"
|
||||
| "wsl_resolve"
|
||||
| "remote_list"
|
||||
| "remote_open"
|
||||
| "remote_of"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Opening the project a window is already opening reuses it: racing opens (a double
|
||||
//! click, a launch path and a restore) must not start a second index and language servers.
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig};
|
||||
use std::sync::{Arc, Barrier};
|
||||
|
||||
#[test]
|
||||
fn racing_opens_of_one_window_share_one_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/Main.java"), "class Main {}\n").unwrap();
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
|
||||
let _guard = runtime.enter();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: runtime.handle().clone(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
|
||||
let start = Arc::new(Barrier::new(4));
|
||||
let opens: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
let (host, root, start) = (host.clone(), root.clone(), start.clone());
|
||||
std::thread::spawn(move || {
|
||||
start.wait();
|
||||
host.open_project("main", &root).unwrap()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let projects: Vec<_> = opens.into_iter().map(|t| t.join().unwrap()).collect();
|
||||
|
||||
let current = host.project("main").unwrap();
|
||||
for project in &projects {
|
||||
assert!(Arc::ptr_eq(project, ¤t), "every open returns the window's one project");
|
||||
}
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -2077,6 +2077,62 @@ fn views_open_project_files_at_a_line() {
|
||||
assert!(opened.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vcs_remotes_clipboard_and_open_url() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&sample("jobs"));
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
|
||||
// Not a git repository yet: `vcs-remotes` returns an empty list.
|
||||
assert_eq!(f.run("jobs", "remotes", None).unwrap().as_deref(), Some(""));
|
||||
|
||||
git(&f.project, &["init", "-b", "main"]);
|
||||
git(&f.project, &["remote", "add", "origin", "[email protected]:l0ng-ai/openidea.git"]);
|
||||
git(&f.project, &["remote", "add", "upstream", "https://github.com/org/openidea"]);
|
||||
assert_eq!(
|
||||
f.run("jobs", "remotes", None).unwrap().as_deref(),
|
||||
Some("[email protected]:l0ng-ai/openidea.git\nupstream=https://github.com/org/openidea")
|
||||
);
|
||||
|
||||
f.host.set_capability("jobs", "vcs.read", false).unwrap();
|
||||
assert!(f.run("jobs", "remotes", None).unwrap_err().contains("vcs.read"), "needs vcs.read");
|
||||
|
||||
// `ui.copy-to-clipboard` requires `clipboard` and addresses the calling window.
|
||||
f.take_events();
|
||||
f.run("jobs", "copy", Some("https://github.com/l0ng-ai/openidea")).unwrap();
|
||||
let copied = f.take_events().into_iter().find_map(|(client, e)| match e {
|
||||
PluginEvent::CopyToClipboard { plugin_id, text } => Some((client, plugin_id, text)),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(copied, Some((Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea".into())));
|
||||
|
||||
f.host.set_capability("jobs", "clipboard", false).unwrap();
|
||||
f.take_events();
|
||||
assert!(f.run("jobs", "copy", Some("secret")).unwrap_err().contains("clipboard"), "needs clipboard");
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::CopyToClipboard { .. })));
|
||||
|
||||
// `ui.open-url` accepts http(s) URLs and rejects other schemes.
|
||||
f.take_events();
|
||||
f.run("jobs", "open-url", Some("https://github.com/l0ng-ai/openidea/blob/main/README.md#L1")).unwrap();
|
||||
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
|
||||
PluginEvent::OpenUrl { plugin_id, url } => Some((client, plugin_id, url)),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(
|
||||
opened,
|
||||
Some(
|
||||
(Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea/blob/main/README.md#L1".into(),)
|
||||
)
|
||||
);
|
||||
|
||||
for bad in ["javascript:alert(1)", "file:///etc/passwd", ""] {
|
||||
f.take_events();
|
||||
let err = f.run("jobs", "open-url", Some(bad)).unwrap_err();
|
||||
assert!(err.contains("http:// or https://"), "{bad:?}: {err}");
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::OpenUrl { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_can_use_an_svg_icon_from_the_package() {
|
||||
let f = fixture(Limits::default());
|
||||
|
||||
@@ -49,6 +49,10 @@ pub mod framing {
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
// A UTF-16LE line break is `\n\0`: the cut after `\n` leaves a NUL at the start of the
|
||||
// next line, which would turn the `Content-Length` after it into noise.
|
||||
let nuls = line.iter().take_while(|&&b| b == 0).count();
|
||||
line.drain(..nuls);
|
||||
if line.last() != Some(&b'\n') {
|
||||
if n as u64 >= MAX_HEADER_LINE {
|
||||
return Err(Error::new(ErrorKind::InvalidData, "header line too long"));
|
||||
|
||||
@@ -69,6 +69,21 @@ mod framing_tests {
|
||||
assert_eq!(noise[2], "Picked up JAVA_TOOL_OPTIONS: -Dx");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn utf16_output_of_wsl_exe_before_a_message_does_not_swallow_it() {
|
||||
// `wsl.exe` reports its own errors as UTF-16LE with a BOM; the real server only starts
|
||||
// after it, so the first message must still be read whole.
|
||||
let notice = "The Windows Subsystem for Linux instance has terminated.";
|
||||
let mut input: Vec<u8> = [0xff, 0xfe].into();
|
||||
input.extend(format!("{notice}\r\n").encode_utf16().flat_map(u16::to_le_bytes));
|
||||
input.extend(b"Content-Length: 2\r\n\r\n{}");
|
||||
let (msgs, noise) = read_with_noise(&input).await;
|
||||
assert_eq!(msgs.unwrap(), vec![b"{}".to_vec()]);
|
||||
// The text is kept for the error shown to the user, whatever the BOM decodes to.
|
||||
let ascii: String = noise.concat().chars().filter(char::is_ascii).filter(|c| *c != '\0').collect();
|
||||
assert_eq!(ascii.trim(), notice);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unterminated_output_then_eof_is_reported() {
|
||||
// `pause` prints its prompt without a newline and the process is gone.
|
||||
|
||||
@@ -22,3 +22,4 @@ wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] }
|
||||
wat = "1.258"
|
||||
@@ -32,7 +32,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
|
||||
/// The API version of the world above, `major.minor` of [`host_api`].
|
||||
pub const API_VERSION: &str = "1.0";
|
||||
pub const API_VERSION: &str = "1.1";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
@@ -45,6 +45,14 @@ pub const CANCELLED: &str = "was cancelled";
|
||||
/// documents. Capabilities are already checked when these are called.
|
||||
pub trait HostApi: Send + Sync {
|
||||
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String);
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
let _ = (client, text);
|
||||
Ok(())
|
||||
}
|
||||
fn open_url(&self, client: Option<&str>, url: String) -> Result<(), HostError> {
|
||||
let _ = (client, url);
|
||||
Ok(())
|
||||
}
|
||||
/// Sets (`Some`) or removes the plugin's status bar item `id`.
|
||||
fn status_item(&self, id: &str, item: Option<StatusItem>);
|
||||
/// Roots of the open projects.
|
||||
@@ -66,6 +74,10 @@ pub trait HostApi: Send + Sync {
|
||||
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError>;
|
||||
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError>;
|
||||
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError>;
|
||||
fn vcs_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, HostError> {
|
||||
let _ = root;
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
|
||||
/// Subscribes to (or unsubscribes from) a kind of workbench event.
|
||||
/// Project files matching `glob` (root-relative), at most `limit`.
|
||||
@@ -199,8 +211,10 @@ impl PluginEngine {
|
||||
/// Rejects a component this host can't run, with a message that says what
|
||||
/// to do: one importing interfaces outside the plugin API and WASI, or one
|
||||
/// built for a plugin API this host doesn't implement (another major, or a
|
||||
/// newer minor). Returns the API version the component was built for
|
||||
/// (`None`: it uses no plugin interface).
|
||||
/// newer minor), or one that doesn't link: an import the host lacks or
|
||||
/// declares with another type (same API version, different WIT), or a
|
||||
/// missing plugin export. Returns the API version the component was built
|
||||
/// for (`None`: it uses no plugin interface).
|
||||
pub fn check_component(&self, component: &Component) -> Result<Option<semver::Version>, String> {
|
||||
let ty = component.component_type();
|
||||
let imports: Vec<String> = ty.imports(&self.engine).map(|(name, _)| name.to_owned()).collect();
|
||||
@@ -217,10 +231,20 @@ impl PluginEngine {
|
||||
if let Some(v) = &built_for {
|
||||
check_api(v, &host_api())?;
|
||||
}
|
||||
let pre = self.linker.instantiate_pre(component).map_err(|e| link_error(&format!("{e:#}")))?;
|
||||
bindings::PluginIndices::new(&pre).map_err(|e| link_error(&format!("{e:#}")))?;
|
||||
Ok(built_for)
|
||||
}
|
||||
}
|
||||
|
||||
/// Explains a component that doesn't link against this host's plugin API.
|
||||
fn link_error(detail: &str) -> String {
|
||||
format!(
|
||||
"the component doesn't match the plugin API of this IDE ({API_VERSION}): {detail}. \
|
||||
If it was built for a newer OpenIDEA, update OpenIDEA; otherwise rebuild it with the {API_VERSION} SDK"
|
||||
)
|
||||
}
|
||||
|
||||
/// Interface names of the plugin API start with this (`openidea:plugin/[email protected]`).
|
||||
const PLUGIN_PACKAGE_PREFIX: &str = "openidea:plugin/";
|
||||
|
||||
@@ -530,11 +554,7 @@ impl Runtime {
|
||||
arm(&mut store, &setup.limits, setup.limits.timeout).map_err(|e| e.to_string())?;
|
||||
let api_hint = |e: wasmtime::Error| {
|
||||
let msg = describe_trap(&e, &setup.limits);
|
||||
if msg.contains("openidea:plugin/") {
|
||||
format!("{msg} (the component was built for a different plugin API; this IDE runs API {API_VERSION})")
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
if msg.contains("openidea:plugin/") { link_error(&msg) } else { msg }
|
||||
};
|
||||
let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?;
|
||||
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
|
||||
|
||||
@@ -242,6 +242,24 @@ impl ui::Host for StoreData {
|
||||
self.host.status_item(&clip(id, 64), None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(&mut self, text: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Clipboard) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.copy_to_clipboard(client.as_deref(), clip(text, crate::runtime::MAX_TEXT)).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn open_url(&mut self, url: String) -> Fallible<()> {
|
||||
let trimmed = url.trim();
|
||||
let valid = (trimmed.starts_with("https://") || trimmed.starts_with("http://")) && trimmed.len() <= 8192;
|
||||
if !valid {
|
||||
return Ok(Err(wit::Error::Invalid(format!("{url:?}: expected an http:// or https:// URL"))));
|
||||
}
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.open_url(client.as_deref(), trimmed.to_owned()).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl storage::Host for StoreData {
|
||||
@@ -438,6 +456,14 @@ impl vcs::Host for StoreData {
|
||||
let token = self.cancel.token();
|
||||
Ok(self.host.vcs_fetch(&root, &token).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn remotes(&mut self, project: Resource<ProjectRes>) -> Fallible<Vec<(String, String)>> {
|
||||
if let Err(e) = self.require(Capability::VcsRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
Ok(self.host.vcs_remotes(&root).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl events::Host for StoreData {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! `PluginEngine::check_component` (what installing and `openidea-plugin check`
|
||||
//! run) refuses a component the IDE could not start: every import has to link
|
||||
//! against the host, function by function, and the plugin world's exports have
|
||||
//! to be there, not only the interface names and their API version.
|
||||
|
||||
use ide_plugin::PluginEngine;
|
||||
|
||||
fn check(wat: &str) -> Result<Option<semver::Version>, String> {
|
||||
let engine = PluginEngine::new().unwrap();
|
||||
let component = engine.compile_bytes(&wat::parse_str(wat).unwrap()).unwrap();
|
||||
engine.check_component(&component)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_the_host_cannot_link_are_refused() {
|
||||
let cases = [
|
||||
// As dengliu.gitlink 0.1.0 was: built from unreleased WIT that added functions under the same version.
|
||||
(
|
||||
"a function the host's interface lacks",
|
||||
r#"(component (import "openidea:plugin/ui@1.0.0" (instance
|
||||
(export "not-a-host-function" (func (param "text" string))))))"#,
|
||||
"not-a-host-function",
|
||||
),
|
||||
(
|
||||
"a function with another signature",
|
||||
r#"(component (import "openidea:plugin/ui@1.0.0" (instance
|
||||
(export "show-notification" (func (param "title" string))))))"#,
|
||||
"show-notification",
|
||||
),
|
||||
(
|
||||
"an interface the host lacks",
|
||||
r#"(component (import "openidea:plugin/clipboard@1.0.0" (instance
|
||||
(export "copy" (func (param "text" string))))))"#,
|
||||
"openidea:plugin/[email protected]",
|
||||
),
|
||||
("no plugin exports", r#"(component (import "openidea:plugin/[email protected]" (instance)))"#, "lifecycle"),
|
||||
];
|
||||
for (case, wat, names) in cases {
|
||||
let err = check(wat).expect_err(case);
|
||||
assert!(err.contains(names), "{case}: {err}");
|
||||
assert!(err.contains("update OpenIDEA") && err.contains("rebuild"), "{case}: {err}");
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
|
||||
455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
|
||||
f7e1ae06a90460ed1d2319bfc51f848654df145fa451f3d959b32776b2b25ce3
|
||||
+1
-1
@@ -12,7 +12,7 @@ activation = ["on-view:jobs", "on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["process:jobs"]
|
||||
optional = ["project.read"]
|
||||
optional = ["project.read", "vcs.read", "clipboard"]
|
||||
|
||||
[[contributes.views]]
|
||||
id = "jobs"
|
||||
|
||||
+14
-1
@@ -32,9 +32,22 @@ impl Jobs {
|
||||
}
|
||||
|
||||
impl Plugin for Jobs {
|
||||
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"follow" => self.follow().map(|()| None),
|
||||
"remotes" => {
|
||||
let project = ctx.project().ok_or("no project is open")?;
|
||||
let remotes = project.vcs_remotes()?;
|
||||
Ok(Some(remotes.into_iter().map(|(n, u)| format!("{n}={u}")).collect::<Vec<_>>().join("\n")))
|
||||
}
|
||||
"copy" => {
|
||||
host::copy_to_clipboard(arg.unwrap_or_default())?;
|
||||
Ok(None)
|
||||
}
|
||||
"open-url" => {
|
||||
host::open_url(arg.unwrap_or_default())?;
|
||||
Ok(None)
|
||||
}
|
||||
_ => Err(Error::NotFound(format!("command {id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
|
||||
12973c7ca7330fc50eb16718fd769d412401b5a92bd014f4804659e4a272122f
|
||||
+2
-2
@@ -5,8 +5,8 @@ wit_bindgen::generate!({
|
||||
inline: "
|
||||
package fixture:misbehave;
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.0.0;
|
||||
export openidea:plugin/providers@1.0.0;
|
||||
include openidea:plugin/plugin-syntax@1.1.0;
|
||||
export openidea:plugin/providers@1.1.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
|
||||
@@ -564,9 +564,8 @@ fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec<RunConfig>) {
|
||||
Some(m) => format!("application:{m}:{class}"),
|
||||
None => format!("application:{class}"),
|
||||
};
|
||||
let mut app = config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class])
|
||||
.in_dir(module_rel.as_ref().map(PathBuf::from))
|
||||
.debug("java-debug");
|
||||
let mut app =
|
||||
config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class]).debug("java-debug");
|
||||
app.extras.module = Some(module_rel.clone().unwrap_or_default());
|
||||
out.push(app);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! its templates (`default="true"`) and temporary configurations. A configuration found in more
|
||||
//! than one place is taken from the first, in that order.
|
||||
//!
|
||||
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and
|
||||
//! compound configurations become the matching kinds; other types are skipped with their type.
|
||||
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script,
|
||||
//! Application, Spring Boot and compound configurations become the matching kinds; other types
|
||||
//! are skipped with their type.
|
||||
//! `$PROJECT_DIR$` (and `$MODULE_DIR$`, `$MODULE_WORKING_DIR$`, `$ContentRoot$`,
|
||||
//! `$ProjectFileDir$`, all the project directory here) and `$USER_HOME$` are replaced; another
|
||||
//! path variable skips the configuration.
|
||||
@@ -162,6 +163,8 @@ const VITEST: &str = "JavaScriptTestRunnerVitest";
|
||||
const NODE: &str = "NodeJSConfigurationType";
|
||||
const SHELL: &str = "ShConfigurationType";
|
||||
const COMPOUND: &str = "CompoundRunConfigurationType";
|
||||
const APPLICATION: &str = "Application";
|
||||
const SPRING_BOOT: &str = "SpringBootApplicationConfigurationType";
|
||||
|
||||
type Res<T> = Result<T, SkipReason>;
|
||||
|
||||
@@ -329,6 +332,8 @@ impl Cx<'_> {
|
||||
JEST | VITEST => self.js_tests(name, c, ty == VITEST, &mut ignored)?,
|
||||
NODE => self.node(name, c, &mut ignored)?,
|
||||
SHELL => self.shell(name, c)?,
|
||||
APPLICATION => self.java_app(name, c, false, &mut ignored)?,
|
||||
SPRING_BOOT => self.java_app(name, c, true, &mut ignored)?,
|
||||
COMPOUND => {
|
||||
members = c.children_named("toRun").filter_map(|t| t.attr("name")).map(str::to_owned).collect();
|
||||
config(name, "compound", String::new(), Vec::new(), None)
|
||||
@@ -695,6 +700,54 @@ impl Cx<'_> {
|
||||
Ok(rc)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Java
|
||||
|
||||
/// IDEA's Application and Spring Boot types, as the `application` / `spring-boot` forms run
|
||||
/// them: `java <vm options> -cp ${classpath} <main> [--spring.profiles.active=…] <arguments>`
|
||||
/// with the module's runtime classpath.
|
||||
fn java_app(&self, name: &str, c: &El, spring: bool, ignored: &mut Vec<String>) -> Res<RunConfig> {
|
||||
let main = if spring { "SPRING_BOOT_MAIN_CLASS" } else { "MAIN_CLASS_NAME" };
|
||||
let Some(main) = self.text(c.option(main))? else { return Err(SkipReason::NothingToRun) };
|
||||
let (_, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
|
||||
let mut args = self.words(c.option("VM_PARAMETERS"))?;
|
||||
args.extend(["-cp".to_owned(), "${classpath}".to_owned(), main]);
|
||||
if spring && let Some(profiles) = self.text(c.option("ACTIVE_PROFILES"))? {
|
||||
args.push(format!("--spring.profiles.active={profiles}"));
|
||||
}
|
||||
args.extend(self.words(c.option("PROGRAM_PARAMETERS"))?);
|
||||
let mut rc =
|
||||
config(name, if spring { "spring-boot" } else { "application" }, "java".into(), args, Some("java-debug"));
|
||||
rc.cwd = cwd.map(Into::into);
|
||||
self.envs(c, &mut rc);
|
||||
if let Some(module) = c.child("module").and_then(|m| m.attr("name")).filter(|m| !m.is_empty()) {
|
||||
match module_dir(self.root, module) {
|
||||
Some(dir) => rc.extras.module = Some(dir),
|
||||
None => ignored.push(format!("module {module}")),
|
||||
}
|
||||
}
|
||||
// Options the forms have no field for.
|
||||
if c.flag("ALTERNATIVE_JRE_PATH_ENABLED") {
|
||||
ignored.push("JRE".into());
|
||||
}
|
||||
if c.flag("INCLUDE_PROVIDED_SCOPE") {
|
||||
ignored.push("provided scope".into());
|
||||
}
|
||||
if c.child("classpathModifications").is_some_and(|m| !m.children.is_empty()) {
|
||||
ignored.push("classpath modifications".into());
|
||||
}
|
||||
if spring {
|
||||
for (option, label) in [("HIDE_BANNER", "hide banner"), ("DEBUG_MODE", "debug output")] {
|
||||
if c.flag(option) {
|
||||
ignored.push(label.into());
|
||||
}
|
||||
}
|
||||
if c.child("additionalParameters").is_some_and(|p| !p.children.is_empty()) {
|
||||
ignored.push("override configuration properties".into());
|
||||
}
|
||||
}
|
||||
Ok(rc)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Shell
|
||||
|
||||
/// A script file (with its interpreter) or script text, kept as the Shell Script form's fields.
|
||||
@@ -835,6 +888,56 @@ fn module_count(root: &Path) -> usize {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The folder (relative, `""` for the project's own) of IDEA module `name`: a Maven module is
|
||||
/// named after its `artifactId` (usually its folder), a Gradle one `<project>.<path>.<source set>`.
|
||||
fn module_dir(root: &Path, name: &str) -> Option<String> {
|
||||
let folder = |dir: &Path| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let exact = |dir: &Path| folder(dir) == name || pom_artifact(dir).is_some_and(|a| a == name);
|
||||
if let Some(dir) = find_module(root, &exact) {
|
||||
return Some(dir);
|
||||
}
|
||||
let gradle = name.strip_suffix(".main").or_else(|| name.strip_suffix(".test")).unwrap_or(name);
|
||||
let last = gradle.rsplit('.').next().filter(|last| *last != name)?;
|
||||
find_module(root, &|dir: &Path| folder(dir) == last)
|
||||
}
|
||||
|
||||
/// The shallowest build folder under `root` (itself included) that `names` accepts.
|
||||
fn find_module(root: &Path, names: &dyn Fn(&Path) -> bool) -> Option<String> {
|
||||
const BUILDS: [&str; 3] = ["pom.xml", "build.gradle", "build.gradle.kts"];
|
||||
let mut level = vec![root.to_path_buf()];
|
||||
for _ in 0..5 {
|
||||
if let Some(found) = level.iter().find(|d| BUILDS.iter().any(|b| d.join(b).is_file()) && names(d)) {
|
||||
let rel = found.strip_prefix(root).ok()?;
|
||||
return Some(rel.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
let mut next: Vec<PathBuf> = level
|
||||
.iter()
|
||||
.filter_map(|d| std::fs::read_dir(d).ok())
|
||||
.flat_map(|entries| entries.flatten().map(|e| e.path()))
|
||||
.filter(|p| p.is_dir())
|
||||
.filter(|p| {
|
||||
let n = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
!n.starts_with('.') && !["node_modules", "target", "build", "out", "src"].contains(&n.as_str())
|
||||
})
|
||||
.collect();
|
||||
next.sort();
|
||||
level = next;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The `artifactId` of the module's own `pom.xml` (not its parent's).
|
||||
fn pom_artifact(dir: &Path) -> Option<String> {
|
||||
let pom = std::fs::read_to_string(dir.join("pom.xml")).ok()?;
|
||||
let own = match (pom.find("<parent>"), pom.find("</parent>")) {
|
||||
(Some(start), Some(end)) if start < end => format!("{}{}", &pom[..start], &pom[end..]),
|
||||
_ => pom,
|
||||
};
|
||||
let start = own.find("<artifactId>")? + "<artifactId>".len();
|
||||
let end = start + own[start..].find("</artifactId>")?;
|
||||
Some(own[start..end].trim().to_owned())
|
||||
}
|
||||
|
||||
/// `--settings` from PyCharm's "Settings" field: a file (`mysite/settings/dev.py`, relative to
|
||||
/// the project) becomes its module relative to the working directory; a module stays as it is.
|
||||
fn settings_module(value: &str, root: &Path, dir: &Path) -> String {
|
||||
|
||||
@@ -215,7 +215,7 @@ fn builds_nested_in_a_folder_of_repositories() {
|
||||
assert_eq!(app.name, "DromaraApplication");
|
||||
assert_eq!(app.kind, "spring-boot");
|
||||
assert_eq!(cmdline(app), "java -cp ${classpath} org.dromara.DromaraApplication");
|
||||
assert_eq!(app.cwd.as_deref(), Some(Path::new("RuoYi-Vue-Plus/ruoyi-admin")));
|
||||
assert_eq!(app.cwd, None);
|
||||
assert_eq!(app.extras.module.as_deref(), Some("RuoYi-Vue-Plus/ruoyi-admin"));
|
||||
assert_eq!(app.debug_adapter.as_deref(), Some("java-debug"));
|
||||
let package = find(&c, "maven:package:RuoYi-Vue-Plus");
|
||||
@@ -258,7 +258,7 @@ fn spring_boot_application_of_a_repository_nested_one_or_two_levels_down() {
|
||||
let module = format!("{prefix}ruoyi-ai/ruoyi-admin");
|
||||
let app = find(&c, &format!("application:{module}:org.ruoyi.RuoYiAIApplication"));
|
||||
assert_eq!((app.name.as_str(), app.kind.as_str()), ("RuoYiAIApplication", "spring-boot"), "{prefix}");
|
||||
assert_eq!(app.cwd.as_deref(), Some(Path::new(&module)));
|
||||
assert_eq!((app.cwd.as_deref(), app.extras.module.as_deref()), (None, Some(module.as_str())), "{prefix}");
|
||||
assert!(ids(&c).contains(&format!("maven:spring-boot:run:{prefix}ruoyi-ai").as_str()), "{prefix}");
|
||||
}
|
||||
}
|
||||
@@ -295,9 +295,9 @@ fn nested_builds_of_every_kind_and_depth() {
|
||||
}
|
||||
assert!(find(&c, "gradle:test:services/api").is_test);
|
||||
let api = find(&c, "application:services/api/app:com.acme.ApiKt");
|
||||
assert_eq!(api.cwd.as_deref(), Some(Path::new("services/api/app")));
|
||||
assert_eq!((api.cwd.as_deref(), api.extras.module.as_deref()), (None, Some("services/api/app")));
|
||||
let billing = find(&c, "application:billing:Billing");
|
||||
assert_eq!(billing.cwd.as_deref(), Some(Path::new("billing")));
|
||||
assert_eq!((billing.cwd.as_deref(), billing.extras.module.as_deref()), (None, Some("billing")));
|
||||
let builds: Vec<&str> =
|
||||
ids(&c).into_iter().filter(|id| id.starts_with("maven:package") || id.starts_with("gradle:build")).collect();
|
||||
assert_eq!(builds, ["gradle:build:services/api", "maven:package:billing"]);
|
||||
|
||||
@@ -239,8 +239,8 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
|
||||
<envs><env name="VITE_API" value="http://localhost:8000" /></envs>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Main" type="Application" factoryName="Application">
|
||||
<option name="MAIN_CLASS_NAME" value="shop.Main" />
|
||||
<configuration name="Main" type="JUnit" factoryName="JUnit">
|
||||
<option name="MAIN_CLASS_NAME" value="shop.MainTest" />
|
||||
<method v="2" />
|
||||
</configuration>"#
|
||||
)),
|
||||
@@ -272,7 +272,7 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
|
||||
assert_eq!(get(&r, "Custom").args, s(&["-m", "pytest", "tests/a.py", "--maxfail=2"]));
|
||||
|
||||
assert_eq!(reason(&r, "Unittests in tests"), &SkipReason::UnsupportedType { type_: "tests (Unittests)".into() });
|
||||
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "Application".into() });
|
||||
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
|
||||
|
||||
let dev = get(&r, "dev");
|
||||
assert_eq!((dev.kind.as_str(), dev.command.as_str()), ("npm", "pnpm"));
|
||||
@@ -526,14 +526,11 @@ fn sources_are_offered_when_their_files_exist() {
|
||||
),
|
||||
);
|
||||
assert!(available(dir.path()).is_empty());
|
||||
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="Application" />"#));
|
||||
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="JUnit" />"#));
|
||||
assert_eq!(available(dir.path()), [RunImportSource::Jetbrains]);
|
||||
write(dir.path(), ".vscode/launch.json", "{}");
|
||||
assert_eq!(available(dir.path()), [RunImportSource::Vscode, RunImportSource::Jetbrains]);
|
||||
assert_eq!(
|
||||
reason(&import_from(dir.path(), &[]), "App"),
|
||||
&SkipReason::UnsupportedType { type_: "Application".into() }
|
||||
);
|
||||
assert_eq!(reason(&import_from(dir.path(), &[]), "App"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -702,3 +699,91 @@ fn flags_defaults_inherited_variables_and_nested_django_tests() {
|
||||
assert_eq!((tests.kind.as_str(), tests.is_test), ("python", true));
|
||||
assert_eq!(tests.args[..3], s(&["backend/manage.py", "test", "orders"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_applications_and_spring_boot_keep_their_module() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
write(&root, "pom.xml", "<project><artifactId>shop</artifactId><packaging>pom</packaging></project>");
|
||||
write(
|
||||
&root,
|
||||
"example-service/pom.xml",
|
||||
"<project><parent><artifactId>shop</artifactId></parent><artifactId>example-service</artifactId></project>",
|
||||
);
|
||||
// IDEA names a Maven module after its artifactId, which needn't be its folder.
|
||||
write(
|
||||
&root,
|
||||
"services/api/pom.xml",
|
||||
"<project><parent><artifactId>shop</artifactId></parent><artifactId>api-service</artifactId></project>",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
".idea/workspace.xml",
|
||||
&workspace(
|
||||
r#" <configuration name="ExampleApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
|
||||
<module name="example-service" />
|
||||
<option name="SPRING_BOOT_MAIN_CLASS" value="com.example.ExampleApplication" />
|
||||
<option name="ACTIVE_PROFILES" value="dev,local" />
|
||||
<option name="VM_PARAMETERS" value="-Xmx512m -Dfile.encoding=UTF-8" />
|
||||
<option name="PROGRAM_PARAMETERS" value="--server.port=8081" />
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/example-service" />
|
||||
<option name="HIDE_BANNER" value="true" />
|
||||
<envs>
|
||||
<env name="TZ" value="UTC" />
|
||||
</envs>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
<configuration name="Api" type="Application" factoryName="Application">
|
||||
<option name="MAIN_CLASS_NAME" value="com.example.api.Main" />
|
||||
<module name="api-service" />
|
||||
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
|
||||
<option name="ALTERNATIVE_JRE_PATH" value="17" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Gone" type="Application" factoryName="Application">
|
||||
<option name="MAIN_CLASS_NAME" value="Gone" />
|
||||
<module name="removed-module" />
|
||||
</configuration>
|
||||
<configuration name="NoMain" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
|
||||
<module name="example-service" />
|
||||
</configuration>"#,
|
||||
),
|
||||
);
|
||||
|
||||
let r = import_from(&root, &[]);
|
||||
|
||||
let boot = get(&r, "ExampleApplication");
|
||||
assert_eq!(boot.kind, "spring-boot");
|
||||
assert_eq!(boot.command, "java");
|
||||
assert_eq!(
|
||||
boot.args,
|
||||
s(&[
|
||||
"-Xmx512m",
|
||||
"-Dfile.encoding=UTF-8",
|
||||
"-cp",
|
||||
"${classpath}",
|
||||
"com.example.ExampleApplication",
|
||||
"--spring.profiles.active=dev,local",
|
||||
"--server.port=8081",
|
||||
])
|
||||
);
|
||||
assert_eq!(boot.extras.module.as_deref(), Some("example-service"));
|
||||
assert_eq!(cwd(boot), Some(PathBuf::from("example-service")));
|
||||
assert_eq!(boot.env.get("TZ").map(String::as_str), Some("UTC"));
|
||||
assert_eq!(boot.debug_adapter.as_deref(), Some("java-debug"));
|
||||
assert_eq!(ignored(&r, "ExampleApplication"), ["before launch", "hide banner"]);
|
||||
|
||||
let api = get(&r, "Api");
|
||||
assert_eq!(api.kind, "application");
|
||||
assert_eq!(api.args, s(&["-cp", "${classpath}", "com.example.api.Main"]));
|
||||
assert_eq!(api.extras.module.as_deref(), Some("services/api"));
|
||||
assert_eq!(ignored(&r, "Api"), ["JRE"]);
|
||||
|
||||
// A module that isn't here any more: the form finds it by the main class.
|
||||
assert_eq!(get(&r, "Gone").extras.module, None);
|
||||
assert_eq!(ignored(&r, "Gone"), ["module removed-module"]);
|
||||
|
||||
assert_eq!(reason(&r, "NoMain"), &SkipReason::NothingToRun);
|
||||
}
|
||||
@@ -60,6 +60,9 @@ struct Session {
|
||||
writer: Mutex<Box<dyn Write + Send>>,
|
||||
killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
|
||||
exited: Arc<AtomicBool>,
|
||||
started: Instant,
|
||||
/// The first input was logged (startup diagnostics, see `spawn`).
|
||||
input_logged: AtomicBool,
|
||||
}
|
||||
|
||||
type Sessions = Arc<Mutex<HashMap<TerminalId, Arc<Session>>>>;
|
||||
@@ -92,7 +95,8 @@ impl TerminalManager {
|
||||
let size = pty_size(options.cols, options.rows);
|
||||
let pair = native_pty_system().openpty(size).map_err(|e| IdeError::internal(format!("openpty failed: {e}")))?;
|
||||
|
||||
let (shell, mut args) = resolve_shell(options.shell.as_deref(), &options.args);
|
||||
let wsl = if cfg!(windows) { wsl_shell(&options) } else { None };
|
||||
let (shell, mut args) = wsl.clone().unwrap_or_else(|| resolve_shell(options.shell.as_deref(), &options.args));
|
||||
let mut env = terminal_env(&options.env);
|
||||
if let Some(msystem) = default_msystem(&shell, &options.env, std::env::var("MSYSTEM").ok().as_deref()) {
|
||||
env.insert("MSYSTEM".to_owned(), msystem.to_owned());
|
||||
@@ -105,7 +109,8 @@ impl TerminalManager {
|
||||
}
|
||||
let mut cmd = CommandBuilder::new(&shell);
|
||||
cmd.args(&args);
|
||||
cmd.cwd(resolve_cwd(&options.cwd));
|
||||
// wsl.exe takes the directory through `--cd`; a UNC share is no valid process cwd.
|
||||
cmd.cwd(if wsl.is_some() { resolve_cwd(Path::new("")) } else { resolve_cwd(&options.cwd) });
|
||||
for (k, v) in ide_core::process::host_env_fixes() {
|
||||
match v {
|
||||
Some(v) => cmd.env(k, v),
|
||||
@@ -136,25 +141,34 @@ impl TerminalManager {
|
||||
writer: Mutex::new(writer),
|
||||
killer: Mutex::new(child.clone_killer()),
|
||||
exited: exited.clone(),
|
||||
started: Instant::now(),
|
||||
input_logged: AtomicBool::new(false),
|
||||
});
|
||||
self.sessions.lock().insert(id, session);
|
||||
|
||||
let (tx, rx) = mpsc::channel::<Msg>();
|
||||
let spawned = spawn_reader(id, reader, tx.clone())
|
||||
.and_then(|_| spawn_waiter(id, child, tx))
|
||||
.and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink));
|
||||
.and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink, Instant::now()));
|
||||
if let Err(e) = spawned {
|
||||
if let Some(s) = self.sessions.lock().remove(&id) {
|
||||
let _ = s.killer.lock().kill();
|
||||
}
|
||||
return Err(IdeError::internal(format!("failed to start terminal threads: {e}")));
|
||||
}
|
||||
tracing::debug!(%id, shell, pid = ?info.pid, "terminal spawned");
|
||||
// Info level, with the first output and input below: a session that starts but never shows a
|
||||
// prompt can then be told apart in a user's log (no output, or no reply to the shell's query).
|
||||
tracing::info!(%id, shell, pid = ?info.pid, cols = size.cols, rows = size.rows, "terminal spawned");
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub fn write(&self, id: TerminalId, data: &str) -> IdeResult<()> {
|
||||
let session = self.session(id)?;
|
||||
if !session.input_logged.swap(true, Ordering::Relaxed) {
|
||||
let cursor_report = data.starts_with("\x1b[") && data.ends_with('R');
|
||||
let ms = session.started.elapsed().as_millis();
|
||||
tracing::info!(%id, ms, bytes = data.len(), cursor_report, "terminal first input");
|
||||
}
|
||||
let mut w = session.writer.lock();
|
||||
w.write_all(data.as_bytes())?;
|
||||
w.flush()?;
|
||||
@@ -387,8 +401,19 @@ fn spawn_pump(
|
||||
exited: Arc<AtomicBool>,
|
||||
sessions: Sessions,
|
||||
sink: SharedSink,
|
||||
started: Instant,
|
||||
) -> std::io::Result<()> {
|
||||
std::thread::Builder::new().name(format!("openidea-term-pump-{}", id.0)).spawn(move || {
|
||||
let first_output = std::cell::Cell::new(true);
|
||||
let on_output = move |text: String| {
|
||||
if first_output.replace(false) {
|
||||
// ConPTY asks for the cursor position (`ESC[6n`) and waits for the answer.
|
||||
let cursor_query = text.contains("\x1b[6n");
|
||||
let ms = started.elapsed().as_millis();
|
||||
tracing::info!(%id, ms, bytes = text.len(), cursor_query, "terminal first output");
|
||||
}
|
||||
on_output(text)
|
||||
};
|
||||
let mut decoder = Utf8Decoder::new();
|
||||
let mut pending = String::new();
|
||||
let mut batch_started: Option<Instant> = None;
|
||||
@@ -456,6 +481,16 @@ fn pty_size(cols: u16, rows: u16) -> PtySize {
|
||||
}
|
||||
}
|
||||
|
||||
/// `wsl.exe` in the distro of a `\\wsl.localhost\...` folder, so its terminal is a Linux
|
||||
/// shell and not PowerShell (#925). An explicitly chosen shell or arguments win.
|
||||
fn wsl_shell(options: &TerminalOptions) -> Option<(String, Vec<String>)> {
|
||||
if options.shell.as_deref().is_some_and(|s| !s.trim().is_empty()) || !options.args.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let w = ide_core::wsl::parse(&options.cwd)?;
|
||||
Some((ide_core::wsl::EXE.into(), vec!["-d".into(), w.distro, "--cd".into(), w.path]))
|
||||
}
|
||||
|
||||
fn resolve_cwd(cwd: &Path) -> PathBuf {
|
||||
if cwd.is_dir() {
|
||||
return cwd.to_path_buf();
|
||||
@@ -504,7 +539,7 @@ pub fn available_shells() -> Vec<String> {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
for exe in ["pwsh.exe", "powershell.exe", "cmd.exe", "wsl.exe"] {
|
||||
for exe in ["pwsh.exe", "powershell.exe", "cmd.exe", ide_core::wsl::EXE] {
|
||||
if let Some(p) = find_in_path(exe) {
|
||||
add(p);
|
||||
}
|
||||
@@ -859,6 +894,24 @@ mod tests {
|
||||
assert!(h.events.try_recv().is_err() && other_events.try_recv().is_err(), "no cross delivery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_folder_gets_the_distro_shell_unless_one_is_chosen() {
|
||||
let mut o = sh("true");
|
||||
o.shell = None;
|
||||
o.args = vec![];
|
||||
let (distro, linux_path) = ("Debian-12", "/srv/app");
|
||||
let want = (ide_core::wsl::EXE.to_owned(), vec!["-d".into(), distro.into(), "--cd".into(), linux_path.into()]);
|
||||
for host in ["wsl.localhost", "wsl$"] {
|
||||
o.cwd = PathBuf::from(format!(r"\\{host}\{distro}{}", linux_path.replace('/', r"\"))).into();
|
||||
assert_eq!(wsl_shell(&o), Some(want.clone()), "{host}");
|
||||
}
|
||||
o.shell = Some("pwsh.exe".into());
|
||||
assert_eq!(wsl_shell(&o), None);
|
||||
o.shell = None;
|
||||
o.cwd = PathBuf::from("/tmp").into();
|
||||
assert_eq!(wsl_shell(&o), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_resolution() {
|
||||
let (shell, args) = resolve_shell(Some("/bin/zsh"), &[]);
|
||||
|
||||
@@ -124,7 +124,7 @@ pub fn read_dir(dir: &Path, excludes: &[String]) -> IdeResult<Vec<FileEntry>> {
|
||||
if !fs::metadata(dir)?.is_dir() {
|
||||
return Err(IdeError::invalid(format!("{} is not a directory", dir.display())));
|
||||
}
|
||||
let gitignore = rules::GitignoreStack::new(dir);
|
||||
let gitignore = rules::GitignoreStack::cached(dir);
|
||||
let base = gitignore.repo_root().unwrap_or(dir).to_path_buf();
|
||||
let excludes = ExcludeMatcher::new(excludes);
|
||||
|
||||
|
||||
+112
-5
@@ -8,6 +8,7 @@ use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
/// Exclude globs from settings (`files.exclude`), e.g. `**/node_modules`.
|
||||
///
|
||||
@@ -72,6 +73,14 @@ pub(crate) fn find_repo_root(start: &Path) -> Option<PathBuf> {
|
||||
start.ancestors().find(|dir| dir.join(".git").exists()).map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// `info/exclude` of the repository at `root` (shared by its worktrees).
|
||||
fn exclude_path(root: &Path) -> PathBuf {
|
||||
gix::discover(root)
|
||||
.ok()
|
||||
.map(|r| r.common_dir().join("info/exclude"))
|
||||
.unwrap_or_else(|| root.join(".git/info/exclude"))
|
||||
}
|
||||
|
||||
/// Layered gitignore matching: per-directory `.gitignore` / `.ignore` from the
|
||||
/// path's directory up to the repository root (or the file system root when
|
||||
/// not in a repository), then `.git/info/exclude` and the global excludes file.
|
||||
@@ -83,6 +92,29 @@ pub(crate) struct GitignoreStack {
|
||||
nested: Mutex<HashMap<PathBuf, Option<Arc<GitignoreStack>>>>,
|
||||
}
|
||||
|
||||
/// How long [`GitignoreStack::cached`] reuses the repository lookup of a directory.
|
||||
const REPO_LEVEL_TTL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// What changes when `.git/info/exclude` is edited.
|
||||
type Stamp = Option<(SystemTime, u64)>;
|
||||
|
||||
struct RepoLevel {
|
||||
at: Instant,
|
||||
repo_root: Option<PathBuf>,
|
||||
/// `<common dir>/info/exclude`, found by a repository discovery.
|
||||
exclude: Option<PathBuf>,
|
||||
stamp: Stamp,
|
||||
rules: Vec<Gitignore>,
|
||||
}
|
||||
|
||||
/// Repo-level rules by the directory they were looked up for.
|
||||
static REPO_LEVEL: Mutex<Option<HashMap<PathBuf, Arc<RepoLevel>>>> = Mutex::new(None);
|
||||
|
||||
fn stamp_of(file: Option<&Path>) -> Stamp {
|
||||
let meta = std::fs::metadata(file?).ok()?;
|
||||
Some((meta.modified().ok()?, meta.len()))
|
||||
}
|
||||
|
||||
impl GitignoreStack {
|
||||
pub fn new(near: &Path) -> Self {
|
||||
let repo_root = find_repo_root(near);
|
||||
@@ -90,15 +122,52 @@ impl GitignoreStack {
|
||||
Self { repo_root, repo_level, cache: Mutex::default(), nested: Mutex::default() }
|
||||
}
|
||||
|
||||
/// [`new`](Self::new) for listing a directory over and over (expanding a tree, a slow share):
|
||||
/// the walk up to the repository root and the repository discovery are reused for
|
||||
/// [`REPO_LEVEL_TTL`]. An edit of `.git/info/exclude` is still seen at once (its mtime and size
|
||||
/// are checked), as is any `.gitignore` / `.ignore`, which are read fresh. Only a repository
|
||||
/// created in the directory, or a change of the global excludes file, can lag by the TTL.
|
||||
pub fn cached(near: &Path) -> Self {
|
||||
let now = Instant::now();
|
||||
let hit = REPO_LEVEL
|
||||
.lock()
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(near))
|
||||
.cloned()
|
||||
.filter(|e| now.duration_since(e.at) < REPO_LEVEL_TTL && stamp_of(e.exclude.as_deref()) == e.stamp);
|
||||
let entry = hit.unwrap_or_else(|| {
|
||||
let repo_root = find_repo_root(near);
|
||||
let exclude = repo_root.as_deref().map(exclude_path);
|
||||
let entry = Arc::new(RepoLevel {
|
||||
at: now,
|
||||
stamp: stamp_of(exclude.as_deref()),
|
||||
rules: Self::load_repo_level_from(repo_root.as_deref(), exclude.as_deref()),
|
||||
repo_root,
|
||||
exclude,
|
||||
});
|
||||
let mut guard = REPO_LEVEL.lock();
|
||||
let map = guard.get_or_insert_with(HashMap::new);
|
||||
map.retain(|_, e| now.duration_since(e.at) < REPO_LEVEL_TTL);
|
||||
map.insert(near.to_path_buf(), entry.clone());
|
||||
entry
|
||||
});
|
||||
Self {
|
||||
repo_root: entry.repo_root.clone(),
|
||||
repo_level: RwLock::new(entry.rules.clone()),
|
||||
cache: Mutex::default(),
|
||||
nested: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_repo_level(repo_root: Option<&Path>) -> Vec<Gitignore> {
|
||||
Self::load_repo_level_from(repo_root, repo_root.map(exclude_path).as_deref())
|
||||
}
|
||||
|
||||
fn load_repo_level_from(repo_root: Option<&Path>, exclude: Option<&Path>) -> Vec<Gitignore> {
|
||||
let mut repo_level = Vec::new();
|
||||
if let Some(root) = repo_root {
|
||||
let mut builder = GitignoreBuilder::new(root);
|
||||
let exclude = gix::discover(root)
|
||||
.ok()
|
||||
.map(|r| r.common_dir().join("info/exclude"))
|
||||
.unwrap_or_else(|| root.join(".git/info/exclude"));
|
||||
if exclude.is_file() {
|
||||
if let Some(exclude) = exclude.filter(|f| f.is_file()) {
|
||||
builder.add(exclude);
|
||||
}
|
||||
if let Ok(gi) = builder.build()
|
||||
@@ -119,6 +188,9 @@ impl GitignoreStack {
|
||||
|
||||
/// Re-reads `.git/info/exclude` and the global excludes file.
|
||||
pub fn reload_repo_level(&self) {
|
||||
if let (Some(root), Some(map)) = (&self.repo_root, REPO_LEVEL.lock().as_mut()) {
|
||||
map.retain(|dir, _| !dir.starts_with(root));
|
||||
}
|
||||
self.nested.lock().clear();
|
||||
*self.repo_level.write() = Self::load_repo_level(self.repo_root.as_deref());
|
||||
}
|
||||
@@ -298,4 +370,39 @@ mod tests {
|
||||
assert!(anchored.is_excluded(Path::new("build/out.o")));
|
||||
assert!(!anchored.is_excluded(Path::new("web/build/out.o")));
|
||||
}
|
||||
|
||||
fn repo_with_exclude(rule: &str) -> tempfile::TempDir {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
|
||||
std::fs::write(tmp.path().join(".git/info/exclude"), rule).unwrap();
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_stack_sees_every_edit_of_the_ignore_files_at_once() {
|
||||
let tmp = repo_with_exclude("secret.txt\n");
|
||||
let (src, secret, note) =
|
||||
(tmp.path().join("src"), tmp.path().join("src/secret.txt"), tmp.path().join("src/note.md"));
|
||||
assert!(GitignoreStack::cached(&src).is_ignored(&secret, false));
|
||||
assert!(!GitignoreStack::cached(&src).is_ignored(¬e, false));
|
||||
std::fs::write(src.join(".gitignore"), "*.md\n").unwrap();
|
||||
assert!(GitignoreStack::cached(&src).is_ignored(¬e, false), "directory .gitignore");
|
||||
std::fs::write(tmp.path().join(".git/info/exclude"), "").unwrap();
|
||||
assert!(!GitignoreStack::cached(&src).is_ignored(&secret, false), "info/exclude");
|
||||
std::fs::write(tmp.path().join(".git/info/exclude"), "note.md\nsecret.txt\n").unwrap();
|
||||
assert!(GitignoreStack::cached(&src).is_ignored(&secret, false), "info/exclude again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_stack_reuses_the_repository_lookup() {
|
||||
let tmp = repo_with_exclude("");
|
||||
let src = tmp.path().join("src");
|
||||
let first = GitignoreStack::cached(&src);
|
||||
let entry = REPO_LEVEL.lock().as_ref().unwrap().get(&src).unwrap().clone();
|
||||
let again = GitignoreStack::cached(&src);
|
||||
assert!(Arc::ptr_eq(&entry, REPO_LEVEL.lock().as_ref().unwrap().get(&src).unwrap()));
|
||||
assert_eq!(first.repo_root(), again.repo_root());
|
||||
assert_eq!(again.repo_root(), Some(tmp.path()));
|
||||
}
|
||||
}
|
||||
@@ -130,14 +130,15 @@ install it from disk.
|
||||
Only port themes whose licence allows redistribution, keep their licence file,
|
||||
and credit the original authors in `authors` and `description`.
|
||||
|
||||
### From IntelliJ IDEA (automatic)
|
||||
### From JetBrains IDEs (automatic)
|
||||
|
||||
To use a theme you already have in IntelliJ, open **Settings › Plugins**, gear
|
||||
To use a theme you already have in a JetBrains IDE, open **Settings › Plugins**, gear
|
||||
menu:
|
||||
|
||||
- **Import Theme from IntelliJ IDE…** lists the theme plugins installed in the
|
||||
JetBrains IDEs (and Android Studio) on this computer, the theme each IDE uses
|
||||
first. It reads their settings folders: `~/Library/Application Support/JetBrains`
|
||||
- **Import Theme from JetBrains IDE…** lists the theme plugins installed in the
|
||||
JetBrains IDEs (and Android Studio) on this computer, one row per plugin of
|
||||
each IDE, the plugins of the themes the IDEs use first. It reads the settings
|
||||
folder of each IDE's newest version: `~/Library/Application Support/JetBrains`
|
||||
(macOS), `%APPDATA%\JetBrains` (Windows), `~/.config/JetBrains` and
|
||||
`~/.local/share/JetBrains` (Linux).
|
||||
- **Import IntelliJ Theme from File…** takes a theme plugin downloaded from the
|
||||
@@ -146,8 +147,8 @@ menu:
|
||||
|
||||
The whole plugin becomes one OpenIDEA theme plugin, `intellij.<plugin id>`, with
|
||||
each of its themes and their editor schemes; it is installed through the usual
|
||||
install page and the chosen theme (otherwise the first) is applied. Importing
|
||||
the plugin again replaces it. Both are actions too (`plugins.importIntellijTheme`,
|
||||
install page, and the theme in use stays as it is: choose one of its themes in
|
||||
**Settings › Appearance › Theme**. Importing the plugin again replaces it. Both are actions too (`plugins.importIntellijTheme`,
|
||||
`plugins.importInstalledIntellijTheme`). The conversion is the one described
|
||||
below.
|
||||
|
||||
|
||||
@@ -186,3 +186,49 @@ test("quick theme menu offers all four palettes and applies their kind", async (
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", palette.kind);
|
||||
}
|
||||
});
|
||||
|
||||
test("gear › Theme previews the theme under the pointer and keeps the one chosen", async ({ workbench: page }) => {
|
||||
const html = page.locator("html");
|
||||
const gear = page.getByRole("button", { name: "IDE and Project Settings", exact: true });
|
||||
const theme = (label: string) => page.getByRole("menuitemcheckbox", { name: label, exact: true });
|
||||
await gear.click();
|
||||
await page.getByRole("menuitem", { name: "Theme", exact: true }).hover();
|
||||
await theme("Solarized Dark").hover();
|
||||
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
|
||||
// A plugin's theme previews too.
|
||||
await theme("Harbor Night").hover();
|
||||
await expect(html).toHaveAttribute("data-plugin-theme", /harbor-night$/);
|
||||
await expect(html).not.toHaveAttribute("data-color-theme");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(html).not.toHaveAttribute("data-plugin-theme");
|
||||
await expect(html).toHaveAttribute("data-theme", "light");
|
||||
|
||||
await gear.click();
|
||||
await page.getByRole("menuitem", { name: "Theme", exact: true }).hover();
|
||||
await theme("One Dark").hover();
|
||||
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
|
||||
await theme("One Dark").click();
|
||||
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
|
||||
// Chosen, not previewed: the next start-up opens with it.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("openidea.theme") ?? "{}").colorTheme))
|
||||
.toBe("one-dark");
|
||||
});
|
||||
|
||||
test("Settings › Appearance › Theme previews the highlighted theme until Cancel", async ({ workbench: page }) => {
|
||||
const html = page.locator("html");
|
||||
const dialog = await settings(page);
|
||||
await page.getByRole("button", { name: "Theme", exact: true }).click();
|
||||
await page.getByRole("option", { name: "Solarized Dark", exact: true }).hover();
|
||||
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(html).toHaveAttribute("data-theme", "light");
|
||||
await expect(html).not.toHaveAttribute("data-color-theme");
|
||||
// Picked: it shows until Apply or Cancel.
|
||||
await choose(page, "Theme", "One Dark");
|
||||
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(html).toHaveAttribute("data-theme", "light");
|
||||
await expect(html).not.toHaveAttribute("data-color-theme");
|
||||
});
|
||||
+3
-1
@@ -14,8 +14,10 @@ args = ["language-server", "--protocol=lsp"]
|
||||
root-markers = ["pubspec.yaml", "analysis_options.yaml"]
|
||||
install-hint = "Install the Dart SDK (https://dart.dev/get-dart) or Flutter"
|
||||
|
||||
# `dart` here; a project that depends on the Flutter SDK is switched to
|
||||
# `flutter debug_adapter` at session start (see `ide-dap`'s `start_with`).
|
||||
[debugger]
|
||||
adapter = "dart"
|
||||
command = "dart"
|
||||
args = ["debug_adapter"]
|
||||
install-hint = "Install the Dart SDK (https://dart.dev/get-dart); use `flutter debug_adapter` for Flutter apps"
|
||||
install-hint = "Install the Dart SDK (https://dart.dev/get-dart) or Flutter"
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "openidea",
|
||||
"private": true,
|
||||
"description": "An open-source IDE inspired by IntelliJ IDEA",
|
||||
"version": "0.1.36",
|
||||
"version": "0.1.37",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+11
-6
@@ -36,7 +36,7 @@ sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
|
||||
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave
|
||||
```
|
||||
|
||||
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.0.0`, world
|
||||
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.1.0`, world
|
||||
`plugin`).
|
||||
|
||||
## 1. Quick start
|
||||
@@ -71,7 +71,8 @@ target through rustup if needed), copies the component to the manifest's
|
||||
`wasm` path and runs `check`. `check` validates the package exactly as
|
||||
_Install Plugin from Disk…_ would — manifest, every contribution, the SVG
|
||||
sanitiser, the component — and that the component imports nothing but the
|
||||
plugin API and WASI. It prints what the user will be asked to grant.
|
||||
plugin API and WASI and links against the IDE's: every function it imports
|
||||
exists with the same signature, and it exports the plugin world. It prints what the user will be asked to grant.
|
||||
|
||||
`link` adds the folder to `links.json` in the IDE's plugins directory
|
||||
(`$OPENIDEA_PLUGINS_DIR`, default `<app-data>/ai.l0ng.openidea/plugins`). Linked
|
||||
@@ -183,7 +184,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
|
||||
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
|
||||
| `editor.read` | low | `TextEditor::document`, `selections` |
|
||||
| `editor.write` | medium | `TextEditor::apply_edits` |
|
||||
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes) |
|
||||
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes` |
|
||||
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only) |
|
||||
| `storage` | low | `host::storage` (1 MiB per plugin) |
|
||||
| `events` | low | file opened/saved, editor focused, repository changed events |
|
||||
@@ -191,7 +192,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
|
||||
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
|
||||
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
|
||||
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
|
||||
| `clipboard` | medium | the clipboard API in the plugin's web views (keyboard copy / paste work without it) |
|
||||
| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views |
|
||||
|
||||
`process:` capabilities are also derived from what the contributions start: a
|
||||
language's `language-servers` and `debugger` commands and a run detector's
|
||||
@@ -299,12 +300,14 @@ then run `openidea-plugin check`.
|
||||
| `TextEditor::apply_edits(version, edits)` | `editor.text-editor.apply-edits` | One undoable change, only if the document is still at `version` — else `Error::Stale` and nothing changes. `transform_selections` retries once. |
|
||||
| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win. |
|
||||
| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). |
|
||||
| `Project::vcs_status()`, `vcs_fetch()` | `vcs` | Fetch runs git, so only in trusted projects; call it inside a task. |
|
||||
| `Project::vcs_status()`, `vcs_remotes()`, `vcs_fetch()` | `vcs` | `vcs_remotes` returns `(name, url)` pairs; fetch runs git, so only in trusted projects (call it inside a task). |
|
||||
| `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. |
|
||||
| `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. |
|
||||
| `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. |
|
||||
| `host::set_status_item(id, item)`, `remove_status_item` | `ui` | `text`, `tooltip`, `command` (one of yours, run on click); at most 8; kept while the plugin is idle. |
|
||||
| `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). |
|
||||
| `host::copy_to_clipboard(text)` | `ui.copy-to-clipboard` | Copies `text` to the system clipboard. Needs `clipboard`. |
|
||||
| `host::open_url(url)` | `ui.open-url` | Opens an `http://` or `https://` URL in the user's default browser. |
|
||||
| `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. |
|
||||
| `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. |
|
||||
| `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. |
|
||||
@@ -766,7 +769,7 @@ granted, and starts the approved program.
|
||||
the package at its root or in its only folder, or a folder's `plugin.toml`;
|
||||
a zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused; IntelliJ
|
||||
theme plugins are imported instead: _Import IntelliJ Theme from File…_ and _Import Theme
|
||||
from IntelliJ IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
|
||||
from JetBrains IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
|
||||
page lists every capability and whether the package is signed by a publisher
|
||||
the marketplace trusts; nothing is copied until you accept.
|
||||
- **Versions:** installed plugins live in `<app-data>/plugins/<id>/<version>/`
|
||||
@@ -942,6 +945,8 @@ only grows:
|
||||
its `openidea:plugin/*` imports and exports, `PluginEngine::check_component`).
|
||||
Installing and `openidea-plugin check` refuse another major or a newer
|
||||
minor with a message that says to update the IDE or rebuild with the SDK.
|
||||
They also link the component, so one that imports a function this host
|
||||
lacks under the same version (built from unreleased WIT) is refused too.
|
||||
5. **One version number**: the `wit/` package version, `ENGINE_VERSION` and
|
||||
`API_VERSION` agree, and the SDK's `wit/` copy is identical to `wit/`.
|
||||
6. **1.x calls are synchronous and pass documents whole.** Host calls block
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
9ad412cb7b853d40467c67305a1126bb87910d179ecfa1c88e13d4a900367c50
|
||||
12c9f58ab05321fa3966a0179c085e2972db278adf20b917b50f30ce048aa67d
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
49a3c6bf2511ba137457397203244bdfca3d6e10f62dbc7fde9473dab07f6457
|
||||
2f4608630c5e41453458ecee5bc4e2f1994a680d4307a5f002f9dd5ccc04d457
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
c199dc52e00d3943882d532e7e6095f7b2f8d7d6026cf1ccaa685dd6300f2319
|
||||
74167252b0e306f94eb00ac9b467a5f30c373b9f15e223b21afa71c67c1dec0b
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
6047be5eeaa1330d7aa1f182e88ae2f352f244829ad9a01fdc3a2a081bdca95d
|
||||
66e7f43eaf7bbadf1c5c817c6ce8da3adca0543ce5e12550440b4039b235581d
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
7a3ee410d228d1e39155aa042237d222d23d7f5a45e261086cffc45c6ee1ad32
|
||||
04cd70714c6bac560783492b3bd31d22f7056b9d8809df14163be935785dd114
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
7658a95eb9fc27468cae4b1d20e3b5ab299fde478e07f69ab0fdb4cc10caf11d
|
||||
674f439626f08e931842c0c108a57c899ea38d2b5b2de38debd3b4b46feb5777
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
227a30ef0028f167a20825bdd775b15392161e641f6c68030e01d0c79e263bb5
|
||||
ee40771fe778d4af1f762c1512155ea375ee3ed238390c65a5461b077fed6f8b
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
046f769822102de0d4ecec99ad41027cb792941065226716f38a6e64c50ff364
|
||||
b9cc6408738acd7a89ff76632d91ec49680c7a5ff6bee292efa77d182003ec25
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
0b9c3572390a3caab7d47f9b975439f8dde3feff6b9044579d99b8334ac2a692
|
||||
6c9c35547a7ce1c240be83bbb30418a28ab8e2e4034401205e35ec2dee87abbf
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
0af62bcd4583d126a616cafbf2ddc80f039a3ead2b5dcb7144222b0ebc673432
|
||||
56af2a764333e739d53fb5c8346835b4796cf76611c36b4685a428b49cc4eae2
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
24ef150beefb05622212228e1c44cb487fffe6526776762d713f3ebdf4c02ac0
|
||||
ac745c20866ed959252778116af068d7264187a53df64e2de090522b7ba5b722
|
||||
@@ -220,11 +220,6 @@ describe("IntelliJ theme import", () => {
|
||||
],
|
||||
});
|
||||
expect(pkg.id).toBe("intellij.com-example-deepsea");
|
||||
expect(pkg.themes).toEqual([
|
||||
{ id: "deep-sea", sourceId: "deep-sea-id", name: "Deep Sea" },
|
||||
{ id: "deep-sea-2", sourceId: "copy-id", name: "Deep Sea" },
|
||||
{ id: "shallow", sourceId: "light-id", name: "Shallow" },
|
||||
]);
|
||||
expect(pkg.files.map((f) => f.path)).toEqual([
|
||||
"plugin.toml",
|
||||
"themes/deep-sea.theme.json",
|
||||
|
||||
@@ -73,6 +73,14 @@ mod imp {
|
||||
wit::ui::remove_status_item(id)
|
||||
}
|
||||
|
||||
pub fn copy_to_clipboard(text: &str) -> Result<(), Error> {
|
||||
wit::ui::copy_to_clipboard(text).map_err(error)
|
||||
}
|
||||
|
||||
pub fn open_url(url: &str) -> Result<(), Error> {
|
||||
wit::ui::open_url(url).map_err(error)
|
||||
}
|
||||
|
||||
pub fn storage_get(key: &str) -> Result<Option<String>, Error> {
|
||||
wit::storage::get(key).map_err(error)
|
||||
}
|
||||
@@ -152,6 +160,10 @@ mod imp {
|
||||
wit::vcs::fetch(p).map_err(error)
|
||||
}
|
||||
|
||||
pub fn vcs_remotes(p: &ProjectRes) -> Result<Vec<(String, String)>, Error> {
|
||||
wit::vcs::remotes(p).map_err(error)
|
||||
}
|
||||
|
||||
pub fn subscribe(k: EventKind) -> Result<(), Error> {
|
||||
wit::events::subscribe(k.name()).map_err(error)
|
||||
}
|
||||
@@ -339,6 +351,11 @@ impl Project {
|
||||
imp::vcs_status(&self.inner)
|
||||
}
|
||||
|
||||
/// Configured remotes (`(name, fetch_url)`), in order. Needs `vcs.read`.
|
||||
pub fn vcs_remotes(&self) -> Result<Vec<(String, String)>, Error> {
|
||||
imp::vcs_remotes(&self.inner)
|
||||
}
|
||||
|
||||
/// Fetches all remotes. Needs `vcs.write`; long-running, so call it in a [`Task`].
|
||||
pub fn vcs_fetch(&self) -> Result<(), Error> {
|
||||
imp::vcs_fetch(&self.inner)
|
||||
@@ -528,6 +545,16 @@ pub fn remove_status_item(id: &str) {
|
||||
imp::remove_status_item(id)
|
||||
}
|
||||
|
||||
/// Copies `text` to the system clipboard. Needs `clipboard`.
|
||||
pub fn copy_to_clipboard(text: &str) -> Result<(), Error> {
|
||||
imp::copy_to_clipboard(text)
|
||||
}
|
||||
|
||||
/// Opens an `http://` or `https://` URL in the user's default browser.
|
||||
pub fn open_url(url: &str) -> Result<(), Error> {
|
||||
imp::open_url(url)
|
||||
}
|
||||
|
||||
/// Delivers `kind` events to [`crate::Plugin::handle_events`] for the rest of
|
||||
/// the session (also after the instance is reclaimed: an event activates the
|
||||
/// plugin again). Needs `events` (`events.input` for `ActionPerformed`).
|
||||
|
||||
@@ -89,7 +89,7 @@ pub mod providers_bindings {
|
||||
export_macro_name: "__export_editor_providers_world",
|
||||
default_bindings_module: "openidea_plugin::providers_bindings",
|
||||
with: {
|
||||
"openidea:plugin/types@1.0.0": crate::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/types@1.1.0": crate::bindings::openidea::plugin::types,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -106,16 +106,16 @@ pub mod views_bindings {
|
||||
export_macro_name: "__export_views_world",
|
||||
default_bindings_module: "openidea_plugin::views_bindings",
|
||||
with: {
|
||||
"openidea:plugin/types@1.0.0": crate::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace@1.0.0": crate::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor@1.0.0": crate::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation@1.0.0": crate::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/types@1.1.0": crate::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace@1.1.0": crate::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor@1.1.0": crate::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation@1.1.0": crate::bindings::openidea::plugin::invocation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// The plugin API version this SDK targets.
|
||||
pub const API_VERSION: &str = "1.0";
|
||||
pub const API_VERSION: &str = "1.1";
|
||||
|
||||
/// A plugin. One instance lives as long as the plugin's Wasm instance (until
|
||||
/// it is disabled, crashes or is reclaimed when idle).
|
||||
|
||||
@@ -63,8 +63,11 @@ struct State {
|
||||
subscriptions: BTreeSet<EventKind>,
|
||||
tasks: Vec<MockTask>,
|
||||
vcs: Option<RepositoryStatus>,
|
||||
remotes: Vec<(String, String)>,
|
||||
fetches: u32,
|
||||
fetch_error: Option<Error>,
|
||||
clipboard: Option<String>,
|
||||
opened_urls: Vec<String>,
|
||||
diagnostics: BTreeMap<String, Vec<Diagnostic>>,
|
||||
secrets: BTreeMap<String, String>,
|
||||
net: Option<NetHandler>,
|
||||
@@ -96,8 +99,17 @@ pub struct MockHost {
|
||||
}
|
||||
|
||||
/// Every capability a plugin can hold, except `process:`/`net:`/`fs.read:`.
|
||||
const ALL: &[&str] =
|
||||
&["project.read", "editor.read", "editor.write", "vcs.read", "vcs.write", "storage", "events", "events.input"];
|
||||
const ALL: &[&str] = &[
|
||||
"project.read",
|
||||
"editor.read",
|
||||
"editor.write",
|
||||
"vcs.read",
|
||||
"vcs.write",
|
||||
"storage",
|
||||
"events",
|
||||
"events.input",
|
||||
"clipboard",
|
||||
];
|
||||
|
||||
impl MockHost {
|
||||
/// Installs a mock granting the usual capabilities, with project `/project`.
|
||||
@@ -197,6 +209,21 @@ impl MockHost {
|
||||
self.state.borrow_mut().vcs = status;
|
||||
}
|
||||
|
||||
/// Remotes `vcs-remotes` returns for every project.
|
||||
pub fn set_vcs_remotes(&self, remotes: &[(&str, &str)]) {
|
||||
self.state.borrow_mut().remotes = remotes.iter().map(|&(n, u)| (n.to_owned(), u.to_owned())).collect();
|
||||
}
|
||||
|
||||
/// Last text copied with [`crate::host::copy_to_clipboard`].
|
||||
pub fn clipboard(&self) -> Option<String> {
|
||||
self.state.borrow().clipboard.clone()
|
||||
}
|
||||
|
||||
/// URLs opened with [`crate::host::open_url`], in order.
|
||||
pub fn opened_urls(&self) -> Vec<String> {
|
||||
self.state.borrow().opened_urls.clone()
|
||||
}
|
||||
|
||||
/// Makes `vcs-fetch` fail.
|
||||
pub fn fail_fetch(&self, error: Option<Error>) {
|
||||
self.state.borrow_mut().fetch_error = error;
|
||||
@@ -350,6 +377,25 @@ pub mod current {
|
||||
with(|s| s.status.remove(id));
|
||||
}
|
||||
|
||||
pub fn copy_to_clipboard(text: &str) -> Result<(), Error> {
|
||||
with(|s| {
|
||||
require(s, "clipboard")?;
|
||||
s.clipboard = Some(text.to_owned());
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_url(url: &str) -> Result<(), Error> {
|
||||
let trimmed = url.trim();
|
||||
if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {
|
||||
return Err(Error::Invalid(format!("{url:?}: expected an http:// or https:// URL")));
|
||||
}
|
||||
with(|s| {
|
||||
s.opened_urls.push(trimmed.to_owned());
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn storage_get(key: &str) -> Result<Option<String>, Error> {
|
||||
with(|s| {
|
||||
require(s, "storage")?;
|
||||
@@ -496,6 +542,13 @@ pub mod current {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn vcs_remotes(_: &ProjectRes) -> Result<Vec<(String, String)>, Error> {
|
||||
with(|s| {
|
||||
require(s, "vcs.read")?;
|
||||
Ok(s.remotes.clone())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subscribe(kind: EventKind) -> Result<(), Error> {
|
||||
with(|s| {
|
||||
let cap = match kind {
|
||||
@@ -680,6 +733,13 @@ mod tests {
|
||||
assert_eq!(host::storage::keys("").unwrap(), ["k"]);
|
||||
host::set_status_item("s", &StatusItem { text: "x".into(), tooltip: None, command: None });
|
||||
assert_eq!(mock.status_items()["s"].text, "x");
|
||||
mock.set_vcs_remotes(&[("origin", "[email protected]:o/r.git")]);
|
||||
assert_eq!(project.vcs_remotes().unwrap(), [("origin".into(), "[email protected]:o/r.git".into())]);
|
||||
host::copy_to_clipboard("copied").unwrap();
|
||||
assert_eq!(mock.clipboard().as_deref(), Some("copied"));
|
||||
host::open_url("https://example.com/a").unwrap();
|
||||
assert_eq!(mock.opened_urls(), ["https://example.com/a"]);
|
||||
assert!(matches!(host::open_url("file:///etc/passwd"), Err(Error::Invalid(_))));
|
||||
{
|
||||
let task = host::begin_task("Work", true);
|
||||
task.report(1, Some(2), None);
|
||||
@@ -709,6 +769,8 @@ mod tests {
|
||||
assert_eq!(editor.apply_edits(1, &[]), Err(Error::Denied("editor.write".into())));
|
||||
assert_eq!(host::storage::get("k"), Err(Error::Denied("storage".into())));
|
||||
assert_eq!(host::subscribe(EventKind::ActionPerformed), Err(Error::Denied("events.input".into())));
|
||||
assert_eq!(mock.invocation().project().unwrap().vcs_remotes(), Err(Error::Denied("vcs.read".into())));
|
||||
assert_eq!(host::copy_to_clipboard("x"), Err(Error::Denied("clipboard".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Workbench events, delivered in batches to `event-handler.handle`.
|
||||
interface events {
|
||||
use types.{error, extension};
|
||||
|
||||
record action-performed {
|
||||
/// Action id (`editor.duplicate-line`).
|
||||
action: string,
|
||||
title: string,
|
||||
/// How the user started it: `keyboard`, `menu`, `toolbar`,
|
||||
/// `context-menu`, `search` or `other` (more may be added).
|
||||
source: string,
|
||||
/// Its keyboard shortcut, when it has one (`Ctrl+D`).
|
||||
shortcut: option<string>,
|
||||
}
|
||||
|
||||
variant event {
|
||||
file-opened(string),
|
||||
file-saved(string),
|
||||
editor-focused(option<string>),
|
||||
action-performed(action-performed),
|
||||
vcs-changed(string),
|
||||
scheduled,
|
||||
/// A kind added after 1.0 (subscribed to by its name).
|
||||
other(extension),
|
||||
}
|
||||
|
||||
/// Delivers `kind` events from now on, for the rest of the session (also
|
||||
/// across idle reclaim: an event activates the plugin again). Declare
|
||||
/// `activation = ["on-event:<kind>"]` to get them before first use.
|
||||
/// Kinds: `file-opened`, `file-saved`, `editor-focused` (the focused editor
|
||||
/// changed), `vcs-changed` (a repository's state changed: commit,
|
||||
/// checkout, fetch…), `action-performed` (an IDE action ran) and
|
||||
/// `scheduled` (the `on-schedule` period elapsed); later IDEs add more,
|
||||
/// and an IDE that doesn't know one fails with `invalid`.
|
||||
/// Requires `events` (`events.input` for `action-performed`).
|
||||
subscribe: func(kind: string) -> result<_, error>;
|
||||
unsubscribe: func(kind: string);
|
||||
}
|
||||
|
||||
/// Long-running work with a progress indicator. While a task is open, the
|
||||
/// call that began it may run for the background budget (60 s) instead of 5 s.
|
||||
interface tasks {
|
||||
resource task {
|
||||
/// `done` of `total` units (`none`: indeterminate), with a short status.
|
||||
report: func(done: u32, total: option<u32>, message: option<string>);
|
||||
/// The user cancelled it: stop soon and return.
|
||||
is-cancelled: func() -> bool;
|
||||
}
|
||||
|
||||
/// Starts a task shown in the status bar; dropping it ends it.
|
||||
begin: func(title: string, cancellable: bool) -> task;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
interface lifecycle {
|
||||
use types.{context, error};
|
||||
|
||||
/// Called once after the instance is created: subscribe to events and
|
||||
/// restore state from `storage`. Contributions (commands, languages,
|
||||
/// themes, …) are declared in plugin.toml, not registered here.
|
||||
/// An error fails the activation (shown on the plugin's page).
|
||||
activate: func(ctx: context) -> result<_, error>;
|
||||
/// Called before the instance is dropped (disable, uninstall, idle reclaim).
|
||||
deactivate: func();
|
||||
}
|
||||
|
||||
interface commands {
|
||||
use types.{error};
|
||||
use invocation.{invocation};
|
||||
|
||||
/// Runs one of the plugin's commands. Returns an optional message.
|
||||
/// `cancelled` (the user gave up) and `denied` (a capability is missing)
|
||||
/// are not counted as the plugin failing.
|
||||
run: func(id: string, ctx: borrow<invocation>, arg: option<string>) -> result<option<string>, error>;
|
||||
}
|
||||
|
||||
interface event-handler {
|
||||
use events.{event};
|
||||
use invocation.{invocation};
|
||||
|
||||
/// A batch of subscribed events, oldest first, from one window (`ctx`).
|
||||
handle: func(events: list<event>, ctx: borrow<invocation>);
|
||||
}
|
||||
|
||||
/// Optional export (world `plugin-syntax`): syntax of the plugin's own
|
||||
/// languages, for languages the IDE has no grammar for. The IDE calls it
|
||||
/// with the whole document; results are flat. Offsets are UTF-8 byte offsets
|
||||
/// into `text` (the IDE converts them); lines are 0-based.
|
||||
interface syntax-provider {
|
||||
/// One highlighted range. `kind` is a highlight category: keyword, string,
|
||||
/// string-special, escape, regexp, atom, number, constant, comment,
|
||||
/// doc-comment, function, function-call, builtin, type, namespace,
|
||||
/// variable, self-variable, property, attribute, macro, label, tag,
|
||||
/// operator, punctuation. Unknown kinds are ignored; ranges must be
|
||||
/// sorted and not overlap.
|
||||
record token {
|
||||
start: u32,
|
||||
end: u32,
|
||||
kind: string,
|
||||
}
|
||||
|
||||
/// A symbol of the structure view. `parent` is the index of the enclosing
|
||||
/// symbol in the same list (which must come first).
|
||||
record symbol {
|
||||
name: string,
|
||||
detail: option<string>,
|
||||
/// class, interface, struct, enum, function, method, constructor,
|
||||
/// field, property, variable, constant, module, namespace, event, …
|
||||
kind: string,
|
||||
start: u32,
|
||||
end: u32,
|
||||
name-start: u32,
|
||||
name-end: u32,
|
||||
parent: option<u32>,
|
||||
}
|
||||
|
||||
/// A foldable line range. `kind`: region, comment or imports.
|
||||
record fold {
|
||||
start-line: u32,
|
||||
end-line: u32,
|
||||
kind: string,
|
||||
}
|
||||
|
||||
highlights: func(language: string, text: string) -> list<token>;
|
||||
outline: func(language: string, text: string) -> list<symbol>;
|
||||
folding: func(language: string, text: string) -> list<fold>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Writes to the IDE log, tagged with the plugin id.
|
||||
interface log {
|
||||
use types.{log-level};
|
||||
|
||||
log: func(level: log-level, message: string);
|
||||
}
|
||||
|
||||
interface ui {
|
||||
use types.{notification-level};
|
||||
|
||||
record status-item {
|
||||
text: string,
|
||||
tooltip: option<string>,
|
||||
/// One of the plugin's commands, run when the item is clicked.
|
||||
command: option<string>,
|
||||
}
|
||||
|
||||
/// Shows a notification balloon in the window the current command or
|
||||
/// event came from (every window when there is none).
|
||||
show-notification: func(level: notification-level, title: string, body: string);
|
||||
/// Shows or updates the plugin's status bar item `id` in every window.
|
||||
set-status-item: func(id: string, item: status-item);
|
||||
remove-status-item: func(id: string);
|
||||
}
|
||||
|
||||
/// Persistent key-value storage of the plugin (survives restarts and idle
|
||||
/// reclaim; instance memory does not). Requires `storage`. At most 1 MiB.
|
||||
interface storage {
|
||||
use types.{error};
|
||||
|
||||
get: func(key: string) -> result<option<string>, error>;
|
||||
set: func(key: string, value: string) -> result<_, error>;
|
||||
delete: func(key: string) -> result<_, error>;
|
||||
/// Keys starting with `prefix`, sorted.
|
||||
keys: func(prefix: string) -> result<list<string>, error>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Problems in files, shown in the editor and the Problems view.
|
||||
interface diagnostics {
|
||||
use types.{error, range, location};
|
||||
|
||||
enum severity {
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
hint,
|
||||
}
|
||||
|
||||
/// Another place that explains a diagnostic (the earlier declaration, say).
|
||||
record related-information {
|
||||
location: location,
|
||||
message: string,
|
||||
}
|
||||
|
||||
record diagnostic {
|
||||
range: range,
|
||||
severity: severity,
|
||||
message: string,
|
||||
/// Rule or check id (`LineLength`).
|
||||
code: option<string>,
|
||||
/// The tool that found it, when not the plugin itself (`checkstyle`).
|
||||
source: option<string>,
|
||||
/// `unnecessary` (shown faded) and `deprecated` (struck through); unknown ones are ignored.
|
||||
tags: list<string>,
|
||||
related: list<related-information>,
|
||||
}
|
||||
|
||||
/// Replaces the plugin's diagnostics for the file at `path` (absolute, in
|
||||
/// an open project); an empty list clears them. Requires `project.read`.
|
||||
publish: func(path: string, diagnostics: list<diagnostic>) -> result<_, error>;
|
||||
}
|
||||
|
||||
/// HTTP(S) to the hosts granted with `net:<host>`.
|
||||
interface net {
|
||||
use types.{error};
|
||||
|
||||
record header {
|
||||
name: string,
|
||||
value: string,
|
||||
}
|
||||
|
||||
record request {
|
||||
method: string,
|
||||
/// `https://…`; plain `http://` only to `localhost` / `127.0.0.1`.
|
||||
url: string,
|
||||
/// `{{secret:<name>}}` in a value is replaced by the plugin's secret
|
||||
/// `name` on the way out (requires `secrets`); the plugin never sees it.
|
||||
headers: list<header>,
|
||||
body: option<list<u8>>,
|
||||
/// Default 30 s; the call's own budget applies too.
|
||||
timeout-ms: option<u32>,
|
||||
}
|
||||
|
||||
record response {
|
||||
status: u16,
|
||||
headers: list<header>,
|
||||
/// At most 8 MiB.
|
||||
body: list<u8>,
|
||||
}
|
||||
|
||||
/// Fails with `denied` for a host the plugin may not reach, `cancelled`
|
||||
/// when a newer request replaced the call or the user cancelled.
|
||||
fetch: func(req: request) -> result<response, error>;
|
||||
}
|
||||
|
||||
/// Secrets the user stored for the plugin (Settings › Plugins, kept in the
|
||||
/// OS keychain). Plugins only use them through `{{secret:<name>}}` in
|
||||
/// `net` requests. Requires `secrets`.
|
||||
interface secrets {
|
||||
is-set: func(name: string) -> bool;
|
||||
}
|
||||
|
||||
/// One-off runs of programs granted with `process:<program>` (the file the
|
||||
/// user approved, checked before every run).
|
||||
interface process {
|
||||
use types.{error};
|
||||
use workspace.{project};
|
||||
|
||||
record output {
|
||||
/// `none` when killed by a signal.
|
||||
status: option<s32>,
|
||||
/// At most 4 MiB each (the rest is dropped).
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
}
|
||||
|
||||
/// Runs `program` with `args` in `cwd`'s root (else the plugin's data
|
||||
/// directory) and waits; cancelled with the task or call. Call it inside
|
||||
/// a task for anything slow.
|
||||
run: func(program: string, args: list<string>, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/// OpenIDEA plugin API, version 1.0. Once released, 1.x only adds (new
|
||||
/// interfaces, new optional exports; plugins/README.md, "Evolving the plugin
|
||||
/// API"); a 2.0 keeps 1.0 running through an adapter in the host.
|
||||
///
|
||||
/// A plugin is a WebAssembly component targeting the `plugin` world (or
|
||||
/// `plugin-syntax`). It runs sandboxed: no filesystem, network, environment
|
||||
/// or processes beyond WASI's clocks and randomness; everything else goes
|
||||
/// through the imports below, gated by the capabilities granted in
|
||||
/// `plugin.toml` (a missing one returns `error::denied`).
|
||||
package openidea:[email protected];
|
||||
|
||||
world plugin {
|
||||
import log;
|
||||
import ui;
|
||||
import storage;
|
||||
import workspace;
|
||||
import editor;
|
||||
import invocation;
|
||||
import vcs;
|
||||
import events;
|
||||
import tasks;
|
||||
import diagnostics;
|
||||
import net;
|
||||
import secrets;
|
||||
import process;
|
||||
import views;
|
||||
import settings;
|
||||
|
||||
export lifecycle;
|
||||
export commands;
|
||||
export event-handler;
|
||||
}
|
||||
|
||||
/// A plugin that also provides syntax for its languages.
|
||||
world plugin-syntax {
|
||||
include plugin;
|
||||
export syntax-provider;
|
||||
}
|
||||
|
||||
/// A plugin with editor providers (`[[contributes.providers]]`).
|
||||
world plugin-providers {
|
||||
include plugin;
|
||||
export providers;
|
||||
}
|
||||
|
||||
/// A plugin with views or custom editors (`[[contributes.views]]`,
|
||||
/// `[[contributes.custom-editors]]`).
|
||||
world plugin-views {
|
||||
include plugin;
|
||||
export view-provider;
|
||||
}
|
||||
|
||||
/// Only the `syntax-provider` export: guests generate it next to `plugin`
|
||||
/// (the component tools merge both into one component).
|
||||
world syntax {
|
||||
export syntax-provider;
|
||||
}
|
||||
|
||||
/// Only the `providers` export, generated next to `plugin` like `syntax`.
|
||||
world editor-providers {
|
||||
export providers;
|
||||
}
|
||||
|
||||
/// Only the `view-provider` export, generated next to `plugin` like `syntax`.
|
||||
world views-provider {
|
||||
export view-provider;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Editor features a plugin provides for the languages it declares under
|
||||
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
|
||||
/// on the editing path: a result later than ~50 ms is dropped, a call still
|
||||
/// running at 500 ms is interrupted.
|
||||
interface providers {
|
||||
use types.{error, position, range, text-edit, location};
|
||||
|
||||
/// The document a request is about.
|
||||
record document {
|
||||
path: string,
|
||||
language: string,
|
||||
version: u64,
|
||||
text: string,
|
||||
}
|
||||
|
||||
record completion-item {
|
||||
label: string,
|
||||
detail: option<string>,
|
||||
/// Icon and sort group: text, method, function, constructor, field,
|
||||
/// variable, class, interface, module, property, unit, value, enum,
|
||||
/// keyword, snippet, color, file, reference, folder, enum-member,
|
||||
/// constant, struct, event, operator or type-parameter. Unknown kinds
|
||||
/// show no icon.
|
||||
kind: option<string>,
|
||||
/// With `snippet`, tab stops and placeholders (`$1`, `${1:name}`, `$0`).
|
||||
insert-text: string,
|
||||
snippet: bool,
|
||||
/// Replaced on accept; `none`: the word at the caret.
|
||||
replace: option<range>,
|
||||
sort-text: option<string>,
|
||||
/// Matched against what was typed instead of `label`.
|
||||
filter-text: option<string>,
|
||||
documentation: option<string>,
|
||||
/// Applied with the item elsewhere in the document (an import).
|
||||
additional-edits: list<text-edit>,
|
||||
}
|
||||
|
||||
record inline-suggestion {
|
||||
insert-text: string,
|
||||
/// Replaced range; `none` inserts at the caret.
|
||||
replace: option<range>,
|
||||
}
|
||||
|
||||
record file-edit {
|
||||
path: string,
|
||||
edits: list<text-edit>,
|
||||
}
|
||||
|
||||
record code-action {
|
||||
title: string,
|
||||
/// `quickfix`, `refactor`, …
|
||||
kind: option<string>,
|
||||
preferred: bool,
|
||||
edits: list<file-edit>,
|
||||
/// The plugin's own reference: with it and no `edits`, the edits are
|
||||
/// computed by `resolve-code-action` once the user picks the action.
|
||||
data: option<string>,
|
||||
}
|
||||
|
||||
record line-marker {
|
||||
line: u32,
|
||||
/// Gutter icon: `navigate` (default), `implemented`, `implementing`, `run`.
|
||||
icon: option<string>,
|
||||
tooltip: string,
|
||||
/// Opened on click.
|
||||
target: option<location>,
|
||||
}
|
||||
|
||||
completion: func(doc: document, at: position, trigger: option<string>) -> list<completion-item>;
|
||||
/// Ghost text at the caret (typing replaces an older request: it is cancelled).
|
||||
inline-completion: func(doc: document, at: position) -> option<inline-suggestion>;
|
||||
/// Actions for `selection` (quick fixes for the plugin's own diagnostics).
|
||||
code-actions: func(doc: document, selection: range) -> list<code-action>;
|
||||
/// The edits of an action `code-actions` returned with `data`, when the
|
||||
/// user picks it (the command budget applies, not the editor's 50 ms).
|
||||
resolve-code-action: func(doc: document, action: code-action) -> result<code-action, error>;
|
||||
definition: func(doc: document, at: position) -> list<location>;
|
||||
line-markers: func(doc: document) -> list<line-marker>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
|
||||
/// as in the editor and LSP.
|
||||
interface types {
|
||||
/// Why a host call failed. A capability that is not granted is reported
|
||||
/// as `denied`, so the plugin can degrade (0.1 trapped instead).
|
||||
variant error {
|
||||
/// Not granted; the payload names the capability (`editor.write`).
|
||||
denied(string),
|
||||
/// The document changed since the version the edit was made for.
|
||||
stale,
|
||||
not-found(string),
|
||||
invalid(string),
|
||||
/// Cancelled by the user, or the plugin is being stopped.
|
||||
cancelled,
|
||||
failed(string),
|
||||
}
|
||||
|
||||
enum log-level {
|
||||
trace,
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
error,
|
||||
}
|
||||
|
||||
enum notification-level {
|
||||
info,
|
||||
warning,
|
||||
error,
|
||||
}
|
||||
|
||||
record position {
|
||||
line: u32,
|
||||
character: u32,
|
||||
}
|
||||
|
||||
record range {
|
||||
start: position,
|
||||
end: position,
|
||||
}
|
||||
|
||||
record text-edit {
|
||||
range: range,
|
||||
text: string,
|
||||
}
|
||||
|
||||
/// A range in a file (absolute path).
|
||||
record location {
|
||||
path: string,
|
||||
range: range,
|
||||
}
|
||||
|
||||
/// A case added after 1.0, carried by the `other` case of a variant that
|
||||
/// grows (events, view bodies, view events, form fields): its kind and its
|
||||
/// data as JSON. Whoever doesn't know the kind ignores it or shows a
|
||||
/// fallback; the SDKs decode the kinds they know.
|
||||
record extension {
|
||||
kind: string,
|
||||
data: string,
|
||||
}
|
||||
|
||||
/// Passed to `lifecycle.activate`.
|
||||
record context {
|
||||
plugin-id: string,
|
||||
plugin-version: string,
|
||||
/// Version of this API the host implements, e.g. "1.0".
|
||||
api-version: string,
|
||||
/// Capabilities granted to the plugin (`editor.write`, `process:git`, …).
|
||||
capabilities: list<string>,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
/// Declarative views: tool windows declared
|
||||
/// under `[[contributes.views]]` and custom editors under
|
||||
/// `[[contributes.customEditors]]`. The plugin describes what to show; the
|
||||
/// IDE renders it with its own components (theme, keyboard, accessibility)
|
||||
/// and sends the user's actions back as `view-event`s. Nothing of the plugin
|
||||
/// runs in the WebView, except a `html` body, which is shown in a sandboxed
|
||||
/// frame that can only exchange messages with the plugin.
|
||||
interface view-types {
|
||||
use types.{extension};
|
||||
|
||||
record button {
|
||||
id: string,
|
||||
label: string,
|
||||
/// An IDE icon name (`refresh`, `add`, `remove`, `run`, …).
|
||||
icon: option<string>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
/// A tree node; `parent` is the key of an earlier item (none: a root).
|
||||
record tree-item {
|
||||
key: string,
|
||||
parent: option<string>,
|
||||
label: string,
|
||||
icon: option<string>,
|
||||
/// Grey text after the label.
|
||||
detail: option<string>,
|
||||
/// Shows an expander even before its children are known
|
||||
/// (`expand` asks for them).
|
||||
expandable: bool,
|
||||
expanded: bool,
|
||||
}
|
||||
|
||||
record list-item {
|
||||
key: string,
|
||||
label: string,
|
||||
icon: option<string>,
|
||||
detail: option<string>,
|
||||
}
|
||||
|
||||
variant field-value {
|
||||
text(string),
|
||||
multiline(string),
|
||||
checkbox(bool),
|
||||
/// The options and the chosen one.
|
||||
choice(tuple<list<string>, string>),
|
||||
/// A field type added after 1.0 (an IDE that doesn't know it leaves it out).
|
||||
other(extension),
|
||||
}
|
||||
|
||||
record field {
|
||||
key: string,
|
||||
label: string,
|
||||
value: field-value,
|
||||
}
|
||||
|
||||
/// Rows `first-row`..`first-row + len(rows)` of `total-rows`: the IDE
|
||||
/// asks for the rows it shows (`viewport`), so large tables stay cheap.
|
||||
record table {
|
||||
columns: list<string>,
|
||||
total-rows: u32,
|
||||
first-row: u32,
|
||||
rows: list<list<string>>,
|
||||
editable: bool,
|
||||
}
|
||||
|
||||
variant body {
|
||||
/// A centred message (empty state, errors).
|
||||
message(string),
|
||||
tree(list<tree-item>),
|
||||
%list(list<list-item>),
|
||||
form(list<field>),
|
||||
table(table),
|
||||
/// HTML shown in a sandboxed frame: no scripts from elsewhere, no
|
||||
/// network, no IDE API. Its script talks to the plugin with
|
||||
/// `openidea.postMessage(text)` / `openidea.onMessage(fn)`.
|
||||
html(string),
|
||||
/// A body added after 1.0; an IDE that doesn't know it says it needs updating.
|
||||
other(extension),
|
||||
}
|
||||
|
||||
record view {
|
||||
toolbar: list<button>,
|
||||
body: body,
|
||||
/// A line below the body (counts, last refresh…).
|
||||
status: option<string>,
|
||||
}
|
||||
|
||||
/// The table rows the IDE shows.
|
||||
record viewport {
|
||||
first-row: u32,
|
||||
rows: u32,
|
||||
}
|
||||
|
||||
record cell-edit {
|
||||
row: u32,
|
||||
column: u32,
|
||||
value: string,
|
||||
}
|
||||
|
||||
variant view-event {
|
||||
/// A toolbar button.
|
||||
button(string),
|
||||
/// Double click / Enter on a tree or list item.
|
||||
activate(string),
|
||||
select(string),
|
||||
expand(string),
|
||||
collapse(string),
|
||||
/// A form field changed: key and new value (`true`/`false` for checkboxes).
|
||||
field(tuple<string, string>),
|
||||
cell(cell-edit),
|
||||
/// Insert an empty row before this one (the row count: append).
|
||||
insert-row(u32),
|
||||
delete-rows(list<u32>),
|
||||
/// From the `html` body's script.
|
||||
message(string),
|
||||
/// An event added after 1.0, from a body or field added after 1.0.
|
||||
other(extension),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a plugin's code can ask of its views.
|
||||
interface views {
|
||||
use types.{error};
|
||||
|
||||
/// The view (or every custom editor of that id) shows outdated content:
|
||||
/// the IDE renders it again.
|
||||
refresh: func(view: string);
|
||||
/// Sends a message to the script of a view's `html` body.
|
||||
post-message: func(view: string, message: string) -> result<_, error>;
|
||||
/// Opens a terminal tab in the window the call came from, running
|
||||
/// `program` (pinned by a `process:<program>` grant) with `args` in the
|
||||
/// project root: long-running output (logs) and interactive sessions.
|
||||
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
|
||||
}
|
||||
|
||||
/// The plugin's own settings (`[contributes.settings]`), as the user set them
|
||||
/// (project over global) or the schema's default.
|
||||
interface settings {
|
||||
/// The value of `key` as JSON (`true`, `3`, `"text"`), none if unknown.
|
||||
get: func(key: string) -> option<string>;
|
||||
}
|
||||
|
||||
/// Optional export (world `plugin-views`): content of declared views and
|
||||
/// custom editors. For a custom editor, `ctx.editor()` is its document.
|
||||
interface view-provider {
|
||||
use view-types.{view, viewport, view-event};
|
||||
use invocation.{invocation};
|
||||
use types.{error};
|
||||
|
||||
render: func(view: string, viewport: option<viewport>, ctx: borrow<invocation>) -> result<view, error>;
|
||||
handle: func(view: string, event: view-event, ctx: borrow<invocation>) -> result<_, error>;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package openidea:[email protected];
|
||||
|
||||
interface workspace {
|
||||
use types.{error};
|
||||
|
||||
/// A project open in the IDE.
|
||||
resource project {
|
||||
/// Absolute path of the project root. Requires `project.read`.
|
||||
root: func() -> result<string, error>;
|
||||
/// Reads a UTF-8 text file inside the project (absolute, or relative
|
||||
/// to the root); paths leaving the root are refused. Unsaved editor
|
||||
/// contents win over the disk. Requires `project.read`.
|
||||
read-file: func(path: string) -> result<string, error>;
|
||||
/// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`),
|
||||
/// at most `limit`, as absolute paths. Ignored and excluded files are
|
||||
/// skipped. Requires `project.read`.
|
||||
find-files: func(glob: string, limit: u32) -> result<list<string>, error>;
|
||||
}
|
||||
|
||||
/// Projects open in any window. Requires `project.read`.
|
||||
projects: func() -> result<list<project>, error>;
|
||||
}
|
||||
|
||||
interface editor {
|
||||
use types.{error, range, text-edit};
|
||||
|
||||
record document {
|
||||
/// Grows with every change; edits name the version they were made for.
|
||||
version: u64,
|
||||
text: string,
|
||||
}
|
||||
|
||||
/// A file open in an editor.
|
||||
resource text-editor {
|
||||
/// Absolute path of the file.
|
||||
path: func() -> string;
|
||||
/// Language id (`rust`, `solidity`, …).
|
||||
language: func() -> string;
|
||||
/// Current version and text. Requires `editor.read`.
|
||||
document: func() -> result<document, error>;
|
||||
/// Selections when the command was invoked (a cursor is an empty
|
||||
/// range). Requires `editor.read`.
|
||||
selections: func() -> result<list<range>, error>;
|
||||
/// Applies `edits` as one undoable change, only if the document is
|
||||
/// still at `version` (else `stale`, and nothing changes). Ranges are
|
||||
/// in that version and must not overlap. Returns the new version.
|
||||
/// Requires `editor.write`.
|
||||
apply-edits: func(version: u64, edits: list<text-edit>) -> result<u64, error>;
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a command or event comes from.
|
||||
interface invocation {
|
||||
use workspace.{project};
|
||||
use editor.{text-editor};
|
||||
|
||||
resource invocation {
|
||||
/// The window's project.
|
||||
project: func() -> option<project>;
|
||||
/// The window's focused editor.
|
||||
editor: func() -> option<text-editor>;
|
||||
}
|
||||
}
|
||||
|
||||
/// Git, for the repository containing a project.
|
||||
interface vcs {
|
||||
use types.{error};
|
||||
use workspace.{project};
|
||||
|
||||
record repository-status {
|
||||
/// Absolute path of the working tree root.
|
||||
root: string,
|
||||
/// `none` when HEAD is detached.
|
||||
branch: option<string>,
|
||||
commit: option<string>,
|
||||
upstream: option<string>,
|
||||
ahead: u32,
|
||||
behind: u32,
|
||||
/// Merge / rebase / cherry-pick in progress.
|
||||
operation: option<string>,
|
||||
/// Files with staged or unstaged changes.
|
||||
changed-files: u32,
|
||||
}
|
||||
|
||||
/// `none` when the project is not in a repository. Requires `vcs.read`.
|
||||
status: func(project: borrow<project>) -> result<option<repository-status>, error>;
|
||||
/// Fetches all remotes (pruning deleted branches). Requires `vcs.write`.
|
||||
/// Long-running: call it inside a task.
|
||||
fetch: func(project: borrow<project>) -> result<_, error>;
|
||||
}
|
||||
@@ -140,12 +140,18 @@ fn released_apis_are_unchanged() {
|
||||
for (key, was) in &release {
|
||||
match current.get(key) {
|
||||
None => broken.push(format!("{key}: removed (was {was})")),
|
||||
// A world may gain imports and exports; everything else stays as it was.
|
||||
// A world may gain imports and exports; everything else stays as it was. Items are
|
||||
// compared without their `@version`: a later minor names them `@1.1.0`, and wasmtime
|
||||
// resolves the release's `@1.0.0` to it.
|
||||
Some(now) if key.starts_with("world ") => {
|
||||
let had = |s: &str, part: &str| -> Vec<String> {
|
||||
let start = s.find(part).unwrap() + part.len();
|
||||
let end = s[start..].find(']').unwrap() + start;
|
||||
s[start..end].split(", ").filter(|x| !x.is_empty()).map(str::to_owned).collect()
|
||||
s[start..end]
|
||||
.split(", ")
|
||||
.filter(|x| !x.is_empty())
|
||||
.map(|x| x.split('@').next().unwrap_or(x).to_owned())
|
||||
.collect()
|
||||
};
|
||||
for part in ["imports [", "exports ["] {
|
||||
let now = had(now, part);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Workbench events, delivered in batches to `event-handler.handle`.
|
||||
interface events {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
interface lifecycle {
|
||||
use types.{context, error};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Writes to the IDE log, tagged with the plugin id.
|
||||
interface log {
|
||||
@@ -8,7 +8,7 @@ interface log {
|
||||
}
|
||||
|
||||
interface ui {
|
||||
use types.{notification-level};
|
||||
use types.{error, notification-level};
|
||||
|
||||
record status-item {
|
||||
text: string,
|
||||
@@ -23,6 +23,10 @@ interface ui {
|
||||
/// Shows or updates the plugin's status bar item `id` in every window.
|
||||
set-status-item: func(id: string, item: status-item);
|
||||
remove-status-item: func(id: string);
|
||||
/// Copies `text` to the system clipboard. Requires `clipboard`.
|
||||
copy-to-clipboard: func(text: string) -> result<_, error>;
|
||||
/// Opens an `http://` or `https://` URL in the user's default browser.
|
||||
open-url: func(url: string) -> result<_, error>;
|
||||
}
|
||||
|
||||
/// Persistent key-value storage of the plugin (survives restarts and idle
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Problems in files, shown in the editor and the Problems view.
|
||||
interface diagnostics {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
/// or processes beyond WASI's clocks and randomness; everything else goes
|
||||
/// through the imports below, gated by the capabilities granted in
|
||||
/// `plugin.toml` (a missing one returns `error::denied`).
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
world plugin {
|
||||
import log;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Editor features a plugin provides for the languages it declares under
|
||||
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
|
||||
/// as in the editor and LSP.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Declarative views: tool windows declared
|
||||
/// under `[[contributes.views]]` and custom editors under
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
interface workspace {
|
||||
use types.{error};
|
||||
@@ -84,6 +84,8 @@ interface vcs {
|
||||
|
||||
/// `none` when the project is not in a repository. Requires `vcs.read`.
|
||||
status: func(project: borrow<project>) -> result<option<repository-status>, error>;
|
||||
/// Configured remotes (`name`, fetch `url`), in order. Requires `vcs.read`.
|
||||
remotes: func(project: borrow<project>) -> result<list<tuple<string, string>>, error>;
|
||||
/// Fetches all remotes (pruning deleted branches). Requires `vcs.write`.
|
||||
/// Long-running: call it inside a task.
|
||||
fetch: func(project: borrow<project>) -> result<_, error>;
|
||||
|
||||
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user