mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf6e640ac0 | ||
|
|
43c88c9512 |
No files matched your search
Generated
+35
-38
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4061,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4075,7 +4075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4090,7 +4090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4099,7 +4099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4115,7 +4115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4129,7 +4129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4147,7 +4147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4169,7 +4169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4183,7 +4183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4195,7 +4195,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4209,7 +4209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4254,7 +4254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4272,7 +4272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4294,7 +4294,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4310,7 +4310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4365,7 +4365,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4374,7 +4374,6 @@ dependencies = [
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-text",
|
||||
"ide-vfs",
|
||||
"ignore",
|
||||
"lsp-types",
|
||||
"parking_lot",
|
||||
@@ -4390,7 +4389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4413,7 +4412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4431,7 +4430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4444,7 +4443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4461,7 +4460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4484,7 +4483,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4503,7 +4502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4523,7 +4522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4536,7 +4535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4555,7 +4554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4570,7 +4569,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4583,7 +4582,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4595,7 +4594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4616,7 +4615,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4635,7 +4634,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4657,7 +4656,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4673,7 +4672,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5808,7 +5807,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
@@ -6144,7 +6142,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"block2 0.6.2",
|
||||
@@ -6186,7 +6184,6 @@ dependencies = [
|
||||
"ide-workspace",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-web-kit 0.3.2",
|
||||
"parking_lot",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
·
|
||||
<a href="#benchmark">Benchmark</a>
|
||||
·
|
||||
<a href="#build">Build</a>
|
||||
·
|
||||
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
|
||||
</p>
|
||||
|
||||
@@ -45,6 +47,24 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
|
||||
both sides. Both are measured from the outside, the same way: see
|
||||
[`perf/compare`](perf/compare) to run it on your machine and projects.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
Native notebook execution tests are opt-in and need two distinct Python environments
|
||||
with `ipykernel` installed:
|
||||
|
||||
```sh
|
||||
OPENIDEA_TEST_PYTHON=/first/env/bin/python \
|
||||
OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \
|
||||
cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
//! Metadata fingerprints for successful Maven launch builds.
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LaunchFingerprint {
|
||||
version: u32,
|
||||
parameters: String,
|
||||
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
}
|
||||
|
||||
fn scan(
|
||||
path: &Path,
|
||||
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
ancestors: &mut HashSet<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let meta = match fs::metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
files.insert(path.to_path_buf(), None);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if meta.is_dir() {
|
||||
let canonical = dunce::canonicalize(path)?;
|
||||
if !ancestors.insert(canonical.clone()) {
|
||||
return Err(IdeError::internal("cyclic launch input directory"));
|
||||
}
|
||||
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
|
||||
files.insert(path.to_path_buf(), None);
|
||||
for entry in fs::read_dir(path)? {
|
||||
scan(&entry?.path(), files, ancestors, cancel)?;
|
||||
}
|
||||
ancestors.remove(&canonical);
|
||||
} else if meta.is_file() {
|
||||
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
|
||||
} else {
|
||||
return Err(IdeError::internal("unsupported launch input file"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `None` means an input cannot be resolved safely: build normally without reuse.
|
||||
/// All filesystem work belongs on the caller's blocking thread.
|
||||
pub fn launch_fingerprint(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<LaunchFingerprint>> {
|
||||
let Some(m) = &model.maven else { return Ok(None) };
|
||||
let projects = maven_launch_projects(root, m, module, test);
|
||||
if projects.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
let mut environment: BTreeMap<String, String> = std::env::vars()
|
||||
.map(|(key, value)| (key.to_ascii_uppercase(), value))
|
||||
.filter(|(key, _)| {
|
||||
key.starts_with("MAVEN_")
|
||||
|| key.starts_with("JAVA_")
|
||||
|| key.starts_with("JDK_")
|
||||
|| matches!(
|
||||
key.as_str(),
|
||||
"PATH" | "HOME" | "LANG" | "LC_ALL" | "_JAVA_OPTIONS" | "M2_HOME" | "MAVEN_HOME"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Explicit step values override the inherited environment just as they do in Command.
|
||||
for step in &plan.steps {
|
||||
environment.extend(step.env.iter().map(|(key, value)| (key.to_ascii_uppercase(), value.clone())));
|
||||
for dir in step.cwd.ancestors() {
|
||||
inputs.extend([dir.join(".mvn"), dir.join("mvnw"), dir.join("mvnw.cmd")]);
|
||||
if dir == root {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let program = process::program_path(&step.program, &step.env);
|
||||
inputs.push(program.clone());
|
||||
// Maven installed via a symlink still reads conf from its distribution.
|
||||
if let Ok(program) = dunce::canonicalize(program)
|
||||
&& let Some(home) = program.parent().and_then(Path::parent)
|
||||
{
|
||||
for conf in ["conf", "libexec/conf"] {
|
||||
inputs.extend([home.join(conf).join("settings.xml"), home.join(conf).join("toolchains.xml")]);
|
||||
}
|
||||
}
|
||||
if !environment.contains_key("JAVA_HOME") {
|
||||
let java = process::program_path(if cfg!(windows) { "java.exe" } else { "java" }, &step.env);
|
||||
inputs.push(java.clone());
|
||||
if let Ok(java) = dunce::canonicalize(java)
|
||||
&& let Some(home) = java.parent().and_then(Path::parent)
|
||||
{
|
||||
inputs.push(home.join("release"));
|
||||
}
|
||||
}
|
||||
for (i, arg) in step.args.iter().enumerate() {
|
||||
if matches!(arg.as_str(), "-s" | "--settings" | "-gs" | "--global-settings" | "-t" | "--toolchains")
|
||||
&& let Some(file) = step.args.get(i + 1)
|
||||
{
|
||||
inputs.push(step.cwd.join(file));
|
||||
}
|
||||
for prefix in ["--settings=", "--global-settings=", "--toolchains="] {
|
||||
if let Some(file) = arg.strip_prefix(prefix) {
|
||||
inputs.push(step.cwd.join(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
inputs.extend([home.join(".m2/settings.xml"), home.join(".m2/toolchains.xml")]);
|
||||
}
|
||||
for key in ["MAVEN_HOME", "M2_HOME"] {
|
||||
if let Some(home) = environment.get(key) {
|
||||
inputs.extend([Path::new(home).join("conf/settings.xml"), Path::new(home).join("conf/toolchains.xml")]);
|
||||
}
|
||||
}
|
||||
if let Some(home) = environment.get("JAVA_HOME") {
|
||||
inputs.extend([
|
||||
Path::new(home).join("release"),
|
||||
Path::new(home).join("lib/modules"),
|
||||
Path::new(home).join("lib/tools.jar"),
|
||||
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
|
||||
]);
|
||||
}
|
||||
let steps: Vec<_> =
|
||||
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
|
||||
// Profiles and plugins can read arbitrary environment variables. Keep a signature
|
||||
// of the inherited environment without persisting unrelated credentials.
|
||||
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
|
||||
let mut inherited_hash = std::hash::DefaultHasher::new();
|
||||
inherited.hash(&mut inherited_hash);
|
||||
let parameters =
|
||||
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let mut files = BTreeMap::new();
|
||||
for path in inputs {
|
||||
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
|
||||
}
|
||||
// Store project inputs relative to the project, including each file's relative path.
|
||||
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
|
||||
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, rel: &str, value: &str) {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, value).unwrap();
|
||||
}
|
||||
|
||||
fn fixture(root: &Path) -> BuildModel {
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
|
||||
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>lib</artifactId><build><sourceDirectory>code</sourceDirectory><resources><resource><directory>conf</directory></resource></resources></build></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"app/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
|
||||
);
|
||||
write(root, "lib/src/main/kotlin/Channel.kt", "value");
|
||||
write(root, "app/src/main/resources/app.properties", "key=value");
|
||||
write(root, "lib/conf/custom.properties", "custom=value");
|
||||
write(root, "lib/code/Custom.java", "class Custom {}");
|
||||
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
|
||||
}
|
||||
|
||||
fn fingerprint(root: &Path, model: &BuildModel) -> Option<LaunchFingerprint> {
|
||||
launch_fingerprint(root, model, &root.join("app"), false, None, &CancellationToken::new()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_inputs_match_but_source_resource_pom_add_delete_and_mtime_invalidate() {
|
||||
for rel in [
|
||||
"lib/src/main/kotlin/Channel.kt",
|
||||
"app/src/main/resources/app.properties",
|
||||
"lib/conf/custom.properties",
|
||||
"lib/code/Custom.java",
|
||||
"app/pom.xml",
|
||||
"lib/pom.xml",
|
||||
"pom.xml",
|
||||
".mvn/maven.config",
|
||||
"lib/src/main/groovy/New.groovy",
|
||||
"app/src/test/scala/Test.scala",
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let before = fingerprint(root, &model);
|
||||
assert!(before.is_some());
|
||||
assert_eq!(before, fingerprint(root, &model), "unchanged {rel}");
|
||||
let serialized = serde_json::to_vec(&before).unwrap();
|
||||
assert_eq!(before, serde_json::from_slice(&serialized).unwrap());
|
||||
let value = if rel.ends_with("pom.xml") {
|
||||
format!("{}<!-- changed -->", fs::read_to_string(root.join(rel)).unwrap())
|
||||
} else {
|
||||
"changed input".to_owned()
|
||||
};
|
||||
write(root, rel, &value);
|
||||
let changed = fingerprint(root, &model);
|
||||
assert_ne!(before, changed, "changed/added {rel}");
|
||||
let file = fs::OpenOptions::new().write(true).open(root.join(rel)).unwrap();
|
||||
file.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42)).unwrap();
|
||||
let touched = fingerprint(root, &model);
|
||||
assert_ne!(changed, touched, "mtime-only {rel}");
|
||||
fs::remove_file(root.join(rel)).unwrap();
|
||||
assert_ne!(touched, fingerprint(root, &model), "deleted {rel}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let mut model = fixture(root);
|
||||
let before = fingerprint(root, &model);
|
||||
write(root, "lib/target/classes/Channel.class", "JDT stub");
|
||||
write(root, "app/target/classes/Main.class", "JDT stub");
|
||||
assert_eq!(before, fingerprint(root, &model));
|
||||
let mut previous = before.clone();
|
||||
for option in ["-Pdev", "-Pprod", "-DskipTests", "-o"] {
|
||||
model.maven.as_mut().unwrap().projects[0].options = vec![option.into()];
|
||||
let current = fingerprint(root, &model);
|
||||
assert_ne!(previous, current, "{option}");
|
||||
previous = current;
|
||||
}
|
||||
model.maven.as_mut().unwrap().projects[0].options.clear();
|
||||
model.maven.as_mut().unwrap().projects[0]
|
||||
.env
|
||||
.insert("JAVA_HOME".into(), root.join("jdk").to_string_lossy().into_owned());
|
||||
let jdk = fingerprint(root, &model);
|
||||
assert_ne!(before, jdk);
|
||||
write(root, "jdk/release", "JAVA_VERSION=21");
|
||||
assert_ne!(jdk, fingerprint(root, &model));
|
||||
model.maven.as_mut().unwrap().projects[0].env.clear();
|
||||
let test = launch_fingerprint(root, &model, &root.join("app"), true, None, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(before, Some(test));
|
||||
write(root, "mvnw", "wrapper changed");
|
||||
assert_ne!(before, fingerprint(root, &model));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_roots_and_cancel_never_claim_up_to_date() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
"<project><artifactId>lib</artifactId><build><sourceDirectory>${unknown}/src</sourceDirectory></build></project>",
|
||||
);
|
||||
assert!(
|
||||
launch_fingerprint(root, &model, &root.join("app"), false, None, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let model = fixture(root);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert!(launch_fingerprint(root, &model, &root.join("app"), false, None, &cancel).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn external_parent_and_selected_settings_files_invalidate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("project");
|
||||
let repo = dir.path().join("repository");
|
||||
write(
|
||||
&repo,
|
||||
"demo/parent/1/parent-1.pom",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></project>",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"pom.xml",
|
||||
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><relativePath/></parent><artifactId>app</artifactId></project>",
|
||||
);
|
||||
let mut model = crate::load(&root, &root.join("cache"), Some(repo.clone()));
|
||||
let settings = dir.path().join("custom-settings.xml");
|
||||
fs::write(&settings, "<settings/>").unwrap();
|
||||
model.maven.as_mut().unwrap().projects[0].options = vec!["-s".into(), settings.to_string_lossy().into_owned()];
|
||||
let get =
|
||||
|| launch_fingerprint(&root, &model, &root, false, Some(repo.clone()), &CancellationToken::new()).unwrap();
|
||||
let before = get();
|
||||
assert!(before.is_some());
|
||||
write(
|
||||
&repo,
|
||||
"demo/parent/1/parent-1.pom",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><!-- changed --></project>",
|
||||
);
|
||||
let parent = get();
|
||||
assert_ne!(before, parent);
|
||||
fs::write(settings, "<settings><activeProfiles><activeProfile>dev</activeProfile></activeProfiles></settings>")
|
||||
.unwrap();
|
||||
assert_ne!(parent, get());
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,8 @@
|
||||
//! background with cancellation and parses the compiler output into messages
|
||||
//! for the Build tool window, the Problems view and the editor.
|
||||
|
||||
mod fingerprint;
|
||||
mod parse;
|
||||
|
||||
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
|
||||
|
||||
use crate::process::{self, Stream};
|
||||
use crate::{BuildModel, maven};
|
||||
use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity};
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
//! Private Maven builds for JDT's Kotlin binary libraries. Never use launch outputs.
|
||||
|
||||
use crate::BuildModel;
|
||||
use crate::compile::{self, BuildEvent, BuildKind, BuildMessage};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Module {
|
||||
key: String,
|
||||
project_uri: String,
|
||||
output: PathBuf,
|
||||
test_output: Option<PathBuf>,
|
||||
dependencies: Vec<Dependency>,
|
||||
has_kotlin: bool,
|
||||
kotlin_goals: Vec<KotlinGoal>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct KotlinGoal {
|
||||
execution: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct Dependency {
|
||||
key: String,
|
||||
scope: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
classifier: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct Library {
|
||||
pub path: PathBuf,
|
||||
pub test: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectLibraries {
|
||||
pub project_uri: String,
|
||||
pub libraries: Vec<Library>,
|
||||
}
|
||||
|
||||
pub struct Outcome {
|
||||
pub projects: Vec<ProjectLibraries>,
|
||||
pub messages: Vec<BuildMessage>,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
/// Inspect sources, excluding generated/build/VCS trees and never following symlinks.
|
||||
pub fn has_kotlin(root: &Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return false };
|
||||
entries.flatten().any(|entry| {
|
||||
let Ok(kind) = entry.file_type() else { return false };
|
||||
if kind.is_file() {
|
||||
entry.path().extension().is_some_and(|e| e == "kt")
|
||||
} else {
|
||||
kind.is_dir()
|
||||
&& !matches!(
|
||||
entry.file_name().to_str(),
|
||||
Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea")
|
||||
)
|
||||
&& has_kotlin(&entry.path())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output
|
||||
/// redirector loaded before Maven calculates its lifecycle execution plans.
|
||||
pub fn build(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
bundle: &Path,
|
||||
generation: &Path,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outcome> {
|
||||
std::fs::create_dir_all(generation)?;
|
||||
let mut plan = compile::plan(root, model, BuildKind::Build, None)?;
|
||||
if plan.system != compile::BuildSystem::Maven {
|
||||
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
|
||||
}
|
||||
let mut manifests = Vec::new();
|
||||
for (index, step) in plan.steps.iter_mut().enumerate() {
|
||||
step.args.push("--fail-at-end".into());
|
||||
let directory = generation.join(format!("reactor-{index}"));
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
manifests.push(directory.join("modules.json"));
|
||||
let mut extensions = vec![bundle.to_path_buf()];
|
||||
step.args.retain(|arg| {
|
||||
if let Some(value) = arg.strip_prefix("-Dmaven.ext.class.path=") {
|
||||
extensions.extend(std::env::split_paths(value));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
let extension_path = std::env::join_paths(extensions).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
step.args.push(format!("-Dmaven.ext.class.path={}", extension_path.to_string_lossy()));
|
||||
step.args.push(format!("-Dopenidea.kotlin.staging={}", directory.join("staging").display()));
|
||||
}
|
||||
let mut messages = Vec::new();
|
||||
// Independent linked reactors must still run after another reactor's javac fails.
|
||||
for step in &plan.steps {
|
||||
let mut reactor = plan.clone();
|
||||
reactor.steps = vec![step.clone()];
|
||||
let result = compile::run(root, &reactor, cancel, &|event| {
|
||||
if let BuildEvent::Output { text, .. } = event {
|
||||
tracing::debug!("kotlin bridge: {}", text.trim_end());
|
||||
}
|
||||
})?;
|
||||
messages.extend(result.messages);
|
||||
}
|
||||
// javac diagnostics belong to JDT, including when a Kotlin target was skipped.
|
||||
messages.retain(|message| {
|
||||
message.path.as_ref().is_some_and(|path| PathBuf::from(path.clone()).extension().is_some_and(|e| e == "kt"))
|
||||
});
|
||||
let mut modules = Vec::new();
|
||||
for manifest in manifests {
|
||||
let parsed: Vec<Module> = serde_json::from_slice(&std::fs::read(manifest)?)
|
||||
.map_err(|e| IdeError::internal(format!("Kotlin module manifest: {e}")))?;
|
||||
modules.extend(parsed);
|
||||
}
|
||||
messages.extend(kotlin_failures(&modules));
|
||||
if messages.iter().any(|message| message.severity == ide_core::Severity::Error) {
|
||||
return Ok(Outcome { projects: Vec::new(), messages, success: false });
|
||||
}
|
||||
let pending = generation.join("libraries.pending");
|
||||
let published = generation.join("libraries");
|
||||
std::fs::create_dir_all(&pending)?;
|
||||
let mut libraries = BTreeMap::new();
|
||||
for (index, module) in modules.iter().enumerate() {
|
||||
let mut outputs = ModuleLibraries::default();
|
||||
for (source, target, name) in [
|
||||
(Some(&module.output), &mut outputs.main, format!("main-{index}")),
|
||||
(module.test_output.as_ref(), &mut outputs.test, format!("test-{index}")),
|
||||
] {
|
||||
let Some(source) = source else { continue };
|
||||
if !source.starts_with(generation) {
|
||||
return Err(IdeError::internal("Kotlin build output escaped staging"));
|
||||
}
|
||||
if copy_kotlin(source, &pending.join(&name), cancel)? {
|
||||
*target = Some(published.join(name));
|
||||
}
|
||||
}
|
||||
libraries.insert(module.key.clone(), outputs);
|
||||
}
|
||||
publish(&pending, &published, cancel)?;
|
||||
Ok(Outcome { projects: consumers(&modules, &libraries), messages, success: true })
|
||||
}
|
||||
|
||||
fn kotlin_failures(modules: &[Module]) -> Vec<BuildMessage> {
|
||||
let mut messages = Vec::new();
|
||||
for module in modules {
|
||||
if !module.has_kotlin {
|
||||
continue;
|
||||
}
|
||||
if module.kotlin_goals.is_empty() {
|
||||
messages.push(kotlin_failure(module, "compile target was not executed"));
|
||||
}
|
||||
for goal in &module.kotlin_goals {
|
||||
if goal.status != "success" {
|
||||
messages.push(kotlin_failure(module, &format!("{} {}", goal.execution, goal.status)));
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
fn kotlin_failure(module: &Module, reason: &str) -> BuildMessage {
|
||||
BuildMessage {
|
||||
severity: ide_core::Severity::Error,
|
||||
text: format!("Kotlin compilation for {}: {reason}", module.key),
|
||||
path: None,
|
||||
range: None,
|
||||
code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(pending: &Path, published: &Path, cancel: &CancellationToken) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
std::fs::rename(pending, published)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ModuleLibraries {
|
||||
main: Option<PathBuf>,
|
||||
test: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn consumers(modules: &[Module], libraries: &BTreeMap<String, ModuleLibraries>) -> Vec<ProjectLibraries> {
|
||||
modules
|
||||
.iter()
|
||||
.map(|consumer| {
|
||||
let mut visible = Vec::<Library>::new();
|
||||
let mut add = |path: &Option<PathBuf>, test: bool| {
|
||||
if let Some(path) = path {
|
||||
if let Some(previous) = visible.iter_mut().find(|entry| &entry.path == path) {
|
||||
previous.test &= test;
|
||||
} else {
|
||||
visible.push(Library { path: path.clone(), test });
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(own) = libraries.get(&consumer.key) {
|
||||
add(&own.test, true);
|
||||
add(&own.main, false);
|
||||
}
|
||||
// Maven's resolved artifacts already apply exclusions, mediation and optional
|
||||
// propagation. Preserve their classpath order, after the module's own output.
|
||||
for dependency in &consumer.dependencies {
|
||||
let Some(output) = libraries.get(&dependency.key) else { continue };
|
||||
let test = match dependency.scope.as_str() {
|
||||
"compile" | "provided" | "system" | "" => false,
|
||||
"test" | "runtime" => true,
|
||||
_ => continue,
|
||||
};
|
||||
match (dependency.kind.as_deref(), dependency.classifier.as_deref()) {
|
||||
(Some("test-jar"), None | Some("tests")) | (Some("jar"), Some("tests")) => add(&output.test, test),
|
||||
(None | Some("jar"), None | Some("")) => add(&output.main, test),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ProjectLibraries { project_uri: consumer.project_uri.clone(), libraries: visible }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn copy_kotlin(source: &Path, destination: &Path, cancel: &CancellationToken) -> IdeResult<bool> {
|
||||
let Ok(entries) = std::fs::read_dir(source) else { return Ok(false) };
|
||||
let mut found = false;
|
||||
for entry in entries {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let entry = entry?;
|
||||
let kind = entry.file_type()?;
|
||||
let path = entry.path();
|
||||
let output = destination.join(entry.file_name());
|
||||
if kind.is_dir() {
|
||||
found |= copy_kotlin(&path, &output, cancel)?;
|
||||
} else if kind.is_file() {
|
||||
let bytes = std::fs::read(&path)?;
|
||||
let class = path.extension().is_some_and(|e| e == "class");
|
||||
let metadata = path.extension().is_some_and(|e| e == "kotlin_module");
|
||||
if metadata || (class && kotlin_class(&bytes).unwrap_or(false)) {
|
||||
std::fs::create_dir_all(destination)?;
|
||||
std::fs::write(output, bytes)?;
|
||||
found |= class;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Read the actual class-level SourceFile attribute, rather than searching strings
|
||||
/// (a Java constant containing "Something.kt" must never become a Kotlin library).
|
||||
fn kotlin_class(bytes: &[u8]) -> Option<bool> {
|
||||
struct Reader<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
impl<'a> Reader<'a> {
|
||||
fn take(&mut self, count: usize) -> Option<&'a [u8]> {
|
||||
let value = self.bytes.get(self.pos..self.pos.checked_add(count)?)?;
|
||||
self.pos += count;
|
||||
Some(value)
|
||||
}
|
||||
fn u2(&mut self) -> Option<usize> {
|
||||
Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?) as usize)
|
||||
}
|
||||
fn u4(&mut self) -> Option<usize> {
|
||||
Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?) as usize)
|
||||
}
|
||||
fn attributes(&mut self) -> Option<()> {
|
||||
for _ in 0..self.u2()? {
|
||||
self.u2()?;
|
||||
let n = self.u4()?;
|
||||
self.take(n)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
let mut reader = Reader { bytes, pos: 0 };
|
||||
if reader.take(4)? != [0xca, 0xfe, 0xba, 0xbe] {
|
||||
return None;
|
||||
}
|
||||
reader.take(4)?;
|
||||
let count = reader.u2()?;
|
||||
let mut utf8 = BTreeMap::new();
|
||||
let mut index = 1;
|
||||
while index < count {
|
||||
match reader.take(1)?[0] {
|
||||
1 => {
|
||||
let n = reader.u2()?;
|
||||
utf8.insert(index, reader.take(n)?);
|
||||
}
|
||||
3 | 4 | 9..=12 | 17 | 18 => {
|
||||
reader.take(4)?;
|
||||
}
|
||||
5 | 6 => {
|
||||
reader.take(8)?;
|
||||
index += 1;
|
||||
}
|
||||
7 | 8 | 16 | 19 | 20 => {
|
||||
reader.take(2)?;
|
||||
}
|
||||
15 => {
|
||||
reader.take(3)?;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
reader.take(6)?;
|
||||
let interfaces = reader.u2()?;
|
||||
reader.take(interfaces.checked_mul(2)?)?;
|
||||
for _ in 0..2 {
|
||||
for _ in 0..reader.u2()? {
|
||||
reader.take(6)?;
|
||||
reader.attributes()?;
|
||||
}
|
||||
}
|
||||
for _ in 0..reader.u2()? {
|
||||
let name = reader.u2()?;
|
||||
let length = reader.u4()?;
|
||||
if utf8.get(&name).copied() == Some(b"SourceFile".as_slice()) && length == 2 {
|
||||
let source = reader.u2()?;
|
||||
return Some(utf8.get(&source)?.ends_with(b".kt"));
|
||||
}
|
||||
reader.take(length)?;
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_kotlin_target_must_succeed_even_when_maven_fails_elsewhere() {
|
||||
let mut modules: Vec<Module> = serde_json::from_str(r#"[
|
||||
{"key":"lib","projectUri":"lib","output":"lib","dependencies":[],"hasKotlin":true,
|
||||
"kotlinGoals":[{"execution":"main:compile","status":"success"},{"execution":"test:test-compile","status":"success"}]},
|
||||
{"key":"java","projectUri":"java","output":"java","dependencies":[],"hasKotlin":false,"kotlinGoals":[]}
|
||||
]"#).unwrap();
|
||||
assert!(kotlin_failures(&modules).is_empty());
|
||||
for status in ["failed", "skipped"] {
|
||||
modules[0].kotlin_goals[1].status = status.into();
|
||||
let failures = kotlin_failures(&modules);
|
||||
assert_eq!(failures.len(), 1);
|
||||
assert!(failures[0].text.contains(&format!("test:test-compile {status}")));
|
||||
}
|
||||
modules[0].kotlin_goals.clear();
|
||||
assert_eq!(kotlin_failures(&modules).len(), 1);
|
||||
modules[0].has_kotlin = false;
|
||||
assert!(kotlin_failures(&modules).is_empty());
|
||||
assert!(
|
||||
serde_json::from_str::<Vec<Module>>(
|
||||
r#"[{"key":"old","projectUri":"old","output":"old","dependencies":[]}]"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_mavens_resolved_closure_and_scopes() {
|
||||
let json = r#"[
|
||||
{"key":"zapp","projectUri":"app","output":"app", "hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"mid","scope":"compile","optional":false},{"key":"lib","scope":"compile","optional":false}]},
|
||||
{"key":"mid","projectUri":"mid","output":"mid", "hasKotlin":false,"kotlinGoals":[],"dependencies":[
|
||||
{"key":"lib","scope":"compile","optional":false},
|
||||
{"key":"optional","scope":"compile","optional":true},
|
||||
{"key":"test","scope":"test","optional":false},
|
||||
{"key":"provided","scope":"provided","optional":false}]},
|
||||
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]}
|
||||
]"#;
|
||||
let modules: Vec<Module> = serde_json::from_str(json).unwrap();
|
||||
let libraries = ["zapp", "lib", "optional", "test", "provided"]
|
||||
.into_iter()
|
||||
.map(|key| (key.to_owned(), ModuleLibraries { main: Some(PathBuf::from(key)), test: None }))
|
||||
.collect();
|
||||
let result = consumers(&modules, &libraries);
|
||||
assert_eq!(
|
||||
result[0].libraries,
|
||||
vec![Library { path: "zapp".into(), test: false }, Library { path: "lib".into(), test: false },]
|
||||
);
|
||||
assert_eq!(result[1].libraries.len(), 4);
|
||||
assert!(result[1].libraries.iter().find(|l| l.path == Path::new("test")).unwrap().test);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outputs_are_private_unless_maven_resolves_a_test_jar() {
|
||||
let modules: Vec<Module> = serde_json::from_str(r#"[
|
||||
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]},
|
||||
{"key":"app","projectUri":"app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"compile","type":"jar","classifier":null}]},
|
||||
{"key":"test-app","projectUri":"test-app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"test","type":"test-jar","classifier":"tests"}]}
|
||||
]"#).unwrap();
|
||||
let libraries =
|
||||
BTreeMap::from([("lib".into(), ModuleLibraries { main: Some("main".into()), test: Some("test".into()) })]);
|
||||
let result = consumers(&modules, &libraries);
|
||||
assert_eq!(
|
||||
result[0].libraries,
|
||||
vec![Library { path: "test".into(), test: true }, Library { path: "main".into(), test: false }]
|
||||
);
|
||||
assert_eq!(result[1].libraries, vec![Library { path: "main".into(), test: false }]);
|
||||
assert_eq!(result[2].libraries, vec![Library { path: "test".into(), test: true }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_complete_directories_and_cancellation_keeps_them_private() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let pending = directory.path().join("pending");
|
||||
let final_path = directory.path().join("published");
|
||||
std::fs::create_dir(&pending).unwrap();
|
||||
std::fs::write(pending.join("Channel.class"), "complete").unwrap();
|
||||
let cancelled = CancellationToken::new();
|
||||
cancelled.cancel();
|
||||
assert!(publish(&pending, &final_path, &cancelled).is_err());
|
||||
assert!(!final_path.exists());
|
||||
assert!(pending.exists());
|
||||
publish(&pending, &final_path, &CancellationToken::new()).unwrap();
|
||||
assert!(!pending.exists());
|
||||
assert_eq!(std::fs::read(final_path.join("Channel.class")).unwrap(), b"complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_runs_another_build_system_with_maven_output_properties() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
std::fs::write(directory.path().join("Cargo.toml"), "").unwrap();
|
||||
let error = build(
|
||||
directory.path(),
|
||||
&BuildModel::default(),
|
||||
Path::new("unused.jar"),
|
||||
&directory.path().join("private"),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.message, "Kotlin bridge requires a Maven build root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_find_deleted_sources_in_build_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("target/generated")).unwrap();
|
||||
std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap();
|
||||
assert!(!has_kotlin(dir.path()));
|
||||
std::fs::write(dir.path().join("New.kt"), "").unwrap();
|
||||
assert!(has_kotlin(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sourcefile_and_rejects_truncated_classes() {
|
||||
let mut class = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52, 0, 3, 1, 0, 10];
|
||||
class.extend_from_slice(b"SourceFile");
|
||||
class.extend_from_slice(&[1, 0, 10]);
|
||||
class.extend_from_slice(b"Channel.kt");
|
||||
class.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 2, 0, 2]);
|
||||
assert_eq!(kotlin_class(&class), Some(true));
|
||||
for end in 0..class.len() {
|
||||
assert!(kotlin_class(&class[..end]).is_none());
|
||||
}
|
||||
let offset = 26;
|
||||
class[offset..offset + 10].copy_from_slice(b"Other.java");
|
||||
assert_eq!(kotlin_class(&class), Some(false));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
pub mod application_update;
|
||||
pub mod compile;
|
||||
pub mod gradle;
|
||||
pub mod kotlin_bridge;
|
||||
pub mod maven;
|
||||
pub mod pom_edit;
|
||||
mod process;
|
||||
|
||||
@@ -601,72 +601,6 @@ impl Resolver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Files and source/resource trees consumed by a launch build. Unresolved custom
|
||||
/// roots disable reuse rather than silently omitting an input.
|
||||
pub(crate) fn launch_inputs(
|
||||
root: &Path,
|
||||
projects: &[&MavenProject],
|
||||
local_repo: Option<PathBuf>,
|
||||
) -> Option<Vec<PathBuf>> {
|
||||
fn roots(el: &Element, parent: &str, dir: &Path, out: &mut Vec<PathBuf>) -> Option<()> {
|
||||
let input =
|
||||
matches!(el.name.as_str(), "sourceDirectory" | "testSourceDirectory" | "sourceDir" | "testSourceDir")
|
||||
|| (matches!(parent, "sources" | "sourceDirs" | "testSources" | "testSourceDirs")
|
||||
&& matches!(el.name.as_str(), "source" | "sourceDir" | "testSourceDir"))
|
||||
|| (matches!(parent, "resource" | "testResource" | "source" | "testSource") && el.name == "directory")
|
||||
|| (parent == "filters" && el.name == "filter");
|
||||
// Profile activation may depend on the presence of an otherwise unrelated file.
|
||||
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
|
||||
if input || activation {
|
||||
// Other properties can be overridden by -D, profiles or settings.
|
||||
// Without Maven's effective roots, conservatively disable reuse for them.
|
||||
let value = el
|
||||
.text
|
||||
.trim()
|
||||
.replace("${project.basedir}", &dir.to_string_lossy())
|
||||
.replace("${basedir}", &dir.to_string_lossy());
|
||||
if value.contains("${") {
|
||||
return None;
|
||||
}
|
||||
if !value.is_empty() {
|
||||
out.push(dir.join(value));
|
||||
}
|
||||
}
|
||||
for child in &el.children {
|
||||
roots(child, &el.name, dir, out)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
let mut resolver = Resolver::new(local_repo.or_else(default_local_repo));
|
||||
let mut out = Vec::new();
|
||||
for project in projects {
|
||||
let dir = root.join(&project.dir);
|
||||
// Include all conventional language/resource roots, including nonstandard files under src.
|
||||
out.push(dir.join("src"));
|
||||
let pom = root.join(&project.pom);
|
||||
resolver.context(&pom, 0);
|
||||
let mut path = pom;
|
||||
let mut seen = HashSet::new();
|
||||
loop {
|
||||
let canonical = dunce::canonicalize(&path).ok()?;
|
||||
if !seen.insert(canonical) || seen.len() > MAX_PARENT_DEPTH {
|
||||
return None;
|
||||
}
|
||||
out.push(path.clone());
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
|
||||
let raw = resolver.pom(&path)?;
|
||||
let Some(parent) = raw.parent else { break };
|
||||
path = resolver.parent_path(path.parent()?, &parent)?;
|
||||
}
|
||||
}
|
||||
// Also track imported BOMs read while resolving inheritance.
|
||||
out.extend(resolver.poms.into_keys());
|
||||
out.sort();
|
||||
out.dedup();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- plugins
|
||||
|
||||
/// `maven-clean-plugin` → `clean`, `spring-boot-maven-plugin` → `spring-boot`.
|
||||
|
||||
@@ -37,7 +37,7 @@ fn kill_tree(child: &mut Child) {
|
||||
/// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's
|
||||
/// search path — the login shell's `PATH` plus version-manager shims — since the
|
||||
/// app's own `PATH` can be minimal and `Command` would not see `mvn` there.
|
||||
pub(crate) fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
|
||||
fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
|
||||
let path = std::path::Path::new(program);
|
||||
if !path.is_relative() || path.components().count() > 1 {
|
||||
return path.to_path_buf();
|
||||
|
||||
@@ -371,12 +371,6 @@ pub struct TextAttributesOverride {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguageCodeStyle {
|
||||
/// Java formatter: `default` | `intellij` | `custom` (other languages ignore it).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub formatter: Option<String>,
|
||||
/// Eclipse formatter XML; relative paths start at the project root.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub formatter_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_tab: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -12,15 +12,6 @@ pub type IdeResult<T> = Result<T, IdeError>;
|
||||
pub struct IdeError {
|
||||
pub kind: ErrorKind,
|
||||
pub message: String,
|
||||
/// Structured detail for errors the UI acts on; `null` for plain errors.
|
||||
#[specta(optional)]
|
||||
pub data: Option<ErrorData>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ErrorData {
|
||||
Tool { tool: String, missing: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -33,7 +24,6 @@ pub enum ErrorKind {
|
||||
/// Document version mismatch; the UI must resync the full text.
|
||||
OutOfSync,
|
||||
Cancelled,
|
||||
TimedOut,
|
||||
/// A required external tool (language server, git, debugger) is unavailable.
|
||||
Unavailable,
|
||||
Io,
|
||||
@@ -43,21 +33,7 @@ pub enum ErrorKind {
|
||||
|
||||
impl IdeError {
|
||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||
Self { kind, message: message.into(), data: None }
|
||||
}
|
||||
pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
|
||||
if self.data.is_none() {
|
||||
self.data = Some(ErrorData::Tool { tool: tool.into(), missing: false });
|
||||
}
|
||||
self
|
||||
}
|
||||
pub fn missing_tool(tool: impl Into<String>) -> Self {
|
||||
let tool = tool.into();
|
||||
Self {
|
||||
kind: ErrorKind::NotFound,
|
||||
message: format!("{tool} is not installed"),
|
||||
data: Some(ErrorData::Tool { tool, missing: true }),
|
||||
}
|
||||
Self { kind, message: message.into() }
|
||||
}
|
||||
pub fn not_found(m: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::NotFound, m)
|
||||
@@ -89,30 +65,3 @@ impl From<std::io::Error> for IdeError {
|
||||
Self::new(kind, e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tool_errors_round_trip_without_changing_diagnostics() {
|
||||
let message = "[error] file.css: Unexpected token\n 1 | }\n";
|
||||
let error = IdeError::invalid(message).with_tool("prettier");
|
||||
let decoded: IdeError = serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap();
|
||||
assert_eq!(decoded.message, message);
|
||||
assert_eq!(decoded.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
let missing = IdeError::missing_tool("prettier").with_tool("prettier");
|
||||
assert_eq!(missing.kind, ErrorKind::NotFound);
|
||||
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_errors_need_no_data() {
|
||||
let error: IdeError = serde_json::from_str(r#"{"kind":"unavailable","message":"no server"}"#).unwrap();
|
||||
assert!(error.data.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_value(error).unwrap(),
|
||||
serde_json::json!({"kind":"unavailable","message":"no server","data":null})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -215,21 +215,13 @@ fn fix_kind(
|
||||
}
|
||||
|
||||
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
|
||||
/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only
|
||||
/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`].
|
||||
pub fn java_debug_vm_args(
|
||||
vm_args: &str,
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: Option<&str>,
|
||||
windows: bool,
|
||||
) -> Option<String> {
|
||||
/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself.
|
||||
pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option<String> {
|
||||
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
|
||||
let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args;
|
||||
if added.is_empty() {
|
||||
if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) {
|
||||
return None;
|
||||
}
|
||||
let added = added.join(" ");
|
||||
let added = options(STD_PROPERTIES, encoding).join(" ");
|
||||
Some(match vm_args.trim() {
|
||||
"" => added,
|
||||
own => format!("{own} {added}"),
|
||||
@@ -454,53 +446,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_write_what_java_debug_decodes() {
|
||||
let debug =
|
||||
|args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows);
|
||||
for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] {
|
||||
let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8");
|
||||
let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" "));
|
||||
assert_eq!(debug("", encoding, true), Some(utf8.clone()));
|
||||
assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}")));
|
||||
assert_eq!(debug("-Xmx1g", encoding, false), None);
|
||||
}
|
||||
for windows in [true, false] {
|
||||
assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" ")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_preserves_user_options_independently() {
|
||||
let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true);
|
||||
assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}")));
|
||||
for property in STD_PROPERTIES {
|
||||
let args = format!("-D{property}=GBK");
|
||||
assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8")));
|
||||
assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None);
|
||||
}
|
||||
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
|
||||
for (own, expected) in [
|
||||
("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())),
|
||||
("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())),
|
||||
("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None),
|
||||
] {
|
||||
assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected);
|
||||
assert_eq!(
|
||||
java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true),
|
||||
expected
|
||||
);
|
||||
}
|
||||
// A launch environment value overrides the inherited one, as in Run.
|
||||
assert_eq!(
|
||||
java_debug_vm_args(
|
||||
"",
|
||||
&vars(&[(key, "")]),
|
||||
|k| (k == key).then(|| "-Dfile.encoding=GBK".into()),
|
||||
None,
|
||||
true
|
||||
),
|
||||
Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8"))
|
||||
);
|
||||
}
|
||||
assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8));
|
||||
assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}")));
|
||||
assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK"));
|
||||
assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None);
|
||||
assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None);
|
||||
assert!(java_debug_vm_args("", Some("GBK"), false).is_some());
|
||||
}
|
||||
|
||||
fn filtered(chunks: &[&str]) -> String {
|
||||
|
||||
@@ -508,50 +508,9 @@ pub struct LanguagesSettings {
|
||||
pub rust: RustSettings,
|
||||
pub go: GoSettings,
|
||||
pub typescript: TypeScriptSettings,
|
||||
pub prettier: PrettierSettings,
|
||||
pub java: JavaSettings,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PrettierSettings {
|
||||
/// Empty path: project node_modules, managed installation, then PATH.
|
||||
pub path: String,
|
||||
pub languages: Vec<String>,
|
||||
/// Prefer Prettier even when a language server offers formatting.
|
||||
pub prefer: bool,
|
||||
pub run_on_save: bool,
|
||||
}
|
||||
|
||||
impl Default for PrettierSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: String::new(),
|
||||
languages: [
|
||||
"markdown",
|
||||
"yaml",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"html",
|
||||
"graphql",
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"json",
|
||||
"jsonc",
|
||||
"vue",
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
prefer: false,
|
||||
run_on_save: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PythonSettings {
|
||||
|
||||
@@ -905,7 +905,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
tree_sitter: None,
|
||||
language_servers: vec![],
|
||||
debugger: Some(ide_language::DebuggerConfig {
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
//! Select the document's formatting provider before executing it on this host.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::settings::PrettierSettings;
|
||||
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
|
||||
use ide_language::FormatterConfig;
|
||||
use ide_lsp::types::FormatOptions;
|
||||
use ide_tools::{Tools, formatter};
|
||||
|
||||
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
|
||||
config.is_some_and(|c| c.tool == "prettier")
|
||||
&& settings.languages.iter().any(|l| l == language)
|
||||
&& (settings.prefer || !lsp)
|
||||
}
|
||||
|
||||
pub async fn format(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
let project = host.project(window)?;
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let settings = project.settings.effective();
|
||||
let prettier = &settings.languages.prettier;
|
||||
let language = project.languages.get(&snap.language);
|
||||
let config = language.as_ref().and_then(|l| l.formatter.as_ref());
|
||||
let candidate =
|
||||
config.is_some_and(|c| c.tool == "prettier") && prettier.languages.iter().any(|l| l == snap.language.as_str());
|
||||
let lsp = if candidate && !prettier.prefer { project.lsp.can_format(doc).await? } else { false };
|
||||
if !external_selected(config, snap.language.as_str(), prettier, lsp) {
|
||||
if options.on_save && !settings.editor.format_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return project.lsp.format(doc, range, options).await;
|
||||
}
|
||||
if options.on_save && !prettier.run_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
host.require_trust(window)?;
|
||||
if snap.read_only {
|
||||
return Err(IdeError::invalid("The file is read-only"));
|
||||
}
|
||||
let config = config.unwrap().clone();
|
||||
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path);
|
||||
let tools = Tools::new(&host.data_dir);
|
||||
let whole_selection = range.is_some() && !config.range;
|
||||
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
|
||||
let text = ide_text::Document::new(&snap.text);
|
||||
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
|
||||
let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?;
|
||||
command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?);
|
||||
command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets));
|
||||
let formatted = formatter::run(command, &snap.text, &Default::default())?;
|
||||
Ok(text
|
||||
.diff(&formatted)
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| TextEdit {
|
||||
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
|
||||
new_text: c.insert,
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
if edits.is_ok() && whole_selection {
|
||||
project.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Info,
|
||||
title: "Prettier".into(),
|
||||
body: Some(
|
||||
"Prettier does not support selection formatting for this language; formatted the whole file".into(),
|
||||
),
|
||||
});
|
||||
}
|
||||
edits.map_err(|e| e.with_tool("prettier"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn language_enablement_and_preference_preserve_lsp_formatters() {
|
||||
let languages = ide_language::LanguageRegistry::load(None).unwrap();
|
||||
let mut settings = PrettierSettings::default();
|
||||
for lang in languages.all() {
|
||||
for lsp in [false, true] {
|
||||
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
|
||||
assert_eq!(
|
||||
external_selected(lang.formatter.as_ref(), lang.id.as_str(), &settings, lsp),
|
||||
configured && !lsp,
|
||||
"{}",
|
||||
lang.id
|
||||
);
|
||||
}
|
||||
}
|
||||
let ts = languages.get(&ide_core::LanguageId::new("typescript")).unwrap();
|
||||
settings.prefer = true;
|
||||
assert!(external_selected(ts.formatter.as_ref(), "typescript", &settings, true));
|
||||
settings.languages.clear();
|
||||
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
|
||||
assert!(!external_selected(None, "rust", &settings, false));
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
//! Saved Kotlin -> isolated Maven output -> marked JDT libraries.
|
||||
|
||||
use ide_build::kotlin_bridge::{self, Outcome, ProjectLibraries};
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink};
|
||||
use ide_lsp::LspManager;
|
||||
use ide_workspace::SettingsStore;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
const DELAY: Duration = Duration::from_millis(750);
|
||||
const SOURCE: &str = "kotlin-bridge";
|
||||
const COMMAND: &str = "openidea.kotlin.replace";
|
||||
|
||||
#[derive(Default)]
|
||||
struct Revision {
|
||||
generation: u64,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
impl Revision {
|
||||
fn change(&mut self) {
|
||||
self.cancel.cancel();
|
||||
self.generation += 1;
|
||||
self.cancel = CancellationToken::new();
|
||||
}
|
||||
fn current(&self, generation: u64) -> bool {
|
||||
self.generation == generation && !self.cancel.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
struct MavenOptions {
|
||||
offline: bool,
|
||||
profiles: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct KotlinBridge {
|
||||
root: PathBuf,
|
||||
cache: PathBuf,
|
||||
bundle: Option<PathBuf>,
|
||||
settings: Arc<SettingsStore>,
|
||||
lsp: Arc<LspManager>,
|
||||
sink: SharedSink,
|
||||
trusted: Arc<AtomicBool>,
|
||||
lifetime: CancellationToken,
|
||||
revision: Mutex<Revision>,
|
||||
changed: Notify,
|
||||
options: Mutex<MavenOptions>,
|
||||
worker: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl KotlinBridge {
|
||||
pub(crate) fn new(
|
||||
root: PathBuf,
|
||||
cache: PathBuf,
|
||||
data: &Path,
|
||||
settings: Arc<SettingsStore>,
|
||||
lsp: Arc<LspManager>,
|
||||
sink: SharedSink,
|
||||
trusted: Arc<AtomicBool>,
|
||||
) -> Arc<Self> {
|
||||
let options = std::fs::read(cache.join("kotlin-bridge-options.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default();
|
||||
Arc::new(Self {
|
||||
root,
|
||||
cache,
|
||||
bundle: ide_sdk::java::kotlin_bridge_bundle(data),
|
||||
settings,
|
||||
lsp,
|
||||
sink,
|
||||
trusted,
|
||||
lifetime: CancellationToken::new(),
|
||||
revision: Mutex::new(Revision::default()),
|
||||
changed: Notify::new(),
|
||||
options: Mutex::new(options),
|
||||
worker: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn start(self: &Arc<Self>, runtime: &tokio::runtime::Handle) {
|
||||
let bridge = self.clone();
|
||||
*self.worker.lock() = Some(runtime.spawn(async move { bridge.run().await }));
|
||||
self.schedule();
|
||||
}
|
||||
|
||||
pub(crate) fn configure_maven(&self, offline: bool, profiles: Vec<String>) {
|
||||
let options = MavenOptions { offline, profiles };
|
||||
if let Ok(bytes) = serde_json::to_vec(&options) {
|
||||
let _ = std::fs::create_dir_all(&self.cache);
|
||||
let _ = std::fs::write(self.cache.join("kotlin-bridge-options.json"), bytes);
|
||||
}
|
||||
*self.options.lock() = options;
|
||||
self.schedule();
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(&self) {
|
||||
self.stop();
|
||||
let worker = self.worker.lock().take();
|
||||
if let Some(worker) = worker {
|
||||
let _ = worker.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn schedule(&self) {
|
||||
if self.lifetime.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
self.revision.lock().change();
|
||||
self.changed.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) fn files_changed<'a>(&self, mut paths: impl Iterator<Item = &'a PathBuf>) {
|
||||
let settings = self.settings.effective();
|
||||
if paths.any(|path| relevant_change(&self.root, path, &settings)) {
|
||||
self.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stop(&self) {
|
||||
self.lifetime.cancel();
|
||||
self.revision.lock().cancel.cancel();
|
||||
}
|
||||
|
||||
async fn replace(&self, projects: &[ProjectLibraries], cancel: &CancellationToken) -> ide_core::IdeResult<()> {
|
||||
let snapshot = serde_json::to_value(projects).map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
|
||||
let response = self.lsp.jdtls_command_cancellable(&self.root, COMMAND, vec![snapshot], cancel).await?;
|
||||
if response.as_u64().is_none() {
|
||||
return Err(ide_core::IdeError::unavailable(
|
||||
"Kotlin bridge command did not acknowledge its library snapshot",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run(self: Arc<Self>) {
|
||||
let mut diagnostics = BTreeSet::new();
|
||||
let mut last_failure = String::new();
|
||||
let mut initialized = false;
|
||||
// Also retain directories from unacknowledged replacements: JDT may have applied them.
|
||||
let mut published = Vec::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = self.lifetime.cancelled() => return,
|
||||
_ = self.changed.notified() => {},
|
||||
}
|
||||
let (generation, cancel) = {
|
||||
let state = self.revision.lock();
|
||||
(state.generation, state.cancel.clone())
|
||||
};
|
||||
if !self.trusted.load(Ordering::Acquire) {
|
||||
continue;
|
||||
}
|
||||
if !debounce(&cancel, &self.lifetime).await {
|
||||
continue;
|
||||
}
|
||||
let root = self.root.clone();
|
||||
let cache = self.cache.clone();
|
||||
let settings = self.settings.effective();
|
||||
let has_sources = tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&root, &cache, &settings);
|
||||
let enabled =
|
||||
model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root);
|
||||
(model, enabled)
|
||||
})
|
||||
.await;
|
||||
let Ok((mut model, enabled)) = has_sources else { continue };
|
||||
if !self.revision.lock().current(generation) {
|
||||
continue;
|
||||
}
|
||||
if !enabled {
|
||||
if self.cache.join("kotlin-bridge").exists()
|
||||
&& let Err(error) = self.replace(&[], &cancel).await
|
||||
{
|
||||
if !cancel.is_cancelled() {
|
||||
tracing::warn!("Kotlin library withdrawal: {error}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for old in published.drain(..) {
|
||||
remove_directory(old).await;
|
||||
}
|
||||
self.clear_diagnostics(&mut diagnostics);
|
||||
last_failure.clear();
|
||||
continue;
|
||||
}
|
||||
if let Some(maven) = &mut model.maven {
|
||||
let options = self.options.lock();
|
||||
for module in &mut maven.projects {
|
||||
if options.offline {
|
||||
module.options.push("-o".into());
|
||||
}
|
||||
if !options.profiles.is_empty() {
|
||||
module.options.push(format!("-P{}", options.profiles.join(",")));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(bundle) = self.bundle.clone() else {
|
||||
self.failure("Kotlin bridge bundle is unavailable".into(), &mut diagnostics, &mut last_failure);
|
||||
continue;
|
||||
};
|
||||
// Clear entries persisted by an earlier incarnation only before our first build.
|
||||
if !initialized {
|
||||
if let Err(error) = self.replace(&[], &cancel).await {
|
||||
if !cancel.is_cancelled() {
|
||||
self.failure(error.message, &mut diagnostics, &mut last_failure);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
initialized = true;
|
||||
}
|
||||
let directory = self.cache.join(format!("kotlin-bridge/{}", uuid::Uuid::new_v4()));
|
||||
let staging = directory.clone();
|
||||
let root = self.root.clone();
|
||||
let token = cancel.clone();
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || kotlin_bridge::build(&root, &model, &bundle, &staging, &token))
|
||||
.await;
|
||||
if !self.revision.lock().current(generation) || self.lifetime.is_cancelled() {
|
||||
remove_directory(directory).await;
|
||||
continue;
|
||||
}
|
||||
match result {
|
||||
Ok(Ok(Outcome { success: true, projects, .. })) => {
|
||||
match self.replace(&projects, &cancel).await {
|
||||
Ok(()) => {
|
||||
for old in published.drain(..) {
|
||||
remove_directory(old).await;
|
||||
}
|
||||
published.push(directory);
|
||||
self.clear_diagnostics(&mut diagnostics);
|
||||
last_failure.clear();
|
||||
}
|
||||
Err(error) => {
|
||||
// Keep both snapshots until an acknowledged replacement.
|
||||
published.push(directory);
|
||||
if !cancel.is_cancelled() {
|
||||
self.failure(error.message, &mut diagnostics, &mut last_failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let text = match other {
|
||||
Ok(Ok(outcome)) => outcome
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.severity == Severity::Error)
|
||||
.map(|m| m.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
Ok(Err(error)) => error.message,
|
||||
Err(error) => error.to_string(),
|
||||
};
|
||||
self.failure(text, &mut diagnostics, &mut last_failure);
|
||||
remove_directory(directory).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_diagnostics(&self, paths: &mut BTreeSet<PathBuf>) {
|
||||
for path in std::mem::take(paths) {
|
||||
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items: Vec::new() });
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(&self, text: String, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
|
||||
let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text };
|
||||
let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API.");
|
||||
self.clear_diagnostics(paths);
|
||||
let path = self.root.join("pom.xml");
|
||||
paths.insert(path.clone());
|
||||
self.sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.into(),
|
||||
source: SOURCE.into(),
|
||||
items: vec![Diagnostic {
|
||||
range: Range { start: Pos { line: 0, col: 0 }, end: Pos { line: 0, col: 0 } },
|
||||
severity: Severity::Error,
|
||||
message: text.clone(),
|
||||
source: Some(SOURCE.into()),
|
||||
server: Some(SOURCE.into()),
|
||||
code: None,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
}],
|
||||
});
|
||||
if previous != &text {
|
||||
self.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Error,
|
||||
title: "Kotlin editor compilation failed".into(),
|
||||
body: Some(text.clone()),
|
||||
});
|
||||
*previous = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool {
|
||||
let input = path.extension().is_some_and(|e| e == "kt")
|
||||
|| path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd")
|
||||
|| path.components().any(|c| c.as_os_str() == ".mvn");
|
||||
let Ok(relative) = path.strip_prefix(root) else {
|
||||
// Content roots outside the project only contribute Kotlin sources.
|
||||
return path.extension().is_some_and(|e| e == "kt");
|
||||
};
|
||||
// Build outputs and generated sources (e.g. KSP's `.kt` under target/) never trigger,
|
||||
// so a build cannot schedule itself.
|
||||
if relative.components().any(|c| {
|
||||
matches!(
|
||||
c.as_os_str().to_str(),
|
||||
Some("target" | "build" | "out" | "bin" | ".git" | "node_modules" | ".openidea")
|
||||
)
|
||||
}) || ide_vfs::ExcludeMatcher::new(&settings.exclude_globs()).is_excluded(relative)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if input {
|
||||
return true;
|
||||
}
|
||||
if path.extension().is_some() {
|
||||
return false;
|
||||
}
|
||||
// Works for deleted directories too; the module POM remains on disk.
|
||||
path.ancestors().take_while(|ancestor| ancestor.starts_with(root)).any(|ancestor| {
|
||||
ancestor.file_name().is_some_and(|name| name == "src")
|
||||
&& ancestor.parent().is_some_and(|module| module.join("pom.xml").is_file())
|
||||
})
|
||||
}
|
||||
|
||||
async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool {
|
||||
tokio::select! {
|
||||
_ = lifetime.cancelled() => false,
|
||||
_ = cancel.cancelled() => false,
|
||||
_ = tokio::time::sleep(DELAY) => true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_directory(path: PathBuf) {
|
||||
let _ = tokio::task::spawn_blocking(move || std::fs::remove_dir_all(path)).await;
|
||||
}
|
||||
|
||||
impl DocumentObserver for KotlinBridge {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
|
||||
self.files_changed(std::iter::once(&path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_package_directory_does_not_trigger() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
|
||||
assert!(!relevant_change(dir.path(), &dir.path().join("target/classes/probe"), &Default::default()));
|
||||
let generated = dir.path().join("target/generated-sources/ksp/main/kotlin/Gen.kt");
|
||||
assert!(!relevant_change(dir.path(), &generated, &Default::default()));
|
||||
assert!(relevant_change(dir.path(), &dir.path().join("src/main/kotlin/A.kt"), &Default::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_source_package_triggers_unless_excluded() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("module")).unwrap();
|
||||
std::fs::write(dir.path().join("module/pom.xml"), "").unwrap();
|
||||
let path = dir.path().join("module/src/main/kotlin/pkg");
|
||||
let mut settings = ide_core::settings::Settings::default();
|
||||
assert!(relevant_change(dir.path(), &path, &settings));
|
||||
settings.project_structure.excluded.push("module/src/main/kotlin".into());
|
||||
assert!(!relevant_change(dir.path(), &path, &settings));
|
||||
assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extensionless_root_file_does_not_trigger() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
|
||||
assert!(!relevant_change(dir.path(), &dir.path().join("LICENSE"), &Default::default()));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn burst_saves_cancel_the_pending_debounce() {
|
||||
let mut state = Revision::default();
|
||||
state.change();
|
||||
let token = state.cancel.clone();
|
||||
let lifetime = CancellationToken::new();
|
||||
let pending_lifetime = lifetime.clone();
|
||||
let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await });
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(DELAY - Duration::from_millis(1)).await;
|
||||
assert!(!waiting.is_finished());
|
||||
for _ in 0..20 {
|
||||
state.change();
|
||||
}
|
||||
assert!(!waiting.await.unwrap());
|
||||
let current = state.generation;
|
||||
assert!(debounce(&state.cancel, &lifetime).await);
|
||||
assert!(state.current(current));
|
||||
lifetime.cancel();
|
||||
assert!(!debounce(&state.cancel, &lifetime).await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_and_cancelled_builds_cannot_publish() {
|
||||
let mut state = Revision::default();
|
||||
state.change();
|
||||
let first = state.generation;
|
||||
let token = state.cancel.clone();
|
||||
state.change();
|
||||
assert!(token.is_cancelled());
|
||||
assert!(!state.current(first));
|
||||
assert!(state.current(state.generation));
|
||||
state.cancel.cancel();
|
||||
assert!(!state.current(state.generation));
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,7 @@
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod environment;
|
||||
mod formatter;
|
||||
mod java_runtime;
|
||||
mod kotlin_bridge;
|
||||
pub mod notebook;
|
||||
pub mod ops;
|
||||
mod project;
|
||||
@@ -150,15 +148,9 @@ impl Host {
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
self.shutdown_project_workers_blocking(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
);
|
||||
self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]);
|
||||
let lsp = project.lsp.clone();
|
||||
self.runtime.spawn(async move { lsp.shutdown_all().await });
|
||||
}
|
||||
@@ -166,14 +158,7 @@ impl Host {
|
||||
|
||||
/// Synchronous project/window destruction must finish owned kernel cleanup,
|
||||
/// not enqueue work which can be lost as the desktop runtime exits.
|
||||
fn shutdown_project_workers_blocking(
|
||||
&self,
|
||||
notebooks: Vec<Arc<notebook::NotebookService>>,
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
) {
|
||||
for bridge in &bridges {
|
||||
bridge.stop();
|
||||
}
|
||||
fn shutdown_notebooks_blocking(&self, notebooks: Vec<Arc<notebook::NotebookService>>) {
|
||||
for service in ¬ebooks {
|
||||
service.stop_admission();
|
||||
}
|
||||
@@ -181,9 +166,6 @@ impl Host {
|
||||
let wait = move || {
|
||||
let worker = std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
@@ -204,11 +186,7 @@ impl Host {
|
||||
|
||||
/// Called before the desktop runtime exits, including update/restart exits.
|
||||
pub fn shutdown_notebooks_on_exit(&self) {
|
||||
let projects = self.projects();
|
||||
self.shutdown_project_workers_blocking(
|
||||
projects.iter().map(|project| project.notebooks.clone()).collect(),
|
||||
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
|
||||
);
|
||||
self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.clone()).collect());
|
||||
}
|
||||
|
||||
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
|
||||
@@ -219,18 +197,12 @@ impl Host {
|
||||
self.terminals.kill_all();
|
||||
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
|
||||
for project in &projects {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.notebooks.stop_admission();
|
||||
project.agents.shutdown();
|
||||
}
|
||||
// Kernel cleanup owns its interrupt/termination/reaping deadlines. An
|
||||
// outer LSP timeout must never abort it before owned children are reaped.
|
||||
for project in &projects {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
project.notebooks.shutdown().await;
|
||||
}
|
||||
let stop_language_servers = async {
|
||||
|
||||
@@ -97,7 +97,6 @@ pub async fn build_sync(
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let bridge_profiles = profiles.clone();
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
@@ -138,9 +137,6 @@ pub async fn build_sync(
|
||||
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
|
||||
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
|
||||
// to update the project configurations re-resolves them (#436).
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.configure_maven(offline, bridge_profiles);
|
||||
}
|
||||
let lsp = project.lsp.clone();
|
||||
let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect();
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -96,13 +96,7 @@ fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchC
|
||||
};
|
||||
let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let encoding = extra.get("encoding").and_then(|v| v.as_str());
|
||||
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(
|
||||
vm_args,
|
||||
&config.env,
|
||||
|key| std::env::var(key).ok(),
|
||||
encoding,
|
||||
windows,
|
||||
) {
|
||||
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) {
|
||||
extra.insert("vmArgs".into(), vm_args.into());
|
||||
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
|
||||
}
|
||||
@@ -458,7 +452,7 @@ mod tests {
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(),
|
||||
env: Default::default(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -474,7 +468,7 @@ mod tests {
|
||||
let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true);
|
||||
assert_eq!(
|
||||
vm_args(&fixed),
|
||||
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -Dfile.encoding=UTF-8"
|
||||
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
|
||||
@@ -487,13 +481,4 @@ mod tests {
|
||||
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_respect_environment_encoding() {
|
||||
for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] {
|
||||
let mut config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Dstdout.encoding=GBK"}));
|
||||
config.env.insert(key.into(), "-Dfile.encoding=GBK".into());
|
||||
assert_eq!(vm_args(&match_java_debug_encoding(config, true)), "-Dstdout.encoding=GBK");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls).
|
||||
pub(crate) mod cache;
|
||||
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
@@ -46,13 +44,10 @@ async fn resolve_launch(
|
||||
let settings = settings.clone();
|
||||
let module = module.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
project.java_launch_snapshots.lock().initialize();
|
||||
let model = load(&project.root, &project.build_cache_dir(), &settings);
|
||||
let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test);
|
||||
let delegated = build
|
||||
&& model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module))
|
||||
&& (non_java || cache::has_record(&project.root, &project.build_cache_dir(), &module));
|
||||
let external = non_java || delegated;
|
||||
let external = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test);
|
||||
let delegated = external
|
||||
&& model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module));
|
||||
(model, external, delegated)
|
||||
})
|
||||
.await
|
||||
@@ -65,24 +60,20 @@ async fn resolve_launch(
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _external_build = external_build;
|
||||
let built = if external {
|
||||
compile::build_for_launch(&project.root, &model, &module, test, &cancel)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let built = if delegated {
|
||||
let outputs = project.java_launch_snapshots.lock().build(
|
||||
&project.root,
|
||||
&model,
|
||||
&module,
|
||||
test,
|
||||
ide_sdk::apply::maven_local_repository(&project.root, &settings),
|
||||
&cancel,
|
||||
)?;
|
||||
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
|
||||
let (snapshot, copies) = snapshot_outputs(&mut info, &built, &cancel)?;
|
||||
project.java_launch_outputs.lock().push(snapshot);
|
||||
info.build = Some(JavaBuildStatus::Succeeded);
|
||||
info.errors.clear();
|
||||
info.error_count = 0;
|
||||
copies
|
||||
} else if external {
|
||||
compile::build_for_launch(&project.root, &model, &module, test, &cancel)?
|
||||
} else {
|
||||
Vec::new()
|
||||
built
|
||||
};
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
@@ -131,10 +122,19 @@ async fn resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
|
||||
fn snapshot_outputs(
|
||||
info: &mut JavaLaunchInfo,
|
||||
built: &[PathBuf],
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<(tempfile::TempDir, Vec<PathBuf>)> {
|
||||
let snapshot = tempfile::Builder::new().prefix("openidea-java-launch-").tempdir()?;
|
||||
let mut copies = Vec::new();
|
||||
for (index, source) in built.iter().enumerate() {
|
||||
let dest = snapshot.join(index.to_string());
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let dest = snapshot.path().join(index.to_string());
|
||||
ide_vfs::copy(source, &dest)?;
|
||||
let original = source.to_string_lossy();
|
||||
info.class_paths.retain(|p| p != original.as_ref());
|
||||
let mut modular = false;
|
||||
@@ -148,7 +148,7 @@ fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::pa
|
||||
copies.push(dest);
|
||||
}
|
||||
}
|
||||
copies
|
||||
Ok((snapshot, copies))
|
||||
}
|
||||
|
||||
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
|
||||
@@ -199,11 +199,8 @@ mod tests {
|
||||
module_paths: vec![lib.to_string_lossy().into_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
let snapshot = tempfile::tempdir().unwrap();
|
||||
for (i, source) in [&app, &lib].iter().enumerate() {
|
||||
ide_vfs::copy(source, &snapshot.path().join(i.to_string())).unwrap();
|
||||
}
|
||||
let paths = use_snapshot(&mut info, &[app.clone(), lib.clone()], snapshot.path());
|
||||
let (snapshot, paths) =
|
||||
snapshot_outputs(&mut info, &[app.clone(), lib.clone()], &CancellationToken::new()).unwrap();
|
||||
assert_eq!(paths, [snapshot.path().join("0")]);
|
||||
assert_eq!(info.class_paths, ["stdlib.jar"]);
|
||||
assert_eq!(info.module_paths, [snapshot.path().join("1").to_string_lossy().into_owned()]);
|
||||
@@ -215,5 +212,11 @@ mod tests {
|
||||
"Maven bytecode"
|
||||
);
|
||||
}
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
snapshot_outputs(&mut info, &[app], &cancel).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
//! Immutable Maven outputs retained in the project cache, including across close/reopen.
|
||||
use ide_build::compile::{self, LaunchFingerprint};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct Outputs {
|
||||
fingerprint: Option<LaunchFingerprint>,
|
||||
pub snapshot: PathBuf,
|
||||
pub originals: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Outputs {
|
||||
fn reusable(&self, fingerprint: &Option<LaunchFingerprint>, snapshots: &Path) -> bool {
|
||||
fingerprint.is_some()
|
||||
&& self.fingerprint == *fingerprint
|
||||
&& self.snapshot.parent() == Some(snapshots)
|
||||
&& !self.originals.is_empty()
|
||||
&& (0..self.originals.len()).all(|i| self.snapshot.join(i.to_string()).is_dir())
|
||||
}
|
||||
}
|
||||
|
||||
fn record_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
|
||||
Some(cache.join("java-launch/records").join(module.strip_prefix(root).ok()?).join(if test {
|
||||
"test.json"
|
||||
} else {
|
||||
"main.json"
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
|
||||
[false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file()))
|
||||
}
|
||||
|
||||
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
|
||||
pub(crate) struct Session {
|
||||
cache: PathBuf,
|
||||
initialized: bool,
|
||||
unreferenced: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) fn new(cache: PathBuf) -> Self {
|
||||
Self { cache, initialized: false, unreferenced: Vec::new() }
|
||||
}
|
||||
|
||||
pub(super) fn initialize(&mut self) {
|
||||
if !self.initialized {
|
||||
self.initialized = true;
|
||||
if let Err(error) = sweep(&self.cache) {
|
||||
tracing::warn!(%error, "Cannot clean previous Java launch snapshots");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
model: &ide_build::BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outputs> {
|
||||
self.initialize();
|
||||
let cache = &self.cache;
|
||||
let record =
|
||||
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
|
||||
let snapshots = cache.join("java-launch/snapshots");
|
||||
let fingerprint = || match compile::launch_fingerprint(root, model, module, test, local_repo.clone(), cancel) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(e) if cancel.is_cancelled() => Err(e),
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "Cannot fingerprint Maven launch; rebuilding");
|
||||
Ok(None)
|
||||
}
|
||||
};
|
||||
let before = fingerprint()?;
|
||||
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
|
||||
if let Some(outputs) = previous.as_ref()
|
||||
&& outputs.reusable(&before, &snapshots)
|
||||
{
|
||||
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
|
||||
return Ok(previous.unwrap());
|
||||
}
|
||||
// Retain a non-reusable record even on failure, so removing the final mixed
|
||||
// source still delegates to Maven to clean its old classes on the next launch.
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
fs::write(&record, b"null")?;
|
||||
if let Some(previous) = previous {
|
||||
self.unreferenced.push(previous.snapshot);
|
||||
}
|
||||
let originals = compile::build_for_launch(root, model, module, test, cancel)?;
|
||||
fs::create_dir_all(&snapshots)?;
|
||||
let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep();
|
||||
// Retain every new snapshot even if copying or publishing its record fails.
|
||||
self.unreferenced.push(snapshot.clone());
|
||||
for (i, source) in originals.iter().enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
ide_vfs::copy(source, &snapshot.join(i.to_string()))?;
|
||||
}
|
||||
let after = fingerprint()?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut outputs = Outputs { fingerprint: None, snapshot, originals };
|
||||
if before.is_some() && before == after {
|
||||
outputs.fingerprint = before;
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
|
||||
serde_json::to_writer(file.as_file_mut(), &outputs).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
file.persist(&record).map_err(|e| IdeError::from(e.error))?;
|
||||
self.unreferenced.retain(|path| path != &outputs.snapshot);
|
||||
}
|
||||
Ok(outputs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
// Recheck all records: another module or launch mode may reference a path.
|
||||
if let Ok(referenced) = referenced_snapshots(&self.cache) {
|
||||
let snapshots = self.cache.join("java-launch/snapshots");
|
||||
for path in &self.unreferenced {
|
||||
remove_unreferenced(path, &snapshots, &referenced);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn referenced_snapshots(cache: &Path) -> std::io::Result<HashSet<PathBuf>> {
|
||||
let mut referenced = HashSet::new();
|
||||
let records = cache.join("java-launch/records");
|
||||
if !records.try_exists()? {
|
||||
return Ok(referenced);
|
||||
}
|
||||
let mut pending = vec![records];
|
||||
while let Some(dir) = pending.pop() {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_dir() {
|
||||
pending.push(entry.path());
|
||||
} else if kind.is_file() && entry.path().extension().is_some_and(|ext| ext == "json") {
|
||||
let outputs: Option<Outputs> = serde_json::from_slice(&fs::read(entry.path())?)?;
|
||||
if let Some(outputs) = outputs
|
||||
&& let Ok(path) = fs::canonicalize(outputs.snapshot)
|
||||
{
|
||||
referenced.insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(referenced)
|
||||
}
|
||||
|
||||
fn remove_unreferenced(path: &Path, snapshots: &Path, referenced: &HashSet<PathBuf>) {
|
||||
// Only real launch-* directories directly inside snapshots, never symlinks.
|
||||
if path.parent() != Some(snapshots)
|
||||
|| !path.file_name().is_some_and(|name| name.to_string_lossy().starts_with("launch-"))
|
||||
|| !fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_dir())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let (Ok(path), Ok(snapshots)) = (fs::canonicalize(path), fs::canonicalize(snapshots))
|
||||
&& path.parent() == Some(snapshots.as_path())
|
||||
&& !referenced.contains(&path)
|
||||
&& let Err(error) = fs::remove_dir_all(&path)
|
||||
{
|
||||
tracing::warn!(%error, path = %path.display(), "Cannot remove Java launch snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
fn sweep(cache: &Path) -> std::io::Result<()> {
|
||||
let referenced = referenced_snapshots(cache)?;
|
||||
let snapshots = cache.join("java-launch/snapshots");
|
||||
if snapshots.try_exists()? {
|
||||
for entry in fs::read_dir(&snapshots)? {
|
||||
remove_unreferenced(&entry?.path(), &snapshots, &referenced);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn fixture(root: &Path) -> ide_build::BuildModel {
|
||||
fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let script = root.join("mvnw");
|
||||
fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
|
||||
ide_build::load(root, &root.join("cache"), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let mut build = || session.build(root, &model, root, false, None, &cancel);
|
||||
let first = build().unwrap();
|
||||
let snapshot = first.snapshot.clone();
|
||||
drop(first);
|
||||
// Dropping an Outputs value leaves the persisted snapshot reusable.
|
||||
let second = build().unwrap();
|
||||
assert_eq!(snapshot, second.snapshot);
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1);
|
||||
fs::write(root.join("target/classes/Main.class"), "JDT stub").unwrap();
|
||||
assert_eq!(snapshot, build().unwrap().snapshot);
|
||||
assert_eq!(fs::read_to_string(snapshot.join("0/Main.class")).unwrap(), "first");
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
|
||||
let updated = build().unwrap();
|
||||
assert_ne!(snapshot, updated.snapshot);
|
||||
assert_eq!(fs::read_to_string(updated.snapshot.join("0/Main.class")).unwrap(), "updated");
|
||||
assert!(snapshot.join("0/Main.class").is_file(), "previous JVMs may still load it");
|
||||
fs::remove_dir_all(updated.snapshot.join("0")).unwrap();
|
||||
let rebuilt = build().unwrap();
|
||||
assert_ne!(rebuilt.snapshot, updated.snapshot);
|
||||
fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap();
|
||||
assert!(build().is_err());
|
||||
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
|
||||
fs::remove_file(root.join("src/main/kotlin/broken")).unwrap();
|
||||
assert_ne!(rebuilt.snapshot, build().unwrap().snapshot, "a failed build invalidates success");
|
||||
cancel.cancel();
|
||||
assert!(build().is_err());
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5);
|
||||
}
|
||||
#[test]
|
||||
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
|
||||
let second = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(second.snapshot.is_dir());
|
||||
// Another mode can keep a superseded snapshot alive.
|
||||
let test_record = cache.join("java-launch/records/nested/test.json");
|
||||
fs::create_dir_all(test_record.parent().unwrap()).unwrap();
|
||||
fs::write(&test_record, serde_json::to_vec(&first).unwrap()).unwrap();
|
||||
drop(session);
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(second.snapshot.is_dir());
|
||||
let mut session = Session::new(cache.clone());
|
||||
let reused = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_eq!(reused.snapshot, second.snapshot);
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap();
|
||||
let third = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert!(second.snapshot.is_dir());
|
||||
drop(session);
|
||||
assert!(!second.snapshot.exists());
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(third.snapshot.is_dir());
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_sweep_only_removes_unreferenced_direct_snapshot_directories() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = dir.path().join("cache");
|
||||
let snapshots = cache.join("java-launch/snapshots");
|
||||
let retained = snapshots.join("launch-retained");
|
||||
let orphan = snapshots.join("launch-orphan");
|
||||
let other = snapshots.join("other");
|
||||
let nested = other.join("launch-nested");
|
||||
let outside = dir.path().join("launch-outside");
|
||||
for path in [&retained, &orphan, &nested, &outside] {
|
||||
fs::create_dir_all(path).unwrap();
|
||||
}
|
||||
fs::write(outside.join("classes"), "keep").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, snapshots.join("launch-link")).unwrap();
|
||||
fs::write(snapshots.join("launch-file"), "keep").unwrap();
|
||||
let record = cache.join("java-launch/records/deep/module/test.json");
|
||||
fs::create_dir_all(record.parent().unwrap()).unwrap();
|
||||
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() };
|
||||
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
|
||||
let mut session = Session::new(cache);
|
||||
session.initialize();
|
||||
assert!(!orphan.exists());
|
||||
assert!(retained.is_dir());
|
||||
assert!(nested.is_dir());
|
||||
assert!(outside.join("classes").is_file());
|
||||
assert!(snapshots.join("launch-link").is_symlink());
|
||||
assert!(snapshots.join("launch-file").is_file());
|
||||
// Initialization must not sweep snapshots still in use by this session.
|
||||
fs::create_dir_all(&orphan).unwrap();
|
||||
session.initialize();
|
||||
assert!(orphan.is_dir());
|
||||
session.unreferenced.extend([outside.clone(), snapshots.join("../../launch-outside"), nested]);
|
||||
drop(session);
|
||||
assert!(outside.join("classes").is_file());
|
||||
assert!(other.join("launch-nested").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_record_publish_retains_snapshot_until_session_drop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
let mut text = fs::read_to_string(&script).unwrap();
|
||||
text.push_str("rm cache/java-launch/records/main.json\nmkdir cache/java-launch/records/main.json\n");
|
||||
fs::write(script, text).unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut session = Session::new(cache.clone());
|
||||
assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err());
|
||||
assert_eq!(session.unreferenced.len(), 1);
|
||||
let snapshot = session.unreferenced[0].clone();
|
||||
assert!(snapshot.join("0/Main.class").is_file());
|
||||
drop(session);
|
||||
assert!(!snapshot.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_during_build_do_not_publish_a_success_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
let mut text = fs::read_to_string(&script).unwrap();
|
||||
text.push_str("echo edited-during-build >> src/main/kotlin/Main.kt\n");
|
||||
fs::write(script, text).unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut session = Session::new(cache.clone());
|
||||
let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap();
|
||||
assert!(outputs.fingerprint.is_none());
|
||||
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
|
||||
assert!(outputs.snapshot.join("0/Main.class").is_file());
|
||||
drop(session);
|
||||
assert!(!outputs.snapshot.exists());
|
||||
}
|
||||
#[test]
|
||||
fn deleting_the_last_mixed_source_keeps_maven_delegation_after_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap();
|
||||
assert!(!compile::launch_has_non_java_sources(root, &model, root, false));
|
||||
assert!(has_record(root, &cache, root));
|
||||
assert!(session.build(root, &model, root, false, None, &cancel).is_err());
|
||||
assert!(has_record(root, &cache, root));
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap();
|
||||
let restored = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_ne!(first.snapshot, restored.snapshot);
|
||||
}
|
||||
}
|
||||
@@ -214,7 +214,7 @@ pub async fn lsp_format(
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
crate::formatter::format(host, window, doc, range, options).await
|
||||
host.project(window)?.lsp.format(doc, range, options).await
|
||||
}
|
||||
|
||||
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
|
||||
@@ -269,13 +269,7 @@ pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> {
|
||||
}
|
||||
|
||||
pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let java = project.lsp.servers().iter().any(|s| s.id == server && s.name == "jdtls");
|
||||
project.lsp.restart(server).await?;
|
||||
if java && let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.schedule();
|
||||
}
|
||||
Ok(())
|
||||
host.project(window)?.lsp.restart(server).await
|
||||
}
|
||||
|
||||
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
|
||||
|
||||
@@ -172,16 +172,11 @@ pub fn set_settings(
|
||||
host.apply_proxy();
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
if ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
|
||||
if code_style || toolchain {
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")))
|
||||
{
|
||||
for project in affected() {
|
||||
if toolchain && let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.schedule();
|
||||
}
|
||||
let lsp = project.lsp.clone();
|
||||
host.runtime.spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@ pub struct Project {
|
||||
pub detected_roots: Arc<RwLock<Vec<SourceFolder>>>,
|
||||
/// Per-project cache directory (build tool sync results…).
|
||||
pub cache_dir: PathBuf,
|
||||
/// Superseded launch snapshots retained until this project session ends.
|
||||
pub(crate) java_launch_snapshots: Mutex<crate::ops::java::cache::Session>,
|
||||
/// Launch snapshots stay alive while any JVM of this project may load classes.
|
||||
pub(crate) java_launch_outputs: Mutex<Vec<tempfile::TempDir>>,
|
||||
/// Database tool window: data sources and their connections.
|
||||
pub db: Arc<ide_db::DbManager>,
|
||||
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
|
||||
@@ -52,7 +52,6 @@ pub struct Project {
|
||||
pub agents: Arc<ide_agent::AgentManager>,
|
||||
/// Native notebooks and owned kernels, scoped to this project incarnation.
|
||||
pub notebooks: Arc<crate::notebook::NotebookService>,
|
||||
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
pub(crate) watcher: Watcher,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
@@ -129,8 +128,6 @@ impl Host {
|
||||
let root = root.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
let config_dir = self.config_dir.clone();
|
||||
let sink = sink.clone();
|
||||
let formatter_problem = std::sync::Mutex::new(None);
|
||||
lsp.set_config_hook(Box::new(move |cfg, server_root| {
|
||||
let member = ide_workspace::workspace::Workspace::load(&root).ok().flatten().and_then(|w| {
|
||||
w.projects.into_iter().filter(|p| server_root.starts_with(p)).max_by_key(|p| p.components().count())
|
||||
@@ -144,27 +141,6 @@ impl Host {
|
||||
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
|
||||
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
|
||||
if cfg.name == "jdtls" {
|
||||
let problem = ide_lsp::jdtls::configure_formatter(
|
||||
cfg,
|
||||
effective.editor.code_style.languages.get("java"),
|
||||
root,
|
||||
&data_dir,
|
||||
effective.language_servers.get("jdtls"),
|
||||
)
|
||||
.err()
|
||||
.map(|e| e.to_string());
|
||||
let mut previous = formatter_problem.lock().unwrap();
|
||||
if problem != *previous {
|
||||
if let Some(body) = &problem {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
title: "Java formatter configuration unavailable".into(),
|
||||
body: Some(body.clone()),
|
||||
});
|
||||
}
|
||||
*previous = problem;
|
||||
}
|
||||
drop(previous);
|
||||
ide_lsp::jdtls::configure_source_paths(
|
||||
cfg,
|
||||
ide_workspace::roots::unmanaged_java_source_paths(
|
||||
@@ -176,7 +152,6 @@ impl Host {
|
||||
let bundles: Vec<PathBuf> = ide_tools::managed_bundle(&data_dir, "java-debug")
|
||||
.into_iter()
|
||||
.chain(ide_sdk::java::usages_bundle(&data_dir))
|
||||
.chain(ide_sdk::java::kotlin_bridge_bundle(&data_dir))
|
||||
.collect();
|
||||
let min_java = ide_sdk::java::jdtls_min_java(cfg);
|
||||
let jdks = ide_sdk::java::jdtls_jdks(min_java, &data_dir);
|
||||
@@ -253,22 +228,11 @@ impl Host {
|
||||
trusted: trusted.clone(),
|
||||
}));
|
||||
|
||||
let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new(
|
||||
root.clone(),
|
||||
self.data_dir.join("cache").join(&project_key).join("build"),
|
||||
&self.data_dir,
|
||||
settings.clone(),
|
||||
lsp.clone(),
|
||||
sink.clone(),
|
||||
trusted.clone(),
|
||||
);
|
||||
documents.add_observer(kotlin_bridge.clone());
|
||||
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
|
||||
for repo in repos.read().all() {
|
||||
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
}
|
||||
let watcher = {
|
||||
let kotlin_bridge = kotlin_bridge.clone();
|
||||
let repos = repos.clone();
|
||||
let settings = settings.clone();
|
||||
let index = index.clone();
|
||||
@@ -289,7 +253,6 @@ impl Host {
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
|
||||
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
|
||||
// Atomic saves by external tools surface as `created`.
|
||||
documents.on_disk_changed(&changed);
|
||||
@@ -369,7 +332,7 @@ impl Host {
|
||||
let content_watchers = extra_roots
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history, Some(&kotlin_bridge))?;
|
||||
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?;
|
||||
Some((r.clone(), w))
|
||||
})
|
||||
.collect();
|
||||
@@ -405,14 +368,11 @@ impl Host {
|
||||
syntax,
|
||||
detected_roots,
|
||||
cache_dir: self.data_dir.join("cache").join(&project_key),
|
||||
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
|
||||
self.data_dir.join("cache").join(&project_key).join("build"),
|
||||
)),
|
||||
java_launch_outputs: Mutex::new(Vec::new()),
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
kotlin_bridge: Some(kotlin_bridge),
|
||||
watcher,
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
_scratch_watcher: scratch_watcher,
|
||||
@@ -431,9 +391,6 @@ impl Host {
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.start(&self.runtime);
|
||||
}
|
||||
if let Some(cmd) = post_start {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
@@ -527,14 +484,11 @@ impl Host {
|
||||
syntax,
|
||||
detected_roots: Arc::default(),
|
||||
cache_dir: self.data_dir.join("cache").join(&project_key),
|
||||
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
|
||||
self.data_dir.join("cache").join(&project_key).join("build"),
|
||||
)),
|
||||
java_launch_outputs: Mutex::new(Vec::new()),
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
kotlin_bridge: None,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: None,
|
||||
@@ -828,15 +782,9 @@ impl Project {
|
||||
if watchers.iter().any(|(p, _)| *p == r) {
|
||||
continue;
|
||||
}
|
||||
if let Some(w) = content_root_watcher(
|
||||
&r,
|
||||
&excludes,
|
||||
&self.sink,
|
||||
&self.index,
|
||||
&self.documents,
|
||||
&self.history,
|
||||
self.kotlin_bridge.as_ref(),
|
||||
) {
|
||||
if let Some(w) =
|
||||
content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history)
|
||||
{
|
||||
watchers.push((r, w));
|
||||
}
|
||||
}
|
||||
@@ -968,9 +916,7 @@ fn content_root_watcher(
|
||||
index: &Arc<ProjectIndex>,
|
||||
documents: &Arc<Documents>,
|
||||
history: &Arc<LocalHistory>,
|
||||
kotlin_bridge: Option<&Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
) -> Option<Watcher> {
|
||||
let kotlin_bridge = kotlin_bridge.cloned();
|
||||
let (index, documents, history) = (index.clone(), documents.clone(), history.clone());
|
||||
Watcher::start(
|
||||
dir,
|
||||
@@ -979,9 +925,6 @@ fn content_root_watcher(
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
if let Some(bridge) = &kotlin_bridge {
|
||||
bridge.files_changed(changed.iter().chain(&batch.removed));
|
||||
}
|
||||
documents.on_disk_changed(&changed);
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
}),
|
||||
|
||||
@@ -106,9 +106,6 @@ impl crate::Host {
|
||||
if previous == trusted {
|
||||
continue;
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.schedule();
|
||||
}
|
||||
if !trusted {
|
||||
project.lsp.set_execution_allowed(false);
|
||||
}
|
||||
|
||||
@@ -53,19 +53,6 @@ pub struct LanguageConfig {
|
||||
pub language_servers: Vec<LanguageServerConfig>,
|
||||
#[serde(default)]
|
||||
pub debugger: Option<DebuggerConfig>,
|
||||
/// External formatter used when no language server can format, or selected in settings.
|
||||
#[serde(default)]
|
||||
pub formatter: Option<FormatterConfig>,
|
||||
}
|
||||
|
||||
/// An external formatting tool and its language-specific parser.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct FormatterConfig {
|
||||
pub tool: String,
|
||||
pub parser: String,
|
||||
#[serde(default)]
|
||||
pub range: bool,
|
||||
}
|
||||
|
||||
/// A CodeMirror mode for the files of a framework's projects.
|
||||
|
||||
@@ -7,7 +7,6 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
dunce.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-language.workspace = true
|
||||
|
||||
@@ -17,9 +17,6 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SERVER: &str = "jdtls";
|
||||
|
||||
mod formatter;
|
||||
pub use formatter::configure_formatter;
|
||||
/// First start imports the Maven / Gradle model, which can take minutes.
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(240);
|
||||
/// A workspace build of a large multi-module project can take far longer than
|
||||
@@ -399,8 +396,7 @@ impl LspManager {
|
||||
self.jdtls_command_cancellable(path, command, arguments, &CancellationToken::new()).await
|
||||
}
|
||||
|
||||
/// Cancellable delegate commands for project-owned background services.
|
||||
pub async fn jdtls_command_cancellable(
|
||||
async fn jdtls_command_cancellable(
|
||||
&self,
|
||||
path: &Path,
|
||||
command: &str,
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::editor_settings::LanguageCodeStyle;
|
||||
use ide_core::settings::LanguageServerSettings;
|
||||
use ide_language::LanguageServerConfig;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
const PROFILE: &str = "OpenIDEA IntelliJ IDEA";
|
||||
const XML: &str = include_str!("intellij.xml");
|
||||
|
||||
/// Protocol settings only: switching profiles reaches a running jdtls through
|
||||
/// didChangeConfiguration. Explicit language-server settings are applied later.
|
||||
pub fn configure_formatter(
|
||||
cfg: &mut LanguageServerConfig,
|
||||
style: Option<&LanguageCodeStyle>,
|
||||
project_root: &Path,
|
||||
data_dir: &Path,
|
||||
overrides: Option<&LanguageServerSettings>,
|
||||
) -> IdeResult<()> {
|
||||
if cfg.name != "jdtls" {
|
||||
return Ok(());
|
||||
}
|
||||
// jdtls retains omitted preferences. Send empty values when returning to
|
||||
// Default, while preserving any profile from the base server configuration.
|
||||
let inherited = crate::handler::all_settings(cfg);
|
||||
let defaults = json!({
|
||||
"url": inherited.pointer("/java/format/settings/url").cloned().unwrap_or(json!("")),
|
||||
"profile": inherited.pointer("/java/format/settings/profile").cloned().unwrap_or(json!("")),
|
||||
});
|
||||
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
|
||||
settings["java"]["format"]["settings"] = formatter_overrides(defaults, overrides);
|
||||
cfg.settings = Some(settings);
|
||||
let Some(style) = style else { return Ok(()) };
|
||||
let (path, profile) = match style.formatter.as_deref() {
|
||||
Some("intellij") => {
|
||||
let path = data_dir.join("lsp/jdtls/intellij.xml");
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
if std::fs::read_to_string(&path).ok().as_deref() != Some(XML) {
|
||||
ide_vfs::write_atomic(&path, XML.as_bytes())?;
|
||||
}
|
||||
(path, PROFILE)
|
||||
}
|
||||
Some("custom") => {
|
||||
let value = style.formatter_path.as_deref().unwrap_or_default().trim();
|
||||
if value.is_empty() {
|
||||
return Err(ide_core::IdeError::invalid("Choose an Eclipse formatter XML file."));
|
||||
}
|
||||
let path = project_root.join(value);
|
||||
// Fail without poisoning the server launch; code intelligence remains available.
|
||||
std::fs::File::open(&path).map_err(|error| {
|
||||
let path = path.display();
|
||||
ide_core::IdeError::invalid(format!("Cannot read Java formatter XML {path}: {error}"))
|
||||
})?;
|
||||
if !path.is_file() {
|
||||
return Err(ide_core::IdeError::invalid(format!("Not a formatter XML file: {}", path.display())));
|
||||
}
|
||||
(path, "")
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
|
||||
settings["java"]["format"]["settings"] = formatter_overrides(
|
||||
json!({
|
||||
"url": crate::uri::path_to_uri_string(&path),
|
||||
"profile": profile,
|
||||
}),
|
||||
overrides,
|
||||
);
|
||||
cfg.settings = Some(settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn formatter_overrides(mut format: serde_json::Value, overrides: Option<&LanguageServerSettings>) -> serde_json::Value {
|
||||
if let Some(overrides) = overrides {
|
||||
// all_settings merges protocol settings over initializationOptions.
|
||||
// Mirror explicit formatter keys so generated protocol values cannot
|
||||
// accidentally mask an advanced initializationOptions override.
|
||||
for layer in [
|
||||
overrides.initialization_options.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
|
||||
overrides.settings.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
|
||||
overrides.settings.as_ref().and_then(|v| v.pointer("/java/format/settings")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
for key in ["url", "profile"] {
|
||||
if let Some(value) = layer.get(key) {
|
||||
format[key] = value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
format
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config(name: &str) -> LanguageServerConfig {
|
||||
serde_json::from_value(json!({"name": name, "command": name})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_resolve_paths_preserve_settings_and_leave_other_servers_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("project with 空格");
|
||||
let data = dir.path().join("data");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let custom = root.join("style.xml");
|
||||
std::fs::write(&custom, XML).unwrap();
|
||||
for name in ["jdtls", "rust-analyzer"] {
|
||||
for mode in ["default", "intellij", "custom", "unknown"] {
|
||||
let mut cfg = LanguageServerConfig {
|
||||
name: name.into(),
|
||||
settings: Some(json!({"java": {"autobuild": {"enabled": false}}})),
|
||||
..config(name)
|
||||
};
|
||||
let initial = cfg.clone();
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some(mode.into()),
|
||||
formatter_path: Some("style.xml".into()),
|
||||
..Default::default()
|
||||
};
|
||||
configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap();
|
||||
if name != "jdtls" {
|
||||
assert_eq!(cfg, initial);
|
||||
continue;
|
||||
}
|
||||
let settings = crate::handler::all_settings(&cfg);
|
||||
if !["intellij", "custom"].contains(&mode) {
|
||||
assert_eq!(settings["java"]["format"]["settings"], json!({"url": "", "profile": ""}));
|
||||
continue;
|
||||
}
|
||||
let path = if mode == "custom" { custom.clone() } else { data.join("lsp/jdtls/intellij.xml") };
|
||||
assert_eq!(settings["java"]["format"]["settings"]["url"], crate::uri::path_to_uri_string(&path));
|
||||
assert_eq!(
|
||||
settings["java"]["format"]["settings"]["profile"],
|
||||
if mode == "custom" { "" } else { PROFILE }
|
||||
);
|
||||
assert_eq!(settings["java"]["autobuild"]["enabled"], false);
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), XML);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_custom_paths_are_nonfatal_and_do_not_set_launch_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for path in ["", "missing.xml", "."] {
|
||||
let mut cfg = config("jdtls");
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some("custom".into()),
|
||||
formatter_path: Some(path.into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), None).is_err());
|
||||
assert!(cfg.launch_error.is_none());
|
||||
assert_eq!(crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"], "");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_keeps_explicit_server_configuration_and_custom_accepts_absolute_paths() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("custom.xml");
|
||||
std::fs::write(&path, XML).unwrap();
|
||||
let mut cfg = config("jdtls");
|
||||
cfg.initialization_options = Some(
|
||||
toml::Value::try_from(json!({
|
||||
"settings": {"java": {"format": {"settings": {"url": "existing.xml", "profile": "Existing"}}}}
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
configure_formatter(&mut cfg, None, dir.path(), dir.path(), None).unwrap();
|
||||
assert_eq!(
|
||||
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
|
||||
json!({"url": "existing.xml", "profile": "Existing"})
|
||||
);
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some("custom".into()),
|
||||
formatter_path: Some(path.to_string_lossy().into_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
configure_formatter(&mut cfg, Some(&style), &dir.path().join("different-root"), dir.path(), None).unwrap();
|
||||
assert_eq!(
|
||||
crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"],
|
||||
crate::uri::path_to_uri_string(&path)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn advanced_formatter_options_win_for_each_profile_and_protocol_layer() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("custom.xml"), XML).unwrap();
|
||||
for mode in ["default", "intellij", "custom"] {
|
||||
for layer in ["initialization", "wrapped", "direct"] {
|
||||
let mut overrides = LanguageServerSettings {
|
||||
initialization_options: Some(json!({"settings": {"java": {"format": {"settings": {
|
||||
"url": "advanced.xml", "profile": "Advanced"
|
||||
}}}}})),
|
||||
..Default::default()
|
||||
};
|
||||
if layer != "initialization" {
|
||||
let value = json!({"java": {"format": {"settings": {"profile": "Protocol"}}}});
|
||||
overrides.settings = Some(if layer == "wrapped" { json!({"settings": value}) } else { value });
|
||||
}
|
||||
let mut cfg = config("jdtls");
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some(mode.into()),
|
||||
formatter_path: Some("custom.xml".into()),
|
||||
..Default::default()
|
||||
};
|
||||
configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), Some(&overrides)).unwrap();
|
||||
assert_eq!(
|
||||
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
|
||||
json!({
|
||||
"url": "advanced.xml",
|
||||
"profile": if layer == "initialization" { "Advanced" } else { "Protocol" },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
IntelliJ IDEA Java defaults, approximated using Eclipse JDT formatter options.
|
||||
Source revision: JetBrains/intellij-community 8fe3709d185cd13053d17b76d9690aef674d4268
|
||||
platform/code-style-api/src/com/intellij/psi/codeStyle/CommonCodeStyleSettings.java
|
||||
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleSettings.java
|
||||
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleDefaults.java
|
||||
java/java-frontback-impl/src/com/intellij/psi/codeStyle/JavaCodeStyleSettings.java
|
||||
JavaLanguageCodeStyleSettingsProvider.customizeDefaults does not override these values.
|
||||
https://www.jetbrains.com/help/idea/code-style-java.html describes the corresponding controls.
|
||||
Each option below names its IDEA setting; JDT has no exact equivalent for every rule.
|
||||
A formatter profile does not sort/group imports (Organize Imports is a separate action).
|
||||
Preserve IDEA KEEP_LINE_BREAKS=true using Wrap where necessary (WRAP_COMPACT) and
|
||||
join_wrapped_lines=false (Never join already wrapped lines). JDT NO_SPLIT joins some
|
||||
manual parameter/call/chain breaks even when joining is disabled. Preserving manual
|
||||
breaks takes priority: wrappable code exceeding 120 columns will also wrap, a known
|
||||
difference from IDEA defaults (DO_NOT_WRAP / WRAP_LONG_LINES=false).
|
||||
Alignment values: 16 = WRAP_COMPACT + INDENT_DEFAULT; 18 = WRAP_COMPACT + INDENT_ON_COLUMN.
|
||||
The comment width 999999 approximates WRAP_COMMENTS=false without JDT integer overflow.
|
||||
JDT has a shared blank-line preservation limit and different empty-body/Javadoc handling.
|
||||
-->
|
||||
<profiles version="23">
|
||||
<profile kind="CodeFormatterProfile" name="OpenIDEA IntelliJ IDEA" version="23">
|
||||
<!-- IndentOptions.USE_TAB_CHARACTER = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.tabulation.char" value="space"/>
|
||||
<!-- IndentOptions.TAB_SIZE = 4 -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.tabulation.size" value="4"/>
|
||||
<!-- IndentOptions.INDENT_SIZE = 4 -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indentation.size" value="4"/>
|
||||
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 (two 4-space units) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation" value="2"/>
|
||||
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer" value="2"/>
|
||||
<!-- CodeStyleSettings.RIGHT_MARGIN = 120; wrap where necessary to preserve manual line breaks (see known difference above) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.lineSplit" value="120"/>
|
||||
<!-- KEEP_LINE_BREAKS = true; Never join already wrapped lines -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.join_wrapped_lines" value="false"/>
|
||||
<!-- KEEP_BLANK_LINES_IN_DECLARATIONS / IN_CODE / BEFORE_RBRACE = 2 (JDT has one shared limit) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve" value="2"/>
|
||||
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration" value="18"/>
|
||||
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration" value="18"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation" value="16"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression" value="16"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call" value="16"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant" value="16"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation" value="16"/>
|
||||
<!-- METHOD_CALL_CHAIN_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation" value="16"/>
|
||||
<!-- ARRAY_INITIALIZER_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer" value="16"/>
|
||||
<!-- ASSIGNMENT_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_assignment" value="16"/>
|
||||
<!-- TERNARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression" value="16"/>
|
||||
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration" value="16"/>
|
||||
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration" value="16"/>
|
||||
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration" value="16"/>
|
||||
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration" value="16"/>
|
||||
<!-- RESOURCE_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_resources_in_try" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_multiplicative_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_additive_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_string_concatenation" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_shift_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_relational_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_bitwise_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_logical_operator" value="16"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_type_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_method_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block_in_case" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_switch" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_constant" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_lambda_body" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_array_initializer" value="end_of_line"/>
|
||||
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement" value="do not insert"/>
|
||||
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement" value="do not insert"/>
|
||||
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement" value="do not insert"/>
|
||||
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement" value="do not insert"/>
|
||||
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type" value="insert"/>
|
||||
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method" value="insert"/>
|
||||
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field" value="insert"/>
|
||||
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter" value="do not insert"/>
|
||||
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable" value="do not insert"/>
|
||||
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_enum_constant" value="do not insert"/>
|
||||
<!-- BLANK_LINES_BEFORE_PACKAGE = 0 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_package" value="0"/>
|
||||
<!-- BLANK_LINES_AFTER_PACKAGE = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_package" value="1"/>
|
||||
<!-- BLANK_LINES_BEFORE_IMPORTS = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_imports" value="1"/>
|
||||
<!-- BLANK_LINES_AFTER_IMPORTS = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_imports" value="1"/>
|
||||
<!-- BLANK_LINES_AROUND_FIELD / BLANK_LINES_AROUND_FIELD_IN_INTERFACE = 0 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_field" value="0"/>
|
||||
<!-- BLANK_LINES_AROUND_METHOD = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_method" value="1"/>
|
||||
<!-- BLANK_LINES_AROUND_METHOD_IN_INTERFACE = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_abstract_method" value="1"/>
|
||||
<!-- BLANK_LINES_AROUND_CLASS = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_member_type" value="1"/>
|
||||
<!-- BLANK_LINES_AFTER_CLASS_HEADER = 0 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration" value="0"/>
|
||||
<!-- BLANK_LINES_BEFORE_CLASS_END = 0 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_last_class_body_declaration" value="0"/>
|
||||
<!-- BLANK_LINES_AROUND_METHOD / AROUND_CLASS = 1; JDT chunks separate member kinds, not consecutive fields -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk" value="1"/>
|
||||
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header" value="true"/>
|
||||
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header" value="true"/>
|
||||
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header" value="true"/>
|
||||
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header" value="true"/>
|
||||
<!-- INDENT_CASE_FROM_SWITCH = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch" value="true"/>
|
||||
<!-- INDENT_BREAK_FROM_CASE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases" value="true"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_code_block_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_method_body_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_lambda_body_block_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_type_declaration_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_anonymous_type_declaration_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_enum_declaration_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_annotation_declaration_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_if_then_body_block_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_loop_body_block_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration" value="insert"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body" value="insert"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block" value="insert"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration" value="insert"/>
|
||||
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE / RBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line" value="true"/>
|
||||
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- ARRAY_INITIALIZER_RBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_lambda_arrow" value="insert"/>
|
||||
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_lambda_arrow" value="insert"/>
|
||||
<!-- WRAP_COMMENTS = false; KEEP_FIRST_COLUMN_COMMENT = true (JDT comment formatting disabled to preserve text) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.format_line_comments" value="false"/>
|
||||
<!-- WRAP_COMMENTS = false (JDT comment formatting disabled to preserve text) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.format_block_comments" value="false"/>
|
||||
<!-- JavaCodeStyleSettings.ENABLE_JAVADOC_FORMATTING = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.format_javadoc_comments" value="true"/>
|
||||
<!-- WRAP_COMMENTS = false; JDT has a separate comment width, so disable its width-based wrapping -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.line_length" value="999999"/>
|
||||
<!-- JavaCodeStyleSettings.JD_INDENT_ON_CONTINUATION = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.indent_parameter_description" value="false"/>
|
||||
<!-- JavaCodeStyleSettings.JD_PARAM_DESCRIPTION_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter" value="false"/>
|
||||
<!-- CodeStyleSettings.FORMATTER_TAGS_ENABLED = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.use_on_off_tags" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_simple_for_body_on_same_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_simple_while_body_on_same_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_simple_do_while_body_on_same_line" value="true"/>
|
||||
</profile>
|
||||
</profiles>
|
||||
@@ -1886,12 +1886,6 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks initialized capabilities, including dynamic registrations, before choosing a formatter.
|
||||
pub async fn can_format(&self, doc: DocId) -> IdeResult<bool> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
Ok(!self.ready_targets(&snap.servers, |c| c.has("documentFormattingProvider")).await.is_empty())
|
||||
}
|
||||
|
||||
pub async fn format(&self, doc: DocId, range: Option<Range>, options: FormatOptions) -> IdeResult<Vec<TextEdit>> {
|
||||
let (method, cap, feature) = match range {
|
||||
None => ("textDocument/formatting", "documentFormattingProvider", "formatting"),
|
||||
|
||||
@@ -219,9 +219,6 @@ pub struct CodeActionQuery {
|
||||
pub struct FormatOptions {
|
||||
pub tab_size: u32,
|
||||
pub insert_spaces: bool,
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub on_save: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package demo;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
@Deprecated @SuppressWarnings("unused") public class FormatterSample {
|
||||
@Deprecated @SuppressWarnings("unused") private String first="first";
|
||||
private String second="second";
|
||||
/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */
|
||||
@Deprecated @SuppressWarnings("unused") public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName, String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName){if(firstParameterWithAVeryLongName==null){return "none";}else{try{return List.of(firstParameterWithAVeryLongName,secondParameterWithAVeryLongName,thirdParameterWithAVeryLongName).stream().map(value->value.trim()).filter(value->!value.isEmpty()).findFirst().orElse("none");}catch(RuntimeException problem){throw problem;}finally{System.out.println("done");}}}
|
||||
public void multiline(String firstParameter,
|
||||
String secondParameter,
|
||||
String thirdParameter){
|
||||
combine(firstParameter,
|
||||
secondParameter,
|
||||
thirdParameter);
|
||||
List.of(firstParameter, secondParameter)
|
||||
.stream()
|
||||
.map(value->value.trim())
|
||||
.forEach(System.out::println);
|
||||
}
|
||||
public void retained(String firstParameter, // retain declaration break
|
||||
String secondParameter){combine(firstParameter, // retain call break
|
||||
secondParameter, "third");}
|
||||
public void arrays(){int[] values=new int[]{1,2,3};int[] empty=new int[]{};Function<String,String> clean=value->{return value.trim();};Runnable run=()->{System.out.println(values.length);};}
|
||||
// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on.
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package demo;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
@Deprecated
|
||||
@SuppressWarnings("unused")
|
||||
public class FormatterSample {
|
||||
@Deprecated
|
||||
@SuppressWarnings("unused")
|
||||
private String first = "first";
|
||||
private String second = "second";
|
||||
|
||||
/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */
|
||||
@Deprecated
|
||||
@SuppressWarnings("unused")
|
||||
public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName,
|
||||
String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName) {
|
||||
if (firstParameterWithAVeryLongName == null) {
|
||||
return "none";
|
||||
} else {
|
||||
try {
|
||||
return List
|
||||
.of(firstParameterWithAVeryLongName, secondParameterWithAVeryLongName,
|
||||
thirdParameterWithAVeryLongName)
|
||||
.stream().map(value -> value.trim()).filter(value -> !value.isEmpty()).findFirst()
|
||||
.orElse("none");
|
||||
} catch (RuntimeException problem) {
|
||||
throw problem;
|
||||
} finally {
|
||||
System.out.println("done");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void multiline(String firstParameter,
|
||||
String secondParameter,
|
||||
String thirdParameter) {
|
||||
combine(firstParameter,
|
||||
secondParameter,
|
||||
thirdParameter);
|
||||
List.of(firstParameter, secondParameter)
|
||||
.stream()
|
||||
.map(value -> value.trim())
|
||||
.forEach(System.out::println);
|
||||
}
|
||||
|
||||
public void retained(String firstParameter, // retain declaration break
|
||||
String secondParameter) {
|
||||
combine(firstParameter, // retain call break
|
||||
secondParameter, "third");
|
||||
}
|
||||
|
||||
public void arrays() {
|
||||
int[] values = new int[]{1, 2, 3};
|
||||
int[] empty = new int[]{};
|
||||
Function<String, String> clean = value -> {
|
||||
return value.trim();
|
||||
};
|
||||
Runnable run = () -> {
|
||||
System.out.println(values.length);
|
||||
};
|
||||
}
|
||||
// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on.
|
||||
}
|
||||
@@ -252,7 +252,6 @@ fn harness_configured(
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
tree_sitter: None,
|
||||
language_servers: servers
|
||||
.iter()
|
||||
@@ -1017,70 +1016,6 @@ async fn settings_changes_reach_servers_without_restarts() {
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_formatter_profiles_reach_initialization_and_live_configuration_without_restart() {
|
||||
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
|
||||
cfg.initialization_options = Some(
|
||||
toml::Value::try_from(json!({
|
||||
"settings": {"java": {"autobuild": {"enabled": false}}}
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
});
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
std::fs::write(h.root.join("custom.xml"), "<profiles/>").unwrap();
|
||||
let style = Arc::new(Mutex::new(ide_core::editor_settings::LanguageCodeStyle {
|
||||
formatter: Some("intellij".into()),
|
||||
formatter_path: Some("custom.xml".into()),
|
||||
..Default::default()
|
||||
}));
|
||||
h.mgr.set_config_hook(Box::new({
|
||||
let style = style.clone();
|
||||
let root = h.root.clone();
|
||||
let data = data.path().to_path_buf();
|
||||
move |cfg, _| ide_lsp::jdtls::configure_formatter(cfg, Some(&style.lock()), &root, &data, None).unwrap()
|
||||
}));
|
||||
h.open("Main.java", "public class Main {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
let init = state.requested("initialize")[0].clone();
|
||||
assert_eq!(
|
||||
init["initializationOptions"]["settings"]["java"]["format"]["settings"]["profile"],
|
||||
"OpenIDEA IntelliJ IDEA"
|
||||
);
|
||||
for mode in ["custom", "default", "intellij", "default"] {
|
||||
style.lock().formatter = Some(mode.into());
|
||||
h.mgr.reconfigure().await;
|
||||
let expected = match mode {
|
||||
"custom" => uri_of(&h.root.join("custom.xml")),
|
||||
"intellij" => uri_of(&data.path().join("lsp/jdtls/intellij.xml")),
|
||||
_ => String::new(),
|
||||
};
|
||||
eventually(|| {
|
||||
state
|
||||
.notified("workspace/didChangeConfiguration")
|
||||
.last()
|
||||
.is_some_and(|p| p["settings"]["java"]["format"]["settings"]["url"] == expected)
|
||||
})
|
||||
.await;
|
||||
let result: Value = state
|
||||
.peer()
|
||||
.request(
|
||||
"workspace/configuration",
|
||||
json!({
|
||||
"items": [{"section": "java.format.settings"}, {"section": "java.autobuild.enabled"}]
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result[0]["url"], expected);
|
||||
assert_eq!(result[0]["profile"], if mode == "intellij" { "OpenIDEA IntelliJ IDEA" } else { "" });
|
||||
assert_eq!(result[1], false);
|
||||
assert_eq!(state.launches.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_source_paths_reach_initialization_live_configuration_and_builds() {
|
||||
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
|
||||
@@ -1313,17 +1248,12 @@ async fn navigation_rename_format_and_decorations() {
|
||||
assert!(matches!(&edit.files[0], FileEdit::Edit { edits, .. } if edits.len() == 2));
|
||||
assert_eq!(s.requested("textDocument/rename")[0]["newName"], "bar");
|
||||
|
||||
assert!(h.mgr.can_format(doc).await.unwrap());
|
||||
s.respond("textDocument/formatting", json!([{ "range": r(0,10,0,11), "newText": "\n}" }]));
|
||||
let edits =
|
||||
h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }).await.unwrap();
|
||||
let edits = h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true }).await.unwrap();
|
||||
assert_eq!(edits[0].range, rng(0, 10, 0, 11));
|
||||
assert_eq!(s.requested("textDocument/formatting")[0]["options"], json!({ "tabSize": 2, "insertSpaces": true }));
|
||||
assert!(
|
||||
h.mgr
|
||||
.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true, on_save: false })
|
||||
.await
|
||||
.is_err()
|
||||
h.mgr.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true }).await.is_err()
|
||||
);
|
||||
|
||||
s.respond("textDocument/semanticTokens/full", json!({ "data": [0, 3, 3, 0, 1, 1, 0, 3, 0, 0] }));
|
||||
@@ -1729,10 +1659,7 @@ async fn class_file_opening_routes_sources_and_binary_documents() {
|
||||
s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]}));
|
||||
for class in ["Outer", "Outer$Nested", "Outer$1"] {
|
||||
let path = h.root.join(format!("target/classes/p/{class}.class"));
|
||||
let resolved = h.mgr.java_class_file(&path, None).await.unwrap();
|
||||
// Compare as paths: the manager joins the relative source path with std::path, so
|
||||
// separators can differ from the literal "p/Outer.java" used to build `source` on Windows.
|
||||
assert_eq!(Path::new(&resolved), source.as_path());
|
||||
assert_eq!(h.mgr.java_class_file(&path, None).await.unwrap(), source.to_string_lossy());
|
||||
}
|
||||
let path = h.root.join("build/classes/java/main/p/Missing.class");
|
||||
let uri = uri_of(&path);
|
||||
@@ -2807,32 +2734,3 @@ async fn interactive_capability_is_scoped_to_gopls() {
|
||||
assert!(form.is_none());
|
||||
assert!(s.requested("command/resolve").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn formatting_provider_selection_tracks_dynamic_registration() {
|
||||
let h = harness(&[("mock", json!({ "textDocumentSync": 1, "documentRangeFormattingProvider": true }))]);
|
||||
let (doc, _) = h.open("a.mk", "hello\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
assert!(!h.mgr.can_format(doc).await.unwrap(), "a range provider alone cannot format the whole document");
|
||||
let peer = h.launcher.state("mock").peer();
|
||||
peer.request::<_, Value>(
|
||||
"client/registerCapability",
|
||||
json!({"registrations": [
|
||||
{"id": "formatter", "method": "textDocument/formatting"}
|
||||
]}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(h.mgr.can_format(doc).await.unwrap());
|
||||
peer.request::<_, Value>(
|
||||
"client/unregisterCapability",
|
||||
json!({"unregisterations": [
|
||||
{"id": "formatter", "method": "textDocument/formatting"}
|
||||
]}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!h.mgr.can_format(doc).await.unwrap());
|
||||
}
|
||||
@@ -55,60 +55,12 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
tree_sitter: None,
|
||||
language_servers: vec![server],
|
||||
debugger: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires jdtls on PATH and a compatible JDK"]
|
||||
async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
let source = include_str!("fixtures/FormatterSample.input.java");
|
||||
let expected = include_str!("fixtures/FormatterSample.intellij.java");
|
||||
let path = root.join("src/demo/FormatterSample.java");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
|
||||
let server = &mut java.language_servers[0];
|
||||
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
|
||||
server.command = bin;
|
||||
}
|
||||
server.args.extend(["-data".into(), root.join("jdtls-workspace").to_string_lossy().into_owned()]);
|
||||
server.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": false}}}));
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java]));
|
||||
mgr.set_config_hook(Box::new({
|
||||
let root = root.clone();
|
||||
move |cfg, _| {
|
||||
ide_lsp::jdtls::configure_source_paths(cfg, Some(vec!["src".into()]));
|
||||
let style = ide_core::editor_settings::LanguageCodeStyle {
|
||||
formatter: Some("intellij".into()),
|
||||
..Default::default()
|
||||
};
|
||||
ide_lsp::jdtls::configure_formatter(cfg, Some(&style), &root, &root.join("data"), None).unwrap();
|
||||
}
|
||||
}));
|
||||
let doc = DocId::next();
|
||||
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
|
||||
retry("jdtls ready", Duration::from_secs(120), || {
|
||||
let status = last_status(&events, "jdtls");
|
||||
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
|
||||
panic!("jdtls failed: {msg:?}");
|
||||
}
|
||||
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
|
||||
})
|
||||
.await;
|
||||
let edits =
|
||||
mgr.format(doc, None, ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false }).await;
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
|
||||
let mut document = ide_text::Document::new(source);
|
||||
document.apply_edits(&edits.unwrap()).unwrap();
|
||||
assert_eq!(document.text(), expected);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn missing_binary_is_reported_with_install_hint() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -948,49 +948,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same jar supplies the JDT command and the private Maven output redirector.
|
||||
pub fn kotlin_bridge_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("kotlin-bridge-1.0.0.jar");
|
||||
let bytes = include_bytes!("../../../plugins/kotlin-bridge/openidea-kotlin-bridge.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
return Some(extension);
|
||||
}
|
||||
let temporary = data_dir.join(format!(
|
||||
"kotlin-bridge-{}-{}.tmp",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos()
|
||||
));
|
||||
let result = std::fs::create_dir_all(data_dir)
|
||||
.and_then(|_| std::fs::write(&temporary, bytes))
|
||||
.and_then(|_| std::fs::rename(&temporary, &extension));
|
||||
if let Err(error) = result {
|
||||
let _ = std::fs::remove_file(temporary);
|
||||
tracing::warn!("Kotlin bridge extension unavailable: {error}");
|
||||
return std::fs::read(&extension).is_ok_and(|old| old == bytes).then_some(extension);
|
||||
}
|
||||
Some(extension)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod kotlin_bridge_bundle_tests {
|
||||
#[test]
|
||||
fn embeds_both_runtime_targets_and_repairs_a_damaged_installation() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = super::kotlin_bridge_bundle(directory.path()).unwrap();
|
||||
let expected = std::fs::read(&path).unwrap();
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&expected)).unwrap();
|
||||
for (class, major) in [("KotlinLibraries", 65), ("IsolatedOutput", 52)] {
|
||||
use std::io::Read;
|
||||
let mut bytes = Vec::new();
|
||||
archive.by_name(&format!("ai/openidea/kotlin/{class}.class")).unwrap().read_to_end(&mut bytes).unwrap();
|
||||
assert_eq!(u16::from_be_bytes([bytes[6], bytes[7]]), major);
|
||||
}
|
||||
std::fs::write(&path, "incomplete").unwrap();
|
||||
assert_eq!(super::kotlin_bridge_bundle(directory.path()), Some(path.clone()));
|
||||
assert_eq!(std::fs::read(path).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.1.1.jar");
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
//! External formatting: resolve a tool, pass unsaved text on stdin, return text without writing files.
|
||||
|
||||
use crate::Tools;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_language::FormatterConfig;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Walk from the file's directory upward, including workspace parents (monorepos).
|
||||
pub fn find_up(file: &Path, relative: &Path) -> Option<PathBuf> {
|
||||
file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
fn package_bin(package: &Path) -> IdeResult<PathBuf> {
|
||||
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
|
||||
.map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?;
|
||||
let bin = json["bin"]
|
||||
.as_str()
|
||||
.or_else(|| json["bin"]["prettier"].as_str())
|
||||
.ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?;
|
||||
let path = package.join(bin);
|
||||
if !path.is_file() {
|
||||
return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display())));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
impl Tools {
|
||||
/// Explicit executable/script, then the nearest project package, then managed tools and PATH.
|
||||
pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
|
||||
self.resolve_formatter_command(tool, file, custom).map_err(|e| e.with_tool(tool))
|
||||
}
|
||||
|
||||
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
|
||||
if tool != "prettier" {
|
||||
return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}")));
|
||||
}
|
||||
let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) {
|
||||
(package_bin(custom)?, true)
|
||||
} else if let Some(custom) = custom {
|
||||
(custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs")))
|
||||
} else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) {
|
||||
(package_bin(manifest.parent().unwrap())?, true)
|
||||
} else if self.root.join("node_modules/prettier/package.json").is_file() {
|
||||
(package_bin(&self.root.join("node_modules/prettier"))?, true)
|
||||
} else {
|
||||
(self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false)
|
||||
};
|
||||
if node {
|
||||
let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?;
|
||||
let mut cmd = self.command(node);
|
||||
cmd.arg(path);
|
||||
Ok(cmd)
|
||||
} else {
|
||||
Ok(self.command(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Project configuration overrides these defaults; Prettier resolves config and plugins itself.
|
||||
pub fn arguments(
|
||||
config: &FormatterConfig,
|
||||
file: &Path,
|
||||
tab_size: u32,
|
||||
insert_spaces: bool,
|
||||
range: Option<(u32, u32)>,
|
||||
) -> Vec<OsString> {
|
||||
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
|
||||
"mdx"
|
||||
} else {
|
||||
&config.parser
|
||||
};
|
||||
let mut args: Vec<OsString> = [
|
||||
"--parser",
|
||||
parser,
|
||||
"--config-precedence",
|
||||
"file-override",
|
||||
"--tab-width",
|
||||
&tab_size.max(1).to_string(),
|
||||
"--use-tabs",
|
||||
if insert_spaces { "false" } else { "true" },
|
||||
]
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
|
||||
if config.range
|
||||
&& let Some((start, end)) = range
|
||||
{
|
||||
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
|
||||
}
|
||||
// CLI defaults only find ignores in cwd; explicitly pass the nearest ancestor's files.
|
||||
for name in [".prettierignore", ".gitignore"] {
|
||||
if let Some(path) = find_up(file, Path::new(name)) {
|
||||
args.extend(["--ignore-path".into(), path.into_os_string()]);
|
||||
}
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_formatter_is_structured_but_invalid_custom_path_is_not_an_install_prompt() {
|
||||
use ide_core::error::{ErrorData, ErrorKind};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tools = Tools::with_paths(dir.path().join("tools"), OsString::new(), crate::platform::Platform::current());
|
||||
let file = dir.path().join("file.md");
|
||||
let missing = tools.formatter_command("prettier", &file, None).unwrap_err();
|
||||
assert_eq!(missing.kind, ErrorKind::NotFound);
|
||||
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
|
||||
let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap();
|
||||
let failure = run(custom, "text", &Default::default()).unwrap_err();
|
||||
assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]);
|
||||
let failure = run(command, "", &Default::default()).unwrap_err();
|
||||
assert_eq!(failure.message, "error\n".repeat(1000));
|
||||
assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_nearest_package_and_ignore_above_nested_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("packages/web/src")).unwrap();
|
||||
std::fs::write(root.join(".prettierignore"), "ignored.md").unwrap();
|
||||
std::fs::create_dir_all(root.join("node_modules/prettier")).unwrap();
|
||||
std::fs::write(root.join("node_modules/prettier/package.json"), "{}").unwrap();
|
||||
let file = root.join("packages/web/src/readme.md");
|
||||
assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join(".prettierignore")));
|
||||
std::fs::write(root.join("packages/web/.prettierignore"), "src/ignored.md").unwrap();
|
||||
assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join("packages/web/.prettierignore")));
|
||||
assert_eq!(
|
||||
find_up(&file, Path::new("node_modules/prettier/package.json")),
|
||||
Some(root.join("node_modules/prettier/package.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_use_stdin_config_precedence_and_only_supported_ranges() {
|
||||
for (parser, supports_range) in [
|
||||
("markdown", false),
|
||||
("css", false),
|
||||
("yaml", false),
|
||||
("typescript", true),
|
||||
("graphql", true),
|
||||
("vue", true),
|
||||
] {
|
||||
let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range };
|
||||
let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
|
||||
let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect();
|
||||
assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"]));
|
||||
assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"]));
|
||||
assert!(args.windows(2).any(|a| a == ["--tab-width", "4"]));
|
||||
assert!(args.windows(2).any(|a| a == ["--use-tabs", "true"]));
|
||||
assert_eq!(args.contains(&"--range-start".into()), supports_range);
|
||||
assert!(!args.contains(&"--write".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
pub mod archive;
|
||||
mod bundled;
|
||||
pub mod formatter;
|
||||
pub mod jdk;
|
||||
pub mod platform;
|
||||
mod process;
|
||||
@@ -324,9 +323,6 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap<String, Usage> {
|
||||
}
|
||||
};
|
||||
for l in langs {
|
||||
if let Some(f) = &l.formatter {
|
||||
add(&f.tool, &f.tool, ToolKind::Formatter, None, &l.name);
|
||||
}
|
||||
for s in &l.language_servers {
|
||||
add(&s.name, &s.command, ToolKind::LanguageServer, s.install_hint.as_ref(), &l.name);
|
||||
}
|
||||
|
||||
@@ -348,89 +348,6 @@ pub fn progress_event(bytes: u64, size: Option<u64>) -> InstallEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a formatter with separate pipes so diagnostics can never become document text.
|
||||
pub(crate) fn run_input(
|
||||
cmd: &mut Command,
|
||||
input: &str,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
use std::io::Write;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let input = input.to_owned();
|
||||
// All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout.
|
||||
enum Pipe {
|
||||
Written(std::io::Result<()>),
|
||||
Read(bool, std::io::Result<Vec<u8>>),
|
||||
}
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes())));
|
||||
});
|
||||
for (is_stderr, mut pipe) in [
|
||||
(false, Box::new(child.stdout.take().unwrap()) as Box<dyn Read + Send>),
|
||||
(true, Box::new(child.stderr.take().unwrap()) as Box<dyn Read + Send>),
|
||||
] {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes)));
|
||||
});
|
||||
}
|
||||
drop(tx);
|
||||
let started = Instant::now();
|
||||
let (mut status, mut output, mut errors, mut written) = (None, None, None, None);
|
||||
let result = loop {
|
||||
if cancel.is_cancelled() {
|
||||
break Err(IdeError::cancelled());
|
||||
}
|
||||
if started.elapsed() >= timeout {
|
||||
break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out"));
|
||||
}
|
||||
if status.is_none() {
|
||||
match child.try_wait() {
|
||||
Ok(s) => status = s,
|
||||
Err(e) => break Err(e.into()),
|
||||
}
|
||||
}
|
||||
if status.is_some() && output.is_some() && errors.is_some() && written.is_some() {
|
||||
break Ok(());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(10)) {
|
||||
Ok(Pipe::Written(result)) => written = Some(result),
|
||||
Ok(Pipe::Read(true, result)) => errors = Some(result),
|
||||
Ok(Pipe::Read(false, result)) => output = Some(result),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
};
|
||||
if result.is_err() {
|
||||
kill(&mut child);
|
||||
}
|
||||
result?;
|
||||
let output = output.unwrap()?;
|
||||
let errors = errors.unwrap()?;
|
||||
let status = status.unwrap();
|
||||
if !status.success() {
|
||||
let diagnostics: String = String::from_utf8_lossy(&errors).into_owned();
|
||||
return Err(IdeError::invalid(if diagnostics.trim().is_empty() {
|
||||
format!("Formatter exited with {status}")
|
||||
} else {
|
||||
diagnostics
|
||||
}));
|
||||
}
|
||||
written.unwrap()?;
|
||||
String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -547,37 +464,3 @@ mod tests {
|
||||
assert_eq!(run_quiet(cmd, Duration::from_secs(5)), Some((true, "v1.2.3\n".into())));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod input_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn separate_diagnostics_and_never_return_failed_output() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "cat; printf warning >&2"]);
|
||||
assert_eq!(run_input(&mut command, "a😀\n", Duration::from_secs(2), &Default::default()).unwrap(), "a😀\n");
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "printf broken; printf 'invalid syntax' >&2; exit 1"]);
|
||||
let e = run_input(&mut command, "", Duration::from_secs(2), &Default::default()).unwrap_err();
|
||||
assert_eq!(e.message, "invalid syntax");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_kills_formatter_and_cancellation_prevents_start() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "sleep 10 &"]);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
run_input(&mut command, "", Duration::from_millis(50), &Default::default()).unwrap_err().kind,
|
||||
ErrorKind::TimedOut
|
||||
);
|
||||
assert!(start.elapsed() < Duration::from_secs(2));
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
run_input(&mut Command::new("does-not-exist"), "", Duration::from_secs(2), &cancel).unwrap_err().kind,
|
||||
ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ pub enum ToolKind {
|
||||
Debugger,
|
||||
/// Loaded into another tool's process, e.g. a jdtls agent.
|
||||
Plugin,
|
||||
Formatter,
|
||||
}
|
||||
|
||||
/// Where release archives come from.
|
||||
@@ -106,7 +105,7 @@ impl ToolSpec {
|
||||
|
||||
use Arch::{Arm64, X64};
|
||||
use Os::{Linux, Mac, Windows};
|
||||
use ToolKind::{Debugger, Formatter, LanguageServer, Plugin};
|
||||
use ToolKind::{Debugger, LanguageServer, Plugin};
|
||||
|
||||
const V: Option<(&str, &[&str])> = None;
|
||||
const JAVA: &[(&str, &str)] = &[("java", "Java is required (https://adoptium.net)")];
|
||||
@@ -123,17 +122,6 @@ const fn ver(cmd: &'static str) -> Option<(&'static str, &'static [&'static str]
|
||||
pub const TYPESCRIPT: &str = "typescript@6";
|
||||
|
||||
pub static TOOLS: &[ToolSpec] = &[
|
||||
ToolSpec {
|
||||
name: "prettier",
|
||||
display: "Prettier",
|
||||
kind: Formatter,
|
||||
commands: &["prettier"],
|
||||
method: Method::Npm { packages: &["prettier"] },
|
||||
fallback: None,
|
||||
version: ver("prettier"),
|
||||
needs: NODE,
|
||||
homepage: "https://prettier.io",
|
||||
},
|
||||
ToolSpec {
|
||||
name: "rust-analyzer",
|
||||
display: "rust-analyzer",
|
||||
@@ -955,11 +943,6 @@ mod tests {
|
||||
known.insert((d.adapter.clone(), d.command.clone()));
|
||||
}
|
||||
}
|
||||
for l in &langs {
|
||||
if let Some(f) = &l.formatter {
|
||||
known.insert((f.tool.clone(), f.tool.clone()));
|
||||
}
|
||||
}
|
||||
for t in TOOLS.iter().filter(|t| t.kind != Plugin) {
|
||||
assert!(
|
||||
known.contains(&(t.name.to_string(), t.commands[0].to_string())),
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
//! `cargo test -p ide-tools --test prettier -- --ignored --nocapture`
|
||||
//! Installs into a temporary managed-tools directory; no repository dependency is added.
|
||||
use ide_core::CancellationToken;
|
||||
use ide_language::FormatterConfig;
|
||||
use ide_tools::{Tools, formatter};
|
||||
use std::path::Path;
|
||||
|
||||
fn format(
|
||||
tools: &Tools,
|
||||
path: &Path,
|
||||
parser: &str,
|
||||
text: &str,
|
||||
range: Option<(u32, u32)>,
|
||||
) -> ide_core::IdeResult<String> {
|
||||
let config = FormatterConfig {
|
||||
tool: "prettier".into(),
|
||||
parser: parser.into(),
|
||||
range: matches!(parser, "typescript" | "json" | "graphql"),
|
||||
};
|
||||
let mut cmd = tools.formatter_command("prettier", path, None)?;
|
||||
cmd.current_dir(path.parent().unwrap());
|
||||
cmd.args(formatter::arguments(&config, path, 4, true, range));
|
||||
formatter::run(cmd, text, &CancellationToken::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Node.js/npm and network; installs real Prettier into a temporary managed directory"]
|
||||
fn real_prettier_config_ignore_languages_ranges_and_errors() {
|
||||
if std::env::var_os("OPENIDEA_TEST_OFFLINE").is_some() {
|
||||
eprintln!("SKIP real Prettier: OPENIDEA_TEST_OFFLINE is set; npm network access is disabled");
|
||||
return;
|
||||
}
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let tools = Tools::new(&temp.path().join("app-data"));
|
||||
let info = tools.info("prettier", &[]).unwrap();
|
||||
if !info.can_install {
|
||||
eprintln!("SKIP real Prettier: {}", info.unavailable_reason.unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
tools.install("prettier", &[], &Default::default(), &|_| {}).expect(
|
||||
"Prettier managed install failed; offline environments may set OPENIDEA_TEST_OFFLINE=1 (reported as SKIP)",
|
||||
);
|
||||
let project = temp.path().join("project with spaces");
|
||||
std::fs::create_dir_all(project.join("docs/nested")).unwrap();
|
||||
let md = project.join("docs/nested/readme.md");
|
||||
let input = "# Title\ntext\n\n| a | b |\n|---|---|\n|1|2|\n\n- a\n - b\n";
|
||||
let output = format(&tools, &md, "markdown", input, None).unwrap();
|
||||
assert!(output.starts_with("# Title\n\ntext\n"), "{output}");
|
||||
assert!(output.contains("| 1 | 2 |"), "{output}");
|
||||
assert!(!md.exists(), "formatting must never write the file");
|
||||
std::fs::write(project.join(".prettierrc"), r#"{"proseWrap":"always","printWidth":24,"tabWidth":2}"#).unwrap();
|
||||
let prose =
|
||||
"This is a long paragraph with several words that should wrap according to the project configuration.\n";
|
||||
assert!(format(&tools, &md, "markdown", prose, None).unwrap().lines().count() > 2);
|
||||
std::fs::write(project.join(".prettierignore"), "docs/nested/readme.md\n").unwrap();
|
||||
assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), input);
|
||||
std::fs::remove_file(project.join(".prettierignore")).unwrap();
|
||||
std::fs::remove_file(project.join(".prettierrc")).unwrap();
|
||||
assert_eq!(
|
||||
format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(),
|
||||
"a {\n color: red;\n}\n"
|
||||
);
|
||||
std::fs::write(project.join("package.json"), r#"{"prettier":{"tabWidth":3,"proseWrap":"never"}}"#).unwrap();
|
||||
assert_eq!(
|
||||
format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(),
|
||||
"a {\n color: red;\n}\n"
|
||||
);
|
||||
assert_eq!(format(&tools, &project.join("a.yaml"), "yaml", "a: [1,2]\n", None).unwrap(), "a: [1, 2]\n");
|
||||
let js = "const a={x:1};\nconst b={y:2};\n";
|
||||
let formatted = format(&tools, &project.join("a.ts"), "typescript", js, Some((0, 14))).unwrap();
|
||||
assert!(formatted.starts_with("const a = { x: 1 };"), "{formatted}");
|
||||
assert!(formatted.contains("const b={y:2};"), "{formatted}");
|
||||
assert!(format(&tools, &project.join("bad.css"), "css", "a {", None).is_err());
|
||||
// Nearest project version must win over the managed install, including npm's object-style bin.
|
||||
let package = project.join("node_modules/prettier");
|
||||
std::fs::create_dir_all(&package).unwrap();
|
||||
std::fs::write(package.join("package.json"), r#"{"bin":{"prettier":"cli.cjs"}}"#).unwrap();
|
||||
std::fs::write(
|
||||
package.join("cli.cjs"),
|
||||
"process.stdin.resume(); process.stdin.on('end', () => process.stdout.write('project version\\n')); ",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), "project version\n");
|
||||
std::fs::write(package.join("cli.cjs"), "setInterval(() => {}, 1000)").unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
assert!(format(&tools, &md, "markdown", input, None).unwrap_err().message.contains("timed out"));
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(20));
|
||||
eprintln!(
|
||||
"PASS: real managed Prettier, config/ignore discovery, editor defaults, YAML/CSS/Markdown, ranges, errors, project precedence, timeout; no disk writes"
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "css"
|
||||
name = "CSS"
|
||||
formatter = { tool = "prettier", parser = "css", range = false }
|
||||
extensions = ["css", "pcss", "postcss"]
|
||||
block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "graphql"
|
||||
name = "GraphQL"
|
||||
formatter = { tool = "prettier", parser = "graphql", range = true }
|
||||
extensions = ["graphql", "graphqls", "gql"]
|
||||
line-comment = "#"
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "html"
|
||||
name = "HTML"
|
||||
formatter = { tool = "prettier", parser = "html", range = false }
|
||||
extensions = ["html", "htm", "xhtml", "shtml", "html.tmpl"]
|
||||
first-line = "^\\s*<!(?i:doctype)\\s+html"
|
||||
block-comment = ["<!--", "-->"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "javascript"
|
||||
name = "JavaScript"
|
||||
formatter = { tool = "prettier", parser = "babel", range = true }
|
||||
extensions = ["js", "mjs", "cjs"]
|
||||
first-line = "^#!.*\\b(node|nodejs|deno|bun)\\b"
|
||||
line-comment = "//"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "javascriptreact"
|
||||
name = "JavaScript JSX"
|
||||
formatter = { tool = "prettier", parser = "babel", range = true }
|
||||
extensions = ["jsx"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "json"
|
||||
name = "JSON"
|
||||
formatter = { tool = "prettier", parser = "json", range = true }
|
||||
extensions = ["json", "geojson", "topojson", "webmanifest", "har", "avsc", "jsonl", "ndjson", "json.map"]
|
||||
filenames = [".prettierrc", ".watchmanconfig", "composer.lock", ".arcconfig"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "jsonc"
|
||||
name = "JSON with Comments"
|
||||
formatter = { tool = "prettier", parser = "jsonc", range = true }
|
||||
extensions = ["jsonc", "code-workspace", "code-snippets"]
|
||||
filenames = ["tsconfig.json", "jsconfig.json", "tsconfig.*.json", "jsconfig.*.json", ".eslintrc", ".eslintrc.json", ".jshintrc", ".babelrc", ".swcrc", "devcontainer.json", ".devcontainer.json", "launch.json", "tasks.json", "settings.json", "extensions.json", "keybindings.json", "tslint.json", "api-extractor.json", "typedoc.json", "deno.json", "biome.json", "turbo.json", "language-configuration.json"]
|
||||
line-comment = "//"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "less"
|
||||
name = "Less"
|
||||
formatter = { tool = "prettier", parser = "less", range = false }
|
||||
extensions = ["less"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "markdown"
|
||||
name = "Markdown"
|
||||
formatter = { tool = "prettier", parser = "markdown", range = false }
|
||||
extensions = ["md", "markdown", "mdown", "mkd", "mkdn", "mdwn", "mdx", "ronn", "workbook"]
|
||||
block-comment = ["<!--", "-->"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "scss"
|
||||
name = "SCSS"
|
||||
formatter = { tool = "prettier", parser = "scss", range = false }
|
||||
extensions = ["scss"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "typescript"
|
||||
name = "TypeScript"
|
||||
formatter = { tool = "prettier", parser = "typescript", range = true }
|
||||
extensions = ["ts", "mts", "cts"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "typescriptreact"
|
||||
name = "TypeScript JSX"
|
||||
formatter = { tool = "prettier", parser = "typescript", range = true }
|
||||
extensions = ["tsx"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "vue"
|
||||
name = "Vue"
|
||||
formatter = { tool = "prettier", parser = "vue", range = true }
|
||||
extensions = ["vue"]
|
||||
block-comment = ["<!--", "-->"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "yaml"
|
||||
name = "YAML"
|
||||
formatter = { tool = "prettier", parser = "yaml", range = false }
|
||||
extensions = ["yaml", "yml"]
|
||||
filenames = [".clang-format", ".clang-tidy", ".clangd", ".gemrc", "pnpm-lock.yaml", "CITATION.cff"]
|
||||
first-line = "^%YAML"
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "openidea",
|
||||
"private": true,
|
||||
"description": "An open-source IDE inspired by IntelliJ IDEA",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.32",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Kotlin binary bridge
|
||||
Bundle-SymbolicName: ai.openidea.kotlin;singleton:=true
|
||||
Bundle-Version: 1.0.0
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-21
|
||||
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
|
||||
org.eclipse.core.runtime,org.eclipse.core.resources,com.google.gson
|
||||
@@ -1,73 +0,0 @@
|
||||
# Kotlin binary bridge
|
||||
|
||||
App-owned source, licensed under this repository's Apache-2.0 license. The jar
|
||||
contains no third-party implementation classes. JDT LS and Maven supply their
|
||||
respective APIs at runtime.
|
||||
|
||||
`KotlinLibraries` is a JDT LS delegate command. `openidea.kotlin.replace` takes
|
||||
one complete array of `{projectUri, libraries: [{path, test}]}` objects and returns
|
||||
the number of changed projects. Paths name fully published, immutable directories.
|
||||
An empty array withdraws all owned libraries. Only library entries carrying
|
||||
`ai.openidea.kotlin=true` are removed; all other `IClasspathEntry` objects are
|
||||
retained, including containers, access rules, attributes and project references.
|
||||
A Java model listener restores the desired snapshot after Maven reimport.
|
||||
|
||||
`IsolatedOutput` is a Java 8 Maven lifecycle participant, activated only in the
|
||||
private editor build via `-Dmaven.ext.class.path=<jar>` and
|
||||
`-Dopenidea.kotlin.staging=<absolute-directory>`. It redirects reactor output
|
||||
before lifecycle planning and records Maven's resolved dependency closure after
|
||||
each session, including failed builds. An execution listener records each module's
|
||||
Kotlin compile/test-compile targets as successful, failed or skipped. The host uses
|
||||
the project's Maven lifecycle/configuration,
|
||||
then publishes only classes whose actual `SourceFile` attribute ends in `.kt`,
|
||||
plus Kotlin module metadata. Java output is not bridged. Main/test Kotlin APIs and
|
||||
same-reactor consumers are covered; Precise Kotlin
|
||||
source navigation, unsaved analysis and refactoring are outside this bridge.
|
||||
|
||||
The host debounces saved/source/configuration changes, cancels superseded process
|
||||
groups, and retains the last successful snapshot while rebuilding. Maven runs
|
||||
with `--fail-at-end`. A new snapshot is attached only when all Kotlin targets in
|
||||
modules containing Kotlin sources succeed, even if javac fails. Java diagnostics
|
||||
belong to JDT. Failed or skipped Kotlin targets retain the previous snapshot and
|
||||
report errors under `kotlin-bridge` Problems and a notification explaining the
|
||||
older API. Old directories are deleted only after JDT acknowledges replacement.
|
||||
With no Kotlin sources left, all managed libraries and bridge diagnostics are
|
||||
withdrawn, even when the POM still declares the Kotlin plugin. Extensionless
|
||||
watcher paths trigger only below a Maven module's `src/`, outside generated and
|
||||
user-excluded directories. Run/Debug preparation is independent of this editor
|
||||
build.
|
||||
|
||||
## Build
|
||||
|
||||
Built against JDT LS 1.63.0 and Maven 3.9.11, using their installed API jars.
|
||||
`build-dependencies.sha256` pins all build inputs. The build refuses different
|
||||
inputs; `--record-dependencies` is an explicit maintainer action when updating
|
||||
those dependencies. Compilation uses the JDT batch compiler with Java 21 target
|
||||
for the JDT command and Java 8 target for the Maven participant. Use Java 25 to
|
||||
run the build compiler. Archive order and timestamps are deterministic.
|
||||
|
||||
```sh
|
||||
python3 plugins/kotlin-bridge/build.py /path/to/jdtls /path/to/maven
|
||||
shasum -a 256 plugins/kotlin-bridge/openidea-kotlin-bridge.jar
|
||||
```
|
||||
|
||||
Committed jar SHA-256:
|
||||
`5c6407a85bd3cfcde0ee68b2f223dd3d1ca0e5165ff76a2249191617496b1fcd`.
|
||||
|
||||
The SDK embeds this jar with `include_bytes!`, writes it atomically into the app
|
||||
data directory, and includes it in JDT LS initialization bundles.
|
||||
|
||||
## Integration regression
|
||||
|
||||
```sh
|
||||
# `java` on PATH must satisfy JDT LS; JAVA_HOME selects Maven's project JDK.
|
||||
JAVA_HOME=/path/to/project/jdk python3 plugins/kotlin-bridge/tests/verify.py /path/to/jdtls
|
||||
```
|
||||
|
||||
Requires Maven on PATH and access to Kotlin 2.0.10 and the fixture's Maven
|
||||
plugins (or their existing local cache). The test creates its own projects,
|
||||
workspace, bundle copy and JDT process. It checks baseline errors, single/reactor
|
||||
resolution, SMS/member completion, binary definition locations, preservation of
|
||||
unowned entries, reimport, API changes, publication despite javac failure,
|
||||
failed/skipped Kotlin target retention, deletion withdrawal and repair. Evidence
|
||||
and protocol transcripts are written to a printed temporary directory. Only the spawned JDT process is terminated.
|
||||
@@ -1,161 +0,0 @@
|
||||
beebede8db065fe1b72909ecc66bb49acda618901635d86c25fce14a5915e37e jdt/ch.qos.logback.classic_1.6.3.jar
|
||||
a6967a28c8b086dee75a694d2f6fb8830c71153539866d7d8af065c9b6df91e0 jdt/ch.qos.logback.core_1.6.3.jar
|
||||
2cbd119bf1961c28788310963dc80ba65f58cdeec1dd139c8bdb1240faa2c36f jdt/com.google.gson_2.14.0.jar
|
||||
cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb jdt/com.google.guava.failureaccess_1.0.3.jar
|
||||
796d8e28ac64e83a47c4c5935a8fecc4682650a04bbdead738ef0f5a3a0e6c46 jdt/com.google.guava_33.7.1.jre.jar
|
||||
3b3864f5b449e9c3c24b16861524b622b086563f44e0cd8384c8efc5a6052f82 jdt/com.sun.jna.platform_5.19.1.jar
|
||||
467b2990d4f406e7d41f7a989f89df257767ca11d566f844560e2ab8d09f6433 jdt/com.sun.jna_5.19.1.v20260612-1000.jar
|
||||
85fb03fc054cdf4efca8efd9b6712bbb418e1ab98241c4539c8585bbc23e1b8a jdt/jakarta.annotation-api_1.3.5.jar
|
||||
a852b0971c589491e24334bd77e92760c048d2c102fc4c68e582e1cd53130f84 jdt/jakarta.inject.jakarta.inject-api_1.0.5.jar
|
||||
2506fb25a748d4a5e5ec549d8bb063889d0f213e9cfbd6fd6fcbdc93b4f35bbb jdt/jakarta.servlet-api_6.2.0.M2.jar
|
||||
cbf2aa09c14441d2ddea7096f60b738b3aee7f08fbf98f5f91e2ca48b08fff4a jdt/org.apache.ant_1.10.18.v20260906-1000.jar
|
||||
8f7f8605d68e15bf32db61ec94eac6fdafc51b1bdbe1e0e0802b57d23f387792 jdt/org.apache.commons.cli_1.11.0.jar
|
||||
78a5d732fbd715e2d10bd7150d2f8030bae57267f8aacc5c88f642cb6c2e5d3f jdt/org.apache.commons.commons-codec_1.22.1.jar
|
||||
69e5c9fa35da7a51a5fd2099dfe56a2d8d32cf233e2f6d770e796146440263f4 jdt/org.apache.commons.lang3_3.20.0.jar
|
||||
c5bd887c8fd5ec4cd563249e7552bb1a1a1ca57e05a1f1ef8425a7b7b7bd2fbe jdt/org.apache.felix.scr_2.2.18.jar
|
||||
c375b6d5b71efe179e5763a6d83fcf10e303c0bf82031310a46769727ac90da0 jdt/org.commonmark.ext-gfm-tables_0.30.1.jar
|
||||
9b0d20064975a17e876fcb4b599c6b3667f46a7739ed61e2f06c96f44b50629f jdt/org.commonmark_0.30.1.jar
|
||||
a6a16f8f2b6977a10f951ab4ff46b155e4e20dd8c2fcd2cb835b74d90f30b456 jdt/org.eclipse.ant.core_3.7.1100.v20260831-0701.jar
|
||||
61c479b6544db7eeebb5ba796051c092032d03a5136295baabd72a233baef29c jdt/org.eclipse.buildship.compat_3.1.13.v20260923-0232-s.jar
|
||||
213baab808816e0661acd61a5382a8f120923288212defbd189b4ee6540ecce1 jdt/org.eclipse.buildship.core_3.1.13.v20260923-0232-s.jar
|
||||
e75b07863128a076989d15e815d63a49cab772a338f1f0444d007bef3fa94601 jdt/org.eclipse.compare.core_3.8.900.v20260731-0809.jar
|
||||
01425989976dccd56d348b207e11239b3d8bf572f7b7ef12dbd29eb39c40205e jdt/org.eclipse.core.commands_3.13.100.v20260902-1129.jar
|
||||
a63b488fb9d5382fbb3b8bae0951f1da5fd3a20211aa6814c7e8c1e21d2ffc78 jdt/org.eclipse.core.contenttype_3.9.1000.v20260921-1824.jar
|
||||
06a18236ae181659e1e5499d912d68de527720686f1f51dab632d3e298e32b78 jdt/org.eclipse.core.expressions_3.9.700.v20260710-0925.jar
|
||||
422a33041ea01954f48ff3969e2c6d96e64831b3e372202a4d2a914282e3ffd9 jdt/org.eclipse.core.filebuffers_3.9.0.v20261010-1449.jar
|
||||
d0f5e3fb7cd17bb8daebc684311198138e380e089292477f12d2dfbb4c8a30a8 jdt/org.eclipse.core.filesystem_1.12.0.v20260927-0940.jar
|
||||
941b3c5feeba4ec0a949a8a94ff10283b2505813fa736c9e6345f146ae1b7e8a jdt/org.eclipse.core.jobs_3.15.900.v20260604-0622.jar
|
||||
6e7a8db7d87a44bb35924bb345ddf0d9372413e3fee2edd842b50ed630ec254f jdt/org.eclipse.core.net_1.5.900.v20260507-0401.jar
|
||||
e22d47e2e68713a74bfacd134dc01b1aa904a1b429769241483e058de166acea jdt/org.eclipse.core.resources_3.25.0.v20261010-1447.jar
|
||||
0947f29f5c5b7d234e3b735de2d00d33ae6e3ee428a61bb818f9c0fe2b809735 jdt/org.eclipse.core.runtime_3.35.100.v20260910-1515.jar
|
||||
553f09a59f2662fa4a3bbc94e84bd83221c1c6fb532d81b57010162fb47460a6 jdt/org.eclipse.core.variables_3.6.900.v20260602-0702.jar
|
||||
a4e87f5e883e6c864783dc8148e9f915c3e7a9e3e0e26acf6b82210d1134ed79 jdt/org.eclipse.debug.core_3.24.100.v20261009-1915.jar
|
||||
fd353398835107608f554779a6e91891725efb94e0f57559d0ab3cea4b6156c7 jdt/org.eclipse.equinox.app_1.7.600.v20251211-1038.jar
|
||||
28749ec64dac9d0e20e00becc3d5f9daccb5637455c9c546ab07be324011ef84 jdt/org.eclipse.equinox.common_3.21.100.v20260829-1451.jar
|
||||
9beeb1098a3da89a470c547aa840dea0d240d144138a5ba7b9ceedb84ed301ab jdt/org.eclipse.equinox.frameworkadmin.equinox_1.3.500.v20260928-0506.jar
|
||||
f2b956930b8383d708f1fe9df92938bffbe2e129082d4fd0b3d5f59c20a5c9f0 jdt/org.eclipse.equinox.frameworkadmin_2.3.600.v20260306-0554.jar
|
||||
8e889f92718fb8c612dd358265da2c94738bbc08b46473b81cad36f1177a8235 jdt/org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.1700.v20260913-1737.jar
|
||||
347674eb3d3d4000c184573618241141297fb9586c8f65ebdbacace59b8c81a2 jdt/org.eclipse.equinox.launcher.cocoa.macosx.x86_64_1.2.1700.v20260913-1737.jar
|
||||
57e8a4b46906751e214dfa8fab1e02462c40ad4e51f2226d9ef70af5f0941ccb jdt/org.eclipse.equinox.launcher.gtk.linux.aarch64_1.2.1700.v20260913-1737.jar
|
||||
d0e7418232627c53288e566728d24d1578650514152992b87f82a5d982429606 jdt/org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.1700.v20260913-1737.jar
|
||||
aa8c31a082b25b87f75008548f6544379174c98091934fb8399e3f917a0445f1 jdt/org.eclipse.equinox.launcher.win32.win32.x86_64_1.3.200.v20260913-1737.jar
|
||||
9b3b1da60813ac5fa09dd5c198f1884be7076d0a5daec54faa3f351a787e34b7 jdt/org.eclipse.equinox.launcher_1.8.0.v20260804-1928.jar
|
||||
fd04e81a28ab32b6bb073286eb293c44b760176fcf1e35ef15614d62df820f86 jdt/org.eclipse.equinox.preferences_3.12.100.v20251111-0704.jar
|
||||
9b149d3367d33fc1f9a19d3f87c425ecf9fc235ee748b3cf200ec387169b042b jdt/org.eclipse.equinox.registry_3.12.700.v20260830-1519.jar
|
||||
60388d163f70329c39a9cf4e32389ec4fba8dc80ae5518d97cd0be2ebb971510 jdt/org.eclipse.equinox.security.linux_1.2.0.v20260914-1851.jar
|
||||
a8408d4bcdc70e019d2d89ed8b1b0413eb74ea73e74fa3d064b7401643539797 jdt/org.eclipse.equinox.security.macosx_1.102.500.v20250521-0414.jar
|
||||
cf9668c1d1e481059366abc2719468aa52e1276475cf9c32d4d10f57b34d7182 jdt/org.eclipse.equinox.security.win32_1.3.0.v20240419-2334.jar
|
||||
91b74719f9c1a130193407808fe5daab3b92431efbf30b8f886f1ff5e97f468f jdt/org.eclipse.equinox.security_1.4.700.v20250622-1644.jar
|
||||
1a24a3c0fb01ad71f97432d5c2b364d9868b5a5ebd99a1308aacb1a2c03c47da jdt/org.eclipse.equinox.simpleconfigurator.manipulator_2.3.700.v20260306-0556.jar
|
||||
d250938d3715529a9822fcdf46ad2f5de39720558d7195b5c1a8713af00bcd13 jdt/org.eclipse.equinox.simpleconfigurator_1.5.700.v20251111-1031.jar
|
||||
db9a4e856bb4728a2719df544b0f2ccef52b54ebf4e643a417fdb471f85703c1 jdt/org.eclipse.equinox.spi_1.0.0.v20261009-1945.jar
|
||||
7615ee7abdd14695957a52864c57869018dc10ddaac80d3cd24b6a97e05d0d9f jdt/org.eclipse.jdt.apt.core_3.8.1000.v20261012-1009.jar
|
||||
ea365808514a22c2e5a1a3fe0b9c07b88a371094d5d5716d31906a8818d56b2a jdt/org.eclipse.jdt.apt.pluggable.core_1.4.800.v20260930-1106.jar
|
||||
f667bb3666d9875a2309fa2447a19e6cdd960281473bd7125a22c5a435246d21 jdt/org.eclipse.jdt.core.compiler.batch_3.46.200.v20261008-1115.jar
|
||||
a6d0f8fabd64c248c37c463789a1a13f801252b68b0a4f4808c207677b5cae17 jdt/org.eclipse.jdt.core.javac_1.0.0.z20260430-1959.jar
|
||||
8ea98fcbecbd89f311849e448f4c4274a2aba5e15cfcdcad29951330c40e0ee8 jdt/org.eclipse.jdt.core.manipulation_1.26.0.v20261009-1526.jar
|
||||
70b4bb168aab096ee7f33da7b72f470f4172fe3e18e80289dd89cf344937ed22 jdt/org.eclipse.jdt.core_3.48.0.v20261007-1246.jar
|
||||
12113df750bb22f7ef0dd3f044f7c334feecd55ddb56b9340529899c55f5893b jdt/org.eclipse.jdt.debug_3.27.0.v20261012-1250.jar
|
||||
f140fad97dc5c452af0cb0f87ed3c6c83ff518f1dda49a43b8b9bec07f17d0fd jdt/org.eclipse.jdt.junit.core_3.16.0.v20261008-2002.jar
|
||||
4ea3fe4a12a0e1e8b10b7ce3f577a0ff4f5115c6a5e40364c53b8b329a8505b0 jdt/org.eclipse.jdt.junit.runtime_3.8.200.v20260911-1643.jar
|
||||
8b928263817f5dd0a9210a8842682903f779dc83253fea501591e150e5303095 jdt/org.eclipse.jdt.launching_3.25.0.v20261010-0421.jar
|
||||
6e5e5e39d82e9ac04925e8b3f432ef6fe81284441e99952d2f720779c9e2831b jdt/org.eclipse.jdt.ls.core_1.63.0.202610191322.jar
|
||||
666cc544f8a63e41d2dc8e6f3626071214027e4f698dea5bf36137f85477bba4 jdt/org.eclipse.jdt.ls.filesystem_1.63.0.202610191322.jar
|
||||
792f7df214d4ab5e4915ac1805922d44de61ebb880f1fc4881ecd4ecfdca6aca jdt/org.eclipse.jdt.ls.logback.appender_1.63.0.202610191322.jar
|
||||
9647feb0524bf763c878e12ab878a684102b81cccb3f77feecbec709d54f9bbb jdt/org.eclipse.lsp4j.jsonrpc_1.0.0.v20260209-1721.jar
|
||||
ccd78893facc6bfcc359d56cba05d3d5b85eb41e4c40d4b4215ca45db5f416d9 jdt/org.eclipse.lsp4j_1.0.0.v20260209-1721.jar
|
||||
a5d6bdc3e6f5017f19375697c7a5929fbc83f5734f35d5ed4540c7b2c843d4bb jdt/org.eclipse.ltk.core.refactoring_3.17.0.v20261010-1449.jar
|
||||
69d1b41401686fd99337e1ddd8aeec309255ae464393ca19a8025c85bfae6601 jdt/org.eclipse.m2e.apt.core_2.3.200.20260815-0704.jar
|
||||
34872f7712dffca0b39804708c0c1b58f2f2576e27cb7176b92a1ea437d384e6 jdt/org.eclipse.m2e.core_2.8.0.20260815-0704.jar
|
||||
8c3fa36fe9f62ace897b0196df8e6bba6a50610c186f6689ada5f386a8b223b1 jdt/org.eclipse.m2e.jdt_2.5.300.20260813-1359.jar
|
||||
5c5b419a101210127f21fa7d37bd2605b90f96efe9d3e91ea31099fb243dec8e jdt/org.eclipse.m2e.maven.runtime_3.9.1601.20260722-2149.jar
|
||||
53376fa2d7bfd2d5166806e74af9576ee3f8f1c1ac916e2cb4d2bb0c3e72c02f jdt/org.eclipse.m2e.workspace.cli_0.4.0.jar
|
||||
20e09b8f88845480b6ad0b89ac001679b6713592cd0ee4bd78cdb326172eee62 jdt/org.eclipse.osgi.compatibility.state_1.3.100.v20260909-1123.jar
|
||||
fe66fd0b071bebc7e1a122be571ed8ecde373d2966fa6b1e161a899b3c7a054d jdt/org.eclipse.osgi_3.25.0.v20261008-2211.jar
|
||||
2714e0c7548a0c23ab99d4e84b68d3e67419bbd6b295efda15d881e7cb173a1c jdt/org.eclipse.search.core_3.17.0.v20261010-1449.jar
|
||||
0ca3b552c867619285b8ae36c7defdd6c0f013e692643d433fd111796da55f19 jdt/org.eclipse.text_3.14.900.v20261012-1310.jar
|
||||
c6c2b8593cd39c279e74e7055cbd146d39dc6f16011961b4928b92728d32d00d jdt/org.eclipse.xtext.xbase.lib_2.44.0.v20260824-1228.jar
|
||||
ea7a53f8b7966039c6f7d54f462cbbaaad38a5e313a4e7d9125fa39640988725 jdt/org.gradle.toolingapi_9.7.1.v20260923-0232-s.jar
|
||||
5d66b6a4a680755cb6ed7cb104fa7835ef644667586ff0737adeb977c39ecdbc jdt/org.hamcrest_3.0.0.jar
|
||||
e9d8c856856680427f096d156f02a414289985c930998fcb519ce5be39b42003 jdt/org.jsoup_1.23.2.jar
|
||||
3efac657139b0b8024748e937e3e1dba71fbdf69fb526c60f504a3fb5490381b jdt/org.junit_4.13.2.v20240929-1000.jar
|
||||
ed825d10ab1399c8c0cb669e688cf0c8c82629b4c8399b58352b68e92ca10fcb jdt/org.objectweb.asm_9.10.1.jar
|
||||
0653852deab5c6f6e9c84e3aa8f72e72b9ce7dfaf7a6431f3d80f6f9d7a433ad jdt/org.osgi.service.component_1.5.1.202212101352.jar
|
||||
43c7c870710e363405d422da653cce0d798a4537f76e4930f79bceadd3a55345 jdt/org.osgi.service.prefs_1.1.2.202109301733.jar
|
||||
208819c7c71690c15a6bb8b187474e7f9d0147946b680182a62b9f222ae014ec jdt/org.osgi.util.function_1.2.0.202109301733.jar
|
||||
7053c57e7d7d88fec6b90979a3af125e1d2bb847268a328a2f1ed65ad0a4c185 jdt/org.osgi.util.promise_1.3.0.202212101352.jar
|
||||
7e1446499b359675d8aabe6af2de86b85e72ae8a2a932c0b40d0e5ba57097439 jdt/slf4j.api_2.0.20.jar
|
||||
3970dab20b4deb8380ed9656392f289f3f3c2ee882d8c3942f1b5b486284620d jdt/wrapped.com.jetbrains.intellij.java.java-decompiler-engine_253.29346.240.jar
|
||||
65d87386b7e926c0b6fb330a8d2eb6a987f5949634f7574731c0a3c2329d5629 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-emoji_0.64.8.jar
|
||||
7a350f43f858da8f4a230552607ced119aeaf97484ca11ce4cd792e1e41e5bf7 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-gfm-strikethrough_0.64.8.jar
|
||||
7a424dae7df0ee86200a08e163d9d1d8f63cd7a1a0c980b84ccbec029571fb4e jdt/wrapped.com.vladsch.flexmark.flexmark-ext-ins_0.64.8.jar
|
||||
75a2e7168145f799a039832708aa0bc277a954c4a367f58711ccd6b9b7e7d04e jdt/wrapped.com.vladsch.flexmark.flexmark-ext-superscript_0.64.8.jar
|
||||
dbe6355ea9e04731d534d3d4151870f83684c7a44c87d541a2c53ef5ee2c4fa4 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-tables_0.64.8.jar
|
||||
91ae2f77e832d20607fad5b7f407d438ca3f16ac032b4f14956e4e7eb3a60931 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-wikilink_0.64.8.jar
|
||||
0b650a4b47d29ffe0a08802c5d58e2270f733ce5443420c0e8904cfa1605964f jdt/wrapped.com.vladsch.flexmark.flexmark-html2md-converter_0.64.8.jar
|
||||
74bab2d767f48f930471c9a72a0c3b89e9073549ad3754e6b7f83f09585463bc jdt/wrapped.com.vladsch.flexmark.flexmark-jira-converter_0.64.8.jar
|
||||
1c7d205afeef618760a5fa907d579835df625ee258cbebd49813f14c2254a535 jdt/wrapped.com.vladsch.flexmark.flexmark-util-ast_0.64.8.jar
|
||||
9179310212700753a4ecdc7aa057a04591b9cfdb089787a248e8269c34740310 jdt/wrapped.com.vladsch.flexmark.flexmark-util-builder_0.64.8.jar
|
||||
796b9acec9af0fa290725a14be8a3fc1d6fe7a9cb518a13f67124b5172a3485b jdt/wrapped.com.vladsch.flexmark.flexmark-util-collection_0.64.8.jar
|
||||
20458c20bc72808e1718ff8f1652272bbb32f62ddaecb2e9a0ddc84d77fd7283 jdt/wrapped.com.vladsch.flexmark.flexmark-util-data_0.64.8.jar
|
||||
b59fbc922e3604442ff6aae1c6b83220245ba4c3f89d38870bc28c982b69d01d jdt/wrapped.com.vladsch.flexmark.flexmark-util-dependency_0.64.8.jar
|
||||
b6073ee177aa2476d64787f9ec813fdb881943d402c195df0631ba672bfb1311 jdt/wrapped.com.vladsch.flexmark.flexmark-util-format_0.64.8.jar
|
||||
c892e6cdbba715c55462decbe1c83cec432548cf2877cfd0715975c44dfab55c jdt/wrapped.com.vladsch.flexmark.flexmark-util-html_0.64.8.jar
|
||||
1726c33ee95f77cfadc58c8ad27949ed72653f4045fbc399aff62827b44c7fcd jdt/wrapped.com.vladsch.flexmark.flexmark-util-misc_0.64.8.jar
|
||||
cd0f9212530aa73f67de94d080018b2c3a3a55d41050d3abb4be8afb69989be0 jdt/wrapped.com.vladsch.flexmark.flexmark-util-options_0.64.8.jar
|
||||
340167af15da00e3ae5fb89d6b44751abfd648951bb6d2e05c48287d9f9e70c9 jdt/wrapped.com.vladsch.flexmark.flexmark-util-sequence_0.64.8.jar
|
||||
a8fad4a006411d020624c7539e6145e97920067844acddc10b019a178a680948 jdt/wrapped.com.vladsch.flexmark.flexmark-util-visitor_0.64.8.jar
|
||||
792c13bc5536704fc0bfba9120e5bac712843a7ae6da6104b1392b382feaf63d jdt/wrapped.com.vladsch.flexmark.flexmark-util_0.64.8.jar
|
||||
a2ec63056bcfe8c4babc62f13f5f49b502fccccf7c011b5d4ba7dd93f4d09393 jdt/wrapped.com.vladsch.flexmark.flexmark_0.64.8.jar
|
||||
482b6aade7b0320e28db530a22f1382b3d30addf26a5ffd55a347f5c32f6dfc2 jdt/wrapped.org.jetbrains.annotations_24.0.1.jar
|
||||
0addec670fedcd3f113c5c8091d783280d23f75e3acb841b61a9cdb079376a08 maven/aopalliance-1.0.jar
|
||||
876eab6a83daecad5ca67eb9fcabb063c97b5aeb8cf1fca7a989ecde17522051 maven/asm-9.8.jar
|
||||
d3d530d0f28fd0fbbffe2b0b338f70e8cb96f1605579e2e3abd4db29cac24e69 maven/commons-cli-1.9.0.jar
|
||||
ba005f304cef92a3dede24a38ad5ac9b8afccf0d8f75839d6c1338634cf7f6e4 maven/commons-codec-1.18.0.jar
|
||||
6661d5335090a5fc61dd869d2095bc6c1e2156e3aa47a6e4ababdf64c99a7889 maven/error_prone_annotations-2.38.0.jar
|
||||
cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb maven/failureaccess-1.0.3.jar
|
||||
94855942d4992f112946d3de1c334e709237b8126d8130bf07807c018a4a2120 maven/gson-2.13.1.jar
|
||||
f3d7f57f67fd622f4d468dfdd692b3a5e3909246c28017ac3263405f0fe617ed maven/guava-33.4.8-jre.jar
|
||||
142ad4475e19524d2fe3ac995b3f7cbc962fc726f2edb9dbdccc61feab9b2bf9 maven/guice-5.1.0-classes.jar
|
||||
c8bc7e1c51a6d4ce72f40d2ebbabf1c4b68bfe76e732104b04381b493478e9d6 maven/httpclient-4.5.14.jar
|
||||
6c9b3dd142a09dc468e23ad39aad6f75a0f2b85125104469f026e52a474e464f maven/httpcore-4.4.16.jar
|
||||
0b7b8b003a90ea491579b62f5118828e45112914c65589b00faa49d6ec785839 maven/jansi-2.4.2.jar
|
||||
e04ba5195bcd555dc95650f7cc614d151e4bcd52d29a10b8aa2197f3ab89ab9b maven/javax.annotation-api-1.3.2.jar
|
||||
91c77044a50c481636c32d916fd89c9118a72195390452c81065080f957de7ff maven/javax.inject-1.jar
|
||||
ab57ca8fd223772c17365d121f59e94ecbf0ae59d08c03a3cb5b81071c019195 maven/jcl-over-slf4j-1.7.36.jar
|
||||
1fad6e6be7557781e4d33729d49ae1cdc8fdda6fe477bb0cc68ce351eafdfbab maven/jspecify-1.0.0.jar
|
||||
0bfd1c121f907642344311d81e4dfd93eba6c24dc712cc0aeae273eb22833022 maven/maven-artifact-3.9.11.jar
|
||||
5b1de1560ec19c4c69a655f986455888f3a0bcc3c160cac1f1edb87de3825f77 maven/maven-builder-support-3.9.11.jar
|
||||
b0e2865fc808fd9fb3bee95bf37c0609eeb20c59bf1e6b958b37b365ee11f52e maven/maven-compat-3.9.11.jar
|
||||
93d3c0523dda6c86c6620d9e692e7df39d0e2e4e4cd860061a8c0d274c2ad185 maven/maven-core-3.9.11.jar
|
||||
7e15e05035331d0a7e0fe56d7354b2b30e15c0a8c60863cbf28df25190e197db maven/maven-embedder-3.9.11.jar
|
||||
163aa596f976264384efeb50b4cb8a806086fd64a9b755ad1f69195fed3998ad maven/maven-model-3.9.11.jar
|
||||
c4c72accdcd848332c1a37995610a6a369d49fdaabc43e85f0022a5b77de3ba7 maven/maven-model-builder-3.9.11.jar
|
||||
8e985a56412564cf687040efed8bcd3a63eac2202a824bb01e1d5ece2b87458f maven/maven-plugin-api-3.9.11.jar
|
||||
f8655f5f0060e0032944f2a23688d186e140d2184749bcfa1927f83cd6a8b1a2 maven/maven-repository-metadata-3.9.11.jar
|
||||
c0ce14849298720dcf3b09fe9e263b3c576d2d6cd43c61f8aa9d6117e04e5e47 maven/maven-resolver-api-1.9.24.jar
|
||||
18b7d557c41379db8b157ba95263c676f52c293f5416b67bd1db083e99b30383 maven/maven-resolver-connector-basic-1.9.24.jar
|
||||
3cd88edda00c46242cdbe57aefb47703519d176d9e8a500333de15faf41b0e1d maven/maven-resolver-impl-1.9.24.jar
|
||||
a222b0a5ca4c145e8e0f45207a23bcce18bdbe1f1c980e4e9fb332591ee8c91a maven/maven-resolver-named-locks-1.9.24.jar
|
||||
b5d6e52ae1ecf1cb62ede2234234ba3093149524066488e84415187ce478c582 maven/maven-resolver-provider-3.9.11.jar
|
||||
e3153906e93333c106ee18babb72ba7984ccb15e9116ea08f88c71b8d9387e01 maven/maven-resolver-spi-1.9.24.jar
|
||||
b09268d41e1dfcae15a829d45a512ca29d62b727421d3bd6383ac6252e5e960f maven/maven-resolver-transport-file-1.9.24.jar
|
||||
a0de339a8dd3473d9abaa1cf974507f38783e26af31d36a90c1e59cc8714ded0 maven/maven-resolver-transport-http-1.9.24.jar
|
||||
78ed57fa2123a2d84fad7a0b15ca73b89df47c10294d02d6f8121c4d0e5050cb maven/maven-resolver-transport-wagon-1.9.24.jar
|
||||
aeb7e776a8c47b1c317a2581f299e2525b044cb8abde31164e970bbbefab12b1 maven/maven-resolver-util-1.9.24.jar
|
||||
026718d47d19e360d978857bc63d3dc065884fc2c801563d8a2a77b5ab74ccf8 maven/maven-settings-3.9.11.jar
|
||||
33c96e4ca5a15b7c93180f6b2f8deb15f511dfc15dda0d2b671c968b22108972 maven/maven-settings-builder-3.9.11.jar
|
||||
b613357e1bad4dfc1dead801691c9460f9585fe7c6b466bc25186212d7d18487 maven/maven-shared-utils-3.4.2.jar
|
||||
76d9fa894fa61d1492877ca599cb5ab28ca4573d5f389d6c12a8233b995335f9 maven/maven-slf4j-provider-3.9.11.jar
|
||||
1cbd7a965a5e2a9ea823bab311962a4e5aa5c240705bdbad5a52b40ffdfa1004 maven/org.eclipse.sisu.inject-0.9.0.M4.jar
|
||||
b90579bc652eac7331436e0a25533fce14130b9c6e015f2dd3a3d4bb07e942b7 maven/org.eclipse.sisu.plexus-0.9.0.M4.jar
|
||||
9a7f1b5c5a9effd61eadfd8731452a2f76a8e79111fac391ef75ea801bea203a maven/plexus-cipher-2.0.jar
|
||||
50edb93c73786e62822b4fe1336e22880fdf147191373cf5c911370e16748fcf maven/plexus-component-annotations-2.2.0.jar
|
||||
ab2a8715570438a2e4164d85ad3e8d489eabc38ea5093c2eb8ab7f58403535b5 maven/plexus-interpolation-1.28.jar
|
||||
873139960c4c780176dda580b003a2c4bf82188bdce5bb99234e224ef7acfceb maven/plexus-sec-dispatcher-2.0.jar
|
||||
27ef130e32c236090e408fb5498d94cb9ea26d14070fb1c8985d607b62d098d1 maven/plexus-utils-3.6.0.jar
|
||||
d3ef575e3e4979678dc01bf1dcce51021493b4d11fb7f1be8ad982877c16a1c0 maven/slf4j-api-1.7.36.jar
|
||||
afc9216fa97b78dad227b4a8d4d67b9897bf113a57f80598d62993841113e103 maven/wagon-file-3.5.3.jar
|
||||
d2b6e48c9fcbe579e1858c622d14464011ff265fa6e28e794004a6882154a509 maven/wagon-http-3.5.3.jar
|
||||
8e7da766f55164fde8779aaaa125832506c2848cab4876b5305138873e28037f maven/wagon-http-shared-3.5.3.jar
|
||||
5e72000338945ed3e96f8e4f578d1d0672e1af7e19c0e9014197ae5b31af3ef4 maven/wagon-provider-api-3.5.3.jar
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Build with JDT LS 1.63.0 + Maven 3.9.11. Checksums pin every compiler dependency."""
|
||||
import hashlib
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parent
|
||||
jdt, maven = map(lambda p: pathlib.Path(p).resolve(), sys.argv[1:3])
|
||||
jars = sorted((jdt / 'plugins').glob('*.jar')) + sorted((maven / 'lib').glob('*.jar'))
|
||||
actual = ''.join(f'{hashlib.sha256(p.read_bytes()).hexdigest()} {"jdt/" if p.parent.name == "plugins" else "maven/"}{p.name}\n' for p in jars)
|
||||
if '--record-dependencies' in sys.argv:
|
||||
(root / 'build-dependencies.sha256').write_text(actual)
|
||||
if (root / 'build-dependencies.sha256').read_text() != actual:
|
||||
sys.exit('Build dependencies differ from build-dependencies.sha256')
|
||||
with tempfile.TemporaryDirectory() as output:
|
||||
for source, target, classpath in [('src', '-21', jars[:len(list((jdt / 'plugins').glob('*.jar')))]), ('maven', '-8', jars)]:
|
||||
subprocess.run(['java', '-cp', str(jdt / 'plugins/*'), 'org.eclipse.jdt.internal.compiler.batch.Main', target, '-proc:none', '-classpath', os.pathsep.join(map(str, classpath)), '-d', output, *map(str, sorted((root/source).rglob('*.java')))], check=True)
|
||||
files = {'META-INF/MANIFEST.MF': root/'MANIFEST.MF', 'plugin.xml': root/'plugin.xml', 'META-INF/plexus/components.xml': root/'components.xml'}
|
||||
files.update({p.relative_to(output).as_posix(): p for p in pathlib.Path(output).rglob('*.class')})
|
||||
archive = pathlib.Path(output)/'bundle.jar'
|
||||
with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as jar:
|
||||
for name, path in sorted(files.items()):
|
||||
data = path.read_bytes()
|
||||
if name == 'META-INF/MANIFEST.MF':
|
||||
data = data.rstrip(b'\r\n') + b'\n\n'
|
||||
jar.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), data)
|
||||
staged = root/'openidea-kotlin-bridge.jar.pending'
|
||||
staged.write_bytes(archive.read_bytes())
|
||||
staged.replace(root/'openidea-kotlin-bridge.jar')
|
||||
@@ -1,5 +0,0 @@
|
||||
<component-set><components><component>
|
||||
<role>org.apache.maven.AbstractMavenLifecycleParticipant</role>
|
||||
<role-hint>openidea-kotlin-output</role-hint>
|
||||
<implementation>ai.openidea.kotlin.IsolatedOutput</implementation>
|
||||
</component></components></component-set>
|
||||
@@ -1,177 +0,0 @@
|
||||
package ai.openidea.kotlin;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import org.apache.maven.AbstractMavenLifecycleParticipant;
|
||||
import org.apache.maven.artifact.Artifact;
|
||||
import org.apache.maven.MavenExecutionException;
|
||||
import org.apache.maven.execution.*;
|
||||
import java.lang.reflect.*;
|
||||
import org.apache.maven.model.*;
|
||||
import org.apache.maven.project.MavenProject;
|
||||
import org.codehaus.plexus.util.xml.Xpp3Dom;
|
||||
|
||||
/** Loaded only by the private bridge build, before lifecycle plans are calculated. */
|
||||
public final class IsolatedOutput extends AbstractMavenLifecycleParticipant {
|
||||
private final Map<MavenProject, File> outputs = new LinkedHashMap<>();
|
||||
private File staging;
|
||||
private final Map<MavenProject, Map<String, String>> kotlin = new LinkedHashMap<>();
|
||||
private final Set<MavenProject> sources = new HashSet<>();
|
||||
|
||||
@Override public void afterProjectsRead(MavenSession session) throws MavenExecutionException {
|
||||
String destination = session.getUserProperties().getProperty("openidea.kotlin.staging");
|
||||
if (destination == null) throw new MavenExecutionException("Missing Kotlin staging directory", (File) null);
|
||||
staging = new File(destination);
|
||||
int index = 0;
|
||||
for (MavenProject project : session.getProjects()) {
|
||||
File build = new File(staging, Integer.toString(index++));
|
||||
File main = new File(build, "classes");
|
||||
File test = new File(build, "test-classes");
|
||||
project.getBuild().setDirectory(build.getAbsolutePath());
|
||||
project.getBuild().setOutputDirectory(main.getAbsolutePath());
|
||||
project.getBuild().setTestOutputDirectory(test.getAbsolutePath());
|
||||
for (Plugin plugin : project.getBuildPlugins()) {
|
||||
if ("kotlin-maven-plugin".equals(plugin.getArtifactId())) {
|
||||
plugin.setConfiguration(redirect(plugin.getConfiguration(), build, main, test));
|
||||
for (PluginExecution execution : plugin.getExecutions())
|
||||
execution.setConfiguration(redirect(execution.getConfiguration(), build, main, test));
|
||||
} else if ("maven-compiler-plugin".equals(plugin.getArtifactId())) {
|
||||
plugin.setConfiguration(compiler(plugin.getConfiguration()));
|
||||
for (PluginExecution execution : plugin.getExecutions())
|
||||
execution.setConfiguration(compiler(execution.getConfiguration()));
|
||||
}
|
||||
}
|
||||
outputs.put(project, main);
|
||||
Map<String, String> goals = new LinkedHashMap<>();
|
||||
for (Plugin plugin : project.getBuildPlugins()) {
|
||||
if (!isKotlin(plugin)) continue;
|
||||
for (PluginExecution execution : plugin.getExecutions()) {
|
||||
if ("none".equals(execution.getPhase())) continue;
|
||||
for (String goal : execution.getGoals())
|
||||
if ("compile".equals(goal) || "test-compile".equals(goal))
|
||||
goals.put(execution.getId() + ":" + goal, "skipped");
|
||||
}
|
||||
}
|
||||
kotlin.put(project, goals);
|
||||
if (hasKotlin(project.getBasedir().toPath(), project.getBasedir().toPath(), session.getProjects()))
|
||||
sources.add(project);
|
||||
}
|
||||
final ExecutionListener previous = session.getRequest().getExecutionListener();
|
||||
// Preserve Maven's logger and any listener installed by another extension.
|
||||
session.getRequest().setExecutionListener((ExecutionListener) Proxy.newProxyInstance(
|
||||
ExecutionListener.class.getClassLoader(), new Class<?>[] { ExecutionListener.class },
|
||||
(proxy, method, args) -> {
|
||||
if (args != null && args.length == 1 && args[0] instanceof ExecutionEvent) {
|
||||
ExecutionEvent event = (ExecutionEvent) args[0];
|
||||
if (Arrays.asList("mojoStarted", "mojoSucceeded", "mojoFailed", "mojoSkipped").contains(method.getName())
|
||||
&& event.getMojoExecution() != null && isKotlin(event.getMojoExecution().getPlugin())) {
|
||||
String goal = event.getMojoExecution().getGoal();
|
||||
if ("compile".equals(goal) || "test-compile".equals(goal)) {
|
||||
String status = method.getName().equals("mojoSucceeded") ? "success" :
|
||||
method.getName().equals("mojoFailed") ? "failed" : "skipped";
|
||||
Map<String, String> goals = kotlin.get(event.getProject());
|
||||
if (goals != null) goals.put(event.getMojoExecution().getExecutionId() + ":" + goal, status);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous == null) return null;
|
||||
try { return method.invoke(previous, args); }
|
||||
catch (InvocationTargetException e) { throw e.getCause(); }
|
||||
}));
|
||||
}
|
||||
|
||||
private static boolean isKotlin(Plugin plugin) {
|
||||
return "org.jetbrains.kotlin".equals(plugin.getGroupId()) && "kotlin-maven-plugin".equals(plugin.getArtifactId());
|
||||
}
|
||||
private static boolean hasKotlin(Path root, Path directory, List<MavenProject> projects) throws MavenExecutionException {
|
||||
if (!directory.equals(root)) {
|
||||
String name = directory.getFileName().toString();
|
||||
if (Arrays.asList("target", "build", "out", "bin", ".git", "node_modules", ".openidea").contains(name)) return false;
|
||||
for (MavenProject project : projects) if (project.getBasedir().toPath().equals(directory)) return false;
|
||||
}
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) return false;
|
||||
try (DirectoryStream<Path> entries = Files.newDirectoryStream(directory)) {
|
||||
for (Path path : entries) {
|
||||
if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && path.toString().endsWith(".kt")) return true;
|
||||
if (hasKotlin(root, path, projects)) return true;
|
||||
}
|
||||
return false;
|
||||
} catch (IOException e) { throw new MavenExecutionException("Cannot inspect Kotlin sources", e); }
|
||||
}
|
||||
|
||||
@Override public void afterSessionEnd(MavenSession session) throws MavenExecutionException {
|
||||
if (staging == null) return;
|
||||
List<String> modules = new ArrayList<>();
|
||||
for (Map.Entry<MavenProject, File> entry : outputs.entrySet()) {
|
||||
MavenProject project = entry.getKey();
|
||||
File main = entry.getValue();
|
||||
List<String> dependencies = new ArrayList<>();
|
||||
// Maven has applied dependency management, mediation, scopes, optional
|
||||
// propagation and exclusions. Do not reconstruct that graph from POMs.
|
||||
for (Artifact dependency : project.getArtifacts()) {
|
||||
dependencies.add("{\"key\":" + quote(dependency.getGroupId() + ":" + dependency.getArtifactId() + ":" + dependency.getVersion()) +
|
||||
",\"scope\":" + quote(dependency.getScope() == null ? "compile" : dependency.getScope()) +
|
||||
",\"optional\":" + dependency.isOptional() + ",\"type\":" + quote(dependency.getType()) +
|
||||
",\"classifier\":" + quote(dependency.getClassifier()) + "}");
|
||||
}
|
||||
List<String> goals = new ArrayList<>();
|
||||
for (Map.Entry<String, String> goal : kotlin.get(project).entrySet())
|
||||
goals.add("{\"execution\":" + quote(goal.getKey()) + ",\"status\":" + quote(goal.getValue()) + "}");
|
||||
modules.add("{\"key\":" + quote(project.getGroupId() + ":" + project.getArtifactId() + ":" + project.getVersion()) +
|
||||
",\"projectUri\":" + quote(project.getBasedir().toURI().toString()) +
|
||||
",\"output\":" + quote(main.getAbsolutePath()) +
|
||||
",\"testOutput\":" + quote(project.getBuild().getTestOutputDirectory()) +
|
||||
",\"hasKotlin\":" + sources.contains(project) +
|
||||
",\"kotlinGoals\":[" + String.join(",", goals) + "]" +
|
||||
",\"dependencies\":[" + String.join(",", dependencies) + "]}");
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(staging.toPath());
|
||||
Files.write(new File(staging.getParentFile(), "modules.json").toPath(),
|
||||
("[" + String.join(",", modules) + "]").getBytes(StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
throw new MavenExecutionException("Cannot write Kotlin module manifest", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Xpp3Dom config(Object value) {
|
||||
return value instanceof Xpp3Dom ? new Xpp3Dom((Xpp3Dom) value) : new Xpp3Dom("configuration");
|
||||
}
|
||||
private static void set(Xpp3Dom config, String name, File path) {
|
||||
Xpp3Dom child = config.getChild(name);
|
||||
if (child == null) { child = new Xpp3Dom(name); config.addChild(child); }
|
||||
child.setValue(path.getAbsolutePath());
|
||||
}
|
||||
private static Xpp3Dom redirect(Object value, File build, File main, File test) {
|
||||
Xpp3Dom config = config(value);
|
||||
set(config, "output", main);
|
||||
set(config, "testOutput", test);
|
||||
set(config, "incrementalCachesRoot", new File(build, "kotlin-ic"));
|
||||
Xpp3Dom incremental = config.getChild("myIncremental");
|
||||
if (incremental == null) { incremental = new Xpp3Dom("myIncremental"); config.addChild(incremental); }
|
||||
incremental.setValue("false");
|
||||
return config;
|
||||
}
|
||||
private static Xpp3Dom compiler(Object value) {
|
||||
Xpp3Dom config = config(value);
|
||||
// compile and testCompile share the parameter name outputDirectory but
|
||||
// have different defaults. Let each mojo read the redirected build model.
|
||||
for (int i = config.getChildCount() - 1; i >= 0; i--) {
|
||||
String name = config.getChild(i).getName();
|
||||
if ("outputDirectory".equals(name) || "testOutputDirectory".equals(name)) config.removeChild(i);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
private static String quote(String text) {
|
||||
if (text == null) return "null";
|
||||
StringBuilder out = new StringBuilder("\"");
|
||||
for (char c : text.toCharArray()) {
|
||||
if (c == '\\' || c == '"') out.append('\\').append(c);
|
||||
else if (c < 32) out.append(String.format("\\u%04x", (int) c));
|
||||
else out.append(c);
|
||||
}
|
||||
return out.append('"').toString();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plugin>
|
||||
<extension point="org.eclipse.jdt.ls.core.delegateCommandHandler">
|
||||
<delegateCommandHandler class="ai.openidea.kotlin.KotlinLibraries">
|
||||
<command id="openidea.kotlin.replace"/>
|
||||
</delegateCommandHandler>
|
||||
</extension>
|
||||
</plugin>
|
||||
@@ -1,94 +0,0 @@
|
||||
package ai.openidea.kotlin;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import java.io.File;
|
||||
import java.net.URI;
|
||||
import java.util.*;
|
||||
import org.eclipse.core.resources.ResourcesPlugin;
|
||||
import org.eclipse.core.runtime.*;
|
||||
import org.eclipse.core.runtime.jobs.Job;
|
||||
import org.eclipse.jdt.core.*;
|
||||
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
|
||||
|
||||
/** Owns only marked binary entries; Maven and user entries retain their identities. */
|
||||
public final class KotlinLibraries implements IDelegateCommandHandler {
|
||||
private static final String OWNER = "ai.openidea.kotlin";
|
||||
private static final Object LOCK = new Object();
|
||||
private static List<ProjectLibraries> desired = List.of();
|
||||
private static boolean listening;
|
||||
private static final Job reconcileJob = new Job("Kotlin binary libraries") {
|
||||
@Override protected IStatus run(IProgressMonitor monitor) {
|
||||
synchronized (LOCK) {
|
||||
try {
|
||||
reconcile(monitor);
|
||||
return Status.OK_STATUS;
|
||||
} catch (CoreException e) {
|
||||
return e.getStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
private static class Library { String path; boolean test; }
|
||||
private static class ProjectLibraries { String projectUri; List<Library> libraries; }
|
||||
|
||||
@Override public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
|
||||
if (!"openidea.kotlin.replace".equals(command) || arguments.size() != 1)
|
||||
throw new IllegalArgumentException("Expected one Kotlin library snapshot");
|
||||
Gson gson = new Gson();
|
||||
ProjectLibraries[] snapshot = gson.fromJson(gson.toJson(arguments.get(0)), ProjectLibraries[].class);
|
||||
for (ProjectLibraries project : snapshot) {
|
||||
new File(URI.create(project.projectUri));
|
||||
for (Library library : project.libraries) {
|
||||
if (!new File(library.path).isAbsolute() || !new File(library.path).isDirectory())
|
||||
throw new IllegalArgumentException("Kotlin library must be a published directory: " + library.path);
|
||||
}
|
||||
}
|
||||
synchronized (LOCK) {
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
desired = List.of(snapshot);
|
||||
if (!listening) {
|
||||
JavaCore.addElementChangedListener(event -> reconcileJob.schedule(200), ElementChangedEvent.POST_CHANGE);
|
||||
listening = true;
|
||||
}
|
||||
return reconcile(monitor);
|
||||
}
|
||||
}
|
||||
|
||||
private static File canonical(URI uri) {
|
||||
File file = new File(uri);
|
||||
try { return file.getCanonicalFile(); }
|
||||
catch (java.io.IOException e) { return file.getAbsoluteFile(); }
|
||||
}
|
||||
|
||||
private static int reconcile(IProgressMonitor monitor) throws CoreException {
|
||||
int changed = 0;
|
||||
for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) {
|
||||
if (!project.getProject().isAccessible()) continue;
|
||||
List<IClasspathEntry> entries = new ArrayList<>();
|
||||
IClasspathEntry[] original = project.getRawClasspath();
|
||||
for (IClasspathEntry entry : original) {
|
||||
boolean owned = entry.getEntryKind() == IClasspathEntry.CPE_LIBRARY &&
|
||||
Arrays.stream(entry.getExtraAttributes()).anyMatch(a -> OWNER.equals(a.getName()) && "true".equals(a.getValue()));
|
||||
if (!owned) entries.add(entry);
|
||||
}
|
||||
URI location = project.getProject().getLocationURI();
|
||||
for (ProjectLibraries item : desired) {
|
||||
if (location == null || !canonical(location).equals(canonical(URI.create(item.projectUri)))) continue;
|
||||
for (Library library : item.libraries) {
|
||||
List<IClasspathAttribute> attributes = new ArrayList<>();
|
||||
attributes.add(JavaCore.newClasspathAttribute(OWNER, "true"));
|
||||
if (library.test) attributes.add(JavaCore.newClasspathAttribute(IClasspathAttribute.TEST, "true"));
|
||||
entries.add(JavaCore.newLibraryEntry(Path.fromOSString(library.path), null, null,
|
||||
new IAccessRule[0], attributes.toArray(IClasspathAttribute[]::new), false));
|
||||
}
|
||||
}
|
||||
IClasspathEntry[] updated = entries.toArray(IClasspathEntry[]::new);
|
||||
if (!Arrays.equals(original, updated)) {
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
project.setRawClasspath(updated, monitor);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
"""Real Maven + JDT LS regression: python3 verify.py JDT_HOME (Java 25; Maven uses JAVA_HOME)."""
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
home = pathlib.Path(sys.argv[1]).resolve()
|
||||
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-kotlin-bridge.jar'
|
||||
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-kotlin-bridge-', dir=os.environ.get('TMPDIR'))).resolve()
|
||||
copied_bundle = work/'openidea-kotlin-bridge.jar'
|
||||
shutil.copyfile(bundle, copied_bundle)
|
||||
bundle = copied_bundle
|
||||
projects = work / 'projects'
|
||||
single = projects / 'single'
|
||||
multi = projects / 'multi'
|
||||
lib = multi / 'channel-lib'
|
||||
app = multi / 'app'
|
||||
for directory in [single, lib, app]:
|
||||
(directory / 'src/main/java/probe').mkdir(parents=True)
|
||||
plugin = '''<build><plugins><plugin><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-maven-plugin</artifactId><version>2.0.10</version><executions><execution><id>kotlin-compile</id><phase>process-sources</phase><goals><goal>compile</goal></goals><configuration><jvmTarget>1.8</jvmTarget><sourceDirs><sourceDir>src/main/java</sourceDir></sourceDirs></configuration></execution></executions></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version></plugin></plugins></build>'''
|
||||
plugin = plugin.replace('</executions>', '<execution><id>kotlin-test</id><phase>process-test-sources</phase><goals><goal>test-compile</goal></goals><configuration><jvmTarget>1.8</jvmTarget><sourceDirs><sourceDir>src/test/java</sourceDir></sourceDirs></configuration></execution></executions>')
|
||||
stdlib = '<dependencies><dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-stdlib</artifactId><version>2.0.10</version></dependency></dependencies>'
|
||||
properties = '<properties><maven.compiler.source>8</maven.compiler.source><maven.compiler.target>8</maven.compiler.target></properties>'
|
||||
prefix = '<project xmlns="http://maven.apache.org/POM/4.0.0"><modelVersion>4.0.0</modelVersion>'
|
||||
(single / 'pom.xml').write_text(prefix + '<groupId>probe</groupId><artifactId>single</artifactId><version>1</version>' + properties + stdlib + plugin + '</project>')
|
||||
(multi / 'pom.xml').write_text(prefix + '<groupId>probe</groupId><artifactId>reactor</artifactId><version>1</version><packaging>pom</packaging>' + properties + '<modules><module>channel-lib</module><module>app</module></modules></project>')
|
||||
parent = '<parent><groupId>probe</groupId><artifactId>reactor</artifactId><version>1</version></parent>'
|
||||
(lib / 'pom.xml').write_text(prefix + parent + '<artifactId>channel-lib</artifactId>' + stdlib + plugin + '</project>')
|
||||
(app / 'pom.xml').write_text(prefix + parent + '<artifactId>app</artifactId><dependencies><dependency><groupId>probe</groupId><artifactId>channel-lib</artifactId><version>1</version></dependency></dependencies></project>')
|
||||
source = 'package probe;\npublic class Caller {\n public Channel channel() {\n return Channel.SMS;\n }\n}\n'
|
||||
for directory in [single, app]:
|
||||
(directory / 'src/main/java/probe/Caller.java').write_text(source)
|
||||
for directory in [single, lib]:
|
||||
(directory / 'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n')
|
||||
|
||||
(single/'src/test/java/probe').mkdir(parents=True)
|
||||
test_source = source.replace('Caller', 'TestCaller').replace('Channel', 'TestChannel')
|
||||
(single/'src/test/java/probe/TestCaller.java').write_text(test_source)
|
||||
(single/'src/test/java/probe/TestChannel.kt').write_text('package probe\nenum class TestChannel { SMS }\n')
|
||||
|
||||
config = work / 'config'
|
||||
config.mkdir()
|
||||
base = {'Darwin': 'config_mac', 'Windows': 'config_win'}.get(platform.system(), 'config_linux')
|
||||
names = [base + '_arm', base] if platform.machine().lower() in ('arm64', 'aarch64') else [base]
|
||||
shutil.copyfile(next(home / n / 'config.ini' for n in names if (home/n/'config.ini').exists()), config/'config.ini')
|
||||
proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home/'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work/'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=(work/'stderr.log').open('w'))
|
||||
messages = queue.Queue()
|
||||
lock = threading.Lock()
|
||||
transcript = []
|
||||
seq = 0
|
||||
def send(message):
|
||||
data = json.dumps({'jsonrpc': '2.0', **message}).encode()
|
||||
with lock:
|
||||
proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data)
|
||||
proc.stdin.flush()
|
||||
def read():
|
||||
while True:
|
||||
headers = {}
|
||||
while True:
|
||||
line = proc.stdout.readline()
|
||||
if not line: return
|
||||
if line == b'\r\n': break
|
||||
key, value = line.decode().split(':', 1)
|
||||
headers[key.lower()] = value.strip()
|
||||
message = json.loads(proc.stdout.read(int(headers['content-length'])))
|
||||
if 'method' in message and 'id' in message:
|
||||
send({'id':message['id'], 'result': [] if message['method']=='workspace/configuration' else None})
|
||||
else:
|
||||
messages.put(message)
|
||||
threading.Thread(target=read, daemon=True).start()
|
||||
def request(method, params):
|
||||
global seq
|
||||
seq += 1
|
||||
send({'id':seq, 'method':method, 'params':params})
|
||||
deadline = time.monotonic() + 240
|
||||
while time.monotonic() < deadline:
|
||||
message = messages.get(timeout=max(1, deadline-time.monotonic()))
|
||||
transcript.append(message)
|
||||
if message.get('id') == seq:
|
||||
if 'error' in message: raise RuntimeError(message['error'])
|
||||
return message.get('result')
|
||||
raise TimeoutError(method)
|
||||
def command(snapshot):
|
||||
response = request('workspace/executeCommand', {'command':'openidea.kotlin.replace', 'arguments':[snapshot]})
|
||||
assert isinstance(response, (int, float)), response
|
||||
return response
|
||||
def build(directory, revision, expected=True):
|
||||
staging = work / revision
|
||||
result = subprocess.run(['mvn', '-B', '-fae', '-Dstyle.color=never', f'-Dmaven.ext.class.path={bundle}', f'-Dopenidea.kotlin.staging={staging}/staging', 'test-compile'], cwd=directory, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
(work/f'{revision}.log').write_bytes(result.stdout)
|
||||
modules = json.loads((staging/'modules.json').read_text())
|
||||
eligible = all(m['kotlinGoals'] and all(g['status'] == 'success' for g in m['kotlinGoals']) for m in modules if m['hasKotlin'])
|
||||
assert eligible == expected, (modules, result.stdout.decode())
|
||||
evidence[revision + '-build'] = {'exitCode':result.returncode, 'modules':modules, 'eligible':eligible}
|
||||
return modules
|
||||
def library(module, revision, test=False, name="Channel"):
|
||||
directory = work/'published'/revision
|
||||
directory.mkdir(parents=True)
|
||||
output = module['testOutput' if test else 'output']
|
||||
for file in pathlib.Path(output).rglob('TestChannel*.class' if test else name+'*.class'):
|
||||
destination = directory/file.relative_to(output)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(file, destination)
|
||||
assert list(directory.rglob('*.class')), module
|
||||
return {'path':str(directory), 'test':test}
|
||||
def state(directory, label, expected, test=False):
|
||||
file = directory/('src/test/java/probe/TestCaller.java' if test else 'src/main/java/probe/Caller.java')
|
||||
deadline = time.monotonic() + 30
|
||||
while True:
|
||||
time.sleep(0.25)
|
||||
request('workspace/symbol', {'query':'Caller'})
|
||||
reports = [m['params'] for m in transcript if m.get('method')=='textDocument/publishDiagnostics' and m['params']['uri']==file.as_uri()]
|
||||
errors = [d for d in reports[-1]['diagnostics'] if d.get('severity')==1] if reports else None
|
||||
if errors is not None and ((errors == []) == expected): break
|
||||
if time.monotonic() > deadline: raise AssertionError((label, errors))
|
||||
completion = request('textDocument/completion', {'textDocument':{'uri':file.as_uri()}, 'position':{'line':3,'character':27 if test else 23}})
|
||||
items = completion.get('items', []) if isinstance(completion, dict) else completion or []
|
||||
labels = [item['label'] for item in items]
|
||||
definition = request('textDocument/definition', {'textDocument':{'uri':file.as_uri()}, 'position':{'line':2,'character':12}})
|
||||
result = {'errors':errors, 'completion':labels, 'definition':definition}
|
||||
evidence[label] = result
|
||||
assert errors is not None, (label, "no published diagnostics")
|
||||
assert (errors == []) == expected, (label, errors)
|
||||
assert any(label.startswith('SMS') for label in labels) == expected, (label, labels)
|
||||
assert bool(definition) == expected, (label, definition)
|
||||
if expected:
|
||||
location = definition[0]
|
||||
assert location.get('uri', location.get('targetUri', '')).startswith('jdt:'), definition
|
||||
return result
|
||||
|
||||
evidence = {'work':str(work)}
|
||||
try:
|
||||
init = request('initialize', {'processId':None, 'rootUri':projects.as_uri(), 'workspaceFolders':[{'uri':projects.as_uri(), 'name':'projects'}], 'capabilities':{'textDocument':{'completion':{'completionItem':{'snippetSupport':True}}, 'definition':{'linkSupport':True}}}, 'initializationOptions':{'bundles':[str(bundle)], 'extendedClientCapabilities':{'classFileContentsSupport':True}, 'settings':{'java':{'import':{'gradle':{'enabled':False}, 'maven':{'enabled':True}}, 'configuration':{'updateBuildConfiguration':'automatic'}}}}})
|
||||
send({'method':'initialized', 'params':{}})
|
||||
request('java/buildWorkspace', True)
|
||||
for directory, test in [(single,False), (app,False), (single,True)]:
|
||||
file = directory/('src/test/java/probe/TestCaller.java' if test else 'src/main/java/probe/Caller.java')
|
||||
send({'method':'textDocument/didOpen','params':{'textDocument':{'uri':file.as_uri(),'languageId':'java','version':1,'text':test_source if test else source}}})
|
||||
# Allow reconcile jobs to publish their first marker batch.
|
||||
time.sleep(2)
|
||||
state(single, 'single-before', False)
|
||||
state(app, 'multi-before', False)
|
||||
state(single, 'test-before', False, True)
|
||||
def raw(directory):
|
||||
entries = ET.parse(directory/'.classpath').getroot()
|
||||
return [ET.tostring(e).decode().strip() for e in entries if not any(a.get('name')=='ai.openidea.kotlin' for a in e.findall('./attributes/attribute'))]
|
||||
before = {str(d):raw(d) for d in [single, lib, app]}
|
||||
sm = build(single, 'single-build')[0]
|
||||
assert not (pathlib.Path(sm['output'])/'probe/TestCaller.class').exists()
|
||||
assert (pathlib.Path(sm['testOutput'])/'probe/TestCaller.class').is_file()
|
||||
mm = build(multi, 'multi-build')
|
||||
lm = next(m for m in mm if 'channel-lib' in m['key'])
|
||||
sl, ml = library(sm, 'single'), library(lm, 'multi')
|
||||
snapshot = [{'projectUri':single.as_uri(),'libraries':[sl, library(sm, 'single-test', True)]}, {'projectUri':lib.as_uri(),'libraries':[ml]}, {'projectUri':app.as_uri(),'libraries':[ml]}]
|
||||
command(snapshot)
|
||||
time.sleep(2)
|
||||
state(single, 'single-after', True)
|
||||
state(app, 'multi-after', True)
|
||||
state(single, 'test-after', True, True)
|
||||
assert all(raw(d) == before[str(d)] for d in [single,lib,app]), 'changed unowned entries'
|
||||
# Simulate m2e removing the extra entries during a configuration update.
|
||||
send({'method':'java/projectConfigurationsUpdate','params':{'identifiers':[{'uri':(d/'pom.xml').as_uri()} for d in [single,lib,app]]}})
|
||||
time.sleep(3)
|
||||
state(single, 'single-reimport', True)
|
||||
state(app, 'multi-reimport', True)
|
||||
assert all(raw(d) == before[str(d)] for d in [single,lib,app]), 'reimport changed unowned entries'
|
||||
# A saved API change must replace the immutable binary directory.
|
||||
(single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS, EMAIL }\n')
|
||||
state(single, 'single-during-rebuild', True)
|
||||
changed = build(single, 'single-changed')[0]
|
||||
snapshot[0]['libraries'] = [library(changed, 'single-changed'), library(changed, 'single-test-changed', True)]
|
||||
command(snapshot)
|
||||
time.sleep(2)
|
||||
changed_state = state(single, 'single-new-enum-member', True)
|
||||
assert any(label.startswith('EMAIL') for label in changed_state['completion'])
|
||||
# Failed Kotlin compilation keeps both main and test APIs attached.
|
||||
(single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { ??? }\n')
|
||||
broken = build(single, 'single-broken', expected=False)[0]
|
||||
assert any(g['status'] == 'failed' for g in broken['kotlinGoals'])
|
||||
state(single, 'single-broken-retains-api', True)
|
||||
state(single, 'single-broken-retains-test-api', True, True)
|
||||
# A javac failure skips test Kotlin targets, so this whole snapshot is retained.
|
||||
(single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Delivery { SMS }\n')
|
||||
skipped = build(single, 'single-skipped-test', expected=False)[0]
|
||||
assert any(g['execution'].endswith(':test-compile') and g['status'] == 'skipped' for g in skipped['kotlinGoals'])
|
||||
assert list(pathlib.Path(skipped['output']).rglob('Delivery.class'))
|
||||
state(single, 'single-skipped-test-retains-api', True)
|
||||
# The reactor fixture has no test Kotlin goal. Publish despite downstream javac failure.
|
||||
kt = lib/'src/main/java/probe/Channel.kt'
|
||||
kt.write_text('package probe\nenum class Delivery { SMS }\n')
|
||||
renamed = build(multi, 'multi-renamed')
|
||||
renamed_lib = next(m for m in renamed if 'channel-lib' in m['key'])
|
||||
new_library = library(renamed_lib, 'multi-renamed', name='Delivery')
|
||||
assert not list(pathlib.Path(renamed_lib['output']).rglob('Channel.class'))
|
||||
snapshot[1]['libraries'] = snapshot[2]['libraries'] = [new_library]
|
||||
command(snapshot)
|
||||
state(app, 'multi-renamed', False)
|
||||
kt.unlink()
|
||||
snapshot[1]['libraries'] = snapshot[2]['libraries'] = []
|
||||
command(snapshot)
|
||||
state(app, 'multi-deleted', False)
|
||||
kt.write_text('package probe\nenum class Channel { SMS }\n')
|
||||
restored = next(m for m in build(multi, 'multi-restored') if 'channel-lib' in m['key'])
|
||||
restored_library = library(restored, 'multi-restored')
|
||||
snapshot[1]['libraries'] = snapshot[2]['libraries'] = [restored_library]
|
||||
command(snapshot)
|
||||
state(app, 'multi-restored', True)
|
||||
(single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n')
|
||||
repaired = build(single, 'single-repaired')[0]
|
||||
snapshot[0]['libraries'] = [library(repaired, 'single-repaired'), library(repaired, 'single-test-repaired', True)]
|
||||
command(snapshot)
|
||||
time.sleep(2)
|
||||
state(single, 'single-repaired', True)
|
||||
# No old API after explicit invalidation, and recovery is repeatable.
|
||||
command([])
|
||||
time.sleep(2)
|
||||
state(single, 'single-withdrawn', False)
|
||||
state(app, 'multi-withdrawn', False)
|
||||
state(single, 'test-withdrawn', False, True)
|
||||
command(snapshot)
|
||||
time.sleep(2)
|
||||
state(single, 'single-recovered', True)
|
||||
state(app, 'multi-recovered', True)
|
||||
# Main sources must not gain visibility of test-only Kotlin libraries.
|
||||
leak = single/'src/main/java/probe/MainLeak.java'
|
||||
leak_source = test_source.replace('TestCaller', 'MainLeak')
|
||||
leak.write_text(leak_source)
|
||||
send({'method':'textDocument/didOpen','params':{'textDocument':{'uri':leak.as_uri(),'languageId':'java','version':1,'text':leak_source}}})
|
||||
deadline = time.monotonic() + 30
|
||||
while True:
|
||||
time.sleep(0.25)
|
||||
request('workspace/symbol', {'query':'MainLeak'})
|
||||
reports = [m['params'] for m in transcript if m.get('method')=='textDocument/publishDiagnostics' and m['params']['uri']==leak.as_uri()]
|
||||
if reports: break
|
||||
assert time.monotonic() < deadline, 'no diagnostics for main/test isolation'
|
||||
evidence['main-test-isolation'] = reports[-1]['diagnostics']
|
||||
assert any(d.get('severity') == 1 for d in reports[-1]['diagnostics']), reports[-1]
|
||||
# Compiler and JDT outputs must not share Kotlin classes.
|
||||
assert not list(single.glob('target/classes/**/Channel.class'))
|
||||
assert not list(lib.glob('target/classes/**/Channel.class'))
|
||||
# -fae runs independent Kotlin modules and records upstream-skipped targets.
|
||||
fail_reactor = work/'fail-at-end'
|
||||
fail_reactor.mkdir()
|
||||
(fail_reactor/'pom.xml').write_text(prefix + '<groupId>probe</groupId><artifactId>failure-reactor</artifactId><version>1</version><packaging>pom</packaging>' + properties + '<modules><module>broken-java</module><module>independent</module><module>downstream</module></modules></project>')
|
||||
failure_parent = '<parent><groupId>probe</groupId><artifactId>failure-reactor</artifactId><version>1</version></parent>'
|
||||
for name in ['broken-java', 'independent', 'downstream']:
|
||||
module = fail_reactor/name
|
||||
(module/'src/main/java/probe').mkdir(parents=True)
|
||||
dependency = '<dependencies><dependency><groupId>probe</groupId><artifactId>broken-java</artifactId><version>1</version></dependency><dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-stdlib</artifactId><version>2.0.10</version></dependency></dependencies>' if name == 'downstream' else stdlib
|
||||
(module/'pom.xml').write_text(prefix + failure_parent + '<artifactId>' + name + '</artifactId>' + dependency + plugin + '</project>')
|
||||
if name == 'broken-java':
|
||||
(module/'src/main/java/probe/Broken.java').write_text('package probe; public class Broken { Missing value; }')
|
||||
else:
|
||||
(module/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n')
|
||||
statuses = build(fail_reactor, 'fail-at-end-statuses', expected=False)
|
||||
independent = next(m for m in statuses if ':independent:' in m['key'])
|
||||
downstream = next(m for m in statuses if ':downstream:' in m['key'])
|
||||
assert all(g['status'] == 'success' for g in independent['kotlinGoals']), independent
|
||||
assert all(g['status'] == 'skipped' for g in downstream['kotlinGoals']), downstream
|
||||
assert list(pathlib.Path(independent['output']).rglob('Channel.class'))
|
||||
print(json.dumps(evidence, indent=2))
|
||||
finally:
|
||||
(work/'evidence.json').write_text(json.dumps(evidence, indent=2))
|
||||
(work/'transcript.json').write_text(json.dumps(transcript, indent=2))
|
||||
proc.terminate()
|
||||
proc.wait(timeout=20)
|
||||
print('Evidence:', work, file=sys.stderr)
|
||||
@@ -74,7 +74,6 @@ ide-github = { path = "../crates/ide-github" }
|
||||
block2 = "0.6"
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSResponder", "NSView", "NSWindow"] }
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "CFRunLoop"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] }
|
||||
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
|
||||
objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] }
|
||||
+9
-16
@@ -8,8 +8,6 @@ mod commands;
|
||||
#[cfg(debug_assertions)]
|
||||
mod dev;
|
||||
mod events;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_window_state;
|
||||
mod logging;
|
||||
mod menu;
|
||||
mod remote_dev;
|
||||
@@ -70,18 +68,16 @@ pub fn run() {
|
||||
// Single instance first: a second launch focuses this one and opens its path argument.
|
||||
// Debug builds have a separate identifier per worktree.
|
||||
let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance));
|
||||
let app = app.plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init());
|
||||
#[cfg(target_os = "linux")]
|
||||
let app = app.plugin(linux_window_state::plugin());
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let app = app.plugin(
|
||||
tauri_plugin_window_state::Builder::default()
|
||||
.with_state_flags(window_state_flags())
|
||||
// One remembered geometry shared by every project's settings window.
|
||||
.map_label(|l| if windows::is_settings_window(l) { "settings" } else { l })
|
||||
.build(),
|
||||
);
|
||||
let app = app
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::default()
|
||||
.with_state_flags(window_state_flags())
|
||||
// One remembered geometry shared by every project's settings window.
|
||||
.map_label(|l| if windows::is_settings_window(l) { "settings" } else { l })
|
||||
.build(),
|
||||
)
|
||||
// Inert until an endpoint is configured (see `commands::window::app_check_update`).
|
||||
.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
// E2E automation bridge (Tauri MCP), debug builds only, localhost only.
|
||||
@@ -156,8 +152,6 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| match event {
|
||||
#[cfg(target_os = "linux")]
|
||||
tauri::WindowEvent::CloseRequested { .. } => linux_window_state::save_before_close(window.app_handle()),
|
||||
tauri::WindowEvent::Focused(true) => {
|
||||
application_focus::changed(window.app_handle(), true);
|
||||
if let Some(w) = window.try_state::<WindowsState>() {
|
||||
@@ -210,7 +204,6 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn window_state_flags() -> tauri_plugin_window_state::StateFlags {
|
||||
use tauri_plugin_window_state::StateFlags;
|
||||
StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED | StateFlags::FULLSCREEN
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
//! Restore GTK geometry after mapping, using the scale of the window's actual output.
|
||||
|
||||
mod geometry;
|
||||
#[cfg(target_os = "linux")]
|
||||
use geometry::*;
|
||||
use tauri::utils::config::WindowConfig;
|
||||
|
||||
fn build_hidden(config: &mut WindowConfig) -> bool {
|
||||
std::mem::replace(&mut config.visible, false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod runtime {
|
||||
use super::*;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Listener, Manager, Window, WindowEvent, plugin::TauriPlugin};
|
||||
|
||||
const FILE: &str = tauri_plugin_window_state::DEFAULT_FILENAME;
|
||||
|
||||
#[derive(Default)]
|
||||
enum Phase {
|
||||
#[default]
|
||||
Mapping,
|
||||
Size {
|
||||
target: Size,
|
||||
scale: f64,
|
||||
request: u64,
|
||||
},
|
||||
Mode,
|
||||
Tracking,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Pending {
|
||||
limits: Limits,
|
||||
configured: bool,
|
||||
presented: bool,
|
||||
presentation_request: u64,
|
||||
saved: Geometry,
|
||||
normal: Size,
|
||||
history: ModeHistory,
|
||||
restore_position: bool,
|
||||
phase: Phase,
|
||||
mode_requested: bool,
|
||||
mode_request: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
saved: HashMap<String, Geometry>,
|
||||
windows: HashMap<String, Pending>,
|
||||
next_request: u64,
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
size: Size,
|
||||
scale: f64,
|
||||
mode: Mode,
|
||||
position: Option<tauri::PhysicalPosition<i32>>,
|
||||
monitors: Vec<tauri::Monitor>,
|
||||
work_area: Option<Size>,
|
||||
visible: bool,
|
||||
}
|
||||
|
||||
fn frame(window: &Window) -> Option<Frame> {
|
||||
let size = window.inner_size().ok()?;
|
||||
Some(Frame {
|
||||
visible: window.is_visible().ok()?,
|
||||
size: Size(size.width, size.height),
|
||||
scale: window.scale_factor().ok()?,
|
||||
mode: Mode {
|
||||
maximized: window.is_maximized().ok()?,
|
||||
fullscreen: window.is_fullscreen().ok()?,
|
||||
minimized: window.is_minimized().ok()?,
|
||||
},
|
||||
position: window.outer_position().ok(),
|
||||
monitors: window.available_monitors().unwrap_or_default(),
|
||||
work_area: window
|
||||
.current_monitor()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|m| Size(m.work_area().size.width, m.work_area().size.height)),
|
||||
})
|
||||
}
|
||||
|
||||
fn key(label: &str) -> &str {
|
||||
if crate::windows::is_settings_window(label) { "settings" } else { label }
|
||||
}
|
||||
|
||||
pub fn prepare(app: &AppHandle, config: &mut WindowConfig) -> bool {
|
||||
let hint_monitor = app.primary_monitor().ok().flatten();
|
||||
let hint_scale = hint_monitor.as_ref().map_or(1.0, |m| m.scale_factor());
|
||||
let hint_area = hint_monitor.map(|m| Size(m.work_area().size.width, m.work_area().size.height));
|
||||
let cache = app.state::<Mutex<Cache>>();
|
||||
let mut cache = cache.lock();
|
||||
let saved = cache.saved.get(key(&config.label)).cloned().unwrap_or_else(|| Geometry {
|
||||
maximized: config.maximized,
|
||||
fullscreen: config.fullscreen,
|
||||
..Default::default()
|
||||
});
|
||||
cache.next_request += 1;
|
||||
let presentation_request = cache.next_request;
|
||||
let pending = Pending {
|
||||
presentation_request,
|
||||
limits: Limits {
|
||||
min: (config.min_width.unwrap_or(1.0), config.min_height.unwrap_or(1.0)),
|
||||
max: (config.max_width, config.max_height),
|
||||
},
|
||||
normal: Size(saved.width, saved.height),
|
||||
saved,
|
||||
restore_position: true,
|
||||
..Default::default()
|
||||
};
|
||||
if pending.saved.width > 0 && pending.saved.height > 0 {
|
||||
// This only reduces the first-frame jump; mapped geometry is authoritative.
|
||||
let size = restore_size(pending.normal, hint_scale, pending.limits.in_work_area(hint_area, hint_scale));
|
||||
config.width = f64::from(size.0) / hint_scale;
|
||||
config.height = f64::from(size.1) / hint_scale;
|
||||
}
|
||||
cache.windows.insert(config.label.clone(), pending);
|
||||
// Establish a normal window before asking GTK to maximize or fullscreen it.
|
||||
config.maximized = false;
|
||||
config.fullscreen = false;
|
||||
build_hidden(config)
|
||||
}
|
||||
|
||||
fn sample(window: &Window, allow_restore: bool, accept_constrained_size: bool) {
|
||||
let Some(frame) = frame(window) else { return };
|
||||
if !frame.visible {
|
||||
return;
|
||||
}
|
||||
let cache = window.state::<Mutex<Cache>>();
|
||||
let mut cache = cache.lock();
|
||||
cache.next_request += 1;
|
||||
let request = cache.next_request;
|
||||
let Some(pending) = cache.windows.get_mut(window.label()) else { return };
|
||||
if !pending.presented && pending.saved.width == 0 {
|
||||
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
|
||||
}
|
||||
if !pending.configured || !pending.presented {
|
||||
return;
|
||||
}
|
||||
if !allow_restore && !matches!(pending.phase, Phase::Tracking) {
|
||||
return;
|
||||
}
|
||||
let transition = if matches!(pending.phase, Phase::Tracking) {
|
||||
pending.history.observe(frame.mode, allow_restore)
|
||||
} else {
|
||||
NormalTransition::Stable
|
||||
};
|
||||
if transition == NormalTransition::WaitForResize {
|
||||
let normal = pending.normal;
|
||||
let saved = cache.saved.entry(key(window.label()).to_owned()).or_default();
|
||||
(saved.width, saved.height) = (normal.0, normal.1);
|
||||
saved.maximized = frame.mode.maximized;
|
||||
saved.fullscreen = frame.mode.fullscreen;
|
||||
return;
|
||||
}
|
||||
if transition == NormalTransition::Restore {
|
||||
// The compositor may have changed output/DPI while maximized or minimized.
|
||||
pending.saved.set_normal_size(pending.normal);
|
||||
pending.phase = Phase::Mapping;
|
||||
pending.mode_requested = false;
|
||||
}
|
||||
let mut fit_settings = false;
|
||||
let mut position = None;
|
||||
if matches!(pending.phase, Phase::Mapping) {
|
||||
if pending.restore_position {
|
||||
pending.restore_position = false;
|
||||
let saved = &pending.saved;
|
||||
let (x, y) = if saved.maximized { (saved.prev_x, saved.prev_y) } else { (saved.x, saved.y) };
|
||||
if frame.monitors.iter().any(|m| {
|
||||
let p = m.position();
|
||||
let s = m.size();
|
||||
i64::from(x) < i64::from(p.x) + i64::from(s.width)
|
||||
&& i64::from(x) + i64::from(saved.width) > i64::from(p.x)
|
||||
&& i64::from(y) < i64::from(p.y) + i64::from(s.height)
|
||||
&& i64::from(y) + i64::from(saved.height) > i64::from(p.y)
|
||||
}) {
|
||||
position = Some(tauri::PhysicalPosition::new(x, y));
|
||||
}
|
||||
}
|
||||
if pending.saved.width > 0 && pending.saved.height > 0 {
|
||||
let target = restore_size(
|
||||
Size(pending.saved.width, pending.saved.height),
|
||||
frame.scale,
|
||||
pending.limits.in_work_area(frame.work_area, frame.scale),
|
||||
);
|
||||
pending.phase = Phase::Size { target, scale: frame.scale, request: 0 };
|
||||
} else {
|
||||
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
|
||||
pending.phase = Phase::Mode;
|
||||
}
|
||||
}
|
||||
if let Phase::Size { target, scale, request: sent } = pending.phase {
|
||||
if frame.size != target && !accept_constrained_size {
|
||||
if sent != 0 && scale == frame.scale {
|
||||
return;
|
||||
}
|
||||
// Re-evaluate after output scale changes, rather than reusing monitor 0's scale.
|
||||
let target = restore_size(
|
||||
Size(pending.saved.width, pending.saved.height),
|
||||
frame.scale,
|
||||
pending.limits.in_work_area(frame.work_area, frame.scale),
|
||||
);
|
||||
pending.phase = Phase::Size { target, scale: frame.scale, request };
|
||||
drop(cache);
|
||||
if let Some(position) = position {
|
||||
let _ = window.set_position(position);
|
||||
}
|
||||
if let Err(error) = window.set_size(tauri::PhysicalSize::new(target.0, target.1)) {
|
||||
tracing::warn!("cannot restore Linux window size: {error}");
|
||||
}
|
||||
settle_constrained_size(window, request);
|
||||
return;
|
||||
}
|
||||
if scale != frame.scale && !accept_constrained_size {
|
||||
pending.phase = Phase::Mapping;
|
||||
drop(cache);
|
||||
sample(window, true, false);
|
||||
return;
|
||||
}
|
||||
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
|
||||
pending.phase = Phase::Mode;
|
||||
}
|
||||
if matches!(pending.phase, Phase::Mode) {
|
||||
let (maximize, fullscreen) = (pending.saved.maximized, pending.saved.fullscreen);
|
||||
let reached = if fullscreen { frame.mode.fullscreen } else { !maximize || frame.mode.maximized };
|
||||
let action = mode_action(reached, pending.mode_requested, accept_constrained_size);
|
||||
if action == ModeAction::Wait {
|
||||
return;
|
||||
}
|
||||
if action == ModeAction::Request {
|
||||
pending.mode_requested = true;
|
||||
pending.mode_request = request;
|
||||
let mut snapshot = pending.saved.clone();
|
||||
(snapshot.width, snapshot.height) = (pending.normal.0, pending.normal.1);
|
||||
cache.saved.insert(key(window.label()).to_owned(), snapshot);
|
||||
drop(cache);
|
||||
if let Some(position) = position {
|
||||
let _ = window.set_position(position);
|
||||
}
|
||||
if maximize {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
if fullscreen {
|
||||
let _ = window.set_fullscreen(true);
|
||||
}
|
||||
settle_constrained_size(window, request);
|
||||
return;
|
||||
}
|
||||
pending.phase = Phase::Tracking;
|
||||
fit_settings = crate::windows::is_settings_window(window.label());
|
||||
}
|
||||
pending.history = ModeHistory(frame.mode);
|
||||
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
|
||||
let normal = pending.normal;
|
||||
let saved = cache.saved.entry(key(window.label()).to_owned()).or_default();
|
||||
(saved.width, saved.height) = (normal.0, normal.1);
|
||||
saved.maximized = frame.mode.maximized;
|
||||
saved.fullscreen = frame.mode.fullscreen;
|
||||
if frame.mode.normal()
|
||||
&& let Some(pos) = frame.position
|
||||
{
|
||||
(saved.x, saved.y, saved.prev_x, saved.prev_y) = (pos.x, pos.y, pos.x, pos.y);
|
||||
}
|
||||
drop(cache);
|
||||
if let Some(position) = position {
|
||||
let _ = window.set_position(position);
|
||||
}
|
||||
if fit_settings {
|
||||
let app = window.app_handle().clone();
|
||||
let label = window.label().to_owned();
|
||||
let _ = window.run_on_main_thread(move || {
|
||||
if let Some(parent) = crate::windows::settings_parent(&label).and_then(|p| app.get_webview_window(p))
|
||||
&& let Some(settings) = app.get_webview_window(&label)
|
||||
&& let Err(error) = crate::windows::place_settings_window(&settings, &parent)
|
||||
{
|
||||
tracing::warn!("cannot fit restored settings window: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn settle_constrained_size(window: &Window, request: u64) {
|
||||
let window = window.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
let cloned = window.clone();
|
||||
let _ = window.run_on_main_thread(move || {
|
||||
let waiting = cloned.state::<Mutex<Cache>>().lock().windows.get(cloned.label()).is_some_and(|p| {
|
||||
matches!(p.phase, Phase::Size { request: current, .. } if current == request)
|
||||
|| matches!(p.phase, Phase::Mode) && p.mode_request == request
|
||||
});
|
||||
if waiting {
|
||||
// Window-manager constraints may reject a size without producing an exact match.
|
||||
sample(&cloned, true, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn normal_size_requested(app: &AppHandle, label: &str, size: tauri::PhysicalSize<u32>) {
|
||||
let cache = app.state::<Mutex<Cache>>();
|
||||
let mut cache = cache.lock();
|
||||
cache.next_request += 1;
|
||||
let request = cache.next_request;
|
||||
if let Some(pending) = cache.windows.get_mut(label) {
|
||||
pending.normal = Size(size.width, size.height);
|
||||
pending.saved.set_normal_size(pending.normal);
|
||||
pending.mode_requested = false;
|
||||
pending.presented = false;
|
||||
pending.presentation_request = request;
|
||||
pending.phase = Phase::Mapping;
|
||||
}
|
||||
let saved = cache.saved.entry(key(label).to_owned()).or_default();
|
||||
saved.set_normal_size(Size(size.width, size.height));
|
||||
drop(cache);
|
||||
if let Some(webview) = app.get_webview_window(label) {
|
||||
request_presentation(webview.as_ref(), request);
|
||||
}
|
||||
}
|
||||
|
||||
fn request_presentation(webview: &tauri::Webview, request: u64) {
|
||||
// A configure precedes output-enter. A second animation frame follows the
|
||||
// first paint, allowing GTK to learn the scale of the assigned output.
|
||||
let script = r#"
|
||||
(() => {
|
||||
const state = window.__OPENIDEA_WINDOW_GEOMETRY__ ??= { request: 0, scheduled: false };
|
||||
state.request = __REQUEST_ID__;
|
||||
state.ready ??= () => {
|
||||
if (document.hidden || state.scheduled) return;
|
||||
const request = state.request;
|
||||
state.scheduled = true;
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
state.scheduled = false;
|
||||
if (document.hidden) return;
|
||||
const api = window.__TAURI_INTERNALS__;
|
||||
const label = api?.metadata?.currentWindow?.label;
|
||||
if (label) api.invoke('plugin:event|emit', {
|
||||
event: 'openidea://window-state-presented', payload: { label, request }
|
||||
});
|
||||
if (request !== state.request) state.ready();
|
||||
}));
|
||||
};
|
||||
if (!state.listening) {
|
||||
state.listening = true;
|
||||
document.addEventListener('visibilitychange', state.ready);
|
||||
}
|
||||
state.ready();
|
||||
})();
|
||||
"#
|
||||
.replace("__REQUEST_ID__", &request.to_string());
|
||||
let _ = webview.eval(script);
|
||||
}
|
||||
|
||||
pub fn save_before_close(app: &AppHandle) {
|
||||
for window in app.webview_windows().values() {
|
||||
sample(&window.as_ref().window(), false, false);
|
||||
}
|
||||
persist(app);
|
||||
}
|
||||
|
||||
fn persist(app: &AppHandle) {
|
||||
let write = || -> Result<(), Box<dyn std::error::Error>> {
|
||||
let dir = app.path().app_config_dir()?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let cache = app.state::<Mutex<Cache>>();
|
||||
let data = serde_json::to_vec_pretty(&cache.lock().saved)?;
|
||||
let temporary = dir.join(format!("{FILE}.tmp"));
|
||||
std::fs::write(&temporary, data)?;
|
||||
std::fs::rename(temporary, dir.join(FILE))?;
|
||||
Ok(())
|
||||
};
|
||||
if let Err(error) = write() {
|
||||
tracing::warn!("cannot save Linux window state: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin() -> TauriPlugin<tauri::Wry> {
|
||||
tauri::plugin::Builder::new("linux-window-state")
|
||||
.setup(|app, _| {
|
||||
let saved = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.ok()
|
||||
.and_then(|dir| std::fs::read(dir.join(FILE)).ok())
|
||||
.and_then(|data| serde_json::from_slice(&data).ok())
|
||||
.unwrap_or_default();
|
||||
app.manage(Mutex::new(Cache { saved, ..Default::default() }));
|
||||
let handle = app.clone();
|
||||
app.listen("openidea://window-state-presented", move |event| {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Presented {
|
||||
label: String,
|
||||
request: u64,
|
||||
}
|
||||
let Ok(presented) = serde_json::from_str::<Presented>(event.payload()) else { return };
|
||||
let Some(webview) = handle.get_webview_window(&presented.label) else { return };
|
||||
let window = webview.as_ref().window();
|
||||
let cloned = window.clone();
|
||||
let _ = window.run_on_main_thread(move || {
|
||||
if !cloned.is_visible().unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
if let Some(pending) = cloned.state::<Mutex<Cache>>().lock().windows.get_mut(cloned.label()) {
|
||||
if pending.presentation_request != presented.request {
|
||||
return;
|
||||
}
|
||||
pending.presented = true;
|
||||
}
|
||||
sample(&cloned, true, false);
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.on_page_load(|webview, payload| {
|
||||
if payload.event() == tauri::webview::PageLoadEvent::Finished {
|
||||
let request = webview
|
||||
.state::<Mutex<Cache>>()
|
||||
.lock()
|
||||
.windows
|
||||
.get(webview.window().label())
|
||||
.map(|p| p.presentation_request);
|
||||
if let Some(request) = request {
|
||||
request_presentation(webview, request);
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_window_ready(|window| {
|
||||
let cloned = window.clone();
|
||||
window.on_window_event(move |event| match event {
|
||||
WindowEvent::Resized(_) => {
|
||||
if let Some(pending) = cloned.state::<Mutex<Cache>>().lock().windows.get_mut(cloned.label()) {
|
||||
pending.configured = true;
|
||||
}
|
||||
let request = cloned
|
||||
.state::<Mutex<Cache>>()
|
||||
.lock()
|
||||
.windows
|
||||
.get(cloned.label())
|
||||
.filter(|p| !p.presented)
|
||||
.map(|p| p.presentation_request);
|
||||
if let Some(request) = request
|
||||
&& let Some(webview) = cloned.app_handle().get_webview_window(cloned.label())
|
||||
{
|
||||
request_presentation(webview.as_ref(), request);
|
||||
}
|
||||
sample(&cloned, true, false);
|
||||
}
|
||||
WindowEvent::Moved(_) => sample(&cloned, false, false),
|
||||
WindowEvent::Destroyed => {
|
||||
cloned.state::<Mutex<Cache>>().lock().windows.remove(cloned.label());
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
})
|
||||
.on_event(|app, event| {
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
persist(app);
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use runtime::{normal_size_requested, plugin, prepare, save_before_close};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hidden_creation_preserves_geometry() {
|
||||
let mut config = WindowConfig { width: 1201.0, height: 801.0, ..Default::default() };
|
||||
assert!(build_hidden(&mut config));
|
||||
assert!(!config.visible);
|
||||
assert_eq!((config.width, config.height), (1201.0, 801.0));
|
||||
assert!(!build_hidden(&mut config));
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) struct Size(pub(super) u32, pub(super) u32);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) struct Mode {
|
||||
pub(super) maximized: bool,
|
||||
pub(super) fullscreen: bool,
|
||||
pub(super) minimized: bool,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub(super) fn normal(self) -> bool {
|
||||
!self.maximized && !self.fullscreen && !self.minimized
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(super) struct ModeHistory(pub(super) Mode);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum NormalTransition {
|
||||
Stable,
|
||||
WaitForResize,
|
||||
Restore,
|
||||
}
|
||||
|
||||
impl ModeHistory {
|
||||
pub(super) fn observe(&mut self, current: Mode, can_restore: bool) -> NormalTransition {
|
||||
let returned = !self.0.normal() && current.normal();
|
||||
if returned && !can_restore {
|
||||
return NormalTransition::WaitForResize;
|
||||
}
|
||||
self.0 = current;
|
||||
if returned { NormalTransition::Restore } else { NormalTransition::Stable }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(super) struct Limits {
|
||||
pub(super) min: (f64, f64),
|
||||
pub(super) max: (Option<f64>, Option<f64>),
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
pub(super) fn in_work_area(self, area: Option<Size>, scale: f64) -> Self {
|
||||
let Some(Size(width, height)) = area.filter(|s| s.0 > 0 && s.1 > 0) else { return self };
|
||||
Self {
|
||||
max: (
|
||||
Some(self.max.0.unwrap_or(f64::INFINITY).min((f64::from(width) / scale).floor())),
|
||||
Some(self.max.1.unwrap_or(f64::INFINITY).min((f64::from(height) / scale).floor())),
|
||||
),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn restore_size(saved: Size, scale: f64, limits: Limits) -> Size {
|
||||
let dimension = |pixels: u32, min: f64, max: Option<f64>| {
|
||||
let logical = (f64::from(pixels) / scale).max(min).max(1.0);
|
||||
// gtk_window_resize takes integer logical dimensions.
|
||||
(logical.min(max.unwrap_or(f64::INFINITY).max(min)).round() * scale).round() as u32
|
||||
};
|
||||
Size(dimension(saved.0, limits.min.0, limits.max.0), dimension(saved.1, limits.min.1, limits.max.1))
|
||||
}
|
||||
|
||||
pub(super) fn remember_size(previous: Size, current: Size, restoring: bool, mode: Mode) -> Size {
|
||||
if restoring || !mode.normal() || current.0 == 0 || current.1 == 0 { previous } else { current }
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum ModeAction {
|
||||
Request,
|
||||
Wait,
|
||||
Track,
|
||||
}
|
||||
|
||||
pub(super) fn mode_action(reached: bool, requested: bool, timed_out: bool) -> ModeAction {
|
||||
if reached {
|
||||
ModeAction::Track
|
||||
} else if !requested {
|
||||
ModeAction::Request
|
||||
} else if timed_out {
|
||||
ModeAction::Track
|
||||
} else {
|
||||
ModeAction::Wait
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub(super) struct Geometry {
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
pub(super) x: i32,
|
||||
pub(super) y: i32,
|
||||
pub(super) prev_x: i32,
|
||||
pub(super) prev_y: i32,
|
||||
pub(super) maximized: bool,
|
||||
pub(super) fullscreen: bool,
|
||||
pub(super) visible: bool,
|
||||
pub(super) decorated: bool,
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
pub(super) fn set_normal_size(&mut self, size: Size) {
|
||||
(self.width, self.height) = (size.0, size.1);
|
||||
self.maximized = false;
|
||||
self.fullscreen = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Geometry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
prev_x: 0,
|
||||
prev_y: 0,
|
||||
maximized: false,
|
||||
fullscreen: false,
|
||||
visible: true,
|
||||
decorated: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn constrained_size_does_not_skip_the_first_mode_request() {
|
||||
assert_eq!(mode_action(false, false, true), ModeAction::Request);
|
||||
assert_eq!(mode_action(false, false, false), ModeAction::Request);
|
||||
assert_eq!(mode_action(false, true, false), ModeAction::Wait);
|
||||
assert_eq!(mode_action(false, true, true), ModeAction::Track);
|
||||
assert_eq!(mode_action(true, false, false), ModeAction::Track);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_sizes_use_the_actual_output_scale() {
|
||||
for scale in [1.0, 2.0] {
|
||||
assert_eq!(restore_size(Size(2468, 1356), scale, Limits::default()), Size(2468, 1356));
|
||||
}
|
||||
// Saved at scale 1, mapped on scale 2: keep physical pixels, not twice that size.
|
||||
assert_eq!(restore_size(Size(1234, 678), 2.0, Limits::default()), Size(1234, 678));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gtk_limits_and_integer_logical_dimensions_are_applied_once() {
|
||||
let limits = Limits { min: (800.0, 500.0), max: (Some(1600.0), Some(1000.0)) };
|
||||
assert_eq!(restore_size(Size(1201, 801), 1.0, limits), Size(1201, 801));
|
||||
assert_eq!(restore_size(Size(1201, 801), 2.0, limits), Size(1600, 1000));
|
||||
assert_eq!(restore_size(Size(2001, 1401), 2.0, limits), Size(2002, 1402));
|
||||
assert_eq!(restore_size(Size(9000, 6000), 2.0, limits), Size(3200, 2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_sizes_are_bounded_in_the_actual_outputs_units() {
|
||||
let limits = Limits { min: (800.0, 500.0), ..Default::default() };
|
||||
for scale in [1.0, 2.0] {
|
||||
let bounded = limits.in_work_area(Some(Size(3840, 2160)), scale);
|
||||
assert_eq!(restore_size(Size(45068, 30000), scale, bounded), Size(3840, 2160));
|
||||
assert_eq!(restore_size(Size(2468, 1356), scale, bounded), Size(2468, 1356));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoration_and_non_normal_events_preserve_the_normal_size() {
|
||||
let normal = Size(1234, 678);
|
||||
for current in [Size(2468, 1356), Size(2648, 1602), Size(5476, 3204)] {
|
||||
assert_eq!(remember_size(normal, current, true, Mode::default()), normal);
|
||||
for mode in [
|
||||
Mode { maximized: true, ..Default::default() },
|
||||
Mode { fullscreen: true, ..Default::default() },
|
||||
Mode { minimized: true, ..Default::default() },
|
||||
] {
|
||||
assert_eq!(remember_size(normal, current, false, mode), normal);
|
||||
}
|
||||
}
|
||||
assert_eq!(remember_size(normal, Size(1201, 801), false, Mode::default()), Size(1201, 801));
|
||||
assert_eq!(remember_size(normal, Size(0, 678), false, Mode::default()), normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mixed_dpi_maximized_launch_does_not_compound() {
|
||||
let mut saved = Size(2468, 1356);
|
||||
for _ in 0..3 {
|
||||
saved = remember_size(saved, Size(saved.0 * 2, saved.1 * 2), true, Mode::default());
|
||||
let restored = restore_size(saved, 2.0, Limits::default());
|
||||
saved = remember_size(saved, restored, false, Mode::default());
|
||||
saved = remember_size(saved, Size(3840, 2160), false, Mode { maximized: true, ..Default::default() });
|
||||
assert_eq!(saved, Size(2468, 1356));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_does_not_consume_the_unmaximize_resize() {
|
||||
for mode in [
|
||||
Mode { maximized: true, ..Default::default() },
|
||||
Mode { fullscreen: true, ..Default::default() },
|
||||
Mode { minimized: true, ..Default::default() },
|
||||
] {
|
||||
let mut history = ModeHistory(mode);
|
||||
assert_eq!(history.observe(Mode::default(), false), NormalTransition::WaitForResize);
|
||||
assert_eq!(history.0, mode);
|
||||
assert_eq!(history.observe(Mode::default(), true), NormalTransition::Restore);
|
||||
assert_eq!(history.observe(Mode::default(), true), NormalTransition::Stable);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_normal_frame_overrides_old_window_modes() {
|
||||
let mut saved = Geometry { maximized: true, fullscreen: true, x: 123, y: 456, ..Default::default() };
|
||||
saved.set_normal_size(Size(1100, 700));
|
||||
assert_eq!((saved.width, saved.height), (1100, 700));
|
||||
assert!(!saved.maximized && !saved.fullscreen);
|
||||
assert_eq!((saved.x, saved.y), (123, 456));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_files_remain_compatible_and_keep_closed_windows() {
|
||||
let saved: std::collections::HashMap<String, Geometry> = serde_json::from_str(
|
||||
r#"{"main":{"width":1234,"height":678,"maximized":true},"settings":{"width":1001,"height":701}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let copy: std::collections::HashMap<String, Geometry> =
|
||||
serde_json::from_value(serde_json::to_value(&saved).unwrap()).unwrap();
|
||||
assert_eq!(copy["settings"].height, 701);
|
||||
assert!(copy["main"].maximized);
|
||||
assert_eq!(copy["main"].width, 1234);
|
||||
}
|
||||
}
|
||||
+23
-36
@@ -5,26 +5,19 @@ use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2_app_kit::{NSWindow, NSWindowButton, NSWindowStyleMask};
|
||||
use objc2_core_foundation::{
|
||||
CFIndex, CFOptionFlags, CFRetained, CFRunLoop, CFRunLoopActivity, CFRunLoopObserver, kCFRunLoopCommonModes,
|
||||
};
|
||||
use objc2_foundation::NSPoint;
|
||||
|
||||
/// AppKit lays out the title bar in a run loop observer and puts the buttons back at their
|
||||
/// defaults on resize, title changes and fullscreen exit. Core Animation commits at 2000000, so
|
||||
/// correcting the frames just before it keeps AppKit's positions off screen.
|
||||
const BEFORE_COMMIT: CFIndex = 1_999_999;
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::ProtocolObject;
|
||||
use objc2_app_kit::{NSWindow, NSWindowButton, NSWindowDidUpdateNotification, NSWindowStyleMask};
|
||||
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol, NSPoint};
|
||||
|
||||
struct Titlebar {
|
||||
height: Rc<Cell<f64>>,
|
||||
spacing: f64,
|
||||
observer: CFRetained<CFRunLoopObserver>,
|
||||
observer: Retained<ProtocolObject<dyn NSObjectProtocol>>,
|
||||
}
|
||||
|
||||
impl Drop for Titlebar {
|
||||
fn drop(&mut self) {
|
||||
self.observer.invalidate();
|
||||
unsafe { NSNotificationCenter::defaultCenter().removeObserver((*self.observer).as_ref()) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,30 +30,22 @@ pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<(
|
||||
// with_webview runs on the AppKit thread; the observer is also delivered there.
|
||||
window.with_webview(move |webview| unsafe {
|
||||
let native = &*webview.ns_window().cast::<NSWindow>();
|
||||
let Some(close) = native.standardWindowButton(NSWindowButton::CloseButton) else { return };
|
||||
let Some(minimize) = native.standardWindowButton(NSWindowButton::MiniaturizeButton) else { return };
|
||||
let Some(run_loop) = CFRunLoop::main() else { return };
|
||||
let spacing = minimize.frame().origin.x - close.frame().origin.x;
|
||||
TITLEBARS.with_borrow_mut(|bars| {
|
||||
if let Some(bar) = bars.get(&label) {
|
||||
bar.height.set(height);
|
||||
center_buttons(native, height, bar.spacing);
|
||||
return;
|
||||
}
|
||||
let shared = Rc::new(Cell::new(height));
|
||||
let current = shared.clone();
|
||||
let pointer = native as *const NSWindow;
|
||||
let block = RcBlock::new(move |_: *mut CFRunLoopObserver, _: CFRunLoopActivity| {
|
||||
center_buttons(&*pointer, current.get(), spacing)
|
||||
let bar = bars.entry(label).or_insert_with(|| {
|
||||
let height = Rc::new(Cell::new(height));
|
||||
let current = height.clone();
|
||||
let pointer = native as *const NSWindow;
|
||||
let block = RcBlock::new(move |_| center_buttons(&*pointer, current.get()));
|
||||
let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
|
||||
Some(NSWindowDidUpdateNotification),
|
||||
Some(native),
|
||||
None,
|
||||
&block,
|
||||
);
|
||||
Titlebar { height, observer }
|
||||
});
|
||||
let activities = CFRunLoopActivity::BeforeWaiting.0 as CFOptionFlags;
|
||||
let Some(observer) = CFRunLoopObserver::with_handler(None, activities, true, BEFORE_COMMIT, Some(&block))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
run_loop.add_observer(Some(&observer), kCFRunLoopCommonModes);
|
||||
center_buttons(native, height, spacing);
|
||||
bars.insert(label, Titlebar { height: shared, spacing, observer });
|
||||
bar.height.set(height);
|
||||
center_buttons(native, height);
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -72,12 +57,14 @@ pub fn forget(window: &tauri::Window) {
|
||||
});
|
||||
}
|
||||
|
||||
unsafe fn center_buttons(window: &NSWindow, height: f64, spacing: f64) {
|
||||
unsafe fn center_buttons(window: &NSWindow, height: f64) {
|
||||
// Fullscreen uses AppKit's own revealable title bar.
|
||||
if window.styleMask().contains(NSWindowStyleMask::FullScreen) {
|
||||
return;
|
||||
}
|
||||
let Some(close) = window.standardWindowButton(NSWindowButton::CloseButton) else { return };
|
||||
let Some(minimize) = window.standardWindowButton(NSWindowButton::MiniaturizeButton) else { return };
|
||||
let spacing = minimize.frame().origin.x - close.frame().origin.x;
|
||||
let Some(container) = (unsafe { close.superview().and_then(|view| view.superview()) }) else { return };
|
||||
let Some(content) = window.contentView() else { return };
|
||||
let mut frame = container.frame();
|
||||
|
||||
@@ -60,10 +60,7 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
|
||||
if !maximized {
|
||||
to.unmaximize().map_err(err)?;
|
||||
to.set_position(from.outer_position().map_err(err)?).map_err(err)?;
|
||||
let size = from.inner_size().map_err(err)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size);
|
||||
to.set_size(size).map_err(err)?;
|
||||
to.set_size(from.inner_size().map_err(err)?).map_err(err)?;
|
||||
} else {
|
||||
to.maximize().map_err(err)?;
|
||||
}
|
||||
|
||||
@@ -284,11 +284,6 @@ fn hex_color(hex: &str) -> Option<Color> {
|
||||
/// Builds a project window painted in the saved theme's frame colour, so it
|
||||
/// doesn't flash the light default before its page (and splash) loads.
|
||||
fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<WebviewWindow> {
|
||||
let visible = cfg!(target_os = "macos") && config.visible;
|
||||
let focused = config.focus;
|
||||
if visible {
|
||||
config.visible = false;
|
||||
}
|
||||
let state = app.try_state::<AppState>();
|
||||
let appearance = state.as_ref().map(|s| s.global_settings.effective().appearance);
|
||||
// A plugin theme replaces the built-in one in either OS mode; the UI records its resolved frame.
|
||||
@@ -298,8 +293,6 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
|
||||
} else if let Some(a) = &appearance {
|
||||
config.background_color = Some(frame_color(a, false));
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
let show_after_build = crate::linux_window_state::prepare(app, &mut config);
|
||||
let window = WebviewWindowBuilder::from_config(app, &config)
|
||||
.and_then(|b| dev_font_preferences(b).build())
|
||||
.map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?;
|
||||
@@ -312,16 +305,6 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
|
||||
{
|
||||
let _ = window.set_background_color(Some(frame_color(&a, true)));
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if show_after_build {
|
||||
window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?;
|
||||
}
|
||||
if visible {
|
||||
window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?;
|
||||
if focused {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
@@ -771,8 +754,6 @@ pub fn open_settings_window(
|
||||
config.background_color = Some(color);
|
||||
}
|
||||
windows.set_launch(&label, Launch { settings_page: page, new_projects, ..Default::default() });
|
||||
#[cfg(target_os = "linux")]
|
||||
crate::linux_window_state::prepare(app, &mut config);
|
||||
let builder = WebviewWindowBuilder::from_config(app, &config);
|
||||
// AppKit child windows can disappear when dragged to another display's Space.
|
||||
// The settings label already tracks ownership and parent-close cleanup.
|
||||
@@ -840,7 +821,7 @@ fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri:
|
||||
/// may still be on another display with a different scale, so its size is converted with its own
|
||||
/// scale and its position is given in physical pixels.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> {
|
||||
fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> {
|
||||
let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenIDEA",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.32",
|
||||
"identifier": "ai.l0ng.openidea",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+2
-25
@@ -2975,11 +2975,9 @@ export type EnvReloaded = {
|
||||
shellRead: boolean,
|
||||
};
|
||||
|
||||
export type ErrorData = { type: "tool"; tool: string; missing: boolean };
|
||||
|
||||
export type ErrorKind = "notFound" | "alreadyExists" | "permissionDenied" | "invalidInput" |
|
||||
/** Document version mismatch; the UI must resync the full text. */
|
||||
"outOfSync" | "cancelled" | "timedOut" |
|
||||
"outOfSync" | "cancelled" |
|
||||
/** A required external tool (language server, git, debugger) is unavailable. */
|
||||
"unavailable" | "io" | "protocol" | "internal";
|
||||
|
||||
@@ -3209,7 +3207,6 @@ export type FormField = {
|
||||
export type FormatOptions = {
|
||||
tabSize: number,
|
||||
insertSpaces: boolean,
|
||||
onSave?: boolean,
|
||||
};
|
||||
|
||||
/** One occurrence of a duplicated code fragment. */
|
||||
@@ -3546,8 +3543,6 @@ export type HunkRange = {
|
||||
export type IdeError = {
|
||||
kind: ErrorKind,
|
||||
message: string,
|
||||
/** Structured detail for errors the UI acts on; `null` for plain errors. */
|
||||
data?: ErrorData | null,
|
||||
};
|
||||
|
||||
export type IdeEvent =
|
||||
@@ -3903,10 +3898,6 @@ export type LanguageCodeStyle = LanguageCodeStyle_Serialize | LanguageCodeStyle_
|
||||
|
||||
/** Editor › Code Style › <Language> › Tabs and Indents (unset fields inherit). */
|
||||
export type LanguageCodeStyle_Deserialize = {
|
||||
/** Java formatter: `default` | `intellij` | `custom` (other languages ignore it). */
|
||||
formatter?: string | null,
|
||||
/** Eclipse formatter XML; relative paths start at the project root. */
|
||||
formatterPath?: string | null,
|
||||
useTab?: boolean | null,
|
||||
tabSize?: number | null,
|
||||
indent?: number | null,
|
||||
@@ -3919,10 +3910,6 @@ export type LanguageCodeStyle_Deserialize = {
|
||||
|
||||
/** Editor › Code Style › <Language> › Tabs and Indents (unset fields inherit). */
|
||||
export type LanguageCodeStyle_Serialize = {
|
||||
/** Java formatter: `default` | `intellij` | `custom` (other languages ignore it). */
|
||||
formatter?: string | null,
|
||||
/** Eclipse formatter XML; relative paths start at the project root. */
|
||||
formatterPath?: string | null,
|
||||
useTab?: boolean | null,
|
||||
tabSize?: number | null,
|
||||
indent?: number | null,
|
||||
@@ -4041,7 +4028,6 @@ export type LanguagesSettings = {
|
||||
rust?: RustSettings,
|
||||
go?: GoSettings,
|
||||
typescript?: TypeScriptSettings,
|
||||
prettier?: PrettierSettings,
|
||||
java?: JavaSettings,
|
||||
};
|
||||
|
||||
@@ -4785,15 +4771,6 @@ export type PreparedRequest = {
|
||||
body: string | null,
|
||||
};
|
||||
|
||||
export type PrettierSettings = {
|
||||
/** Empty path: project node_modules, managed installation, then PATH. */
|
||||
path?: string,
|
||||
languages?: string[],
|
||||
/** Prefer Prettier even when a language server offers formatting. */
|
||||
prefer?: boolean,
|
||||
runOnSave?: boolean,
|
||||
};
|
||||
|
||||
/** Memory of one row of the breakdown. */
|
||||
export type ProcessMemory = {
|
||||
category: MemoryCategory,
|
||||
@@ -6209,7 +6186,7 @@ export type ToolInfo = {
|
||||
|
||||
export type ToolKind = "languageServer" | "debugger" |
|
||||
/** Loaded into another tool's process, e.g. a jdtls agent. */
|
||||
"plugin" | "formatter";
|
||||
"plugin";
|
||||
|
||||
export type ToolSettings = {
|
||||
/** Executable to launch instead of the managed / `PATH` one; empty = auto-detect. */
|
||||
|
||||
@@ -5,7 +5,6 @@ import { executeAction, registerActions, type Action } from "@/platform/actions"
|
||||
import { activeEditor, editorsFor, registerSaveParticipant, type TextEditorHandle } from "@/editor/api";
|
||||
import { maybeEditorService } from "@/workbench/contributions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { validateLanguages } from "@/platform/languageSettings";
|
||||
import { explicitCompletion } from "./completion";
|
||||
import { triggerSignatureHelp } from "./signature";
|
||||
import { quickDefinition, quickDocumentation } from "./hover";
|
||||
@@ -123,15 +122,8 @@ export function registerFormatOnSave(): () => void {
|
||||
id: "lsp.formatOnSave",
|
||||
order: 50,
|
||||
beforeSave: async (ctx, handle) => {
|
||||
const settings = useProject.getState().settings;
|
||||
const prettier = validateLanguages(settings?.languages).prettier;
|
||||
if (
|
||||
!handle ||
|
||||
ctx.readOnly ||
|
||||
(!settings?.editor?.formatOnSave && !(prettier.runOnSave && prettier.languages.includes(ctx.language)))
|
||||
)
|
||||
return;
|
||||
await formatDocument(handle.view, { explicit: false, selectionOnly: false, onSave: true });
|
||||
if (!handle || ctx.readOnly || !useProject.getState().settings?.editor?.formatOnSave) return;
|
||||
await formatDocument(handle.view, { explicit: false, selectionOnly: false });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.diagnostics {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EditorState, Text } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { CONTRIBUTED_FORMAT } from "@/editor/api";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { Settings } from "@/bindings";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Text } from "@codemirror/state";
|
||||
import {
|
||||
formatExcluded,
|
||||
guardFormatChanges,
|
||||
@@ -11,7 +7,7 @@ import {
|
||||
protectedRegions,
|
||||
type FormatGuardOptions,
|
||||
} from "@/platform/formatGuard";
|
||||
import { dedentsToColumnZero, formatDocument, formattedLines } from "./format";
|
||||
import { dedentsToColumnZero, formattedLines } from "./format";
|
||||
|
||||
describe("formattedLines", () => {
|
||||
const doc = Text.of(["fn main(){", "let x=1;", "}", "", "fn other() {}"]);
|
||||
@@ -109,28 +105,3 @@ describe("dedentsToColumnZero", () => {
|
||||
expect(dedentsToColumnZero("fun f() {}\n", "fun f() { }\n")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("format on save provider isolation", () => {
|
||||
it("does not enable contributed formatters when only Prettier save is enabled", async () => {
|
||||
const previous = useProject.getState();
|
||||
const formatter = vi.fn(() => [{ from: 0, to: 6, insert: "SELECT" }]);
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "select",
|
||||
extensions: [EditorState.languageData.of(() => [{ [CONTRIBUTED_FORMAT]: formatter }])],
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const settings = { editor: { formatOnSave: false }, languages: { prettier: { runOnSave: true } } } as Settings;
|
||||
useProject.setState({ settings });
|
||||
expect((await formatDocument(view, { explicit: false, onSave: true })).applied).toBe(false);
|
||||
expect(formatter).not.toHaveBeenCalled();
|
||||
useProject.setState({ settings: { ...settings, editor: { ...settings.editor, formatOnSave: true } } });
|
||||
expect((await formatDocument(view, { explicit: false, onSave: true })).applied).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe("SELECT");
|
||||
} finally {
|
||||
view.destroy();
|
||||
useProject.setState(previous);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,14 +5,11 @@
|
||||
* reports "Formatted N lines" with a link to the Reformat File dialog
|
||||
* (⌥⇧⌘L: scope and Optimize Imports).
|
||||
*/
|
||||
import { createElement } from "react";
|
||||
import styles from "./format.module.css";
|
||||
import { indentUnit } from "@codemirror/language";
|
||||
import { isolateHistory } from "@codemirror/commands";
|
||||
import type { EditorState, Text } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { commands, IpcError } from "@/platform/ipc";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { commands } from "@/platform/ipc";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import {
|
||||
@@ -81,17 +78,14 @@ export function formatOptionsOf(state: EditorState): { tabSize: number; insertSp
|
||||
|
||||
export async function formatDocument(
|
||||
view: EditorView,
|
||||
opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean } = { explicit: true },
|
||||
opts: { explicit: boolean; selectionOnly?: boolean } = { explicit: true },
|
||||
): Promise<FormatResult> {
|
||||
const result = await format(view, opts);
|
||||
if (opts.explicit && result.applied) formattedHint(view, result.lines);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function format(
|
||||
view: EditorView,
|
||||
opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean },
|
||||
): Promise<FormatResult> {
|
||||
async function format(view: EditorView, opts: { explicit: boolean; selectionOnly?: boolean }): Promise<FormatResult> {
|
||||
const { project, settings } = useProject.getState();
|
||||
const guard = formatGuardOptions(settings);
|
||||
const path = docOf(view)?.path;
|
||||
@@ -101,10 +95,7 @@ async function format(
|
||||
return NOTHING;
|
||||
}
|
||||
const own = contributedAt<ContributedFormatter>(view.state, CONTRIBUTED_FORMAT, 0);
|
||||
if (own) {
|
||||
if (opts.onSave && !settings?.editor?.formatOnSave) return NOTHING;
|
||||
return applyContributedFormat(view, own, opts, guard);
|
||||
}
|
||||
if (own) return applyContributedFormat(view, own, opts, guard);
|
||||
const doc = docOf(view);
|
||||
if (!doc || doc.readOnly) return NOTHING;
|
||||
// The server formats the backend's copy: let pending keystrokes reach it first.
|
||||
@@ -116,25 +107,9 @@ async function format(
|
||||
const options = formatOptionsOf(state);
|
||||
let edits;
|
||||
try {
|
||||
edits = await lspRequest(() => commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave }));
|
||||
edits = await lspRequest(() => commands.lspFormat(doc.doc, range, options));
|
||||
} catch (e) {
|
||||
if (e instanceof IpcError && e.data?.type === "tool" && e.data.tool === "prettier") {
|
||||
if (e.kind === "notFound" && e.data.missing) {
|
||||
await executeAction("tools.promptInstall", { name: "prettier", sticky: !opts.onSave });
|
||||
} else if (e.kind !== "cancelled") {
|
||||
notifications().notify({
|
||||
group: "tools",
|
||||
level: "error",
|
||||
title: tx("Prettier formatting failed"),
|
||||
body:
|
||||
e.kind === "timedOut"
|
||||
? tx("Prettier formatting timed out")
|
||||
: createElement("span", { className: styles.diagnostics }, e.message),
|
||||
key: `prettier-format-error:${doc.path}`,
|
||||
sticky: false,
|
||||
});
|
||||
}
|
||||
} else reportLspError(view, e, opts.explicit);
|
||||
reportLspError(view, e, opts.explicit);
|
||||
return NOTHING;
|
||||
}
|
||||
if (view.state.doc !== state.doc) {
|
||||
@@ -204,7 +179,7 @@ export async function formatLines(view: EditorView, from: number, to: number): P
|
||||
function applyContributedFormat(
|
||||
view: EditorView,
|
||||
format: ContributedFormatter,
|
||||
opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean },
|
||||
opts: { explicit: boolean; selectionOnly?: boolean },
|
||||
guard: FormatGuardOptions,
|
||||
): FormatResult {
|
||||
const state = view.state;
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { IdeError } from "@/bindings";
|
||||
import { registerAction } from "@/platform/actions";
|
||||
import { _resetI18nForTests, setLocale } from "@/platform/i18n";
|
||||
import { IpcError } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { provideNotificationService } from "@/workbench/contributions";
|
||||
import { Balloons } from "@/workbench/Notifications";
|
||||
import {
|
||||
balloonTimeout,
|
||||
clearNotifications,
|
||||
notificationServiceImpl,
|
||||
useNotifications,
|
||||
} from "@/workbench/notificationStore";
|
||||
import { formatDocument } from "./format";
|
||||
import * as lsp from "./lsp";
|
||||
|
||||
const tool = { type: "tool", tool: "prettier", missing: false } as const;
|
||||
const views: EditorView[] = [];
|
||||
const viewFor = (path = "/project/file.css") => {
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: ".card {",
|
||||
extensions: [lsp.lspDoc.of({ doc: 1, path, language: "css", uiMode: null, readOnly: false })],
|
||||
}),
|
||||
});
|
||||
views.push(view);
|
||||
return view;
|
||||
};
|
||||
const failWith = (error: IdeError) => vi.spyOn(lsp, "lspRequest").mockRejectedValue(new IpcError(error));
|
||||
|
||||
beforeEach(() => {
|
||||
clearNotifications();
|
||||
provideNotificationService(notificationServiceImpl);
|
||||
});
|
||||
afterEach(() => {
|
||||
views.splice(0).forEach((view) => view.destroy());
|
||||
clearNotifications();
|
||||
vi.restoreAllMocks();
|
||||
_resetI18nForTests();
|
||||
});
|
||||
|
||||
describe("external formatter errors", () => {
|
||||
it("uses structured missing-tool information to request the shared install prompt", async () => {
|
||||
const prompt = vi.fn();
|
||||
const dispose = registerAction({
|
||||
id: "tools.promptInstall",
|
||||
title: "Install Missing Tool",
|
||||
run: ({ arg }) => prompt(arg),
|
||||
});
|
||||
try {
|
||||
failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } });
|
||||
await formatDocument(viewFor());
|
||||
expect(prompt).toHaveBeenCalledWith({ name: "prettier", sticky: true });
|
||||
await formatDocument(viewFor(), { explicit: false, onSave: true });
|
||||
expect(prompt).toHaveBeenLastCalledWith({ name: "prettier", sticky: false });
|
||||
expect(useNotifications.getState().items).toHaveLength(0);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat text alone or a broken custom executable as a missing installation", async () => {
|
||||
const prompt = vi.fn();
|
||||
const dispose = registerAction({
|
||||
id: "tools.promptInstall",
|
||||
title: "Install Missing Tool",
|
||||
run: ({ arg }) => prompt(arg),
|
||||
});
|
||||
try {
|
||||
const request = failWith({ kind: "unavailable", message: "Prettier is not installed" });
|
||||
const view = viewFor();
|
||||
await formatDocument(view, { explicit: false });
|
||||
expect(useNotifications.getState().items).toHaveLength(0);
|
||||
request.mockRejectedValue(new IpcError({ kind: "notFound", message: "custom executable missing", data: tool }));
|
||||
await formatDocument(view);
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
expect(useNotifications.getState().items).toHaveLength(1);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps stderr untranslated in a Chinese notification and replaces failures per file, including on save", async () => {
|
||||
await setLocale("zh");
|
||||
const previous = useProject.getState().settings;
|
||||
useProject.setState({ settings: null });
|
||||
try {
|
||||
const stderr = "Formatting timed out";
|
||||
failWith({ kind: "invalidInput", message: stderr, data: tool });
|
||||
const view = viewFor();
|
||||
for (let i = 0; i < 3; i++) await formatDocument(view, { explicit: false, onSave: true });
|
||||
const notes = useNotifications.getState().items;
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toMatchObject({ sticky: false, title: "Prettier 格式化失败" });
|
||||
expect(balloonTimeout(notes[0]!)).toBe(10000);
|
||||
render(<Balloons />);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(stderr);
|
||||
expect(screen.queryByText("格式化超时")).toBeNull();
|
||||
expect(view.state.doc.toString()).toBe(".card {");
|
||||
await act(async () => {
|
||||
await formatDocument(viewFor("/project/other.css"));
|
||||
});
|
||||
expect(useNotifications.getState().items).toHaveLength(2);
|
||||
} finally {
|
||||
useProject.setState({ settings: previous });
|
||||
}
|
||||
});
|
||||
|
||||
it("localizes the timeout explanation by kind and ignores cancellation", async () => {
|
||||
await setLocale("zh");
|
||||
const request = failWith({ kind: "timedOut", message: "arbitrary backend text", data: tool });
|
||||
const view = viewFor();
|
||||
await formatDocument(view);
|
||||
expect(useNotifications.getState().items[0]).toMatchObject({ body: "Prettier 格式化超时", sticky: false });
|
||||
clearNotifications();
|
||||
request.mockRejectedValue(new IpcError({ kind: "cancelled", message: "cancelled", data: tool }));
|
||||
await formatDocument(view);
|
||||
expect(useNotifications.getState().items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Go to declaration / implementation / type declaration (⌘B, ⌥⌘B, ⇧⌘B,
|
||||
* ⌘-click with a link underline while ⌘ is held, and a plain middle-button
|
||||
* click), the multiple-targets chooser, and document highlights (occurrences
|
||||
* of the symbol at the caret, plus the sticky find.highlightUsages variant).
|
||||
* Go to declaration / implementation / type declaration (⌘B, ⌥⌘B, ⇧⌘B and
|
||||
* ⌘-click with a link underline while ⌘ is held), the multiple-targets
|
||||
* chooser, and document highlights (occurrences of the symbol at the caret,
|
||||
* plus the sticky find.highlightUsages variant).
|
||||
*/
|
||||
import { EditorSelection, Prec, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
|
||||
import {
|
||||
@@ -489,7 +489,7 @@ const linkPlugin = ViewPlugin.fromClass(
|
||||
this.show(false);
|
||||
},
|
||||
mousedown(e, view) {
|
||||
if (e.button !== 0 && e.button !== 1) return false;
|
||||
if (e.button !== 0) return false;
|
||||
if (
|
||||
dispatchMouseShortcut(e, () => {
|
||||
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
|
||||
@@ -502,8 +502,7 @@ const linkPlugin = ViewPlugin.fromClass(
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
// IDEA parity: middle button navigates on a plain click, without the modifier.
|
||||
if (e.button === 1 ? e.metaKey || e.ctrlKey || e.altKey || e.shiftKey : !isNavigationGesture(e)) return false;
|
||||
if (!isNavigationGesture(e)) return false;
|
||||
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
|
||||
if (pos == null) return false;
|
||||
e.preventDefault();
|
||||
|
||||
@@ -94,14 +94,6 @@ describe("mouse navigation", () => {
|
||||
expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "definition");
|
||||
});
|
||||
|
||||
it("opens the definition from a plain middle-button click", async () => {
|
||||
const event = click({ button: 1 });
|
||||
await vi.waitFor(() => expect(openFile).toHaveBeenCalledOnce());
|
||||
expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "definition");
|
||||
expect(view.state.selection.main.head).toBe(9);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ button: 1, metaKey: true, altKey: true },
|
||||
{ button: 2, metaKey: true, altKey: true },
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
@@ -136,7 +137,7 @@
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* A docked conversation owns the tool window; history opens on demand. */
|
||||
/* A conversation docked in the tool window, above the task list. */
|
||||
.docked {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
@@ -167,31 +168,30 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
.tasks {
|
||||
flex: 1;
|
||||
flex: 2 1 0;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(400px, calc(100vw - 24px));
|
||||
height: min(420px, 65vh);
|
||||
overflow: hidden;
|
||||
}
|
||||
.historyTitle {
|
||||
flex: none;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 1px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
/* Keep the conversation usable in a short bottom tool window; the tree scrolls. */
|
||||
.docked + .tasks {
|
||||
flex: 0 0 20%;
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
/* A wide bottom window has room beside the conversation, preserving message height. */
|
||||
@container (min-width: 600px) {
|
||||
.content {
|
||||
flex-direction: row;
|
||||
}
|
||||
.docked + .tasks {
|
||||
flex: 0 0 220px;
|
||||
max-width: 30%;
|
||||
max-height: none;
|
||||
border-top: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import { registerActions } from "@/platform/actions";
|
||||
import { EDITOR_TAB_MIME, type EditorInput } from "@/workbench/contributions";
|
||||
import { confirmChecked, showError } from "@/workbench/messages";
|
||||
import { ContextMenuHost, dragSession, hideContextMenu } from "@/ui";
|
||||
import { AgentsHeaderActions, AgentsLocationActions, AgentsView } from "./AgentsView";
|
||||
import { AgentsView } from "./AgentsView";
|
||||
import { SESSION_KIND } from "./model";
|
||||
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, setSessionLocation, useAgents } from "./store";
|
||||
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, undockTask, useAgents } from "./store";
|
||||
import { taskActions, taskMenu } from "./taskActions";
|
||||
import styles from "./Agents.module.css";
|
||||
|
||||
@@ -39,7 +39,7 @@ vi.mock("./store", async (original) => ({
|
||||
unarchiveTask: vi.fn(async () => {}),
|
||||
deleteTask: vi.fn(async () => {}),
|
||||
dockTask: vi.fn(),
|
||||
setSessionLocation: vi.fn(),
|
||||
undockTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./SessionEditor", () => ({ SessionEditor: () => <div data-testid="docked-session" /> }));
|
||||
@@ -103,8 +103,6 @@ beforeEach(() => {
|
||||
error: null,
|
||||
tasks: new Map([task("one"), task("two"), task("three"), task("four")].map((t) => [t.id, t])),
|
||||
docked: null,
|
||||
homeOpen: false,
|
||||
sessionLocation: "editor",
|
||||
});
|
||||
dispose = registerActions(taskActions);
|
||||
});
|
||||
@@ -117,8 +115,6 @@ afterEach(() => {
|
||||
function setup() {
|
||||
render(
|
||||
<>
|
||||
<AgentsLocationActions />
|
||||
<AgentsHeaderActions />
|
||||
<AgentsView />
|
||||
<ContextMenuHost />
|
||||
</>,
|
||||
@@ -162,44 +158,24 @@ describe("Agents selection", () => {
|
||||
});
|
||||
|
||||
describe("Agents docked view", () => {
|
||||
it("opens session history on demand and closes it after switching tasks", () => {
|
||||
it("shows the docked conversation above the list and switches it by selecting a task", () => {
|
||||
useAgents.setState({ docked: "one" });
|
||||
setup();
|
||||
expect(screen.getByTestId("docked-session")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Chat in Editor" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Open in Editor" })).toBeTruthy();
|
||||
expect(openTask).not.toHaveBeenCalled();
|
||||
select("two");
|
||||
expect(actions.executeAction).toHaveBeenCalledWith("agents.dockTask", "two");
|
||||
expect(dockTask).toHaveBeenCalledWith("two");
|
||||
expect(openTask).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
|
||||
fireEvent.click(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("two"));
|
||||
expect(screen.queryByRole("dialog", { name: "Agent sessions" })).toBeNull();
|
||||
expect(actions.executeAction).toHaveBeenCalledWith("agents.openTask", "two");
|
||||
expect(openTask).toHaveBeenCalledWith("two");
|
||||
});
|
||||
|
||||
it("lets keyboard users browse history before confirming a session", () => {
|
||||
it("hides the docked task from the list below", () => {
|
||||
useAgents.setState({ docked: "one" });
|
||||
setup();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
|
||||
const tree = screen.getByRole("tree", { name: "Agent tasks" });
|
||||
fireEvent.keyDown(tree, { key: "ArrowDown" });
|
||||
expect(screen.getByRole("dialog", { name: "Agent sessions" })).toBeTruthy();
|
||||
expect(openTask).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(tree, { key: "Enter" });
|
||||
expect(openTask).toHaveBeenCalledWith("two");
|
||||
expect(screen.queryByRole("dialog", { name: "Agent sessions" })).toBeNull();
|
||||
});
|
||||
|
||||
it("includes the current session in history and keeps the conversation mounted", () => {
|
||||
useAgents.setState({ docked: "one" });
|
||||
setup();
|
||||
const session = screen.getByTestId("docked-session");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
|
||||
const tree = within(screen.getByRole("tree", { name: "Agent tasks" }));
|
||||
expect(tree.getByText("one")).toBeTruthy();
|
||||
expect(tree.queryByText("one")).toBeNull();
|
||||
expect(tree.getByText("two")).toBeTruthy();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull();
|
||||
expect(screen.getByTestId("docked-session")).toBe(session);
|
||||
});
|
||||
|
||||
it("gives the whole tool window to the conversation when the docked task is the only one", () => {
|
||||
@@ -209,15 +185,11 @@ describe("Agents docked view", () => {
|
||||
expect(screen.getByTestId("docked-session")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches placement from the global Agents header", () => {
|
||||
useAgents.setState({ docked: "one", sessionLocation: "toolWindow" });
|
||||
it("undocks from the docked conversation's bar", () => {
|
||||
useAgents.setState({ docked: "one" });
|
||||
setup();
|
||||
expect(screen.queryByRole("button", { name: "Open in Editor" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Chat in Tool Window" })).toHaveAttribute("aria-pressed", "true");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Chat in Editor" }));
|
||||
expect(setSessionLocation).toHaveBeenCalledWith("editor");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Chat in Tool Window" }));
|
||||
expect(setSessionLocation).toHaveBeenCalledWith("toolWindow");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open in Editor" }));
|
||||
expect(undockTask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens tabs as usual without a docked conversation", () => {
|
||||
@@ -310,12 +282,21 @@ describe("Agents bulk actions", () => {
|
||||
});
|
||||
|
||||
describe("Agents task menu", () => {
|
||||
it.each([null, "one"])("keeps placement out of session menus (docked: %s)", (docked) => {
|
||||
useAgents.setState({ docked });
|
||||
it("offers to dock a task into the tool window", async () => {
|
||||
setup();
|
||||
context("one");
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Open in Tool Window" }));
|
||||
await waitFor(() => expect(dockTask).toHaveBeenCalledWith("one"));
|
||||
});
|
||||
|
||||
it("offers to move the docked task back to an editor", async () => {
|
||||
useAgents.setState({ docked: "one" });
|
||||
setup();
|
||||
// The docked task is not listed; its menu lives on the docked conversation's header.
|
||||
const menu = taskMenu([useAgents.getState().tasks.get("one")!]);
|
||||
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.dockTask" }));
|
||||
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.undockTask" }));
|
||||
expect(menu).toContainEqual(expect.objectContaining({ action: "agents.openTask" }));
|
||||
expect(menu).toContainEqual(expect.objectContaining({ action: "agents.undockTask", label: "Open in Editor" }));
|
||||
await taskActions.find((action) => action.id === "agents.undockTask")!.run({ ctx: {}, arg: "one" });
|
||||
await waitFor(() => expect(undockTask).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+106
-153
@@ -7,7 +7,6 @@ import {
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
Popup,
|
||||
Spinner,
|
||||
T,
|
||||
Tree,
|
||||
@@ -19,29 +18,16 @@ import { SESSION_KIND, TASK_DRAG, age, branchLabel, groupTasks, sessionKey, type
|
||||
import { openHome, useAgents } from "./store";
|
||||
import { taskMenu } from "./taskActions";
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
import { HomeEditor } from "./HomeEditor";
|
||||
import styles from "./Agents.module.css";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
|
||||
export function AgentsHeaderActions() {
|
||||
return <IconButton icon="plus" action="agents.newTask" size="small" />;
|
||||
}
|
||||
|
||||
export function AgentsLocationActions() {
|
||||
const location = useAgents((s) => s.sessionLocation);
|
||||
const tx = useTx();
|
||||
return (
|
||||
<div role="group" aria-label={tx("Conversation location")} className={styles.location}>
|
||||
<IconButton
|
||||
icon="open-in-tool-window"
|
||||
action="agents.useToolWindow"
|
||||
pressed={location === "toolWindow"}
|
||||
size="small"
|
||||
/>
|
||||
<IconButton icon="editor-only" action="agents.useEditor" pressed={location === "editor"} size="small" />
|
||||
</div>
|
||||
<>
|
||||
<IconButton icon="plus" action="agents.newTask" size="small" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,10 +77,6 @@ export function AgentsView() {
|
||||
const unread = useAgents((s) => s.unread);
|
||||
const providers = useAgents((s) => s.providers);
|
||||
const docked = useAgents((s) => s.docked);
|
||||
const homeOpen = useAgents((s) => s.homeOpen);
|
||||
const inline = docked !== null || homeOpen;
|
||||
const historyRef = useRef<HTMLButtonElement>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["g:archived"]));
|
||||
const [selected, setSelected] = useState<Set<string>>(() => {
|
||||
const task = sessionTask(maybeEditorService()?.activeInput?.());
|
||||
@@ -113,7 +95,11 @@ export function AgentsView() {
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const roots = useMemo<Node[]>(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]);
|
||||
// The docked conversation is already shown above; listing it again is noise.
|
||||
const roots = useMemo<Node[]>(
|
||||
() => groupTasks([...tasks.values()].filter((t) => t.id !== docked)).map((group) => ({ kind: "group", group })),
|
||||
[tasks, docked],
|
||||
);
|
||||
const expanded = useMemo(() => new Set(roots.map(nodeKey).filter((k) => !collapsed.has(k))), [roots, collapsed]);
|
||||
// Dropping a session editor tab anywhere on the tool window docks it.
|
||||
const viewRef = useRef<HTMLDivElement>(null);
|
||||
@@ -140,156 +126,123 @@ export function AgentsView() {
|
||||
);
|
||||
if (error) return <EmptyState title="Cannot load agent tasks">{error}</EmptyState>;
|
||||
const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
|
||||
// Opening any session follows the global placement preference.
|
||||
const showTask = (task: string) => {
|
||||
setHistoryOpen(false);
|
||||
void executeAction("agents.openTask", task);
|
||||
};
|
||||
// While a conversation is docked, clicking a task switches it instead of opening a tab.
|
||||
const showTask = (task: string) => void executeAction(docked ? "agents.dockTask" : "agents.openTask", task);
|
||||
const dockedTitle = docked ? (tasks.get(docked)?.title ?? docked) : null;
|
||||
const dragTask = (e: ReactPointerEvent<HTMLSpanElement>, task: string) =>
|
||||
startPointerDrag(e, e.currentTarget, { type: TASK_DRAG, payload: task });
|
||||
|
||||
const taskList = (
|
||||
<div className={styles.tasks}>
|
||||
<Tree<Node>
|
||||
aria-label="Agent tasks"
|
||||
roots={roots}
|
||||
getKey={nodeKey}
|
||||
getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)}
|
||||
getChildren={(n) =>
|
||||
n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined
|
||||
}
|
||||
isExpandable={(n) => n.kind === "group"}
|
||||
expanded={expanded}
|
||||
onExpandedChange={(_next, node, open) =>
|
||||
setCollapsed((c) => {
|
||||
const s = new Set(c);
|
||||
if (open) s.delete(nodeKey(node));
|
||||
else s.add(nodeKey(node));
|
||||
return s;
|
||||
})
|
||||
}
|
||||
selected={selected}
|
||||
onSelectionChange={(sel, lead) => {
|
||||
setSelected(sel);
|
||||
if (!inline && sel.size === 1 && lead?.kind === "task" && sel.has(lead.task.id)) showTask(lead.task.id);
|
||||
}}
|
||||
onActivate={(n) => n.kind === "task" && showTask(n.task.id)}
|
||||
onContextMenu={(n, sel, e) => {
|
||||
if (n?.kind !== "task") return;
|
||||
const selection = [...sel].flatMap((id) => {
|
||||
const task = tasks.get(id);
|
||||
return task ? [task] : [];
|
||||
});
|
||||
showContextMenu(e, taskMenu(selection));
|
||||
}}
|
||||
renderNode={(n) =>
|
||||
n.kind === "group" ? (
|
||||
<span className={styles.row}>
|
||||
<span className={styles.groupLabel}>
|
||||
<T>{n.group.label}</T>
|
||||
</span>
|
||||
<span className={styles.count}>{n.group.tasks.length}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={styles.row}
|
||||
title={n.task.error ?? n.task.title}
|
||||
onPointerDown={(e) => dragTask(e, n.task.id)}
|
||||
onClick={(e) => {
|
||||
if (inline && !e.metaKey && !e.ctrlKey && !e.shiftKey) showTask(n.task.id);
|
||||
}}
|
||||
>
|
||||
<TaskStatusIcon status={n.task.status} />
|
||||
<span className={styles.title} data-unread={unread.has(n.task.id) || undefined}>
|
||||
{n.task.title}
|
||||
</span>
|
||||
{unread.has(n.task.id) && (
|
||||
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
|
||||
)}
|
||||
{project?.workspace && (
|
||||
<span className={styles.taskProject} title={n.task.projectRoot ?? project.root}>
|
||||
{basename(n.task.projectRoot ?? project.root)}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.detail}>
|
||||
{providerName(n.task.provider)}
|
||||
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
|
||||
</span>
|
||||
<span className={styles.age}>{age(n.task.updatedMs ?? 0)}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
empty={
|
||||
<div className={styles.empty}>
|
||||
<T>No agent tasks yet</T>
|
||||
<button type="button" className={styles.link} onClick={() => openHome()}>
|
||||
<T>New Task…</T>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={viewRef} className={clsx(styles.view, tabOver && styles.dropTarget)}>
|
||||
<div className={styles.content}>
|
||||
{inline && (
|
||||
{docked && (
|
||||
<div className={styles.docked}>
|
||||
<div className={styles.dockedBar}>
|
||||
<IconButton
|
||||
ref={historyRef}
|
||||
icon="history"
|
||||
title="Agent sessions"
|
||||
size="small"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={historyOpen}
|
||||
onClick={() => {
|
||||
setSelected(new Set(docked ? [docked] : []));
|
||||
setHistoryOpen((open) => !open);
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={styles.dockedTitle}
|
||||
title={dockedTitle ?? undefined}
|
||||
onPointerDown={(e) => docked && dragTask(e, docked)}
|
||||
onPointerDown={(e) => dragTask(e, docked)}
|
||||
>
|
||||
{homeOpen ? <T>New Agent Task</T> : dockedTitle}
|
||||
{dockedTitle}
|
||||
</span>
|
||||
<IconButton
|
||||
icon="editor-only"
|
||||
action="agents.undockTask"
|
||||
actionArg={docked}
|
||||
title="Open in Editor"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.dockedSession}>
|
||||
{homeOpen ? (
|
||||
<HomeEditor docked />
|
||||
) : (
|
||||
docked && (
|
||||
<SessionEditor
|
||||
docked
|
||||
input={{
|
||||
kind: SESSION_KIND,
|
||||
key: sessionKey(docked),
|
||||
data: { task: docked, title: tasks.get(docked)?.title },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<SessionEditor
|
||||
input={{
|
||||
kind: SESSION_KIND,
|
||||
key: sessionKey(docked),
|
||||
data: { task: docked, title: tasks.get(docked)?.title },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!inline && taskList}
|
||||
{inline && (
|
||||
<Popup
|
||||
open={historyOpen}
|
||||
anchor={historyRef}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
aria-label={tx("Agent sessions")}
|
||||
className={styles.history}
|
||||
>
|
||||
<div className={styles.historyTitle}>
|
||||
<T>Agent sessions</T>
|
||||
</div>
|
||||
{taskList}
|
||||
</Popup>
|
||||
{/* Docked with nothing else to list: give the whole window to the conversation. */}
|
||||
{(roots.length > 0 || !docked) && (
|
||||
<div className={styles.tasks}>
|
||||
<Tree<Node>
|
||||
aria-label="Agent tasks"
|
||||
roots={roots}
|
||||
getKey={nodeKey}
|
||||
getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)}
|
||||
getChildren={(n) =>
|
||||
n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined
|
||||
}
|
||||
isExpandable={(n) => n.kind === "group"}
|
||||
expanded={expanded}
|
||||
onExpandedChange={(_next, node, open) =>
|
||||
setCollapsed((c) => {
|
||||
const s = new Set(c);
|
||||
if (open) s.delete(nodeKey(node));
|
||||
else s.add(nodeKey(node));
|
||||
return s;
|
||||
})
|
||||
}
|
||||
selected={selected}
|
||||
onSelectionChange={(sel, lead) => {
|
||||
setSelected(sel);
|
||||
if (sel.size === 1 && lead?.kind === "task" && sel.has(lead.task.id)) showTask(lead.task.id);
|
||||
}}
|
||||
onActivate={(n) => n.kind === "task" && showTask(n.task.id)}
|
||||
onContextMenu={(n, sel, e) => {
|
||||
if (n?.kind !== "task") return;
|
||||
const selection = [...sel].flatMap((id) => {
|
||||
const task = tasks.get(id);
|
||||
return task ? [task] : [];
|
||||
});
|
||||
showContextMenu(e, taskMenu(selection));
|
||||
}}
|
||||
renderNode={(n) =>
|
||||
n.kind === "group" ? (
|
||||
<span className={styles.row}>
|
||||
<span className={styles.groupLabel}>
|
||||
<T>{n.group.label}</T>
|
||||
</span>
|
||||
<span className={styles.count}>{n.group.tasks.length}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={styles.row}
|
||||
title={n.task.error ?? n.task.title}
|
||||
onPointerDown={(e) => dragTask(e, n.task.id)}
|
||||
>
|
||||
<TaskStatusIcon status={n.task.status} />
|
||||
<span className={styles.title} data-unread={unread.has(n.task.id) || undefined}>
|
||||
{n.task.title}
|
||||
</span>
|
||||
{unread.has(n.task.id) && (
|
||||
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
|
||||
)}
|
||||
{project?.workspace && (
|
||||
<span className={styles.taskProject} title={n.task.projectRoot ?? project.root}>
|
||||
{basename(n.task.projectRoot ?? project.root)}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.detail}>
|
||||
{providerName(n.task.provider)}
|
||||
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
|
||||
</span>
|
||||
<span className={styles.age}>{age(n.task.updatedMs ?? 0)}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
empty={
|
||||
<div className={styles.empty}>
|
||||
<T>No agent tasks yet</T>
|
||||
<button type="button" className={styles.link} onClick={() => openHome()}>
|
||||
<T>New Task…</T>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/* A framed text area (IDEA text field frame), with the pickers in its bottom row. */
|
||||
.composer {
|
||||
container: agent-composer / inline-size;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -46,85 +44,9 @@
|
||||
padding: 4px 6px 6px;
|
||||
cursor: default;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.configPicker {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
.configPicker > button {
|
||||
max-width: 100%;
|
||||
}
|
||||
.settingsMenu {
|
||||
display: none;
|
||||
}
|
||||
.settingsPopup {
|
||||
width: min(320px, calc(100vw - 24px));
|
||||
padding: 12px;
|
||||
}
|
||||
.settingsTitle {
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.settingsRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 80px) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.settingsLabel {
|
||||
min-width: 0;
|
||||
color: var(--fg-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@container agent-composer (max-width: 600px) {
|
||||
.bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
.toolbar {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.toolbar > button,
|
||||
.toolbar > .configPicker {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: calc((100% - 28px) / 2);
|
||||
}
|
||||
.configPicker > button {
|
||||
width: 100%;
|
||||
}
|
||||
.inlineSetting {
|
||||
display: none;
|
||||
}
|
||||
.settingsMenu {
|
||||
display: flex;
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
.actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
.pickerValue {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -403,37 +403,30 @@ export function Composer(props: ComposerProps) {
|
||||
}}
|
||||
/>
|
||||
<div className={styles.bar} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.toolbar}>{props.toolbar}</div>
|
||||
<div className={styles.actions}>
|
||||
<IconButton
|
||||
icon="file"
|
||||
title="Reference a File (@)"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const glue = text && !/\s$/.test(text) ? " " : "";
|
||||
setText(`${text}${glue}@`, text.length + glue.length + 1);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon="file-image"
|
||||
title="Attach Image…"
|
||||
size="small"
|
||||
onClick={() => imageInput.current?.click()}
|
||||
/>
|
||||
<input
|
||||
ref={imageInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
readImageFiles(e.target.files ?? []);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<span className={styles.spacer} />
|
||||
{props.actions}
|
||||
</div>
|
||||
{props.toolbar}
|
||||
<span className={styles.spacer} />
|
||||
<IconButton
|
||||
icon="file"
|
||||
title="Reference a File (@)"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const glue = text && !/\s$/.test(text) ? " " : "";
|
||||
setText(`${text}${glue}@`, text.length + glue.length + 1);
|
||||
}}
|
||||
/>
|
||||
<IconButton icon="file-image" title="Attach Image…" size="small" onClick={() => imageInput.current?.click()} />
|
||||
<input
|
||||
ref={imageInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
readImageFiles(e.target.files ?? []);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{props.actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AgentConfigOption } from "@/bindings";
|
||||
import { ConfigPickers, configPickerOptions } from "./ConfigPickers";
|
||||
@@ -85,28 +85,3 @@ it("sends the complete nested model id through ConfigPickers", () => {
|
||||
fireEvent.click(screen.getByRole("option", { name: "Beamcmc/Beam" }));
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith("model", "9router/cmc/Beam");
|
||||
});
|
||||
|
||||
it("edits secondary settings from the compact popup and restores focus when dismissed", () => {
|
||||
const approval: AgentConfigOption = {
|
||||
id: "approvals",
|
||||
name: "Approvals",
|
||||
category: "approval",
|
||||
description: null,
|
||||
current: "ask",
|
||||
via: "option",
|
||||
values: [value("ask", "Ask first"), value("bypass", "Bypass approvals")],
|
||||
};
|
||||
const onChange = vi.fn();
|
||||
render(<ConfigPickers config={[approval]} values={{ approvals: "bypass" }} onChange={onChange} />);
|
||||
const trigger = screen.getByRole("button", { name: "Agent settings" });
|
||||
trigger.focus();
|
||||
fireEvent.click(trigger);
|
||||
const dialog = screen.getByRole("dialog", { name: "Agent settings" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Approvals" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Ask first" }));
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith("approvals", "ask");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog", { name: "Agent settings" })).not.toBeInTheDocument();
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
@@ -2,11 +2,9 @@
|
||||
* Model, mode, approvals and thinking-effort dropdowns for a composer's bottom row.
|
||||
* Modes and approvals use icons; levels are plain text.
|
||||
*/
|
||||
import { useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { AgentConfigOption } from "@/bindings";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { IconButton, Popup, Select, T } from "@/ui";
|
||||
import { Select } from "@/ui";
|
||||
import type { SelectOption } from "@/ui/Select";
|
||||
import { mainOptions, modeIcon } from "./composerModel";
|
||||
import styles from "./Composer.module.css";
|
||||
@@ -45,100 +43,47 @@ export function ConfigPickers({
|
||||
onChange: (option: string, value: string) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchor = useRef<HTMLButtonElement>(null);
|
||||
const options = mainOptions(config);
|
||||
const secondary = options.filter((o) => o.category !== "model");
|
||||
const approval = secondary.find((o) => o.category === "approval" || o.category === "mode");
|
||||
const summary = secondary
|
||||
.map((o) => {
|
||||
const value = values?.[o.id] ?? o.current;
|
||||
return `${tx(o.name)}: ${o.values.find((v) => v.value === value)?.name ?? tx("Default")}`;
|
||||
})
|
||||
.join(" · ");
|
||||
return (
|
||||
<>
|
||||
{options.map((o) => (
|
||||
<span key={o.id} className={clsx(styles.configPicker, o.category !== "model" && styles.inlineSetting)}>
|
||||
<ConfigPicker option={o} value={values?.[o.id] ?? o.current ?? null} onChange={onChange} />
|
||||
</span>
|
||||
))}
|
||||
{secondary.length > 0 && (
|
||||
<div className={styles.settingsMenu}>
|
||||
<IconButton
|
||||
ref={anchor}
|
||||
icon={approval ? modeIcon(values?.[approval.id] ?? approval.current ?? "") : "settings"}
|
||||
title={`${tx("Agent settings")}: ${summary}`}
|
||||
aria-label={tx("Agent settings")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
size="small"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
{mainOptions(config).map((o) => {
|
||||
const isMode = o.category === "mode";
|
||||
const value = values?.[o.id] ?? o.current ?? null;
|
||||
const options = configPickerOptions(o);
|
||||
|
||||
return (
|
||||
<Select
|
||||
key={o.id}
|
||||
variant="ghost"
|
||||
aria-label={o.name}
|
||||
value={value}
|
||||
placeholder={tx(o.name)}
|
||||
options={options.map((opt) => ({
|
||||
...opt,
|
||||
icon:
|
||||
o.category === "approval"
|
||||
? opt.value === "bypass"
|
||||
? "unlock"
|
||||
: "lock"
|
||||
: isMode
|
||||
? modeIcon(opt.value)
|
||||
: undefined,
|
||||
}))}
|
||||
searchable={o.category === "model"}
|
||||
onChange={(v) => onChange(o.id, v)}
|
||||
// Effort and other levels say what they are: "Effort Medium".
|
||||
renderValue={
|
||||
o.category === "thought_level"
|
||||
? (current) => (
|
||||
<span className={styles.pickerValue}>
|
||||
<span className={styles.pickerName}>{o.name}</span>
|
||||
{current?.label ?? tx("Default")}
|
||||
</span>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Popup
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
onClose={() => setOpen(false)}
|
||||
aria-label={tx("Agent settings")}
|
||||
className={styles.settingsPopup}
|
||||
>
|
||||
<div className={styles.settingsTitle}>
|
||||
<T>Agent settings</T>
|
||||
</div>
|
||||
{secondary.map((o) => (
|
||||
<div key={o.id} className={styles.settingsRow}>
|
||||
<span className={styles.settingsLabel}>{tx(o.name)}</span>
|
||||
<ConfigPicker option={o} value={values?.[o.id] ?? o.current ?? null} onChange={onChange} framed />
|
||||
</div>
|
||||
))}
|
||||
</Popup>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigPicker({
|
||||
option,
|
||||
value,
|
||||
onChange,
|
||||
framed = false,
|
||||
}: {
|
||||
option: AgentConfigOption;
|
||||
value: string | null;
|
||||
onChange: (option: string, value: string) => void;
|
||||
framed?: boolean;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
return (
|
||||
<Select
|
||||
variant={framed ? "framed" : "ghost"}
|
||||
aria-label={option.name}
|
||||
value={value}
|
||||
placeholder={tx(option.name)}
|
||||
options={configPickerOptions(option).map((opt) => ({
|
||||
...opt,
|
||||
icon:
|
||||
option.category === "approval"
|
||||
? opt.value === "bypass"
|
||||
? "unlock"
|
||||
: "lock"
|
||||
: option.category === "mode"
|
||||
? modeIcon(opt.value)
|
||||
: undefined,
|
||||
}))}
|
||||
searchable={option.category === "model"}
|
||||
onChange={(v) => onChange(option.id, v)}
|
||||
renderValue={
|
||||
option.category === "thought_level" && !framed
|
||||
? (current) => (
|
||||
<span className={styles.pickerValue}>
|
||||
<span className={styles.pickerName}>{option.name}</span>
|
||||
{current?.label ?? tx("Default")}
|
||||
</span>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -60,47 +60,3 @@
|
||||
.warning {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.docked {
|
||||
justify-content: flex-end;
|
||||
padding: 16px 12px 12px;
|
||||
}
|
||||
.docked .inner {
|
||||
gap: 8px;
|
||||
}
|
||||
.docked .where {
|
||||
order: -1;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
.docked .project,
|
||||
.docked .projectPicker {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 30%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.docked .branch,
|
||||
.docked .basePicker {
|
||||
order: 1;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.docked .isolation {
|
||||
order: 2;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 45%;
|
||||
margin-left: auto;
|
||||
}
|
||||
.branchName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
/** New Task page: one prompt box with the agent and its settings; where it works under it. */
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AgentProbe, Branch } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
@@ -8,7 +7,7 @@ import { useTx, useTxTemplate } from "@/platform/i18n";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
import { Icon, Select, T, type SelectOption } from "@/ui";
|
||||
import { defaultTaskProject } from "./project";
|
||||
import { maybeEditorService, type EditorInput } from "@/workbench/contributions";
|
||||
import { maybeEditorService } from "@/workbench/contributions";
|
||||
import { Composer } from "./Composer";
|
||||
import { SubmitButton } from "./SubmitButton";
|
||||
import { canSend, emptyDraft } from "./composerModel";
|
||||
@@ -18,7 +17,7 @@ import { ADD_AGENT, providerOptions as agentOptions } from "./providerOptions";
|
||||
import { HOME, NO_COMMANDS, refreshProviders, startTask, updateDraft, useAgentCommands, useAgents } from "./store";
|
||||
import styles from "./Home.module.css";
|
||||
|
||||
export function HomeEditor({ docked = false }: { docked?: boolean; input?: EditorInput }) {
|
||||
export function HomeEditor() {
|
||||
const tx = useTx();
|
||||
const txTemplate = useTxTemplate();
|
||||
const draft = useAgents((s) => s.draft);
|
||||
@@ -87,19 +86,15 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
const setOption = (id: string, value: string) => updateDraft({ config: { ...draft.config, [id]: value } });
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.home, docked && styles.docked)}>
|
||||
<div className={styles.home}>
|
||||
<div className={styles.inner}>
|
||||
{!docked && (
|
||||
<h1 className={styles.heading}>
|
||||
<T>What should the agent do?</T>
|
||||
</h1>
|
||||
)}
|
||||
<h1 className={styles.heading}>
|
||||
<T>What should the agent do?</T>
|
||||
</h1>
|
||||
<Composer
|
||||
id={HOME}
|
||||
autoFocus
|
||||
placeholder={
|
||||
docked ? "Describe a task…" : "Describe a task for the agent. @ references a file, / runs a command"
|
||||
}
|
||||
placeholder="Describe a task for the agent. @ references a file, / runs a command"
|
||||
root={root}
|
||||
projectRoot={root}
|
||||
commands={agentCommands}
|
||||
@@ -136,7 +131,6 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
<Select
|
||||
variant="ghost"
|
||||
aria-label={tx("Project")}
|
||||
className={styles.projectPicker}
|
||||
value={root}
|
||||
options={members.map((path) => ({ value: path, label: basename(path), detail: path }))}
|
||||
onChange={(path) => {
|
||||
@@ -145,15 +139,12 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.project} title={root}>
|
||||
{members.length ? basename(root) : project?.name}
|
||||
</span>
|
||||
<span className={styles.project}>{members.length ? basename(root) : project?.name}</span>
|
||||
)}
|
||||
{isGit && (
|
||||
<Select
|
||||
variant="ghost"
|
||||
aria-label="Isolation"
|
||||
className={styles.isolation}
|
||||
value={isolation}
|
||||
options={[
|
||||
{ value: "worktree", label: tx("New worktree"), icon: "branch", detail: tx("away from your changes") },
|
||||
@@ -169,28 +160,23 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
)}
|
||||
{isolation === "local" && current && (
|
||||
<>
|
||||
{!docked && (
|
||||
<span className={styles.note}>
|
||||
<T>on</T>
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.branch} title={current}>
|
||||
<span className={styles.note}>
|
||||
<T>on</T>
|
||||
</span>
|
||||
<span className={styles.branch}>
|
||||
<Icon name="branch" size={14} />
|
||||
<span className={styles.branchName}>{current}</span>
|
||||
{current}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{isolation === "worktree" && branches.length > 0 && (
|
||||
<>
|
||||
{!docked && (
|
||||
<span className={styles.note}>
|
||||
<T>from</T>
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.note}>
|
||||
<T>from</T>
|
||||
</span>
|
||||
<Select
|
||||
variant="ghost"
|
||||
aria-label="Base branch"
|
||||
className={styles.basePicker}
|
||||
value={base}
|
||||
options={branches.map((b) => ({ value: b.name, label: b.name }))}
|
||||
onChange={(b) => updateDraft({ base: b === current ? null : b })}
|
||||
@@ -198,7 +184,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{probe && (!docked || probe.error) && (
|
||||
{probe && (
|
||||
<p className={probe.error ? styles.warning : styles.note}>
|
||||
{probe.error
|
||||
? txTemplate(`${provider?.name ?? probe.provider} didn't start: ${probe.error}`)
|
||||
|
||||
@@ -16,20 +16,6 @@
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.conversation[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.docked {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
.docked .conversation {
|
||||
border-right: 0;
|
||||
}
|
||||
.docked .changes {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* The conversation hugs its content; spare space stays below the composer
|
||||
instead of inside the chat. */
|
||||
.streamArea,
|
||||
@@ -582,7 +568,6 @@
|
||||
width: 280px;
|
||||
}
|
||||
.changesHeader {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -592,17 +577,11 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.changesTitle {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.changesSummary {
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 4px 10px;
|
||||
color: var(--fg-subtle);
|
||||
white-space: pre;
|
||||
@@ -629,11 +608,8 @@
|
||||
gap: 4px;
|
||||
}
|
||||
.changeName {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.changeDir {
|
||||
flex: 1 1 0;
|
||||
|
||||
@@ -68,13 +68,15 @@ import { setFocusedSession, focusedSession, type FocusedSession } from "./sessio
|
||||
import { taskMenu } from "./taskActions";
|
||||
import styles from "./Session.module.css";
|
||||
|
||||
export function SessionEditor({ input, docked = false }: { input: EditorInput; docked?: boolean }) {
|
||||
export function SessionEditor({ input }: { input: EditorInput }) {
|
||||
const id = (input.data as { task: string }).task;
|
||||
return <TaskSession key={id} id={id} docked={docked} />;
|
||||
return <TaskSession key={id} id={id} />;
|
||||
}
|
||||
|
||||
function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
function TaskSession({ id }: { id: string }) {
|
||||
const task = useAgents((s) => s.tasks.get(id));
|
||||
// The tool window bar above already shows the title of a docked conversation.
|
||||
const docked = useAgents((s) => s.docked === id);
|
||||
const entries = useAgents((s) => s.entries.get(id));
|
||||
const changes = useAgents((s) => s.changes.get(id));
|
||||
const project = useProject((s) => s.project);
|
||||
@@ -82,7 +84,7 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
task?.isolation === "local" ? task.status : null,
|
||||
project?.workspace ? (task?.projectRoot ?? project.root) : null,
|
||||
);
|
||||
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null, docked);
|
||||
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null);
|
||||
// The subagent conversation shown, with the subagents that lead to it (empty: the task's own).
|
||||
const [path, setPath] = useState<string[]>([]);
|
||||
const view = path.at(-1) ?? null;
|
||||
@@ -94,10 +96,8 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
useEffect(() => {
|
||||
void loadEntries(id).catch(() => undefined);
|
||||
void refreshChanges(id);
|
||||
return markVisible(id);
|
||||
}, [id]);
|
||||
useEffect(() => {
|
||||
if (!docked || !changesOpen) return markVisible(id);
|
||||
}, [id, docked, changesOpen]);
|
||||
useEffect(() => {
|
||||
if (view) void loadEntries(id, view).catch(() => undefined);
|
||||
}, [id, view]);
|
||||
@@ -127,7 +127,7 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.conversation} hidden={docked && changesOpen}>
|
||||
<div className={styles.conversation}>
|
||||
<Header
|
||||
task={task}
|
||||
entries={entries ?? null}
|
||||
@@ -158,13 +158,7 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
{changesOpen && (
|
||||
<ChangesPane
|
||||
task={task}
|
||||
changes={changes ?? null}
|
||||
head={head}
|
||||
docked={docked}
|
||||
onHide={() => setChangesOpen(false)}
|
||||
/>
|
||||
<ChangesPane task={task} changes={changes ?? null} head={head} onHide={() => setChangesOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -276,7 +270,6 @@ function Header({
|
||||
className={styles.changesToggle}
|
||||
aria-pressed={changesOpen}
|
||||
disabled={count === 0 && !changesOpen}
|
||||
aria-label={changesOpen ? tx("Hide changes") : tx("Show changes")}
|
||||
title={changesOpen ? tx("Hide changes") : tx("Show changes")}
|
||||
onClick={onToggleChanges}
|
||||
>
|
||||
@@ -1299,22 +1292,16 @@ function loadChangesOpen(): Record<string, boolean> {
|
||||
|
||||
/**
|
||||
* Whether the Changes pane is shown for `task`: the user's choice once made, else
|
||||
* only while the task has changes. Docked sessions stay in the conversation until
|
||||
* the user explicitly opens changes; navigation there does not change editor preferences.
|
||||
* only while the task has changes (so it opens when the agent first edits a file).
|
||||
*/
|
||||
function useChangesOpen(
|
||||
task: string,
|
||||
changes: AgentChange[] | null,
|
||||
docked: boolean,
|
||||
): [boolean, (open: boolean) => void] {
|
||||
function useChangesOpen(task: string, changes: AgentChange[] | null): [boolean, (open: boolean) => void] {
|
||||
const [state, setState] = useState<{ task: string; open: boolean | undefined }>(() => ({
|
||||
task,
|
||||
open: docked ? false : loadChangesOpen()[task],
|
||||
open: loadChangesOpen()[task],
|
||||
}));
|
||||
const chosen = state.task === task ? state.open : docked ? false : loadChangesOpen()[task];
|
||||
const chosen = state.task === task ? state.open : loadChangesOpen()[task];
|
||||
const set = (open: boolean) => {
|
||||
setState({ task, open });
|
||||
if (docked) return;
|
||||
try {
|
||||
globalThis.localStorage?.setItem(CHANGES_OPEN_KEY, JSON.stringify({ ...loadChangesOpen(), [task]: open }));
|
||||
} catch {
|
||||
@@ -1328,13 +1315,11 @@ function ChangesPane({
|
||||
task,
|
||||
changes,
|
||||
head,
|
||||
docked,
|
||||
onHide,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
changes: AgentChange[] | null;
|
||||
head: string | null;
|
||||
docked: boolean;
|
||||
onHide: () => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
@@ -1343,7 +1328,6 @@ function ChangesPane({
|
||||
return (
|
||||
<aside className={styles.changes} aria-label={tx("Changes")}>
|
||||
<div className={styles.changesHeader}>
|
||||
{docked && <IconButton icon="arrow-left" title="Back to conversation" size="small" onClick={onHide} />}
|
||||
<span className={styles.changesTitle}>
|
||||
<T>Changes</T>
|
||||
</span>
|
||||
@@ -1354,7 +1338,7 @@ function ChangesPane({
|
||||
)}
|
||||
<span className={styles.bannerSpacer} />
|
||||
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshChanges(task.id)} />
|
||||
{!docked && <IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />}
|
||||
<IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />
|
||||
</div>
|
||||
{(head ?? task.base) && (
|
||||
<div className={styles.changesSummary}>
|
||||
|
||||
@@ -54,7 +54,6 @@ export function activate(): () => void {
|
||||
anchor: "right",
|
||||
order: 0.5,
|
||||
component: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsView)),
|
||||
headerContent: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsLocationActions)),
|
||||
headerActions: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsHeaderActions)),
|
||||
useBadge: () => {
|
||||
const n = useAgents(
|
||||
@@ -81,10 +80,11 @@ export function activate(): () => void {
|
||||
icon: () => "toolwindow-agents",
|
||||
restorable: true,
|
||||
}),
|
||||
// An explicit editor drop sets the placement for subsequent sessions too.
|
||||
// A task dragged from the tool window onto the editor opens as a tab (or undocks).
|
||||
registerEditorDropHandler({
|
||||
accepts: (type) => type === TASK_DRAG,
|
||||
drop: ({ payload }) => void executeAction("agents.openTaskInEditor", payload),
|
||||
drop: ({ payload }) =>
|
||||
void executeAction(useAgents.getState().docked === payload ? "agents.undockTask" : "agents.openTask", payload),
|
||||
}),
|
||||
registerStatusItem({ id: "agents.status", align: "right", priority: 3, title: "Agents", component: AgentsStatus }),
|
||||
registerMenuGroup({
|
||||
@@ -96,6 +96,26 @@ export function activate(): () => void {
|
||||
? [{ kind: "action", action: "agents.sendToAgent", arg: menu === "projectView" ? paths : undefined }]
|
||||
: [],
|
||||
}),
|
||||
registerMenuGroup({
|
||||
// Session tabs have no path; "Open in Tool Window" docks them beside the task list.
|
||||
id: "agents.tab",
|
||||
menus: ["editorTab"],
|
||||
order: 50,
|
||||
items: (_menu, { paths, input }) => {
|
||||
const task = input?.kind === SESSION_KIND ? (input.data as { task?: string } | undefined)?.task : undefined;
|
||||
return paths.length || !task
|
||||
? []
|
||||
: [
|
||||
{
|
||||
kind: "action",
|
||||
action: "agents.dockTask",
|
||||
label: "Open in Tool Window",
|
||||
icon: "open-in-tool-window",
|
||||
arg: task,
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
registerActions([
|
||||
...taskActions,
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import type { AgentChange, AgentEntry, AgentTask } from "@/bindings";
|
||||
import type { AgentEntry, AgentTask } from "@/bindings";
|
||||
import { AgentsView } from "./AgentsView";
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
import { entriesKey, SESSION_KIND, sessionKey, type SubagentEntry } from "./model";
|
||||
@@ -9,7 +9,7 @@ import { disconnect, useAgents } from "./store";
|
||||
import { taskActions } from "./taskActions";
|
||||
import styles from "./Session.module.css";
|
||||
|
||||
const backend = vi.hoisted(() => ({ entries: new Map<string, AgentEntry[]>(), changes: [] as AgentChange[] }));
|
||||
const backend = vi.hoisted(() => ({ entries: new Map<string, AgentEntry[]>() }));
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
commands: {
|
||||
@@ -17,7 +17,7 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
status: "ok",
|
||||
data: backend.entries.get(sub ? `${id}/${sub}` : id) ?? [],
|
||||
}),
|
||||
agentChanges: async () => ({ status: "ok", data: backend.changes }),
|
||||
agentChanges: async () => ({ status: "ok", data: [] }),
|
||||
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
|
||||
},
|
||||
}));
|
||||
@@ -76,8 +76,6 @@ afterEach(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
disconnect();
|
||||
localStorage.clear();
|
||||
backend.changes = [];
|
||||
backend.entries = new Map([
|
||||
["A", [{ type: "agent", text: "Answer A" }, subagent]],
|
||||
[entriesKey("A", "child"), [{ type: "agent", text: "Child answer" }]],
|
||||
@@ -85,8 +83,6 @@ beforeEach(() => {
|
||||
]);
|
||||
useAgents.setState({
|
||||
loaded: true,
|
||||
sessionLocation: "editor",
|
||||
homeOpen: false,
|
||||
tasks: new Map([task("A"), task("B")].map((t) => [t.id, t])),
|
||||
entries: new Map(backend.entries),
|
||||
});
|
||||
@@ -96,13 +92,12 @@ describe("session state across tasks and hosts", () => {
|
||||
it("returns to the task conversation when the docked task changes after opening a subagent", async () => {
|
||||
const dispose = registerActions(taskActions);
|
||||
try {
|
||||
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
|
||||
useAgents.setState({ docked: "A" });
|
||||
render(<AgentsView />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Explore task A/ }));
|
||||
expect(await screen.findByText("Child answer")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox", { name: "Message" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
|
||||
fireEvent.click(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("B"));
|
||||
fireEvent.mouseDown(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("B"), { button: 0 });
|
||||
expect(await screen.findByText("Answer B")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Message" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Child answer")).not.toBeInTheDocument();
|
||||
@@ -115,45 +110,6 @@ describe("session state across tasks and hosts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps docked changes opt-in, preserves the draft, and returns to the same conversation", async () => {
|
||||
backend.changes = [
|
||||
{
|
||||
path: "/project/very-long-file-name.ts",
|
||||
relPath: "very-long-file-name.ts",
|
||||
kind: "modified",
|
||||
added: 8,
|
||||
removed: 2,
|
||||
},
|
||||
];
|
||||
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
|
||||
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
|
||||
render(<AgentsView />);
|
||||
await screen.findByText("Answer A");
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Show changes" })).toBeEnabled());
|
||||
expect(screen.queryByRole("complementary", { name: "Changes" })).toBeNull();
|
||||
const markVisible = vi.spyOn(await import("./store"), "markVisible");
|
||||
vi.spyOn(document, "hasFocus").mockReturnValue(true);
|
||||
const message = screen.getByRole("textbox", { name: "Message" });
|
||||
fireEvent.change(message, { target: { value: "Draft to keep" } });
|
||||
const conversation = stream();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show changes" }));
|
||||
expect(screen.getByRole("complementary", { name: "Changes" })).toBeVisible();
|
||||
expect(screen.getByText("Answer A")).not.toBeVisible();
|
||||
expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
|
||||
act(() => useAgents.setState({ unread: new Set(["A"]) }));
|
||||
fireEvent(window, new Event("focus"));
|
||||
expect(useAgents.getState().unread.has("A")).toBe(true);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back to conversation" }));
|
||||
expect(screen.getByText("Answer A")).toBeVisible();
|
||||
expect(stream()).toBe(conversation);
|
||||
expect(markVisible).toHaveBeenCalledWith("A");
|
||||
expect(useAgents.getState().unread.has("A")).toBe(false);
|
||||
expect(screen.getByRole("textbox", { name: "Message" })).toBe(message);
|
||||
expect(message).toHaveValue("Draft to keep");
|
||||
expect(useAgents.getState().composers.get("A")?.text).toBe("Draft to keep");
|
||||
expect(JSON.parse(localStorage.getItem("openidea.agents.changesOpen.v1")!).A).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])("retains scrolling and follow-output (%s) when moving in both directions", async (stick) => {
|
||||
// Give jsdom measurable scroll geometry; each host has a different viewport.
|
||||
let viewport = 400;
|
||||
@@ -177,7 +133,7 @@ describe("session state across tasks and hosts", () => {
|
||||
expect(useAgents.getState().scrollPositions.get("A")).toEqual({ top: stick ? 1600 : 230, stick });
|
||||
view.unmount();
|
||||
viewport = 150;
|
||||
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
|
||||
useAgents.setState({ docked: "A" });
|
||||
const dock = render(<AgentsView />);
|
||||
await screen.findByText("Answer A");
|
||||
expect(stream().scrollTop).toBe(stick ? height - viewport : 230);
|
||||
|
||||
@@ -44,8 +44,6 @@ export interface Draft {
|
||||
/** Composer key of the New Task page; sessions use their task id. */
|
||||
export const HOME = "home";
|
||||
|
||||
export type SessionLocation = "editor" | "toolWindow";
|
||||
|
||||
interface AgentsState {
|
||||
loaded: boolean;
|
||||
/** Why the tasks could not be loaded. */
|
||||
@@ -69,15 +67,11 @@ interface AgentsState {
|
||||
focus: { key: string; seq: number } | null;
|
||||
/** Task shown inside the Agents tool window instead of an editor tab. */
|
||||
docked: string | null;
|
||||
sessionLocation: SessionLocation;
|
||||
lastTask: string | null;
|
||||
homeOpen: boolean;
|
||||
}
|
||||
|
||||
const DRAFT_KEY = "openidea.agents.draft.v2";
|
||||
const HISTORY_KEY = "openidea.agents.history.v1";
|
||||
const HISTORY_SIZE = 50;
|
||||
const SESSION_LOCATION_KEY = "openidea.agents.sessionLocation.v1";
|
||||
|
||||
function load<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
@@ -112,9 +106,6 @@ export const useAgents = create<AgentsState>(() => ({
|
||||
history: load<string[]>(HISTORY_KEY, []),
|
||||
focus: null,
|
||||
docked: null,
|
||||
sessionLocation: load<unknown>(SESSION_LOCATION_KEY, null) === "toolWindow" ? "toolWindow" : "editor",
|
||||
lastTask: null,
|
||||
homeOpen: false,
|
||||
scrollPositions: new Map(),
|
||||
}));
|
||||
|
||||
@@ -219,8 +210,6 @@ export function disconnect() {
|
||||
entries: new Map(),
|
||||
changes: new Map(),
|
||||
docked: null,
|
||||
lastTask: null,
|
||||
homeOpen: false,
|
||||
scrollPositions: new Map(),
|
||||
});
|
||||
}
|
||||
@@ -364,76 +353,24 @@ export function useAgentCommands(
|
||||
}
|
||||
|
||||
export function openTask(task: string) {
|
||||
if (useAgents.getState().sessionLocation === "toolWindow") {
|
||||
showDockedTask(task);
|
||||
return;
|
||||
}
|
||||
useAgents.setState({ lastTask: task, homeOpen: false });
|
||||
openTaskInEditor(task);
|
||||
}
|
||||
|
||||
function openTaskInEditor(task: string) {
|
||||
// The title rides along so a restored tab has it before the tasks load.
|
||||
const title = useAgents.getState().tasks.get(task)?.title;
|
||||
void editorService().openInput({ kind: SESSION_KIND, key: sessionKey(task), data: { task, title } }, { focus: true });
|
||||
}
|
||||
|
||||
function showDockedTask(task: string) {
|
||||
useAgents.setState({ docked: task, lastTask: task, homeOpen: false });
|
||||
/** Shows the conversation inside the Agents tool window, closing its editor tab. */
|
||||
export function dockTask(task: string) {
|
||||
useAgents.setState({ docked: task });
|
||||
toolWindowService().show(TOOL_WINDOW, true);
|
||||
void editorService().closeInput?.(sessionKey(task));
|
||||
}
|
||||
|
||||
function saveSessionLocation(location: SessionLocation) {
|
||||
useAgents.setState({ sessionLocation: location });
|
||||
save(SESSION_LOCATION_KEY, location);
|
||||
if (location === "toolWindow") {
|
||||
for (const { input } of editorService().openTabs?.() ?? []) {
|
||||
if (input.kind === SESSION_KIND || input.kind === HOME_KIND) void editorService().closeInput?.(input.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Conversation placement is an Agents-wide preference, shared by every task. */
|
||||
export function setSessionLocation(location: SessionLocation) {
|
||||
const s = useAgents.getState();
|
||||
if (s.sessionLocation === location) return;
|
||||
const active = editorService().activeInput?.();
|
||||
const activeTask = active?.kind === SESSION_KIND ? (active.data as { task: string }).task : null;
|
||||
const home = s.homeOpen || active?.kind === HOME_KIND;
|
||||
const task =
|
||||
s.docked ??
|
||||
activeTask ??
|
||||
(s.lastTask && s.tasks.has(s.lastTask) ? s.lastTask : null) ??
|
||||
[...s.tasks.values()]
|
||||
.filter((t) => t.status !== "archived")
|
||||
.sort((a, b) => (b.updatedMs ?? b.createdMs ?? 0) - (a.updatedMs ?? a.createdMs ?? 0))[0]?.id;
|
||||
saveSessionLocation(location);
|
||||
useAgents.setState({ docked: null, homeOpen: false });
|
||||
if (home) {
|
||||
openHome();
|
||||
} else if (task) {
|
||||
openTask(task);
|
||||
} else if (location === "toolWindow") {
|
||||
openHome();
|
||||
}
|
||||
}
|
||||
|
||||
/** Dragging a session into the tool window selects the same placement for other tasks. */
|
||||
export function dockTask(task: string) {
|
||||
saveSessionLocation("toolWindow");
|
||||
showDockedTask(task);
|
||||
}
|
||||
|
||||
/** Moves the docked conversation back to an editor tab. */
|
||||
export function undockTask() {
|
||||
setSessionLocation("editor");
|
||||
}
|
||||
|
||||
/** An explicit editor drop also updates where subsequent sessions open. */
|
||||
export function openTaskInEditorLocation(task: string) {
|
||||
saveSessionLocation("editor");
|
||||
useAgents.setState({ docked: null, homeOpen: false });
|
||||
openTask(task);
|
||||
const docked = useAgents.getState().docked;
|
||||
if (!docked) return;
|
||||
useAgents.setState({ docked: null });
|
||||
openTask(docked);
|
||||
}
|
||||
|
||||
/** Opens the New Task page; `prompt` pre-fills it. */
|
||||
@@ -445,12 +382,6 @@ export function openHome(prompt?: string) {
|
||||
updateDraft({ projectRoot, ...(projectRoot !== previous ? { base: null } : {}) });
|
||||
}
|
||||
if (prompt !== undefined) updateComposer(HOME, { text: prompt });
|
||||
if (useAgents.getState().sessionLocation === "toolWindow") {
|
||||
useAgents.setState({ docked: null, homeOpen: true });
|
||||
toolWindowService().show(TOOL_WINDOW, true);
|
||||
void editorService().closeInput?.(HOME_KEY);
|
||||
return;
|
||||
}
|
||||
void editorService().openInput({ kind: HOME_KIND, key: HOME_KEY, data: {} }, { focus: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,18 +4,7 @@ import { tx } from "@/platform/i18n";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { showError, confirmChecked } from "@/workbench/messages";
|
||||
import { isBusy } from "./model";
|
||||
import {
|
||||
archiveTask,
|
||||
unarchiveTask,
|
||||
deleteTask,
|
||||
dockTask,
|
||||
forkTask,
|
||||
openTask,
|
||||
openTaskInEditorLocation,
|
||||
setSessionLocation,
|
||||
undockTask,
|
||||
useAgents,
|
||||
} from "./store";
|
||||
import { archiveTask, unarchiveTask, deleteTask, dockTask, forkTask, openTask, undockTask, useAgents } from "./store";
|
||||
|
||||
const canArchive = (task: AgentTask) => !isBusy(task.status) && task.status !== "archived";
|
||||
const canFork = (task: AgentTask) => canArchive(task) && !!task.capabilities?.fork;
|
||||
@@ -70,29 +59,6 @@ async function confirmDelete(tasks: AgentTask[]) {
|
||||
}
|
||||
|
||||
export const taskActions: Action[] = [
|
||||
{
|
||||
id: "agents.useToolWindow",
|
||||
title: "Chat in Tool Window",
|
||||
category: "Agents",
|
||||
icon: "open-in-tool-window",
|
||||
run: () => setSessionLocation("toolWindow"),
|
||||
},
|
||||
{
|
||||
id: "agents.useEditor",
|
||||
title: "Chat in Editor",
|
||||
category: "Agents",
|
||||
icon: "editor-only",
|
||||
run: () => setSessionLocation("editor"),
|
||||
},
|
||||
{
|
||||
id: "agents.openTaskInEditor",
|
||||
title: "Open Agent Task in Editor",
|
||||
category: "Agents",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
if (typeof arg === "string") openTaskInEditorLocation(arg);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agents.openTask",
|
||||
title: "Open Agent Task",
|
||||
@@ -165,10 +131,20 @@ export function taskMenu(tasks: AgentTask[]): MenuItem[] {
|
||||
const multiple = tasks.length > 1;
|
||||
const archived = tasks.filter((task) => task.status === "archived").length;
|
||||
const archivable = tasks.filter(canArchive).length;
|
||||
const docked = useAgents.getState().docked;
|
||||
return [
|
||||
...(!multiple
|
||||
? ([
|
||||
{ kind: "action", action: "agents.openTask", label: "Open", arg: task.id },
|
||||
docked === task.id
|
||||
? { kind: "action", action: "agents.undockTask", label: "Open in Editor", icon: "editor-only" }
|
||||
: {
|
||||
kind: "action",
|
||||
action: "agents.dockTask",
|
||||
label: "Open in Tool Window",
|
||||
icon: "open-in-tool-window",
|
||||
arg: task.id,
|
||||
},
|
||||
{ kind: "action", action: "terminal.openAt", label: "Open in Terminal", icon: "terminal", arg: task.cwd },
|
||||
...(task.capabilities?.fork && task.status !== "archived"
|
||||
? ([
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { AgentEntry, AgentEvent, AgentTask } from "@/bindings";
|
||||
import { dropOnEditor, useContributions, type EditorInput, type OpenEditorTab } from "@/workbench/contributions";
|
||||
import { dropOnEditor, useContributions } from "@/workbench/contributions";
|
||||
import { AgentsView } from "./AgentsView";
|
||||
import { AgentsStatus } from "./AgentsStatus";
|
||||
import { activate } from "./index";
|
||||
import * as actions from "@/platform/actions";
|
||||
import { TASK_DRAG } from "./model";
|
||||
import {
|
||||
connect,
|
||||
disconnect,
|
||||
dockTask,
|
||||
markVisible,
|
||||
openHome,
|
||||
openTask,
|
||||
setSessionLocation,
|
||||
undockTask,
|
||||
useAgents,
|
||||
} from "./store";
|
||||
import { connect, disconnect, dockTask, markVisible, undockTask, useAgents } from "./store";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
active: null as EditorInput | null,
|
||||
tabs: [] as OpenEditorTab[],
|
||||
channel: null as { onmessage: (event: AgentEvent) => void } | null,
|
||||
tasks: [] as AgentTask[],
|
||||
notify: vi.fn(),
|
||||
@@ -50,12 +38,7 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", async (original) => ({
|
||||
...(await original<typeof import("@/workbench/contributions")>()),
|
||||
editorService: () => ({
|
||||
openInput: backend.openInput,
|
||||
closeInput: backend.closeInput,
|
||||
activeInput: () => backend.active,
|
||||
openTabs: () => backend.tabs,
|
||||
}),
|
||||
editorService: () => ({ openInput: backend.openInput, closeInput: backend.closeInput }),
|
||||
toolWindowService: () => ({ show: backend.showToolWindow }),
|
||||
notifications: () => ({ notify: backend.notify }),
|
||||
}));
|
||||
@@ -98,10 +81,6 @@ beforeEach(async () => {
|
||||
vi.spyOn(document, "hasFocus").mockReturnValue(true);
|
||||
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
|
||||
disconnect();
|
||||
backend.active = null;
|
||||
backend.tabs = [];
|
||||
useAgents.setState({ sessionLocation: "editor" });
|
||||
localStorage.clear();
|
||||
backend.tasks = [task("one"), task("two", "ready")];
|
||||
await connect();
|
||||
});
|
||||
@@ -272,7 +251,7 @@ describe("docking a conversation in the tool window", () => {
|
||||
const dispose = activate();
|
||||
const execute = vi.spyOn(actions, "executeAction");
|
||||
act(() => dropOnEditor({ type: TASK_DRAG, payload: "two" }));
|
||||
expect(execute).toHaveBeenCalledWith("agents.openTaskInEditor", "two");
|
||||
expect(execute).toHaveBeenCalledWith("agents.openTask", "two");
|
||||
expect(useAgents.getState().docked).toBeNull();
|
||||
expect(backend.openInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "agent-session", key: "agent:two" }),
|
||||
@@ -281,7 +260,7 @@ describe("docking a conversation in the tool window", () => {
|
||||
backend.openInput.mockClear();
|
||||
act(() => dockTask("one"));
|
||||
act(() => dropOnEditor({ type: TASK_DRAG, payload: "one" }));
|
||||
expect(execute).toHaveBeenCalledWith("agents.openTaskInEditor", "one");
|
||||
expect(execute).toHaveBeenCalledWith("agents.undockTask", "one");
|
||||
expect(useAgents.getState().docked).toBeNull();
|
||||
expect(backend.openInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "agent-session", key: "agent:one" }),
|
||||
@@ -290,47 +269,9 @@ describe("docking a conversation in the tool window", () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("moves the active session and applies the header choice to every subsequent session", () => {
|
||||
backend.active = { kind: "agent-session", key: "agent:two", data: { task: "two" } };
|
||||
backend.tabs = ["one", "two"].map((id) => ({
|
||||
input: { kind: "agent-session", key: `agent:${id}`, data: { task: id } },
|
||||
title: id,
|
||||
icon: "toolwindow-agents",
|
||||
lastActive: 0,
|
||||
}));
|
||||
act(() => setSessionLocation("toolWindow"));
|
||||
expect(backend.closeInput).toHaveBeenCalledWith("agent:one");
|
||||
expect(backend.closeInput).toHaveBeenCalledWith("agent:two");
|
||||
expect(useAgents.getState().docked).toBe("two");
|
||||
expect(backend.openInput).not.toHaveBeenCalled();
|
||||
act(() => openTask("one"));
|
||||
expect(useAgents.getState().docked).toBe("one");
|
||||
expect(backend.openInput).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(localStorage.getItem("openidea.agents.sessionLocation.v1")!)).toBe("toolWindow");
|
||||
act(() => setSessionLocation("editor"));
|
||||
expect(useAgents.getState().docked).toBeNull();
|
||||
expect(backend.openInput).toHaveBeenLastCalledWith(expect.objectContaining({ key: "agent:one" }), { focus: true });
|
||||
act(() => openTask("two"));
|
||||
expect(backend.openInput).toHaveBeenLastCalledWith(expect.objectContaining({ key: "agent:two" }), { focus: true });
|
||||
});
|
||||
|
||||
it("opens new tasks in the selected location and moves an unfinished draft with the layout", () => {
|
||||
act(() => setSessionLocation("toolWindow"));
|
||||
backend.openInput.mockClear();
|
||||
act(() => openHome("Keep this draft"));
|
||||
expect(useAgents.getState().homeOpen).toBe(true);
|
||||
expect(useAgents.getState().docked).toBeNull();
|
||||
expect(backend.openInput).not.toHaveBeenCalled();
|
||||
act(() => setSessionLocation("editor"));
|
||||
expect(useAgents.getState().homeOpen).toBe(false);
|
||||
expect(backend.openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: "agent-home" }), { focus: true });
|
||||
expect(useAgents.getState().composers.get("home")?.text).toBe("Keep this draft");
|
||||
});
|
||||
|
||||
it("drops the dock when the project changes", async () => {
|
||||
act(() => dockTask("one"));
|
||||
disconnect();
|
||||
expect(useAgents.getState().docked).toBeNull();
|
||||
expect(useAgents.getState().sessionLocation).toBe("toolWindow");
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,6 @@ const projectCurrent = vi.fn();
|
||||
const windowOpenProject = vi.fn();
|
||||
const windowOpenFiles = vi.fn();
|
||||
const windowTakeLaunch = vi.fn();
|
||||
const cancelClose = vi.fn();
|
||||
const notify = vi.fn();
|
||||
vi.mock("@/platform/remote", async (original) => ({
|
||||
...(await original<typeof import("@/platform/remote")>()),
|
||||
isRemoteWindow: vi.fn(() => false),
|
||||
}));
|
||||
// The backend answers with the global settings, here: whatever the test set up.
|
||||
const lightEditOpen = vi.fn(async (_path: string) => ({ status: "ok", data: useProject.getState().settings ?? {} }));
|
||||
const fsReadDir = vi.fn(async (_path: string) => ({ status: "ok", data: [] }));
|
||||
@@ -54,10 +48,7 @@ const {
|
||||
windowActions,
|
||||
} = await import("../windows");
|
||||
const { useProject } = await import("@/platform/project");
|
||||
const { provideEditorService, provideNotificationService } = await import("@/workbench/contributions");
|
||||
const { isRemoteWindow } = await import("@/platform/remote");
|
||||
const { IpcError } = await import("@/platform/ipc");
|
||||
provideNotificationService({ notify });
|
||||
const { provideEditorService } = await import("@/workbench/contributions");
|
||||
|
||||
const opened: string[] = [];
|
||||
let activePath: string | null = null;
|
||||
@@ -67,7 +58,6 @@ provideEditorService({
|
||||
activePath: () => activePath,
|
||||
activeCursor: () => null,
|
||||
saveAll: async () => {},
|
||||
cancelClose,
|
||||
});
|
||||
|
||||
const ok = (data: unknown) => ({ status: "ok", data });
|
||||
@@ -76,9 +66,6 @@ const projectState = (root: string) => ({ project: { root, name: root, trusted:
|
||||
beforeEach(() => {
|
||||
for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch]) f.mockReset();
|
||||
for (const f of [lightEditOpen, fsReadDir, openProject]) f.mockClear();
|
||||
cancelClose.mockClear();
|
||||
notify.mockClear();
|
||||
vi.mocked(isRemoteWindow).mockReturnValue(false);
|
||||
opened.length = 0;
|
||||
activePath = null;
|
||||
useProject.setState({ project: null, lightEdit: false, settings: null } as never);
|
||||
@@ -114,37 +101,6 @@ describe("openProjectInWindow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("replacing a project in this window", () => {
|
||||
it.each([
|
||||
new Error("local folder unavailable"),
|
||||
new IpcError({ kind: "cancelled", message: "Project opening cancelled" }),
|
||||
])("releases the close decision after opening fails or is cancelled: %s", async (error) => {
|
||||
useProject.setState({
|
||||
...projectState("/remote"),
|
||||
settings: { general: { openProjectIn: "same" } },
|
||||
} as never);
|
||||
windowOpenProject.mockResolvedValue(ok({ kind: "current" }));
|
||||
openProject.mockRejectedValueOnce(error);
|
||||
await openProjectWithPolicy("/local");
|
||||
expect(cancelClose).toHaveBeenCalledOnce();
|
||||
expect(useProject.getState().project?.root).toBe("/remote");
|
||||
await openProjectWithPolicy("/local");
|
||||
expect(useProject.getState().project?.root).toBe("/local");
|
||||
});
|
||||
|
||||
it("opens the local project when its path matches the remote root", async () => {
|
||||
useProject.setState({
|
||||
...projectState("/same"),
|
||||
settings: { general: { openProjectIn: "same" } },
|
||||
} as never);
|
||||
vi.mocked(isRemoteWindow).mockReturnValue(true);
|
||||
windowOpenProject.mockResolvedValue(ok({ kind: "current" }));
|
||||
await openProjectWithPolicy("/same");
|
||||
expect(windowOpenProject).toHaveBeenCalledWith("/same", false, []);
|
||||
expect(openProject).toHaveBeenCalledWith("/same");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loose files (LightEdit)", () => {
|
||||
it("turns a window without a project into LightEdit when no LightEdit window exists", async () => {
|
||||
windowOpenFiles.mockResolvedValue(ok(["/x/a.txt", "/y/b.md"]));
|
||||
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user