Compare commits

..
Author SHA1 Message Date
l0ng-ai b1ae3ca91d fix(maven): complete versions of artifacts whose id has dots
Version completion split the artifact id on dots like the group id, so
org.eclipse.platform:org.eclipse.core.runtime was looked up under
.../org/eclipse/core/runtime locally and on Central and found nothing. Only
the group id is split now.
2026-11-13 15:28:33 +08:00
l0ng-ai 11b42041d3 fix(mcp): check_files no longer takes diagnostics from before the last text change
check_files counted a server's last publication as fresh unless this call
itself reloaded the file, so when the file watcher had already applied an
outside edit, diagnostics of the old text came back as checked. The LSP
document now records when its servers last got new text, and only
publications after that are fresh.
2026-11-13 15:28:33 +08:00
l0ng-ai 4c811ba4f5 fix(plugins): keep a plugin's text diff off the disk
views.open-diff used the plugin's file name (or title) as the diff's path, and
the diff viewer probes that path on disk to offer the source file, so a
plugin without file system access could make the IDE touch any path, a UNC
share included. Plugin diffs get a virtual plugin-diff: path that only keeps
the file name for syntax highlighting, and the viewer doesn't probe virtual
paths.
2026-11-13 15:28:33 +08:00
l0ng-ai 573f98627e fix(python): run only trusted recent projects' interpreters when listing them
All Interpreters probed the interpreter of every recent project, so one last
opened in Restricted Mode had its .venv python (or the program its settings
name) executed from another, trusted window. Only projects trusted in their
own right are probed now.
2026-11-13 15:28:32 +08:00
150 changed files with 589 additions and 4663 deletions

No files matched your search

Generated
+44 -44
View File
@@ -4144,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4168,7 +4168,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4182,7 +4182,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"serde",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"blake3",
"chardetng",
@@ -4211,7 +4211,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"gtk",
"objc2 0.6.5",
@@ -4235,7 +4235,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4252,7 +4252,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-run",
@@ -4266,7 +4266,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"futures",
"ide-core",
@@ -4292,7 +4292,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"bb8",
@@ -4328,7 +4328,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"serde",
@@ -4341,7 +4341,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4362,7 +4362,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4374,7 +4374,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4387,7 +4387,7 @@ dependencies = [
[[package]]
name = "ide-heap"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"criterion",
"ide-core",
@@ -4405,7 +4405,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4476,7 +4476,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-vfs",
@@ -4495,7 +4495,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"blake3",
"criterion",
@@ -4517,7 +4517,7 @@ dependencies = [
[[package]]
name = "ide-jdwp"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-jvm-inspect",
"proptest",
@@ -4529,7 +4529,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"encoding_rs",
"futures",
@@ -4546,7 +4546,7 @@ dependencies = [
[[package]]
name = "ide-jvm-attach"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"libc",
"tempfile",
@@ -4555,7 +4555,7 @@ dependencies = [
[[package]]
name = "ide-jvm-inspect"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"futures",
"serde",
@@ -4566,7 +4566,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"blake3",
"criterion",
@@ -4623,7 +4623,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4651,7 +4651,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"bytes",
"dunce",
@@ -4670,7 +4670,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"futures",
"ide-core",
@@ -4693,7 +4693,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"blake3",
"dunce",
@@ -4716,7 +4716,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-jvm-attach",
@@ -4730,7 +4730,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dunce",
"futures",
@@ -4750,7 +4750,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4774,7 +4774,7 @@ dependencies = [
[[package]]
name = "ide-scaffold"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-tools",
@@ -4790,7 +4790,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dunce",
"ide-core",
@@ -4811,7 +4811,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"criterion",
"encoding_rs",
@@ -4832,7 +4832,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"parking_lot",
@@ -4845,7 +4845,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4870,7 +4870,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-language",
@@ -4885,7 +4885,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"libc",
@@ -4898,7 +4898,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"criterion",
"ide-core",
@@ -4911,7 +4911,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"flate2",
"hex",
@@ -4932,7 +4932,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"blake3",
"dunce",
@@ -4953,7 +4953,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"chardetng",
"dunce",
@@ -4977,7 +4977,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dirs 6.0.0",
"ide-core",
@@ -4993,7 +4993,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"blake3",
"dunce",
@@ -6594,7 +6594,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"blake3",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.44"
version = "0.1.43"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+24 -79
View File
@@ -225,15 +225,6 @@ fn same_file(a: &Path, b: &Path) -> bool {
}
}
/// The bytes of `path` (`Some(None)`: no file there), or `None` when it is there but can't be read.
fn existing(path: &Path) -> Option<Option<Vec<u8>>> {
match std::fs::read(path) {
Ok(bytes) => Some(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(None),
Err(_) => None,
}
}
fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
for (i, &b) in bytes.iter().enumerate() {
@@ -420,31 +411,25 @@ pub(crate) fn accept(
let copyable = |rel: &str| task_side.allows(rel) && checkout_side.allows(rel);
let renamed_from: BTreeMap<String, String> =
repo.diff_with_worktree(base)?.into_iter().filter_map(|f| Some((f.path, f.old_path?))).collect();
// The task's version of one path of the checkout (`Some(None)`: absent there), or `None` when
// the checkout changed it itself.
let target = |rel: &str| -> IdeResult<Option<Option<Vec<u8>>>> {
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
let apply = |rel: &str| -> IdeResult<bool> {
let local = local_root.join(rel);
let in_task = repo.root().join(rel);
let current = std::fs::read(&in_task).ok();
// A file there that can't be read is the checkout's own: never written over.
let Some(local_bytes) = existing(&local_root.join(rel)) else { return Ok(None) };
if local_bytes == current {
return Ok(Some(current));
}
let base_bytes = repo.file_bytes_at(&in_task, Some(base))?;
let current = std::fs::read(&in_task).ok();
let local_bytes = std::fs::read(&local).ok();
if local_bytes == current {
return Ok(true);
}
// The checkout may hold the committed LF text with CRLF line ends (`core.autocrlf`).
let unchanged = match (&local_bytes, &base_bytes) {
(Some(now), Some(at_base)) => now == at_base || crlf_to_lf(now) == *at_base,
(now, at_base) => now == at_base,
};
Ok(unchanged.then_some(current))
};
// Puts `bytes` (`None`: nothing) at `rel` of the checkout.
let write = |rel: &str, bytes: Option<Vec<u8>>| -> IdeResult<()> {
let local = local_root.join(rel);
if std::fs::read(&local).ok() == bytes {
return Ok(());
if !unchanged {
return Ok(false);
}
match bytes {
match current {
Some(bytes) => {
if let Some(parent) = local.parent() {
std::fs::create_dir_all(parent)?;
@@ -456,12 +441,6 @@ pub(crate) fn accept(
_ => {}
},
}
Ok(())
};
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
let apply = |rel: &str| -> IdeResult<bool> {
let Some(bytes) = target(rel)? else { return Ok(false) };
write(rel, bytes)?;
Ok(true)
};
let mut accepted = Vec::with_capacity(changes.len());
@@ -487,9 +466,8 @@ pub(crate) fn accept(
// A case-only rename (`a.txt` → `A.txt`). Where the disk resolves both
// spellings to one file it is renamed in place and `apply` then refreshes
// the content. On a case-sensitive disk the two names are different files:
// both are checked before either changes — the old one must be the file the
// checkout had, the new one absent or already the task's — then the task's
// version lands at the new name before the old one goes.
// the old one's unchanged-ness (it is the file the checkout had) decides —
// then the task's version lands at the new name and the old one goes.
let case_only =
old.is_some_and(|o| o != &change.rel_path && o.to_lowercase() == change.rel_path.to_lowercase());
if case_only {
@@ -498,18 +476,20 @@ pub(crate) fn accept(
if same_file(&old_path, &new_path) {
std::fs::rename(&old_path, &new_path)?;
} else {
let Some(old_target) = target(old)? else {
if !apply(old)? {
skipped.push(skip(old));
continue;
};
let task_bytes = std::fs::read(repo.root().join(&change.rel_path)).ok();
let at_new = existing(&new_path);
if at_new.as_ref().is_none_or(|at_new| at_new.is_some() && *at_new != task_bytes) {
skipped.push(skip(&change.rel_path));
continue;
}
write(&change.rel_path, task_bytes)?;
write(old, old_target)?;
// `apply(old)` removed the old file (the task's tree has it only at
// the new name); now write that version under the new name. `apply`
// can't do it — it reads the new name's own base/absence — so the
// write is open-coded from the same task bytes `apply` would use.
if let Ok(bytes) = std::fs::read(repo.root().join(&change.rel_path)) {
if let Some(parent) = new_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&new_path, bytes)?;
}
accepted.push(change.rel_path.clone());
continue;
}
@@ -980,41 +960,6 @@ mod tests {
assert_eq!(names, ["A.txt"]);
}
#[test]
fn a_case_only_rename_keeps_a_file_the_checkout_has_at_the_new_name() {
let (_tmp, repo, created) = accept_repo();
std::fs::write(repo.join("A.txt"), "mine\n").unwrap();
if std::fs::read_to_string(repo.join("a.txt")).unwrap() == "mine\n" {
eprintln!("this disk doesn't tell a.txt from A.txt; nothing to check");
return;
}
git(&created.path, &["mv", "a.txt", "A.txt"]);
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["A.txt"]);
assert_eq!(std::fs::read_to_string(repo.join("A.txt")).unwrap(), "mine\n");
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\ntwo\n");
}
#[cfg(unix)]
#[test]
fn accept_leaves_a_checkout_file_it_cannot_read() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, repo, created) = accept_repo();
std::fs::write(created.path.join("new.txt"), "task\n").unwrap();
std::fs::write(repo.join("new.txt"), "mine\n").unwrap();
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o200)).unwrap();
if std::fs::read(repo.join("new.txt")).is_ok() {
eprintln!("running as a user that reads any file; nothing to check");
return;
}
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["new.txt"]);
assert_eq!(std::fs::read_to_string(repo.join("new.txt")).unwrap(), "mine\n");
}
#[cfg(windows)]
#[test]
fn accept_does_not_write_into_the_checkouts_git_folder_through_its_short_name() {
+1 -2
View File
@@ -12,8 +12,7 @@ mod up_to_date;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
pub use launch_resources::process_launch_resources;
pub use single::{
SingleFileTarget, compile_file_plan, find_javac, javac_step, module_classpath, single_file_classpath,
single_file_target,
SingleFileTarget, compile_file_plan, find_javac, javac_step, single_file_classpath, single_file_target,
};
pub use up_to_date::{menu_up_to_date, record_menu_build};
+3 -210
View File
@@ -4,21 +4,12 @@
//! walks the whole reactor; compiling one file only needs that file's module
//! outputs plus its dependency classpath, which is much faster to assemble.
use super::*;
use serde::{Deserialize, Serialize};
use std::fs;
use std::process::Command as ProcCommand;
/// Output file of the `dependency:build-classpath` probe, relative to each module.
const CP_FILE: &str = "target/ide-compile-cp.txt";
/// The resolved dependency classpath for one module and scope, tied to the same
/// inputs as its launch build so POM, JDK, settings and profile changes invalidate it.
#[derive(Debug, Deserialize, Serialize)]
struct CachedClasspath {
fingerprint: LaunchFingerprint,
classpath: String,
}
/// A `.java` source and the module build that compiles it.
pub struct SingleFileTarget {
/// Absolute module directory.
@@ -73,28 +64,6 @@ pub fn single_file_target(root: &Path, model: &BuildModel, file: &Path) -> IdeRe
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
}
/// The Maven reactor target for a launch module whose source file is not known yet.
fn module_target(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<SingleFileTarget> {
let Some(m) = &model.maven else {
return Err(IdeError::not_found("no linked Maven project found for module"));
};
let module = if module.is_absolute() { module.to_path_buf() } else { root.join(module) };
let project = m
.projects
.iter()
.find(|p| root.join(&p.dir) == module)
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
if project.packaging == "pom" {
return Err(IdeError::not_found("the launch module is a Maven aggregator without sources"));
}
let top = maven_reactor(m, &project.dir)
.ok_or_else(|| IdeError::not_found("no linked Maven reactor found for module"))?;
let top_dir = subdir(root, &top.dir);
let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
let out_dir = module.join("target").join(if test { "test-classes" } else { "classes" });
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
}
/// `javac` for the module: its `JAVA_HOME`, else `PATH`.
pub fn find_javac(env: &BTreeMap<String, String>) -> String {
let home = env.get("JAVA_HOME").map(Path::new);
@@ -109,88 +78,6 @@ pub fn find_javac(env: &BTreeMap<String, String>) -> String {
/// `mvn dependency:build-classpath` (which resolves without compiling anything,
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> {
classpath(root, model, target, "test", false)
}
/// Runtime (or test) dependency classpath for a launch module.
///
/// The Java language server runs on its own JDK, so its Maven model can select a
/// different JDK profile and dependency classifier than the project launch. Use the
/// same Maven process that builds the module instead.
pub fn module_classpath(
root: &Path,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cache: &Path,
cancel: &ide_core::CancellationToken,
) -> IdeResult<String> {
let target = module_target(root, model, module, test)?;
let fingerprint = launch_fingerprint(root, model, &target.module, test, local_repo, cancel)?;
let cache_path = classpath_cache_path(root, cache, &target.module, test);
if let (Some(fingerprint), Some(path)) = (&fingerprint, &cache_path)
&& let Ok(bytes) = fs::read(path)
&& let Ok(cached) = serde_json::from_slice::<CachedClasspath>(&bytes)
&& cached.fingerprint == *fingerprint
&& classpath_entries_exist(root, &cached.classpath)
{
return Ok(cached.classpath);
}
let classpath = classpath(root, model, &target, if test { "test" } else { "runtime" }, true)?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if let (Some(fingerprint), Some(path)) = (fingerprint, cache_path) {
let cached = CachedClasspath { fingerprint, classpath: classpath.clone() };
if let Err(error) = cache_classpath(&path, &cached) {
tracing::debug!(path = %path.display(), %error, "Cannot cache the Java launch classpath");
}
}
Ok(classpath)
}
/// One cache file per module and scope. Mirroring the module path keeps nested
/// modules and their different dependency scopes from overwriting each other.
fn classpath_cache_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
let relative = module.strip_prefix(root).ok()?;
let mut path = cache.join("java-launch/classpaths");
path.push(if relative.as_os_str().is_empty() { Path::new("_root") } else { relative });
path.push(if test { "test.json" } else { "runtime.json" });
Some(path)
}
/// Best-effort cache persistence. A failed write only costs one later Maven
/// probe, so the launch still succeeds even when the cache directory is unusable.
fn cache_classpath(path: &Path, cached: &CachedClasspath) -> IdeResult<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_vec(cached).map_err(|e| IdeError::internal(e.to_string()))?)?;
Ok(())
}
/// A cache entry stays valid while every file exists. Maven also emits reactor
/// output directories for modules with no classes, which may legitimately never be
/// created; the launch snapshot already supplies the module's classes in that case.
fn classpath_entries_exist(root: &Path, classpath: &str) -> bool {
// Temp directories on macOS are commonly reached through `/var`, while Maven
// reports the canonical `/private/var` path. Compare against both spellings.
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
std::env::split_paths(classpath).all(|entry| {
entry.exists()
|| ((entry.starts_with(root) || entry.starts_with(&canonical_root))
&& (entry.ends_with(Path::new("target/classes")) || entry.ends_with(Path::new("target/test-classes"))))
})
}
fn classpath(
root: &Path,
model: &BuildModel,
target: &SingleFileTarget,
scope: &str,
compile_first: bool,
) -> IdeResult<String> {
let mut entries: Vec<String> = Vec::new();
// The module's own classes first: the file almost always uses classes next to it.
let own: &[&str] = if target.test { &["target/test-classes", "target/classes"] } else { &["target/classes"] };
@@ -223,14 +110,7 @@ fn classpath(
args.extend(["-pl", target.pl.as_str(), "-am"]);
}
let output_arg = format!("-Dmdep.outputFile={probe}");
let scope_arg = format!("-Dmdep.includeScope={scope}");
// A launch build may only have reached `compile`. Running `compile` in this
// same Maven session lets the dependency plugin resolve Reactor siblings from
// their target/classes without paying for package-phase plugins.
if compile_first {
args.push("compile");
}
args.extend(["dependency:build-classpath", output_arg.as_str(), scope_arg.as_str(), "-q"]);
args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]);
let top_dir = subdir(root, &target.top.dir);
let status = ProcCommand::new(&target.top.command)
.args(&args)
@@ -239,10 +119,8 @@ fn classpath(
.output()
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
if !status.status.success() {
let stdout = String::from_utf8_lossy(&status.stdout);
let stderr = String::from_utf8_lossy(&status.stderr);
// Maven reports reactor/dependency failures on stdout, not only stderr.
let tail: Vec<&str> = stdout.lines().chain(stderr.lines()).filter(|l| !l.trim().is_empty()).collect();
let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n");
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
}
@@ -427,7 +305,7 @@ mod tests {
let script = root.join("mvnw");
fs::write(
&script,
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
"#!/bin/sh\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
@@ -449,90 +327,5 @@ mod tests {
assert!(cp.contains("/repo/a.jar"), "{cp}");
assert!(cp.contains("/repo/b.jar"), "{cp}");
assert!(cp.split(separator).count() >= 3, "{cp}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(!args.lines().any(|a| a == "compile" || a == "package"), "{args}");
}
/// A launch uses runtime dependencies, while tests additionally see test-scope ones.
#[cfg(unix)]
#[test]
fn launch_module_classpath_uses_the_matching_dependency_scope() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fixture(root);
let script = root.join("mvnw");
fs::write(
&script,
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/runtime.jar:/repo/test.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let model = crate::load(root, &root.join("cache"), None);
let cache = root.join("cache");
let runtime =
module_classpath(root, &model, &root.join("app"), false, None, &cache, &CancellationToken::new()).unwrap();
assert!(runtime.contains("/repo/runtime.jar"), "{runtime}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(args.lines().any(|a| a == "compile"), "{args}");
assert!(!args.lines().any(|a| a == "package"), "{args}");
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=runtime"), "{args}");
let test =
module_classpath(root, &model, &root.join("app"), true, None, &cache, &CancellationToken::new()).unwrap();
assert!(test.contains("/repo/test.jar"), "{test}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=test"), "{args}");
}
/// The launch classpath follows the Maven runner JDK and is reused until that
/// build input changes; the probe does not run packaging plugins.
#[cfg(unix)]
#[test]
fn launch_classpath_cache_follows_the_runner_jdk() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fixture(root);
let script = root.join("mvnw");
// `dependency:build-classpath` reports reactor outputs as absolute paths. Keep
// the fake probe faithful so the cache's existence check sees the output.
fs::write(
&script,
"#!/bin/sh\nprintf x >> calls.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target/classes\" \"$JAVA_HOME/classpath\"\n: > \"$JAVA_HOME/classpath/jdk.jar\"\nprintf '%s/classpath/jdk.jar:%s/%s/target/classes:%s/empty/target/classes' \"$JAVA_HOME\" \"$(pwd)\" \"$pl\" \"$(pwd)\" > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let mut model = crate::load(root, &root.join("cache"), None);
for (name, version) in [("jdk21", "21"), ("jdk8", "8")] {
let home = root.join(name);
fs::create_dir_all(home.join("bin")).unwrap();
fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
fs::write(home.join("bin/java"), "").unwrap();
}
let cache = root.join("classpath-cache");
let cancel = CancellationToken::new();
let jdk21 = root.join("jdk21");
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), jdk21.to_string_lossy().into_owned());
}
let first = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert!(first.contains("jdk21/classpath/jdk.jar"), "{first}");
assert!(!first.contains("jdk8/classpath/jdk.jar"), "{first}");
let second = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert_eq!(second, first);
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "x");
let jdk8 = root.join("jdk8");
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), jdk8.to_string_lossy().into_owned());
}
let switched = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert!(switched.contains("jdk8/classpath/jdk.jar"), "{switched}");
assert!(!switched.contains("jdk21/classpath/jdk.jar"), "{switched}");
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "xx");
}
}
+12 -67
View File
@@ -712,27 +712,18 @@ pub(crate) fn launch_inputs(
// 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 {
// A source root under Maven's build directory is generated by the build itself.
// Skip it without resolving the property: `<build><directory>` may be custom, and
// hashing its rewritten files would make every launch look changed.
if !(input && el.text.contains("${project.build.directory}")) {
// Other properties can be overridden by -D, profiles or settings. Without
// Maven's effective model, leave them unresolved to conservatively disable reuse.
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() {
let path = dir.join(value);
// Conventional generated sources under target are also build outputs.
if !(input && path.starts_with(dir.join("target"))) {
out.push(path);
}
}
// 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 {
@@ -2149,52 +2140,6 @@ mod tests {
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
}
#[test]
fn launch_inputs_skip_generated_sources_under_the_project_build_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>\
<packaging>pom</packaging><modules><module>app</module></modules></project>",
);
write(
root,
"app/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>app</artifactId><build><plugins><plugin>
<groupId>org.codehaus.mojo</groupId><artifactId>build-helper-maven-plugin</artifactId>
<executions><execution><configuration><sources>
<source>${project.build.directory}/generated-sources/java</source>
</sources></configuration></execution></executions>
</plugin></plugins></build></project>"#,
);
let model = load_with_repo(root, None);
let projects: Vec<&MavenProject> = model.projects.iter().collect();
let inputs = launch_inputs(root, &projects, None).expect("portable launch inputs");
assert!(!inputs.contains(&root.join("app/target/generated-sources/java")), "{inputs:?}");
assert!(inputs.contains(&root.join("app/src")), "conventional sources stay fingerprinted");
}
#[test]
fn launch_inputs_disable_reuse_for_file_activation_on_the_build_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version>
<build><directory>${project.basedir}/custom-build</directory></build>
<profiles><profile><id>generated</id><activation><file>
<exists>${project.build.directory}/enable-profile</exists>
</file></activation></profile></profiles></project>"#,
);
let model = load_with_repo(root, None);
let projects: Vec<&MavenProject> = model.projects.iter().collect();
assert!(launch_inputs(root, &projects, None).is_none());
}
fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
-9
View File
@@ -270,13 +270,6 @@ fn run(
/// The first error lines of a failed build, for the tool window.
pub(crate) fn error_summary(text: &str) -> String {
// The JVM's "could not find or load main class" (localized) when `gradlew` has no jar.
if text.contains("org.gradle.wrapper.GradleWrapperMain") {
return "gradle/wrapper/gradle-wrapper.jar is missing (often excluded by a `*.jar` rule in .gitignore). \
Restore it, run `gradle wrapper` with an installed Gradle, or choose Distribution: Local installation \
in Settings › Build, Execution, Deployment › Gradle."
.to_owned();
}
let lines: Vec<&str> = text
.lines()
.filter(|l| {
@@ -916,8 +909,6 @@ mod tests {
let text = "[INFO] x\n[ERROR] Failed to execute goal\n[ERROR] \n[ERROR] Re-run Maven using -X\n";
assert_eq!(error_summary(text), "Failed to execute goal");
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
let missing_jar = "错误: 找不到或无法加载主类 org.gradle.wrapper.GradleWrapperMain\n";
assert!(error_summary(missing_jar).starts_with("gradle/wrapper/gradle-wrapper.jar is missing"));
}
#[test]
-3
View File
@@ -77,9 +77,6 @@ pub enum IdeEvent {
WorkspaceEditApplied {
id: u32,
paths: Vec<crate::IpcPath>,
/// Made by an agent through the MCP server's tools: undoable like a refactoring, but
/// not announced as one.
by_agent: bool,
},
/// An open document's language changed (Settings › File Types, Override File
/// Type), or its UI mode (a framework's root marker appeared or went).
+1 -20
View File
@@ -300,30 +300,11 @@ pub struct McpServerSettings {
pub agent_tab_limit: u32,
/// Archived agent tabs kept; older ones are closed.
pub agent_archive_limit: u32,
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
/// port forwards keep working after a restart.
pub fixed_port: bool,
/// The port of [`Self::fixed_port`].
pub port: u16,
/// Keep the bearer token across runs (in the app data, readable by this user only) rather than
/// make a new one each run.
pub fixed_token: bool,
}
/// The default of [`McpServerSettings::port`].
pub const DEFAULT_MCP_PORT: u16 = 26371;
impl Default for McpServerSettings {
fn default() -> Self {
Self {
enabled: false,
run_without_confirmation: false,
agent_tab_limit: 1,
agent_archive_limit: 999,
fixed_port: true,
port: DEFAULT_MCP_PORT,
fixed_token: false,
}
Self { enabled: false, run_without_confirmation: false, agent_tab_limit: 1, agent_archive_limit: 999 }
}
}
+1 -1
View File
@@ -753,7 +753,7 @@ impl Pool {
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
}
if source.driver == DbDriver::Snowflake {
return crate::snowflake::SnowflakePool::connect(source, password, setup, root).await.map(Pool::Snowflake);
return crate::snowflake::SnowflakePool::connect(source, password, setup).await.map(Pool::Snowflake);
}
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
let init = Arc::new(setup.statements);
+9 -12
View File
@@ -1491,21 +1491,18 @@ impl DbManager {
let Some(session) = session else { return Ok(()) };
let mut held = session.lock().await;
let Some(mut conn) = held.take() else { return Ok(()) };
if let Err(e) = conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
// Discarding a Snowflake connection doesn't end its session, which the data source's
// other targets share: the transaction stays open, so it stays held to be ended again.
if matches!(conn, conn::Held::Snowflake(..)) {
*held = Some(conn);
} else {
let result = async {
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
conn.restore_default().await
}
.await;
match result {
Ok(()) => Ok(()),
Err(e) => {
conn.discard().await;
Err(e)
}
return Err(e);
}
if let Err(e) = conn.restore_default().await {
conn.discard().await;
return Err(e);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
+24 -94
View File
@@ -78,8 +78,7 @@ pub(crate) fn sso_seed(key: &str, token: &str) {
}
}
/// Live sessions by data source and connection settings, so the targets of one data source (its
/// databases, the tree, consoles) opened with the same settings share one.
/// Live sessions by their connection settings, so targets opened with the same settings share one.
static SESSIONS: LazyLock<parking_lot::Mutex<HashMap<String, Weak<State>>>> =
LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
@@ -107,15 +106,6 @@ struct Lane {
context: parking_lot::Mutex<Option<String>>,
}
/// What [`SnowflakePool::statement`] does when the session is gone and it signs in again.
#[derive(Clone, Copy)]
enum Lost<'a> {
/// Runs the statement again on the new session, after this `USE` when there is one.
Enter(Option<&'a str>),
/// Fails: the statement was part of a transaction that ended with the session.
Fail,
}
const CONSOLE: usize = 0;
const METADATA: usize = 1;
@@ -261,12 +251,7 @@ fn gunzip(bytes: Vec<u8>) -> IdeResult<Vec<u8>> {
}
impl SnowflakePool {
pub(crate) async fn connect(
source: &DataSource,
secret: Option<&str>,
setup: Setup,
root: Option<&std::path::Path>,
) -> IdeResult<Self> {
pub(crate) async fn connect(source: &DataSource, secret: Option<&str>, setup: Setup) -> IdeResult<Self> {
let settings = source.snowflake();
let endpoint = account::endpoint(source)?;
let user = source.user.trim();
@@ -317,15 +302,9 @@ impl SnowflakePool {
lane: CONSOLE,
};
// Everything that makes a session what it is, the secret included (hashed: the key is only compared).
// The data source and its project too: its databases share a session, but another data source
// with the same settings, or the same one in another project (a plugin's source has the same id
// in every project), gets its own: its statements and transactions are not this one's. A browser
// sign-in is still shared through the cached ID token.
let key = blake3::hash(
format!(
"{:?}\0{}\0{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
root,
source.id,
"{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
api.address(),
settings.auth,
secret.unwrap_or_default(),
@@ -657,18 +636,13 @@ impl SnowflakePool {
() = self.closed.cancelled() => return Err(closed()),
turn = self.lane().turn.lock() => turn,
};
// A `USE` needs no schema to run in, and may be what makes the schema reachable. Ending a
// transaction needs none either, and must not fail because the schema is gone meanwhile.
let context = match first_word(sql).as_str() {
"USE" | "COMMIT" | "ROLLBACK" => None,
_ => {
self.enter(schema, cancel).await?;
self.context(schema)
}
};
// A `USE` needs no schema to run in, and may be what makes the schema reachable.
if first_word(sql) != "USE" {
self.enter(schema, cancel).await?;
}
// These can move the session to another database or schema.
let moves = matches!(first_word(sql).as_str(), "USE" | "CALL" | "EXECUTE");
let result = self.statement(sql, skip, max_rows, Lost::Enter(context.as_deref()), cancel).await;
let result = self.statement(sql, skip, max_rows, cancel).await;
if moves {
*self.lane().context.lock() = None;
}
@@ -681,14 +655,16 @@ impl SnowflakePool {
&& self.lane().context.lock().as_deref() != Some(wanted.as_str())
{
*self.lane().context.lock() = None;
// Refused (the session's role may not use this database or schema): the statement
// does not run, since the session is still wherever it was, which may be another
// target's database. Tried again with the next statement.
if let Err(error) = self.statement(&wanted, 0, 0, Lost::Enter(None), cancel).await {
return Err(IdeError {
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
..error
});
match self.statement(&wanted, 0, 0, cancel).await {
Ok(_) => {}
// The session's role may not use this database or schema (after `USE ROLE`, or a
// schema that is gone). The statement still runs, in the session's own context:
// it may be the `USE` that leads back, or name its objects in full. Asked again
// once a statement moves the session.
Err(refused) if refused.kind == ErrorKind::InvalidInput => {
tracing::warn!("Snowflake: the session did not enter its database and schema");
}
Err(error) => return Err(error),
}
*self.lane().context.lock() = Some(wanted);
}
@@ -704,11 +680,11 @@ impl SnowflakePool {
let _turn = self.lane().turn.lock().await;
let cancel = CancellationToken::new();
self.enter(None, &cancel).await?;
self.statement("BEGIN", 0, 0, Lost::Enter(self.context(None).as_deref()), &cancel).await?;
self.statement("BEGIN", 0, 0, &cancel).await?;
let changes = async {
let mut total = 0;
for (sql, expect_one) in statements {
let changed = self.statement(sql, 0, 0, Lost::Fail, &cancel).await?.affected.unwrap_or(0.0) as u64;
let changed = self.statement(sql, 0, 0, &cancel).await?.affected.unwrap_or(0.0) as u64;
if *expect_one && changed != 1 {
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {changed}; nothing was saved"
@@ -721,24 +697,22 @@ impl SnowflakePool {
.await;
match changes {
Ok(total) => {
self.statement("COMMIT", 0, 0, Lost::Fail, &cancel).await?;
self.statement("COMMIT", 0, 0, &cancel).await?;
Ok(total)
}
Err(error) => {
let _ = self.statement("ROLLBACK", 0, 0, Lost::Fail, &cancel).await;
let _ = self.statement("ROLLBACK", 0, 0, &cancel).await;
Err(error)
}
}
}
/// One statement on the session; the caller holds the session's turn. `lost`: what to do when
/// the session is gone and a new one is signed in to.
/// One statement on the session; the caller holds the session's turn.
async fn statement(
&self,
sql: &str,
skip: usize,
max_rows: usize,
lost: Lost<'_>,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let started = Instant::now();
@@ -769,24 +743,7 @@ impl SnowflakePool {
Ok(Outcome::Done(result)) => return Ok(*result),
Ok(Outcome::Expired { gone }) if !renewed => {
renewed = true;
if !gone {
tokens = self.renew(&tokens).await?;
continue;
}
if let Lost::Fail = lost {
// The transaction ended with the session: nothing of it was saved.
let _ = self.login().await;
return Err(IdeError::new(
ErrorKind::Io,
"Snowflake: the session was lost during the transaction; nothing was saved",
));
}
// The new session is under the login's database and schema, which may be another
// target's: back to this one's before the statement runs again.
tokens = self.login().await?;
if let Lost::Enter(Some(wanted)) = lost {
self.reenter(wanted, &tokens, cancel).await?;
}
tokens = if gone { self.login().await? } else { self.renew(&tokens).await? };
}
Ok(Outcome::Expired { .. }) => {
return Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"));
@@ -796,33 +753,6 @@ impl SnowflakePool {
}
}
/// [`enter`](Self::enter) on a session just signed in to (its `tokens`), within [`statement`](Self::statement).
async fn reenter(&self, wanted: &str, tokens: &Tokens, cancel: &CancellationToken) -> IdeResult<()> {
let request_id = uuid();
let outcome = tokio::select! {
biased;
() = cancel.cancelled() => return Err(IdeError::cancelled()),
() = self.closed.cancelled() => return Err(closed()),
outcome = self.run(wanted, 0, 0, tokens, &request_id, Instant::now()) => outcome,
};
match outcome {
Ok(Outcome::Done(_)) => {
*self.lane().context.lock() = Some(wanted.to_owned());
Ok(())
}
Ok(Outcome::Expired { .. }) => {
Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"))
}
Err(error) => {
let error = self.shared.api.scrub.clean(error);
Err(IdeError {
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
..error
})
}
}
}
async fn run(
&self,
sql: &str,
+16 -106
View File
@@ -407,64 +407,13 @@ async fn a_lost_session_signs_in_again() {
let (listener, port) = bind().await;
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string();
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(status), ok(one()), ok(CLOSED_OK)]);
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(one()), ok(CLOSED_OK)]);
let pool = Pool::connect(&source(port), None, None).await.unwrap();
pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap();
pool.close().await;
let seen = server.await.unwrap();
assert!(seen[2].line().starts_with("POST /session/v1/login-request?"), "{}", seen[2].line());
// The new session enters the target's schema before the statement runs again.
assert_eq!(seen[3].json()["sqlText"], "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"");
assert_eq!(seen[4].json()["sqlText"], "select 1");
assert_eq!(seen[4].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
}
#[tokio::test]
async fn a_lost_session_runs_the_statement_in_its_own_database_again() {
let (listener, port) = bind().await;
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
let status = || {
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string()
};
// Raw Data entered, the statement finds the session gone, a new login (under ANALYTICS),
// Raw Data entered again, the statement.
let replies = vec![ok(LOGIN_OK), ok(status()), ok(gone), ok(again), ok(status()), ok(one()), ok(CLOSED_OK)];
let server = serve(listener, replies);
let first = Pool::connect(&source(port), None, None).await.unwrap();
let mut other = source(port);
other.database = "Raw Data".into();
other.options.default_schema = String::new();
let second = Pool::connect(&other, None, None).await.unwrap();
second.execute("delete from t", 1, &CancellationToken::new()).await.unwrap();
first.close().await;
second.close().await;
let sent: Vec<String> =
server.await.unwrap()[1..6].iter().map(|r| r.json()["sqlText"].as_str().unwrap_or("").to_owned()).collect();
let raw = "USE DATABASE \"Raw Data\"";
assert_eq!(sent, [raw, "delete from t", "", raw, "delete from t"]);
}
#[tokio::test]
async fn a_transaction_ends_even_when_its_schema_is_gone() {
let (listener, port) = bind().await;
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string();
let server = serve(listener, vec![ok(LOGIN_OK), ok(status), ok(CLOSED_OK)]);
let pool = Pool::connect(&source(port), None, None).await.unwrap();
// The console's schema was dropped since it was picked: ending the transaction doesn't switch to it.
let mut console = crate::conn::Held::acquire(&pool).await.unwrap();
console.apply_context(Some("Dropped")).await.unwrap();
console.execute(&pool, "ROLLBACK", 0, &CancellationToken::new()).await.unwrap();
pool.close().await;
let seen = server.await.unwrap();
assert_eq!(seen[1].json()["sqlText"], "ROLLBACK");
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
}
#[tokio::test]
@@ -765,7 +714,7 @@ async fn a_console_schema_is_switched_with_its_statement_and_only_when_needed()
}
#[tokio::test]
async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
async fn a_role_that_may_not_use_the_schema_still_runs_statements() {
let status = || {
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
@@ -773,12 +722,14 @@ async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
};
let refused = r#"{"success":false,"code":"002043","message":"SQL compilation error:\nObject does not exist, or operation cannot be performed."}"#;
let (listener, port) = bind().await;
// use role | the schema is refused, the statement is not sent | asked again, refused again |
// use role back, the schema is entered, the statement runs
// use role | the schema is refused once, the statement runs | the next one does not ask again |
// a role that is not granted | use role back, the schema is entered again
let replies = vec![
ok(LOGIN_OK),
ok(status()),
ok(refused),
ok(one()),
ok(one()),
ok(refused),
ok(status()),
ok(status()),
@@ -789,22 +740,20 @@ async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
let pool = Pool::connect(&source(port), None, None).await.unwrap();
let cancel = CancellationToken::new();
pool.execute("use role auditor", 0, &cancel).await.unwrap();
let error = pool.execute("delete from t", 1, &cancel).await.unwrap_err();
assert!(
error.message.contains("USE SCHEMA")
&& error.message.contains("was not run")
&& error.message.contains("002043"),
"{}",
error.message
);
assert!(pool.execute("select 2", 1, &cancel).await.is_err());
pool.execute("select 1", 1, &cancel).await.unwrap();
pool.execute("select 2", 1, &cancel).await.unwrap();
let missing = pool.execute("use role nobody", 0, &cancel).await.unwrap_err();
assert!(missing.message.contains("002043") && missing.message.contains("granted"), "{}", missing.message);
pool.execute("use role analyst", 0, &cancel).await.unwrap();
pool.execute("select 3", 1, &cancel).await.unwrap();
pool.close().await;
let sent: Vec<String> =
server.await.unwrap()[1..7].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
server.await.unwrap()[1..9].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
let schema = "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"";
assert_eq!(sent, ["use role auditor", schema, schema, "use role analyst", schema, "select 3"]);
assert_eq!(
sent,
["use role auditor", schema, "select 1", "select 2", "use role nobody", "use role analyst", schema, "select 3"]
);
}
#[tokio::test]
@@ -836,45 +785,6 @@ async fn databases_of_one_data_source_share_a_login() {
assert!(seen[4].line().starts_with("POST /session?delete=true"), "{}", seen[4].line());
}
#[tokio::test]
async fn data_sources_with_the_same_settings_have_their_own_sessions() {
let (listener, port) = bind().await;
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
let server = serve(listener, vec![ok(LOGIN_OK), ok(second), ok(one()), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
let first = Pool::connect(&source(port), None, None).await.unwrap();
let mut copy = source(port);
copy.id = format!("{}-copy", copy.id);
let other = Pool::connect(&copy, None, None).await.unwrap();
let cancel = CancellationToken::new();
first.execute("select 1", 1, &cancel).await.unwrap();
other.execute("select 2", 1, &cancel).await.unwrap();
first.close().await;
other.close().await;
let seen = server.await.unwrap();
assert!(seen[1].line().starts_with("POST /session/v1/login-request?"), "{}", seen[1].line());
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"session-token-1\""));
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
}
#[tokio::test]
async fn one_data_source_in_two_projects_has_a_session_in_each() {
let (listener, port) = bind().await;
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
let server = serve(listener, vec![ok(LOGIN_OK), ok(second), ok(one()), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
// A plugin's data source has the same id in every project.
let first = Pool::connect(&source(port), None, Some(std::path::Path::new("/projects/a"))).await.unwrap();
let other = Pool::connect(&source(port), None, Some(std::path::Path::new("/projects/b"))).await.unwrap();
let cancel = CancellationToken::new();
first.execute("select 1", 1, &cancel).await.unwrap();
other.execute("select 2", 1, &cancel).await.unwrap();
first.close().await;
other.close().await;
let seen = server.await.unwrap();
assert!(seen[1].line().starts_with("POST /session/v1/login-request?"), "{}", seen[1].line());
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"session-token-1\""));
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
}
#[tokio::test]
async fn metadata_runs_on_its_own_session_beside_a_busy_console() {
let (listener, port) = bind().await;
+3 -3
View File
@@ -87,7 +87,7 @@ async fn create_or_replace(call: &Call, path: PathBuf, text: Option<String>, ove
return Ok(());
}
let edit = crate::ops::lsp::prepare_workspace_edit(&call.host, call.window(), edit).await?;
crate::ops::lsp::apply_agent_workspace_edit(&call.host, call.window(), edit).await
crate::ops::lsp::apply_workspace_edit(&call.host, call.window(), edit).await
}
/// The open document of `path`, or a transient one (`true`: close it after use).
@@ -317,7 +317,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
preconditions: Vec::new(),
};
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_agent_workspace_edit(&host, call.window(), edit).await
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await
}
FileOp::Update { path: first, move_to, hunks, or } => {
// A plain diff's other names count when its `+++` path does not exist.
@@ -400,7 +400,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
edit.files.push(FileEdit::Rename { from: path.into(), to: to.into() });
}
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_agent_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await?;
if dirty {
notes.push(format!("{shown}: patched the unsaved editor buffer"));
}
+1 -1
View File
@@ -385,7 +385,7 @@ targetIndex, or narrow the call with a contextSnippet that shows the one you mea
_ => None,
});
let (h, w) = (host.clone(), window.to_owned());
let written = crate::ops::lsp::apply_agent_workspace_edit(&h, &w, edit).await;
let written = crate::ops::lsp::apply_workspace_edit(&h, &w, edit).await;
if let Err(e) = written {
let hint = format!("The rename stopped while it wrote. {} Read the files again before you retry.", e.message);
return Ok(RenameResult::failed("write_failed", hint).with_symbol(symbol));
-32
View File
@@ -985,38 +985,6 @@ mod build_tool_settings_tests {
}
}
/// Changing Project JDK updates the next Maven build without reopening the project.
#[test]
fn changing_project_jdk_updates_the_next_maven_build() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(
root.join("pom.xml"),
r#"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>app</artifactId><version>1</version></project>"#,
)
.unwrap();
for (name, version) in [("jdk8", "1.8"), ("jdk21", "21")] {
let home = root.join(name);
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("bin").join(if cfg!(windows) { "java.exe" } else { "java" }), "").unwrap();
std::fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
}
let mut settings = Settings::default();
settings.languages.java.maven.runner_jre.clear();
for name in ["jdk8", "jdk21"] {
settings.languages.java.jdk = name.into();
let expected = root.join(name);
let model = load(&root, &root.join("cache"), &settings);
assert_eq!(model.maven.as_ref().unwrap().projects[0].env["JAVA_HOME"], expected.to_string_lossy());
let plan = compile::plan(&root, &model, BuildKind::Build, None, None).unwrap();
assert!(!plan.steps.is_empty());
for step in plan.steps {
assert_eq!(step.env["JAVA_HOME"], expected.to_string_lossy());
}
}
}
#[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
+27 -97
View File
@@ -96,19 +96,18 @@ async fn resolve_launch(
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
let local_repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
let built = if delegated {
let rebuild_on_jdt_errors = rebuild_on_jdt_errors(policy, prefer_maven, info.error_count);
let policy = delegated_policy(policy, prefer_maven, info.error_count);
let outputs = project.java_launch_snapshots.lock().build(
&project.root,
&model,
&module,
cache::BuildOptions { test, policy, rebuild_on_jdt_errors },
local_repo.clone(),
cache::BuildOptions { test, policy },
ide_sdk::apply::maven_local_repository(&project.root, &settings),
&cancel,
)?;
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
apply_external_build_result(&mut info, outputs.failure, outputs.authoritative);
apply_external_build_result(&mut info, outputs.failure, policy == BuildPolicy::Rebuild);
copies
} else if external {
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
@@ -124,22 +123,10 @@ async fn resolve_launch(
Vec::new()
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
if delegated {
// m2e runs inside jdtls on the server JDK and can resolve a different
// Maven profile/classifier than the module build. Ask the same Maven
// process that built the module for its runtime/test dependencies.
let maven_cp = compile::module_classpath(
&project.root,
&model,
&module,
test,
local_repo,
&project.build_cache_dir(),
&cancel,
);
merge_maven_launch_classpath(&mut class_paths, &mut info, maven_cp)?;
} else {
append_unique_class_paths(&mut class_paths, std::mem::take(&mut info.class_paths));
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
class_paths.push(p);
}
}
info.class_paths = class_paths;
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
@@ -204,18 +191,21 @@ async fn resolve_launch(
/// With Settings › Maven › Delegate IDE build/run actions to Maven, javac's verdict replaces
/// JDT's. Classes JDT wrote for sources with errors look up to date to Maven's incremental
/// compiler, so a launch JDT reports errors for recompiles everything.
fn rebuild_on_jdt_errors(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> bool {
prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make)
fn delegated_policy(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> BuildPolicy {
if prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make) {
BuildPolicy::Rebuild
} else {
policy
}
}
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, authoritative: bool) {
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) {
info.build = Some(if let Some(failure) = failure {
info.hints.push(failure);
JavaBuildStatus::Failed
} else if authoritative {
// A clean rebuild or a fingerprint-verified snapshot is authoritative. JDT diagnostics
// may describe an older output or its own classpath, so they must not mark this launch
// as failed after Maven has produced usable classes.
} else if clean {
// A clean external build recompiled every source; JDT's diagnostics still describe
// the previous output (and reject sources javac accepts, e.g. a package mismatch).
info.errors.clear();
info.error_count = 0;
JavaBuildStatus::Succeeded
@@ -296,44 +286,6 @@ fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows)
}
/// Merge the Maven launch classpath without turning a recoverable probe failure into a
/// failed launch. Cancellation still propagates; ordinary failures keep jdtls' classpath
/// and leave the reason in the run console.
fn merge_maven_launch_classpath(
class_paths: &mut Vec<String>,
info: &mut JavaLaunchInfo,
classpath: IdeResult<String>,
) -> IdeResult<()> {
match classpath {
Ok(classpath) => {
append_unique_class_paths(
class_paths,
std::env::split_paths(&classpath).map(|path| path.to_string_lossy().into_owned()),
);
Ok(())
}
Err(error) if error.kind == ErrorKind::Cancelled => Err(error),
Err(error) => {
info.hints.push(format!(
"Maven launch classpath resolution failed; using the Java language server classpath instead:\n{}",
error.message
));
append_unique_class_paths(class_paths, std::mem::take(&mut info.class_paths));
Ok(())
}
}
}
/// Append entries without changing their precedence; `same_entry` also catches
/// Windows case-insensitive duplicates that would otherwise lengthen the command.
fn append_unique_class_paths(class_paths: &mut Vec<String>, entries: impl IntoIterator<Item = String>) {
for entry in entries {
if !class_paths.iter().any(|existing| same_entry(existing, &entry, cfg!(windows))) {
class_paths.push(entry);
}
}
}
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
use_snapshot_on(info, built, snapshot, cfg!(windows))
}
@@ -458,36 +410,14 @@ mod tests {
#[test]
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
assert!(rebuild_on_jdt_errors(BuildPolicy::Make, true, 1));
assert!(rebuild_on_jdt_errors(BuildPolicy::Automatic, true, 2));
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, true, 0));
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, false, 1));
assert!(!rebuild_on_jdt_errors(BuildPolicy::MakeAndContinueOnErrors, true, 1));
}
#[test]
fn maven_classpath_probe_failure_falls_back_to_jdtls_classpaths() {
let maven = std::env::join_paths(["maven.jar", "snapshot.jar"]).unwrap().to_string_lossy().into_owned();
let mut class_paths = vec!["snapshot.jar".to_owned()];
let mut info =
JavaLaunchInfo { class_paths: vec!["snapshot.jar".into(), "jdtls.jar".into()], ..Default::default() };
merge_maven_launch_classpath(&mut class_paths, &mut info, Ok(maven)).unwrap();
assert_eq!(class_paths, ["snapshot.jar", "maven.jar"]);
merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::internal("offline dependency")))
.unwrap();
assert_eq!(class_paths, ["snapshot.jar", "maven.jar", "jdtls.jar"]);
assert!(info.hints[0].contains("offline dependency"), "{:?}", info.hints);
}
#[test]
fn cancelled_maven_classpath_probe_propagates_without_fallback() {
let mut class_paths = vec!["snapshot.jar".to_owned()];
let mut info = JavaLaunchInfo { class_paths: vec!["jdtls.jar".into()], ..Default::default() };
let error = merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::cancelled())).unwrap_err();
assert_eq!(error.kind, ErrorKind::Cancelled);
assert_eq!(class_paths, ["snapshot.jar"]);
assert!(info.hints.is_empty());
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Automatic, true, 2), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Make, true, 0), BuildPolicy::Make);
assert_eq!(delegated_policy(BuildPolicy::Make, false, 1), BuildPolicy::Make);
assert_eq!(
delegated_policy(BuildPolicy::MakeAndContinueOnErrors, true, 1),
BuildPolicy::MakeAndContinueOnErrors
);
}
#[test]
@@ -531,7 +461,7 @@ mod tests {
}
#[test]
fn authoritative_external_result_clears_stale_jdt_errors() {
fn clean_external_build_clears_stale_jdt_errors() {
let mut info = JavaLaunchInfo {
error_count: 7,
errors: vec![ide_lsp::jdtls::JavaCompileError {
+4 -71
View File
@@ -14,10 +14,6 @@ pub(super) struct Outputs {
pub originals: Vec<PathBuf>,
#[serde(skip)]
pub failure: Option<String>,
/// Whether this result replaces jdtls diagnostics with a clean Maven build or a
/// verified snapshot. It is runtime-only because reuse is determined per launch.
#[serde(skip)]
pub authoritative: bool,
}
impl Outputs {
@@ -46,9 +42,6 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
pub(super) struct BuildOptions {
pub test: bool,
pub policy: BuildPolicy,
/// A JDT error makes a cache miss rebuild, but it must not prevent an unchanged
/// snapshot from being reused on the next launch.
pub rebuild_on_jdt_errors: bool,
}
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
@@ -82,7 +75,7 @@ impl Session {
cancel: &CancellationToken,
) -> IdeResult<Outputs> {
self.initialize();
let BuildOptions { test, policy, rebuild_on_jdt_errors } = options;
let BuildOptions { test, policy } = options;
let cache = &self.cache;
let record =
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
@@ -122,14 +115,8 @@ impl Session {
&& outputs.reusable(&before, &snapshots)
{
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
let mut outputs = previous.unwrap();
outputs.authoritative = true;
return Ok(outputs);
return Ok(previous.unwrap());
}
// JDT can report errors from its own classpath while the last Maven snapshot is
// still valid. Reuse that snapshot first; only a cache miss needs the expensive
// clean rebuild that replaces JDT's error-stub classes.
let policy = if rebuild_on_jdt_errors { BuildPolicy::Rebuild } else { policy };
// 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())?;
@@ -169,13 +156,7 @@ impl Session {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut outputs = Outputs {
fingerprint: None,
snapshot,
originals,
failure: built.failure,
authoritative: policy == BuildPolicy::Rebuild,
};
let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure };
if outputs.failure.is_none() && before.is_some() && before == after {
outputs.fingerprint = before;
fs::create_dir_all(record.parent().unwrap())?;
@@ -353,48 +334,6 @@ mod tests {
);
}
#[test]
fn jdt_errors_reuse_an_unchanged_snapshot_and_rebuild_only_on_a_cache_miss() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cancel = CancellationToken::new();
let mut session = Session::new(root.join("cache"));
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert!(!first.authoritative);
let reused = session
.build(
root,
&model,
root,
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_eq!(reused.snapshot, first.snapshot);
assert!(reused.authoritative);
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1);
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
let rebuilt = session
.build(
root,
&model,
root,
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot);
assert!(rebuilt.authoritative);
let calls = fs::read_to_string(root.join("calls")).unwrap();
assert_eq!(calls.lines().count(), 2);
assert!(calls.lines().last().unwrap().contains("clean"));
}
#[test]
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
let dir = tempfile::tempdir().unwrap();
@@ -544,13 +483,7 @@ mod tests {
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(),
failure: None,
authoritative: false,
};
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None };
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
let mut session = Session::new(cache);
session.initialize();
+12 -27
View File
@@ -1483,38 +1483,23 @@ pub async fn lsp_restart_named(host: &Host, name: &str) -> IdeResult<()> {
pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
record_before_multi_file_edit(&project, &edit);
// Multi-file refactorings leave a Local History point to go back to.
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
apply_edit(&project.documents, &edit)
})
.await
}
/// [`apply_workspace_edit`] for the MCP server's tools: undoable the same way, but the UI does
/// not announce it as a refactoring.
pub async fn apply_agent_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
record_before_multi_file_edit(&project, &edit);
project.documents.apply_agent_workspace_edit(&edit)
})
.await
}
/// Multi-file edits leave a Local History point to go back to.
fn record_before_multi_file_edit(project: &crate::Project, edit: &WorkspaceEdit) {
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
}
pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
let project = host.project(window)?;
blocking(move || {
+1 -5
View File
@@ -231,11 +231,7 @@ async fn mutations_preserve_unsaved_text_and_reject_metadata_and_escapes() {
.iter()
.rev()
.find_map(|event| match event {
// Undoable like a refactoring, but marked as the agent's, so it isn't announced as one.
IdeEvent::WorkspaceEditApplied { id, by_agent, .. } => {
assert!(*by_agent, "an MCP edit is the agent's");
Some(*id)
}
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
_ => None,
})
.unwrap();
-71
View File
@@ -252,77 +252,6 @@ async fn maven_sync() {
assert!(!junit.children.is_empty(), "{junit:?}");
}
/// The launch classpath must come from the runner JDK, not the JDK that happened
/// to load the language-server model: JDK-activated profiles can select a
/// different dependency classifier. Use real Maven so this covers the scenario
/// rather than only the probe arguments.
#[test]
#[ignore = "project smoke: needs Maven and a JDK"]
fn maven_launch_classpath_uses_the_runner_jdk_classifier() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let java_home = PathBuf::from(std::env::var("JAVA_HOME").expect("JAVA_HOME"));
let release = std::fs::read_to_string(java_home.join("release")).unwrap();
let version = release
.lines()
.find_map(|line| line.strip_prefix("JAVA_VERSION=\""))
.and_then(|line| line.strip_suffix('"'))
.expect("JAVA_VERSION in release");
let major = version.strip_prefix("1.").unwrap_or(version).split(['.', '_']).next().unwrap().parse::<u32>().unwrap();
let jar = java_home.join(if cfg!(windows) { "bin/jar.exe" } else { "bin/jar" });
let jars = root.join("libs");
let staging = root.join("empty");
std::fs::create_dir_all(&jars).unwrap();
std::fs::create_dir_all(&staging).unwrap();
for name in ["api-jdk8.jar", "api-jdk9.jar"] {
let status = std::process::Command::new(&jar)
.args(["cf"])
.arg(jars.join(name))
.arg(".")
.current_dir(&staging)
.status()
.unwrap();
assert!(status.success());
}
std::fs::write(
root.join("pom.xml"),
r#"<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>demo</groupId><artifactId>jdk-classifier</artifactId><version>1</version>
<profiles>
<profile><id>jdk8</id><activation><jdk>1.8</jdk></activation><dependencies>
<dependency><groupId>demo</groupId><artifactId>api</artifactId><version>1</version><classifier>jdk8</classifier>
<scope>system</scope><systemPath>${project.basedir}/libs/api-jdk8.jar</systemPath></dependency>
</dependencies></profile>
<profile><id>jdk9</id><activation><jdk>[9,)</jdk></activation><dependencies>
<dependency><groupId>demo</groupId><artifactId>api</artifactId><version>1</version><classifier>jdk9</classifier>
<scope>system</scope><systemPath>${project.basedir}/libs/api-jdk9.jar</systemPath></dependency>
</dependencies></profile>
</profiles>
</project>"#,
)
.unwrap();
let cache = root.join("cache");
let mut model = ide_build::load(root, &cache, None);
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), java_home.to_string_lossy().into_owned());
}
let classpath = ide_build::compile::module_classpath(
root,
&model,
root,
false,
None,
&root.join("classpath-cache"),
&ide_core::CancellationToken::new(),
)
.unwrap();
let expected = if major <= 8 { "api-jdk8.jar" } else { "api-jdk9.jar" };
let other = if major <= 8 { "api-jdk9.jar" } else { "api-jdk8.jar" };
assert!(classpath.contains(expected), "{classpath}");
assert!(!classpath.contains(other), "{classpath}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Gradle and a JDK"]
async fn gradle_sync() {
+6 -17
View File
@@ -223,8 +223,6 @@ impl ClientHandler {
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
return;
}
let mut params = params;
crate::virtual_doc::normalize_uris(&mut params);
let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => {
@@ -252,14 +250,7 @@ impl ClientHandler {
{
return;
}
// A late answer for an older version of the text is shown, but isn't fresh (`check_files`).
let obsolete =
params.version.is_some_and(|v| mgr.docs.lock().values().any(|d| d.path == path && d.version > v));
let shown = if obsolete {
self.server.set_obsolete_diagnostics(path.clone(), params.diagnostics)
} else {
self.server.set_diagnostics(path.clone(), params.diagnostics)
};
let shown = self.server.set_diagnostics(path.clone(), params.diagnostics);
mgr.emit_diagnostics(&self.server, path, shown, self.encoding());
}
@@ -324,13 +315,11 @@ impl ClientHandler {
}
fn apply_edit(&self, mgr: &LspManager, params: Value) -> Result<Value, (i64, String)> {
let edit: lsp::WorkspaceEdit =
params.get("edit").cloned().ok_or_else(|| (codes::INVALID_PARAMS, "missing edit".to_owned())).and_then(
|mut e| {
crate::virtual_doc::normalize_uris(&mut e);
serde_json::from_value(e).map_err(|e| (codes::INVALID_PARAMS, e.to_string()))
},
)?;
let edit: lsp::WorkspaceEdit = params
.get("edit")
.cloned()
.ok_or_else(|| (codes::INVALID_PARAMS, "missing edit".to_owned()))
.and_then(|e| serde_json::from_value(e).map_err(|e| (codes::INVALID_PARAMS, e.to_string())))?;
// Convert before calling the hook: it re-enters document sync.
let edit = convert::workspace_edit(&mgr.conv(self.encoding()), edit);
let applied = match mgr.apply_edit_hook() {
+7 -3
View File
@@ -1440,9 +1440,13 @@ impl LspManager {
let params = serde_json::to_value(params).map_err(|e| IdeError::internal(e.to_string()))?;
let stamp = self.injected_request_stamp(&params);
let fut = async {
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
virtual_doc::normalize_uris(&mut value);
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
if t.server.name == "csharp-ls" {
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
virtual_doc::normalize_csharp_uris(&mut value);
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
} else {
t.run.peer.request::<Value, R>(method, params, cancel.cloned()).await
}
};
let result = match tokio::time::timeout(timeout, fut).await {
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
-14
View File
@@ -770,20 +770,6 @@ impl Server {
self.combine(path)
}
/// [`Server::set_diagnostics`] for a publication tagged with a document version older than
/// the text the server has now: shown, but not an answer for the current text, so
/// [`Server::last_published`] keeps its time.
pub fn set_obsolete_diagnostics(&self, path: PathBuf, items: Vec<lsp::Diagnostic>) -> Vec<lsp::Diagnostic> {
let before = self.last_published(&path);
let shown = self.set_diagnostics(path.clone(), items);
let mut published = self.published.lock();
match before {
Some(at) => published.insert(path, at),
None => published.remove(&path),
};
shown
}
/// Records a pulled report for `path` (`None` items: unchanged since `result_id`'s
/// predecessor); returns what the file now shows.
pub fn set_pulled(
+10 -58
View File
@@ -18,36 +18,23 @@ const URI_ESCAPES: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
/// Servers can leave spaces and Unicode unescaped in document URIs: csharp-ls uses .NET
/// `Uri.ToString()`, and Java servers build some URIs with `java.net.URI.toString()` (which keeps
/// non-ASCII characters), e.g. jdtls in a refactoring's edit for a project under a Chinese path.
/// Escape them in document URI fields (and `WorkspaceEdit.changes` keys) before lsp-types' strict
/// URI deserialization, which otherwise rejects the whole response.
pub(crate) fn normalize_uris(value: &mut serde_json::Value) {
/// csharp-ls uses .NET Uri.ToString(), which can leave spaces and Unicode unescaped.
/// Normalize only document URI fields, before lsp-types' strict URI deserialization.
pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) {
match value {
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_uris),
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_csharp_uris),
serde_json::Value::Object(fields) => {
for (key, value) in fields.iter_mut() {
for (key, value) in fields {
// Opaque payloads must survive resolve/execute round trips unchanged.
if matches!(key.as_str(), "data" | "arguments") {
continue;
}
if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri")
&& let Some(uri) = value.as_str().and_then(escaped_uri)
if matches!(key.as_str(), "uri" | "targetUri")
&& let Some(uri) = value.as_str().filter(|s| s.starts_with("csharp:"))
{
*value = uri.into();
} else if key == "changes"
&& let serde_json::Value::Object(changes) = value
{
*changes = std::mem::take(changes)
.into_iter()
.map(|(uri, mut edits)| {
normalize_uris(&mut edits);
(escaped_uri(&uri).unwrap_or(uri), edits)
})
.collect();
*value = utf8_percent_encode(uri, URI_ESCAPES).to_string().into();
} else {
normalize_uris(value);
normalize_csharp_uris(value);
}
}
}
@@ -55,16 +42,6 @@ pub(crate) fn normalize_uris(value: &mut serde_json::Value) {
}
}
/// `uri` with the characters a URI can't hold escaped; `None` when nothing changes or it is
/// not a document URI this applies to (other schemes are passed back to servers as given).
fn escaped_uri(uri: &str) -> Option<String> {
if !(uri.starts_with("file:") || uri.starts_with("csharp:")) {
return None;
}
let escaped = utf8_percent_encode(uri, URI_ESCAPES).to_string();
(escaped != uri).then_some(escaped)
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CSharpMetadata {
@@ -225,7 +202,7 @@ mod tests {
{ "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } }
]);
let original = response.clone();
normalize_uris(&mut response);
normalize_csharp_uris(&mut response);
assert_eq!(response[0]["uri"], encoded);
assert_eq!(response[0]["source"], uri);
assert_eq!(response[1]["location"]["targetUri"], encoded);
@@ -233,31 +210,6 @@ mod tests {
assert!(encoded.parse::<lsp_types::Uri>().is_ok());
}
/// #3089: jdtls's Introduce Variable edit for a project under a Chinese path names the file
/// with the characters unescaped, and the resolved code action failed to deserialize.
#[test]
fn file_uris_with_unicode_and_spaces_deserialize_in_workspace_edits() {
let uri = "file:///C:/项目/my app/src/A.java";
let encoded = "file:///C:/%E9%A1%B9%E7%9B%AE/my%20app/src/A.java";
let edit = serde_json::json!([{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } }, "newText": "x" }]);
let mut action = serde_json::json!({
"title": "Extract to local variable",
"edit": {
"documentChanges": [{ "textDocument": { "uri": uri, "version": 3 }, "edits": edit }],
"changes": { (uri): edit },
},
"data": { "uri": uri },
});
assert!(serde_json::from_value::<lsp_types::CodeAction>(action.clone()).is_err());
normalize_uris(&mut action);
let action: lsp_types::CodeAction = serde_json::from_value(action.clone()).unwrap();
let edit = action.edit.unwrap();
let Some(lsp_types::DocumentChanges::Edits(edits)) = edit.document_changes else { panic!() };
assert_eq!(edits[0].text_document.uri.as_str(), encoded);
assert_eq!(edit.changes.unwrap().keys().next().unwrap().as_str(), encoded);
assert_eq!(action.data.unwrap()["uri"], uri, "opaque data is passed back unchanged");
}
#[test]
fn jdt_titles() {
assert_eq!(title(JDT, true), "SpringApplication.class");
-13
View File
@@ -2210,19 +2210,6 @@ async fn checking_a_document_saves_it_and_waits_for_fresh_diagnostics() {
h.mgr.did_change(doc, 2, &[change(rng(0, 0, 0, 3), "good")], &|| "good\n".to_owned());
let reloaded = h.mgr.check_document(doc, None, Duration::from_millis(800), &cancel).await.unwrap();
assert!(reloaded.servers[0].incomplete, "{:?}", reloaded.servers);
// A late publication tagged with the old version is no answer for the current text.
s.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "version": 1, "diagnostics": [] }))
.unwrap();
let late = h.mgr.check_document(doc, None, Duration::from_millis(800), &cancel).await.unwrap();
assert!(late.servers[0].incomplete, "{:?}", late.servers);
// One for the current version is.
s.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "version": 2, "diagnostics": [] }))
.unwrap();
let fresh = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
assert!(fresh.servers[0].checked && !fresh.servers[0].incomplete, "{:?}", fresh.servers);
}
#[tokio::test(flavor = "multi_thread")]
+9 -56
View File
@@ -1,7 +1,6 @@
//! `<app-data>/mcp/server.json`: where the running IDE's server listens and
//! its token, for the stdio bridge. Readable only by the user; removed when
//! the server stops. `<app-data>/mcp/token` keeps the token across runs when
//! the user chose a fixed token.
//! the server stops.
use serde::{Deserialize, Serialize};
use std::io;
@@ -15,28 +14,6 @@ pub fn discovery_path(app_data: &Path) -> PathBuf {
app_data.join(DISCOVERY_FILE)
}
/// The kept token, relative to the app data directory.
pub const TOKEN_FILE: &str = "mcp/token";
/// The kept token of the IDE whose app data directory is `app_data`.
pub fn token_path(app_data: &Path) -> PathBuf {
app_data.join(TOKEN_FILE)
}
/// The token kept at `path`; a new one is made and kept there when there is none (or the
/// file doesn't hold one). Readable only by the user.
pub fn kept_token(path: &Path) -> io::Result<String> {
if let Ok(text) = std::fs::read_to_string(path) {
let token = text.trim();
if token.len() == 64 && token.bytes().all(|b| b.is_ascii_hexdigit()) {
return Ok(token.to_owned());
}
}
let token = crate::server::new_token()?;
write_atomic(path, token.as_bytes())?;
Ok(token)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discovery {
pub port: u16,
@@ -55,8 +32,15 @@ impl Discovery {
/// Writes the file atomically, readable only by the user.
pub fn write(&self, path: &Path) -> io::Result<()> {
let dir = path.parent().ok_or_else(|| io::Error::other("no parent directory"))?;
std::fs::create_dir_all(dir)?;
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
let bytes = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
write_atomic(path, &bytes)
let written = write_private(&tmp, &bytes).and_then(|()| std::fs::rename(&tmp, path));
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
/// Removes the file at `path` if it still describes this process's server.
@@ -67,18 +51,6 @@ impl Discovery {
}
}
/// Writes `bytes` to `path` atomically, readable only by the user.
fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let dir = path.parent().ok_or_else(|| io::Error::other("no parent directory"))?;
std::fs::create_dir_all(dir)?;
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
let written = write_private(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
#[cfg(unix)]
fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
@@ -119,23 +91,4 @@ mod tests {
Discovery::remove_if_ours(&path);
assert!(!path.exists());
}
#[test]
fn keeps_one_token_until_its_file_is_removed() {
let tmp = tempfile::tempdir().unwrap();
let path = token_path(tmp.path());
let first = kept_token(&path).unwrap();
assert_eq!(first.len(), 64);
assert_eq!(kept_token(&path).unwrap(), first, "the same token on the next run");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
}
std::fs::write(&path, "not a token").unwrap();
let replaced = kept_token(&path).unwrap();
assert_ne!(replaced, first);
std::fs::remove_file(&path).unwrap();
assert_ne!(kept_token(&path).unwrap(), replaced, "a removed token is made anew");
}
}
+2 -2
View File
@@ -22,8 +22,8 @@ mod protocol;
mod server;
pub mod stdio;
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
pub use server::{McpServer, ServerOptions};
pub use discovery::{DISCOVERY_FILE, Discovery, discovery_path};
pub use server::McpServer;
pub use tokio_util::sync::CancellationToken;
use std::path::{Path, PathBuf};
+3 -43
View File
@@ -49,25 +49,12 @@ struct Shared {
/// removes its discovery file.
pub struct McpServer {
port: u16,
/// Why the port asked for in [`ServerOptions::port`] was not used.
port_unavailable: Option<String>,
token: String,
discovery: Option<PathBuf>,
shutdown: watch::Sender<bool>,
cancel: CancellationToken,
}
/// How [`McpServer::start_with`] listens.
#[derive(Clone, Debug, Default)]
pub struct ServerOptions {
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
/// `None`: a free port.
pub port: Option<u16>,
/// The bearer token; `None`: a new one.
pub token: Option<String>,
}
impl McpServer {
/// Starts listening on `127.0.0.1:<random port>` with a new token, on
/// `runtime`, and writes the discovery file at `discovery` if given.
@@ -76,32 +63,10 @@ impl McpServer {
discovery: Option<PathBuf>,
runtime: &tokio::runtime::Handle,
) -> std::io::Result<Self> {
Self::start_with(registry, discovery, runtime, ServerOptions::default())
}
/// [`Self::start`] on the port and with the token of `options`.
pub fn start_with(
registry: Arc<dyn ToolRegistry>,
discovery: Option<PathBuf>,
runtime: &tokio::runtime::Handle,
options: ServerOptions,
) -> std::io::Result<Self> {
let (listener, port_unavailable) = match options.port {
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => (listener, None),
Err(e) => {
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
}
},
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
};
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
let token = match options.token {
Some(token) => token,
None => new_token()?,
};
let token = new_token()?;
let listener = {
let _entered = runtime.enter();
tokio::net::TcpListener::from_std(listener)?
@@ -126,18 +91,13 @@ impl McpServer {
});
runtime.spawn(accept(listener, shared, stopped));
tracing::info!("MCP server on 127.0.0.1:{port}");
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
Ok(Self { port, token, discovery, shutdown, cancel })
}
pub fn port(&self) -> u16 {
self.port
}
/// Why the port of [`ServerOptions::port`] is not the one listened on.
pub fn port_unavailable(&self) -> Option<&str> {
self.port_unavailable.as_deref()
}
/// The bearer token of this run.
pub fn token(&self) -> &str {
&self.token
+1 -28
View File
@@ -1,7 +1,7 @@
//! The server over real HTTP, and the stdio bridge in front of it, with a
//! registry standing in for the IDE host.
use ide_mcp::{Discovery, McpServer, ServerOptions, Tool, ToolCallError, ToolRegistry, discovery_path, stdio};
use ide_mcp::{Discovery, McpServer, Tool, ToolCallError, ToolRegistry, discovery_path, stdio};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -366,30 +366,3 @@ async fn a_cancellation_that_arrives_before_the_call_is_remembered() {
let (_, body) = post(&s, "/mcp", &headers, &call).await;
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["isError"], true);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_one() {
let mut s = setup();
s.server.take().unwrap().stop();
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let token = "ab".repeat(32);
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
s.server =
Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, options.clone()).unwrap());
assert_eq!((s.server().port(), s.server().token()), (free, token.as_str()));
assert!(s.server().port_unavailable().is_none());
assert_eq!(Discovery::read(&s.discovery).unwrap().port, free);
let list = rpc(&s, "tools/list", json!({})).await;
assert_eq!(list["result"]["tools"][0]["name"], "echo");
// The port is taken (by the first server): a second one listens elsewhere and says why.
let second = McpServer::start_with(registry, None, &runtime, options).unwrap();
assert_ne!(second.port(), free);
assert!(
second.port_unavailable().is_some_and(|why| why.contains(&free.to_string())),
"{:?}",
second.port_unavailable()
);
}
-27
View File
@@ -323,18 +323,6 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp
}
let wrapper = build_dir.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
if use_wrapper && wrapper.is_file() {
// `gradlew` can't start without its jar, which `.gitignore`s with `*.jar` often leave
// out: run the distribution the wrapper already unpacked instead.
if build_dir.join("gradle/wrapper/gradle-wrapper.jar").is_file() {
return wrapper;
}
let home = setting_path(root, &g.user_home)
.or_else(|| std::env::var_os("GRADLE_USER_HOME").map(PathBuf::from))
.or_else(|| Some(crate::home_dir()?.join(".gradle")));
if let Some(gradle) = home.and_then(|home| unpacked_wrapper_distribution(build_dir, &home)) {
return gradle;
}
// Nothing to run instead: the wrapper's failure explains what is missing.
return wrapper;
}
pinned::pinned_install(build_dir, Tool::Gradle)
@@ -342,21 +330,6 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp
.unwrap_or_else(|| name.into())
}
/// `bin/gradle` of the distribution `gradle/wrapper/gradle-wrapper.properties` names, once the
/// wrapper has unpacked it in `<gradle_user_home>/wrapper/dists` (and marked it complete).
fn unpacked_wrapper_distribution(build_dir: &Path, gradle_user_home: &Path) -> Option<PathBuf> {
let properties = std::fs::read_to_string(build_dir.join("gradle/wrapper/gradle-wrapper.properties")).ok()?;
let url = properties.lines().find_map(|line| line.trim().strip_prefix("distributionUrl="))?;
let zip = url.trim().replace("\\:", ":").rsplit('/').next()?.to_owned();
let name = zip.strip_suffix(".zip")?;
let gradle = if cfg!(windows) { "gradle.bat" } else { "gradle" };
// One directory per distribution URL hash, each holding the unpacked `gradle-<version>`.
let hashes = std::fs::read_dir(gradle_user_home.join("wrapper/dists").join(name)).ok()?;
hashes.flatten().map(|e| e.path()).filter(|dir| dir.join(format!("{zip}.ok")).is_file()).find_map(|dir| {
std::fs::read_dir(&dir).ok()?.flatten().map(|e| e.path().join("bin").join(gradle)).find(|p| p.is_file())
})
}
fn gradle_run_jdk<'a>(
release: Option<u32>,
configured: Option<&'a crate::java::Jdk>,
-31
View File
@@ -1866,37 +1866,6 @@ fn max_java_picks_an_older_jdk() {
assert_eq!(free.env["JAVA_HOME"], j25.home.to_string_lossy());
}
/// #3086: `gradle-wrapper.jar` excluded by a `*.jar` rule in `.gitignore`: `gradlew` can't
/// start, so the distribution the wrapper already unpacked runs instead.
#[test]
fn gradle_wrapper_without_its_jar_runs_the_unpacked_distribution() {
use crate::apply::gradle_command;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let plain = if cfg!(windows) { "gradle.bat" } else { "gradle" };
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
std::fs::write(&wrapper, "").unwrap();
std::fs::create_dir_all(root.join("gradle/wrapper")).unwrap();
std::fs::write(
root.join("gradle/wrapper/gradle-wrapper.properties"),
"distributionUrl=https\\://services.gradle.org/distributions/gradle-4.10-bin.zip\n",
)
.unwrap();
let mut settings = Settings::default();
settings.languages.java.gradle.user_home = "user-home".into();
let hash = root.join("user-home/wrapper/dists/gradle-4.10-bin/7ddd1b3f");
let gradle = hash.join("gradle-4.10/bin").join(plain);
std::fs::create_dir_all(gradle.parent().unwrap()).unwrap();
std::fs::write(&gradle, "").unwrap();
// Not marked complete: possibly half unpacked.
assert_eq!(gradle_command(root, root, &settings), wrapper);
std::fs::write(hash.join("gradle-4.10-bin.zip.ok"), "").unwrap();
assert_eq!(gradle_command(root, root, &settings), gradle);
// With its jar the wrapper runs as usual.
std::fs::write(root.join("gradle/wrapper/gradle-wrapper.jar"), "").unwrap();
assert_eq!(gradle_command(root, root, &settings), wrapper);
}
#[test]
fn gradle_settings_precedence() {
use crate::apply::{gradle_command, gradle_env};
-3
View File
@@ -281,9 +281,6 @@ impl Repo {
let commitish = arg(commitish, "revision")?;
let mut args: Vec<String> = vec!["worktree".into(), "add".into()];
if let Some(b) = new_branch.map(str::trim).filter(|b| !b.is_empty()) {
if crate::write::renamed_remote_start(self, b, &commitish) {
args.push("--no-track".into());
}
args.extend(["-b".into(), arg(b, "branch name")?]);
}
args.extend(["--".into(), path, commitish]);
+2 -27
View File
@@ -800,41 +800,16 @@ pub(crate) fn create_branch(
let repo = r.gix()?;
resolve(&repo, s)?;
}
let no_track = start.is_some_and(|s| renamed_remote_start(r, name, s));
let mut args = match (checkout, overwrite) {
(true, false) => vec!["switch", "-c", name],
(true, true) => vec!["switch", "-C", name],
(false, false) => vec!["branch"],
(false, true) => vec!["branch", "--force"],
(false, false) => vec!["branch", "--", name],
(false, true) => vec!["branch", "--force", "--", name],
};
if no_track {
args.push("--no-track");
}
if !checkout {
args.extend(["--", name]);
}
args.extend(start);
run(r.root(), &args, None).map(|_| ())
}
/// Whether `start` is a remote-tracking branch with another name than the new branch
/// `name`. Git would make it the upstream, so pushing the new branch would go to the
/// start branch (`feature` from `origin/main` pushes to `main`); such a branch is left
/// without upstream and gets its own one on the first push. A remote branch under the
/// same name stays tracked.
pub(crate) fn renamed_remote_start(r: &Repo, name: &str, start: &str) -> bool {
let Ok(out) = cli::run_raw(r.root(), &["rev-parse", "--verify", "--quiet", "--symbolic-full-name", start], None)
else {
return false;
};
let full = out.stdout_str().trim().to_string();
let Some(rest) = full.strip_prefix("refs/remotes/").filter(|_| out.code == Some(0)) else { return false };
let Ok(repo) = r.gix() else { return false };
let names = repo.remote_names();
let branch = names.iter().find_map(|remote| rest.strip_prefix(&format!("{remote}/")));
branch.is_some_and(|b| b != name)
}
/// Undo Commit: moves the current branch back to the parent of `commit` (which must be
/// HEAD), keeping its changes staged. Returns the undone commit's message.
/// Edit Commit Message: writes `commit` again with `message`, and its descendants on top
+3 -29
View File
@@ -984,41 +984,15 @@ fn branch_overwrite_preserves_the_ref_if_checkout_fails() {
}
#[test]
fn new_branch_from_remote_branch_tracks_it_only_under_the_same_name() {
let (fx, bare) = Fixture::with_origin();
fn new_branch_from_remote_branch_tracks_it() {
let (fx, _bare) = Fixture::with_origin();
fx.git(&["push", "-q", "origin", "main:release"]);
fx.git(&["fetch", "-q"]);
let repo = fx.repo();
let upstream = |name: &str| {
repo.branches().unwrap().into_iter().find(|b| b.name == name && !b.remote).and_then(|b| b.upstream)
};
repo.create_branch("release", Some("origin/release"), false).unwrap();
assert_eq!(upstream("release").as_deref(), Some("origin/release"));
// Another name: no upstream, whether switched to or not, created plainly or overwritten.
repo.create_branch("other", Some("origin/release"), false).unwrap();
assert_eq!(upstream("other"), None);
repo.create_branch_with_overwrite("other", Some("origin/main"), false, true).unwrap();
assert_eq!(upstream("other"), None);
repo.create_branch("my-release", Some("origin/release"), true).unwrap();
let info = repo.branch_info().unwrap();
assert_eq!(info.head.as_deref(), Some("my-release"));
assert_eq!(info.upstream, None);
// The first push then goes to a branch of its own name and tracks it.
fx.write("a.txt", "mine\n");
let tip = fx.commit_all("mine");
let (cancel, out) = quiet();
repo.push(false, true, &cancel, &out).unwrap();
assert_eq!(git_in(&bare, &["rev-parse", "my-release"]), tip);
assert_ne!(git_in(&bare, &["rev-parse", "release"]), tip);
assert_eq!(fx.git(&["config", "branch.my-release.merge"]), "refs/heads/my-release");
// New worktree on a new branch from a remote branch: the same rule.
let wt = fx.tmp.path().join("wt");
repo.add_worktree(&wt, "origin/release", Some("wt-branch")).unwrap();
assert_eq!(upstream("wt-branch"), None);
assert_eq!(info.upstream.as_deref(), Some("origin/release"));
}
#[test]
@@ -389,12 +389,6 @@ impl Documents {
self.apply_workspace_edit_inner(edit, true, &mut |_| Ok(()))
}
/// [`Self::apply_workspace_edit`] for an agent's edit (the MCP server's tools): undoable the
/// same way, announced as an agent's (`by_agent`) rather than as a refactoring.
pub fn apply_agent_workspace_edit(&self, edit: &WorkspaceEdit) -> IdeResult<()> {
self.apply_workspace_edit_as(edit, true, true, &mut |_| Ok(()))
}
/// File moves already own a combined move/reference undo record.
pub fn apply_file_move_edits(&self, edit: &WorkspaceEdit) -> IdeResult<()> {
self.apply_workspace_edit_inner(edit, false, &mut |_| Ok(()))
@@ -405,16 +399,6 @@ impl Documents {
edit: &WorkspaceEdit,
remember: bool,
before_operation: &mut dyn FnMut(usize) -> IdeResult<()>,
) -> IdeResult<()> {
self.apply_workspace_edit_as(edit, remember, false, before_operation)
}
fn apply_workspace_edit_as(
&self,
edit: &WorkspaceEdit,
remember: bool,
by_agent: bool,
before_operation: &mut dyn FnMut(usize) -> IdeResult<()>,
) -> IdeResult<()> {
let _operation = self.operations.lock();
if edit.files.is_empty() {
@@ -501,7 +485,7 @@ impl Documents {
});
}
history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE);
self.sink.emit(IdeEvent::WorkspaceEditApplied { id, paths: affected, by_agent });
self.sink.emit(IdeEvent::WorkspaceEditApplied { id, paths: affected });
}
Ok(())
}
+2 -13
View File
@@ -118,20 +118,9 @@ SQL completion knows the connected schema: tables after `FROM`/`JOIN` (with join
## Results and data editing
Results appear in the **Services** tool window under the **Database** tab: an **Output** tab logs every statement, and each result set gets a **Result N** tab (up to 12, pinned tabs excepted).
Results appear in the **Services** tool window under the **Database** tab: an **Output** tab logs every statement, and each result set gets a **Result N** tab (up to 12).
Running a console again replaces its previous results: its unpinned result tabs make way for the new run, which gets one tab per result set (**Result N**, or **Result N**, **Result N-2**, … when several selected statements return rows). A run with no result sets, such as an `UPDATE`, keeps the previous results. Results from other consoles stay.
To keep a result, pin it with **Pin Tab** in the result tab's context menu or the pin button on the result toolbar; the tab's close button becomes a pin button that unpins it, so a stray click or middle-click can't close it, and later runs open new tabs instead. Rerunning or paging a pinned result updates it in place.
<Warning>
Replacing a result discards changes you have not submitted in it. **Submit** or **Revert** them, or pin the tab,
before running the console again.
</Warning>
Right-click a result tab (or focus it and press `Shift+F10`) for **Close**, **Close Others**, **Close All**, **Close All but Pinned**, **Pin Tab** / **Unpin Tab**, **Rename Tab**, **Select Next Tab** and **Select Previous Tab**. They act on the result tabs only; **Output** stays. Renamed titles last until the result is closed or replaced.
With focus on a result tab or inside a result, use `⇧⌘]` / `⇧⌘[` in the IntelliJ macOS keymap (`⌃→` / `⌃←` also work), or `Alt+→` / `Alt+←` in IntelliJ Windows/Linux. VS Code and Eclipse presets use their respective editor-tab navigation defaults. Switching focuses the destination result tab. Result navigation stays scoped to Results and is blocked while a menu or dialog is open. Customize database result shortcuts independently of editor shortcuts in **Settings › Keymap**.
Right-click a result tab (or focus it and press `Shift+F10`) for **Close**, **Close Others**, **Close All**, **Rename Tab**, **Select Next Tab** and **Select Previous Tab**. They act on the result tabs only; **Output** stays. Renamed titles last while the result is open.
Closing a selected result activates its left neighbor, or its right neighbor if there is no result on the left. When no results remain, **Output** is selected. Closing an inactive result keeps the current selection.
+2 -5
View File
@@ -18,17 +18,14 @@ Agent tasks started inside OpenIDEA (see [Agents](/en/guide/agents)) get the sam
</Step>
</Steps>
The server listens on `127.0.0.1` only, and every request must carry a bearer token. Requests from another origin or host are refused.
- **Port.** By default it listens on the same port, 26371, in every run (**Use a fixed port** and **Port**), so HTTP clients and port forwards (an SSH tunnel to another machine, say) keep working after a restart. When the port is in use or reserved, that run takes a free port and **Status** says why. Turn **Use a fixed port** off for a free port in every run.
- **Token.** By default the token changes each time OpenIDEA starts. Turn on **Keep the token** to keep one across runs, stored in OpenIDEA's application data for your account only; **Regenerate Token** replaces it, and clients configured with the old one stop working.
The server listens on `127.0.0.1` only, on a random port, and every request must carry a token that changes each time OpenIDEA starts. Requests from another origin or host are refused.
## Connect a client
The settings page has two buttons under **Client configuration**. Both copy an `mcpServers` entry in the JSON format most clients accept.
- **Copy Stdio Config** (recommended): the client starts OpenIDEA's executable with `--mcp-stdio`, which relays to the running IDE. It holds no token and keeps working after OpenIDEA restarts.
- **Copy HTTP Config**: the client connects to `http://127.0.0.1:<port>/mcp` with `Authorization: Bearer <token>`. It keeps working after OpenIDEA restarts only with a fixed port and a kept token; otherwise copy it again after each restart.
- **Copy HTTP Config**: the client connects to `http://127.0.0.1:<port>/mcp` with `Authorization: Bearer <token>`. It stops working when OpenIDEA restarts, because the port and token change.
The stdio configuration looks like this (the path is the one on your machine):
+2 -12
View File
@@ -118,19 +118,9 @@ SQL 补全基于已连接的架构:`FROM`/`JOIN` 后补全表(并根据外
## 结果与数据编辑
结果显示在 **服务** 工具窗口的 **数据库** 标签页中:**输出** 标签页记录每条语句,每个结果集一个 **Result N** 标签页(最多 12 个,已固定的标签页除外)。
结果显示在 **服务** 工具窗口的 **数据库** 标签页中:**输出** 标签页记录每条语句,每个结果集一个 **Result N** 标签页(最多 12 个)。
再次运行控制台会替换它之前的结果:它未固定的结果标签页会让位给新的运行,新运行每个结果集一个标签页(**Result N**;选中的多条语句都返回行时为 **Result N**、**Result N-2**……)。没有结果集的运行(如 `UPDATE`)会保留之前的结果。其他控制台的结果不受影响。
要保留某个结果,可在结果标签页的右键菜单中选择 **固定标签页**,或点击结果工具栏上的固定按钮;标签页的关闭按钮会变成固定按钮,点击即可取消固定,误点或中键点击都不会关闭它;之后的运行会改为打开新标签页。重新运行或翻页已固定的结果时,会在原标签页中更新。
<Warning>
替换结果会丢弃其中尚未提交的更改。再次运行控制台前,请先 **提交** 或 **还原** 这些更改,或固定该标签页。
</Warning>
右键点击结果标签页(或聚焦后按 `Shift+F10`),可使用 **关闭**、**关闭其他**、**全部关闭**、**关闭除已固定外的所有标签页**、**固定标签页** / **取消固定标签页**、**重命名标签页**、**选择下一个标签页** 和 **选择上一个标签页**。这些操作只作用于结果标签页,**输出** 始终保留。重命名的标题在结果关闭或被替换前一直有效。
焦点位于结果标签页或结果内容中时,IntelliJ macOS 快捷键方案使用 `⇧⌘]` / `⇧⌘[`(也支持 `⌃→` / `⌃←`),IntelliJ Windows/Linux 使用 `Alt+→` / `Alt+←`。VS Code 和 Eclipse 方案分别使用各自默认的编辑器标签页导航快捷键。切换后焦点移到目标结果标签页。结果导航快捷键仅在结果区域中生效,菜单或对话框打开时不会切换结果。可在 **设置 › 快捷键** 中单独修改数据库结果快捷键,不影响编辑器快捷键。
右键点击结果标签页(或聚焦后按 `Shift+F10`),可使用 **关闭**、**关闭其他**、**全部关闭**、**重命名标签页**、**选择下一个标签页** 和 **选择上一个标签页**。这些操作只作用于结果标签页,**输出** 始终保留。重命名的标题在结果关闭前一直有效。
关闭当前选中的结果标签页时,优先选中左侧相邻的结果;没有左侧结果时,选中右侧相邻结果。没有剩余结果时,选中 **输出**。关闭未选中的结果不会改变当前选择。
+2 -5
View File
@@ -17,17 +17,14 @@ OpenIDEA 内置了一个 [Model Context Protocol](https://modelcontextprotocol.i
</Step>
</Steps>
服务器只监听 `127.0.0.1`,每个请求都必须带上访问令牌。来自其他 Origin 或 Host 的请求会被拒绝。
- **端口。** 默认每次运行都监听同一个端口 26371(**使用固定端口** 和 **端口**),这样 OpenIDEA 重启后 HTTP 客户端和端口转发(例如到另一台机器的 SSH 隧道)仍然有效。端口被占用或被系统保留时,本次运行改用一个空闲端口,**状态** 中会说明原因。关闭 **使用固定端口** 则每次运行使用一个空闲端口。
- **令牌。** 默认每次启动 OpenIDEA 都会换一个新令牌。打开 **保留令牌** 后会在多次运行间保留同一个令牌,保存在 OpenIDEA 的应用数据目录中,只有你的账户可读;**重新生成令牌** 会替换它,使用旧令牌配置的客户端随之失效。
服务器只监听 `127.0.0.1` 上的一个随机端口,每个请求都必须带上令牌,令牌在每次启动 OpenIDEA 时都会变化。来自其他 Origin 或 Host 的请求会被拒绝。
## 连接客户端
设置页面的 **客户端配置** 下有两个按钮,都会复制一段大多数客户端都能识别的 `mcpServers` JSON 配置。
- **复制 Stdio 配置**(推荐):客户端以 `--mcp-stdio` 参数启动 OpenIDEA 的可执行文件,由它转发给正在运行的 IDE。配置里没有令牌,OpenIDEA 重启后依然可用。
- **复制 HTTP 配置**:客户端带着 `Authorization: Bearer <token>` 连接 `http://127.0.0.1:<port>/mcp`。只有同时使用固定端口并保留令牌时,OpenIDEA 重启后这份配置才继续有效;否则每次重启后都要重新复制。
- **复制 HTTP 配置**:客户端带着 `Authorization: Bearer <token>` 连接 `http://127.0.0.1:<port>/mcp`。由于端口和令牌会变,OpenIDEA 重启后这份配置就失效了。
stdio 配置大致如下(路径以你机器上的为准):
-91
View File
@@ -1,91 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
const expected =
'[\n {\n "id": 9223372036854775807,\n "amount": 1.0000000000000000002,\n "note": "9007199254740993",\n "empty": null\n }\n]';
async function seedResult(page: Page) {
await stubDesktop(page);
await page.evaluate(async () => {
window.__ipc.handlers.clipboard_write_text = () => null;
window.__ipc.handlers.db_export_file = () => null;
window.__ipc.handlers["plugin:path|resolve_directory"] = () => "/tmp";
const storePath = "/src/features/database/store.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const { useDatabase, RESULTS_VIEW } = await import(storePath);
const { showServiceView } = await import(contributionsPath);
const text = "SELECT id, amount, note, empty FROM numbers";
useDatabase.setState({
loaded: true,
sources: [{ id: "exact", name: "Exact numbers", driver: "postgres", readOnly: true }],
consoles: { "exact-console.sql": "exact" },
results: [
{
id: 1,
title: "Result 1",
path: "exact-console.sql",
sourceId: "exact",
outcome: {
statement: { text, from: 0, to: text.length },
error: null,
result: {
statement: text,
columns: [
{ name: "id", typeName: "BIGINT" },
{ name: "amount", typeName: "NUMERIC(38,19)" },
{ name: "note", typeName: "TEXT" },
{ name: "empty", typeName: "TEXT" },
],
rows: [["9223372036854775807", "1.0000000000000000002", "9007199254740993", null]],
affected: null,
hasMore: false,
elapsedMs: 1,
executionMs: 1,
},
},
page: 0,
pageSize: 500,
},
],
activeResult: 1,
});
showServiceView(RESULTS_VIEW);
});
await expect(page.getByRole("grid", { name: "Result 1", exact: true }).getByRole("gridcell")).toHaveCount(4);
}
async function openExport(page: Page) {
await page.getByRole("button", { name: "Export Data", exact: true }).click();
await page.getByRole("menuitem", { name: "Export Data…", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Export Data", exact: true });
await dialog.getByRole("button", { name: "Extractor", exact: true }).click();
await page.getByRole("option", { name: "JSON", exact: true }).click();
return dialog;
}
test("Copy as JSON preserves exact numeric values", async ({ workbench: page }) => {
await seedResult(page);
await page.getByRole("button", { name: "Export Data", exact: true }).click();
await page.getByRole("menuitem", { name: "Copy as JSON", exact: true }).click();
await expect.poll(() => ipcCalls(page, "clipboard_write_text")).toHaveLength(1);
expect((await ipcCalls(page, "clipboard_write_text"))[0]!.text).toBe(expected);
});
test("Export Data preserves exact preview and clipboard values", async ({ workbench: page }) => {
await seedResult(page);
const dialog = await openExport(page);
await expect(dialog.getByLabel("Export preview", { exact: true })).toHaveText(expected);
await dialog.getByRole("button", { name: "Copy to Clipboard", exact: true }).click();
await expect.poll(() => ipcCalls(page, "clipboard_write_text")).toHaveLength(1);
expect((await ipcCalls(page, "clipboard_write_text"))[0]!.text).toBe(expected);
});
test("Export Data preserves exact preview and exported file values", async ({ workbench: page }) => {
await seedResult(page);
const dialog = await openExport(page);
await expect(dialog.getByLabel("Export preview", { exact: true })).toHaveText(expected);
await dialog.getByRole("textbox", { name: "Output file", exact: true }).fill("/tmp/exact-numbers.json");
await dialog.getByRole("button", { name: "Export to File", exact: true }).click();
await expect.poll(() => ipcCalls(page, "db_export_file")).toHaveLength(1);
expect((await ipcCalls(page, "db_export_file"))[0]).toEqual({ path: "/tmp/exact-numbers.json", text: expected });
});
-103
View File
@@ -1,103 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./fixtures";
const integers = ["9007199254740993", "9007199254740992", "-9007199254740992", "-9007199254740993"];
const decimals = ["1.0000000000000000002", "1.0000000000000000001", "-0.0000000000000000001", "0"];
async function seedResult(page: Page, values: string[], type: string) {
await page.evaluate(
async ({ values, type }) => {
const storePath = "/src/features/database/store.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const { useDatabase, RESULTS_VIEW } = await import(storePath);
const { showServiceView } = await import(contributionsPath);
const text = "SELECT value FROM exact_numbers";
useDatabase.setState({
loaded: true,
sources: [{ id: "exact", name: "Exact numbers", driver: "postgres", readOnly: true }],
consoles: { "exact-console.sql": "exact" },
results: [
{
id: 1,
title: "Result 1",
path: "exact-console.sql",
sourceId: "exact",
outcome: {
statement: { text, from: 0, to: text.length },
error: null,
result: {
statement: text,
columns: [{ name: "value", typeName: type }],
rows: values.map((value) => [value]),
affected: null,
hasMore: false,
elapsedMs: 1,
executionMs: 1,
},
},
page: 0,
pageSize: 500,
},
],
activeResult: 1,
});
showServiceView(RESULTS_VIEW);
},
{ values, type },
);
await expect(page.getByRole("grid", { name: "Result 1", exact: true }).getByRole("gridcell")).toHaveCount(
values.length,
);
}
for (const [name, values, type, ascending] of [
["BIGINT", integers, "BIGINT", [integers[3]!, integers[2]!, integers[1]!, integers[0]!]],
["DECIMAL", decimals, "NUMERIC(38,19)", [decimals[2]!, decimals[3]!, decimals[1]!, decimals[0]!]],
] as const) {
test(`${name}: result headers sort exact values ascending, descending and back to query order`, async ({
workbench: page,
}) => {
await seedResult(page, [...values], type);
const grid = page.getByRole("grid", { name: "Result 1", exact: true });
const cells = grid.getByRole("gridcell");
const header = grid.getByRole("columnheader", { name: /^value\b/ }).getByText("value", { exact: true });
await expect(cells).toHaveText([...values]);
await header.click();
await expect(cells).toHaveText([...ascending]);
await header.click();
await expect(cells).toHaveText([...ascending].reverse());
await header.click();
await expect(cells).toHaveText([...values]);
});
}
test("precise filter values are ordered and filtering composes with result sorting", async ({ workbench: page }) => {
await seedResult(page, [...integers], "BIGINT");
const grid = page.getByRole("grid", { name: "Result 1", exact: true });
await grid.getByRole("button", { name: "Filter value", exact: true }).click();
const values = page.getByRole("group", { name: "Values", exact: true });
const ascending = [integers[3]!, integers[2]!, integers[1]!, integers[0]!];
await expect(values.locator("label")).toHaveText(ascending);
await values.getByRole("checkbox", { name: integers[3], exact: true }).uncheck();
await page.keyboard.press("Escape");
await grid
.getByRole("columnheader", { name: /^value\b/ })
.getByText("value", { exact: true })
.click();
await expect(grid.getByRole("gridcell")).toHaveText(ascending.slice(1));
});
test("selection aggregates preserve exact minimum and maximum values", async ({ workbench: page }) => {
await seedResult(page, [...decimals], "NUMERIC(38,19)");
const grid = page.getByRole("grid", { name: "Result 1", exact: true });
await grid.getByRole("gridcell").first().click();
await grid
.getByRole("gridcell")
.last()
.click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "Show Record / Value / Aggregates", exact: true }).click();
const panel = page.getByRole("complementary", { name: "Value panel", exact: true });
await panel.getByRole("tab", { name: "Aggregates", exact: true }).click();
await expect(panel.getByRole("row", { name: `Min ${decimals[2]}`, exact: true })).toBeVisible();
await expect(panel.getByRole("row", { name: `Max ${decimals[0]}`, exact: true })).toBeVisible();
});
+6 -162
View File
@@ -1,5 +1,5 @@
import type { Page } from "@playwright/test";
import { expect, openFromTree, test } from "./fixtures";
import { expect, test } from "./fixtures";
const edgeTitle = "Result 3 — a long query result at the edge of the Services pane";
const bindings = { "alpha-console.sql": "alpha", "beta-console.sql": "beta" };
@@ -64,9 +64,7 @@ function menu(page: Page) {
async function choose(page: Page, title: string, command: string) {
await tab(page, title).click({ button: "right" });
await menu(page)
.getByRole("menuitem", { name: command.startsWith("Select ") ? new RegExp(`^${command}`) : command, exact: true })
.click();
await menu(page).getByRole("menuitem", { name: command, exact: true }).click();
await expect(menu(page)).toHaveCount(0);
}
@@ -92,11 +90,9 @@ test("result context menu fits the viewport, responds to hover and keyboard, and
"Close",
"Close Others",
"Close All",
"Close All but Pinned",
"Pin Tab",
"Rename Tab",
"Select Next Tab⇧⌘]",
"Select Previous Tab⇧⌘[",
"Select Next Tab",
"Select Previous Tab",
]);
await expect(popup.getByRole("separator")).toHaveCount(2);
await selected(page, "Result 2", "beta rows");
@@ -144,7 +140,7 @@ test("result context menu fits the viewport, responds to hover and keyboard, and
await page.keyboard.press("ArrowUp");
await expect(popup.getByRole("menuitem", { name: "Close", exact: true })).toBeFocused();
await page.keyboard.press("ArrowUp");
await expect(popup.getByRole("menuitem", { name: /^Select Previous Tab/ })).toBeFocused();
await expect(popup.getByRole("menuitem", { name: "Select Previous Tab", exact: true })).toBeFocused();
await page.keyboard.press("Enter");
await expect(popup).toHaveCount(0);
await selected(page, "Result 1", "alpha rows");
@@ -211,9 +207,7 @@ for (const [theme, label] of [
await expect(surface).toHaveCSS("background-color", idle);
await expect(otherSurface).toHaveCSS("background-color", hovered);
await selected(page, "Result 2", "beta rows");
await menu(page)
.getByRole("menuitem", { name: /^Select Previous Tab/ })
.click();
await menu(page).getByRole("menuitem", { name: "Select Previous Tab", exact: true }).click();
await expect(menu(page)).toHaveCount(0);
await page.mouse.move(0, 0);
await expect(otherSurface).toHaveCSS("background-color", idle);
@@ -272,59 +266,6 @@ test("renames the clicked inactive result without changing its rows and navigate
await selected(page, "Result 3", "third rows");
});
test("default tab shortcuts stay in results across repeated tab and grid switches, and remain editor shortcuts elsewhere", async ({
workbench: page,
}) => {
await openFromTree(page, "build.gradle.kts");
await openFromTree(page, "settings.gradle.kts");
const editors = page.getByRole("tablist", { name: "Editor tabs", exact: true });
const build = editors.getByRole("tab", { name: "build.gradle.kts", exact: true });
const settings = editors.getByRole("tab", { name: "settings.gradle.kts", exact: true });
await seedResults(page);
await tab(page, "Result 1").focus();
for (const [shortcut, title, value] of [
["Meta+Shift+BracketRight", "Result 3", "third rows"],
["Meta+Shift+BracketRight", "Output"],
["Meta+Shift+BracketRight", "Result 1", "alpha rows"],
["Meta+Shift+BracketLeft", "Output"],
["Meta+Shift+BracketLeft", "Result 3", "third rows"],
]) {
await page.keyboard.press(shortcut!);
await selected(page, title!, value);
await expect(settings).toHaveAttribute("aria-selected", "true");
}
// The grid is replaced on a tab switch; the second press must still belong to results.
await page.getByRole("gridcell", { name: "third rows", exact: true }).click();
await page.keyboard.press("Meta+Shift+BracketRight");
await selected(page, "Output");
await page.keyboard.press("Meta+Shift+BracketRight");
await selected(page, "Result 1", "alpha rows");
await page.getByRole("gridcell", { name: "alpha rows", exact: true }).click();
await page.keyboard.press("Meta+Shift+BracketRight");
await selected(page, "Result 2", "beta rows");
await page.keyboard.press("Meta+Shift+BracketRight");
await selected(page, "Result 3", "third rows");
await expect(settings).toHaveAttribute("aria-selected", "true");
await settings.click();
await page.locator(".cm-content[contenteditable=true]").first().click();
await page.keyboard.press("Meta+Shift+BracketRight");
await expect(build).toHaveAttribute("aria-selected", "true");
await selected(page, "Result 3", "third rows");
await page.keyboard.press("Meta+Shift+BracketLeft");
await expect(settings).toHaveAttribute("aria-selected", "true");
await selected(page, "Result 3", "third rows");
await tab(page, "Result 1").click({ button: "right" });
await page.keyboard.press("Meta+Shift+BracketRight");
await page.keyboard.press("Meta+Shift+BracketLeft");
await selected(page, "Result 3", "third rows");
await expect(menu(page)).toBeVisible();
await page.keyboard.press("Escape");
});
test("rename owns deletion and Cmd+Enter without editing the previously focused query", async ({ workbench: page }) => {
await seedResults(page);
await page.evaluate(async () => {
@@ -415,100 +356,3 @@ test("closes clicked results, other results and all results without removing Out
await expect(page.getByRole("treeitem", { name: "Alpha database", exact: true })).toBeVisible();
await expect(page.getByRole("treeitem", { name: "Beta database", exact: true })).toBeVisible();
});
/** Runs `text` in console `path` through the production store, with the backend's answers stubbed. */
async function runConsole(page: Page, path: string, text: string) {
await page.evaluate(
async ({ path, text }) => {
const bindingsPath = "/src/bindings.ts";
const storePath = "/src/features/database/store.ts";
const { commands } = await import(bindingsPath);
const { executeConsole } = await import(storePath);
commands.dbStatements = async (_id: string, sql: string) => {
let from = 0;
const data = sql.split(";").flatMap((part) => {
const start = from + part.length - part.trimStart().length;
from += part.length + 1;
const st = part.trim();
return st ? [{ from: start, to: start + st.length, text: st }] : [];
});
return { status: "ok", data };
};
commands.dbExecute = async (_r: unknown, _id: string, statements: { text: string }[]) => ({
status: "ok",
data: statements.map((statement) => ({
statement,
error: null,
result: {
statement: statement.text,
columns: [{ name: "payload", typeName: "text" }],
rows: [[/'(.*)'/.exec(statement.text)![1]]],
affected: null,
hasMore: false,
elapsedMs: 1,
executionMs: 1,
},
})),
});
await executeConsole(path, text, 0, text.length);
},
{ path, text },
);
}
test("console runs replace their unpinned results; pinned results stay until Close All but Pinned", async ({
workbench: page,
}) => {
await seedResults(page);
const strip = page.getByRole("tablist", { name: "Results", exact: true }).getByRole("tab");
await choose(page, "Result 1", "Close All");
// The seeded Output line's id would collide with the ids of the lines the runs log.
await page.evaluate(async () => {
const storePath = "/src/features/database/store.ts";
const { useDatabase } = await import(storePath);
useDatabase.setState({ output: [] });
});
await runConsole(page, "alpha-console.sql", "SELECT 'one'");
const n = Number(/\d+/.exec((await strip.nth(1).textContent())!)![0]);
const result = (run: number, index = 1) => `Result ${n + run}${index > 1 ? `-${index}` : ""}`;
await runConsole(page, "alpha-console.sql", "SELECT 'two'");
await expect(strip).toHaveText(["Output", result(1)]);
await selected(page, result(1), "two");
const pin = page.getByRole("button", { name: "Pin Tab", exact: true });
await pin.click();
await expect(pin).toHaveAttribute("aria-pressed", "true");
await runConsole(page, "alpha-console.sql", "SELECT 'three'; SELECT 'four'");
await runConsole(page, "beta-console.sql", "SELECT 'beta'");
await expect(strip).toHaveText(["Output", result(1), result(2), result(2, 2), result(3)]);
await selected(page, result(3), "beta");
await tab(page, result(2)).click({ button: "right" });
await expect(menu(page).getByRole("menuitem", { name: "Close All but Pinned", exact: true })).toBeEnabled();
await menu(page).getByRole("menuitem", { name: "Pin Tab", exact: true }).click();
await expect(menu(page)).toHaveCount(0);
await runConsole(page, "alpha-console.sql", "SELECT 'five'");
await expect(strip).toHaveText(["Output", result(1), result(2), result(4), result(3)]);
await selected(page, result(4), "five");
await expect(pin).toHaveAttribute("aria-pressed", "false");
await tab(page, result(2)).click();
await expect(pin).toHaveAttribute("aria-pressed", "true");
await choose(page, result(3), "Close All but Pinned");
await expect(strip).toHaveText(["Output", result(1), result(2)]);
await selected(page, result(2), "three");
await tab(page, result(1)).click({ button: "right" });
await expect(menu(page).getByRole("menuitem", { name: "Unpin Tab", exact: true })).toBeVisible();
await expect(menu(page).getByRole("menuitem", { name: "Close All but Pinned", exact: true })).toBeDisabled();
await page.keyboard.press("Escape");
// A pinned tab's close button is its Unpin button; middle-click leaves it open.
const slot = tab(page, result(1)).locator("..");
await expect(slot.getByRole("button", { name: "Close Tab", exact: true })).toHaveCount(0);
await tab(page, result(1)).click({ button: "middle" });
await expect(strip).toHaveText(["Output", result(1), result(2)]);
await slot.getByRole("button", { name: "Unpin Tab", exact: true }).click();
await selected(page, result(2), "three");
await slot.getByRole("button", { name: "Close Tab", exact: true }).click();
await expect(strip).toHaveText(["Output", result(2)]);
});
Binary file not shown.
@@ -8,11 +8,6 @@ import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jdt.core.*;
import org.eclipse.jdt.core.search.IJavaSearchScope;
import org.eclipse.jdt.core.search.SearchEngine;
import org.eclipse.jdt.internal.core.CreateTypeHierarchyOperation;
import org.eclipse.jdt.internal.core.DefaultWorkingCopyOwner;
import org.eclipse.jdt.internal.core.JavaModelManager;
import org.eclipse.jdt.internal.corext.util.MethodOverrideTester;
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
import org.eclipse.jdt.ls.core.internal.JDTUtils;
@@ -61,7 +56,7 @@ public final class ImplementationsCommand implements IDelegateCommandHandler {
return false;
}
if (type == null || type.getJavaProject() == null) return false;
List<IJavaElement> sources = new ArrayList<>();
IRegion sources = JavaCore.newRegion();
for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) {
for (IPackageFragmentRoot fragmentRoot : project.getPackageFragmentRoots()) {
if (fragmentRoot.getKind() == IPackageFragmentRoot.K_SOURCE || isKotlinOutput(fragmentRoot)) {
@@ -69,13 +64,7 @@ public final class ImplementationsCommand implements IDelegateCommandHandler {
}
}
}
// IType.newTypeHierarchy's operation over the sources: the index narrows the subtypes to the
// files that name a supertype, where a region hierarchy resolves every type in the region.
ICompilationUnit[] workingCopies = JavaModelManager.getJavaModelManager().getWorkingCopies(DefaultWorkingCopyOwner.PRIMARY, true);
IJavaSearchScope scope = SearchEngine.createJavaSearchScope(sources.toArray(IJavaElement[]::new));
CreateTypeHierarchyOperation operation = new CreateTypeHierarchyOperation(type, workingCopies, scope, true);
operation.runOperation(monitor);
ITypeHierarchy hierarchy = operation.getResult();
ITypeHierarchy hierarchy = type.getJavaProject().newTypeHierarchy(type, sources, monitor);
for (IType subtype : hierarchy.getAllSubtypes(type)) {
if (monitor.isCanceled()) throw new OperationCanceledException();
IMember found;
+4 -19
View File
@@ -72,21 +72,6 @@ public class Task implements Runnable {
public void run() {
}
}
''',
'Anon.java': '''package zoo;
public class Anon {
Repo<Double> make() {
class Local implements Repo<Double> {
public void save(Double item) {
}
}
return new Repo<Double>() {
public void save(Double item) {
}
};
}
}
''',
'Use.java': '''package zoo;
@@ -189,11 +174,11 @@ try:
request('java/buildWorkspace', True)
# (implementations in the sources, whether they are all there are)
cases = {
# Classes, abstract, local and anonymous ones too, and those of the bridged Kotlin output; not the sub-interface.
('Repo.java', 'Repo<T>'): (['Anon.java:4', 'Anon.java:8', 'Base.java:2', 'Longs.java:0', 'Numbers.java:2', 'Words.java:2'], True),
# Classes, abstract ones too, and those of the bridged Kotlin output; not the sub-interface.
('Repo.java', 'Repo<T>'): (['Base.java:2', 'Longs.java:0', 'Numbers.java:2', 'Words.java:2'], True),
# Through the type arguments; not the abstract re-declaration.
('Repo.java', 'save(T'): (['Anon.java:5', 'Anon.java:9', 'Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
('Use.java', 'save("x")'): (['Anon.java:5', 'Anon.java:9', 'Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
('Repo.java', 'save(T'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
('Use.java', 'save("x")'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
('Repo.java', 'name()'): (['Words.java:6'], True),
# A library type's implementations in the sources; the library has more.
('Use.java', 'Runnable task'): (['Task.java:2'], False),
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "openidea",
"private": true,
"description": "An open-source IDE inspired by IntelliJ IDEA",
"version": "0.1.44",
"version": "0.1.43",
"license": "Apache-2.0",
"type": "module",
"scripts": {
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// The commit being prepared in the window the current command or event came
/// from (the Commit tool window, or the Commit dialog while it is open): the
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Plugin-owned project data sources. Requires database.sources and project trust.
/// Runs on the Database subsystem's host, including on remote project servers.
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Project-scoped decorations (API 1.4). Requires project.read; paths must be inside
/// an open project. Sets belong to the plugin and disappear when it stops.
@@ -19,7 +19,4 @@ interface decorations {
set-file: func(path: string, set-id: string, item: file-decoration) -> result<_, error>;
/// Remove this tree set from every file/project.
clear-file: func(set-id: string) -> result<_, error>;
/// Replace this set's tree annotation for a file with a note shown after its name (API 1.7):
/// one line of at most 200 characters, with an optional tooltip.
set-file-note: func(path: string, set-id: string, note: string, tooltip: option<string>) -> result<_, error>;
}
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// What the pane beside the Services tree shows for a plugin's node.
interface detail-types {
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Optional export (world `plugin-diagnostics`): problems in the current,
/// possibly unsaved document, for the languages declared with the
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// HTTP endpoints found in a project (`[[contributes.endpoints]]`).
interface endpoints-provider {
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
interface lifecycle {
use types.{context, error};
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// What other plugins contributed to the extension points this plugin declares
/// (`[[extension-points]]` in `plugin.toml`; the contributors list this plugin
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Optional export (world `plugin-file-actions`): quick fixes and
/// refactorings that create, rename or delete files, next to the text edits
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Files outside the open projects: the configuration of the program a
/// plugin integrates with, a tool installed elsewhere, a licence file.
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Optional export (world `plugin-hover`): Markdown added to the hover of
/// documents of the languages declared with the `hover` provider kind,
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Cross-file associations a plugin publishes for the files it declares under
/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Embedded languages. The host routes plugin providers and configured language
/// servers inside these ranges, preserving host-document UTF-16 coordinates.
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Optional export (world `plugin-inlay-hints`): inline hints in documents of
/// the languages declared with the `inlay-hints` provider kind, including
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Additional language features, called only for declared API 1.4 provider kinds.
/// Editor calls have a 50 ms soft / 500 ms hard budget; formatting has 5 seconds.
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Discovery of the IDE's built-in MCP server; never exposes its URL or token.
interface mcp {
@@ -8,7 +8,7 @@
/// `fs` and `workspace`, and everything else goes through the imports below,
/// gated by the capabilities granted in `plugin.toml` (a missing one returns
/// `error::denied`).
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
world plugin {
import log;
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Project-scoped targets owned by the registering plugin (API 1.4).
interface run-targets {
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Launches this plugin's configurations in the invoking window's Run tool window.
interface run {
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Optional export (world `plugin-semantic-tokens`): semantic colouring of
/// documents of the languages declared with the `semantic-tokens` provider
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
/// as in the editor and LSP.
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
@@ -149,12 +149,6 @@ interface views {
open-content-tab: func(tab: content-tab) -> result<_, error>;
/// Answers content-tab-action in its originating window.
reply-content-tab: func(request-id: string, reply: content-tab-reply) -> result<_, error>;
/// Opens two texts in the diff viewer of the window the call came from
/// (API 1.7): a review's file before and after, say. `file-name` (a name or
/// relative path, at most 256 characters) picks the syntax highlighting and
/// names the tab when `diff.title` is absent. Only from a command or event
/// of a window; each side at most 1 MiB.
open-diff: func(diff: content-diff, file-name: option<string>) -> result<_, error>;
/// The view (or every custom editor of that id) shows outdated content:
/// the IDE renders it again.
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.6.0;
interface workspace {
use types.{error, location};
+6 -73
View File
@@ -15,92 +15,45 @@ use tauri::{State, Window};
#[derive(Default)]
pub struct McpService {
server: Mutex<Option<ide_mcp::McpServer>>,
/// The port and token settings the running server was started with.
running_with: Mutex<Option<Listen>>,
error: Mutex<Option<String>>,
}
/// What the port and token settings ask the server for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Listen {
/// `mcpServer.port` with `mcpServer.fixedPort`; otherwise a free port.
port: Option<u16>,
fixed_token: bool,
}
/// Starts, stops or restarts the server to match the global `mcpServer` settings: `enabled`,
/// and the port and token it listens with.
/// Starts or stops the server to match the global `mcpServer.enabled`.
pub fn apply(state: &AppState) {
let settings = state.global_settings.effective().mcp_server;
let enabled = state.global_settings.effective().mcp_server.enabled;
let mut server = state.mcp.server.lock();
if !settings.enabled {
if !enabled {
if let Some(running) = server.take() {
running.stop();
}
*state.mcp.running_with.lock() = None;
ide_agent::set_mcp_executable(None);
state.host.set_mcp_executable(None);
*state.mcp.error.lock() = None;
return;
}
let wanted = Listen {
port: settings.fixed_port.then_some(settings.port).filter(|p| *p != 0),
fixed_token: settings.fixed_token,
};
if server.is_some() && *state.mcp.running_with.lock() == Some(wanted) {
if server.is_some() {
return;
}
// Another port or token: clients connect anew.
if let Some(running) = server.take() {
running.stop();
if let Some(port) = wanted.port {
wait_until_free(port);
}
}
let token = if wanted.fixed_token {
match ide_mcp::kept_token(&ide_mcp::token_path(&state.data_dir)) {
Ok(token) => Some(token),
Err(e) => {
tracing::warn!("MCP server: cannot keep the token ({e}); using a new one for this run");
None
}
}
} else {
None
};
let discovery = ide_mcp::discovery_path(&state.data_dir);
match ide_mcp::McpServer::start_with(
match ide_mcp::McpServer::start(
Arc::new(ide_host::mcp::Registry::new(state.host.clone())),
Some(discovery),
&state.host.runtime,
ide_mcp::ServerOptions { port: wanted.port, token },
) {
Ok(started) => {
let exe = std::env::current_exe().unwrap_or_default();
state.host.set_mcp_executable(Some(exe.clone()));
ide_agent::set_mcp_executable(Some(exe));
*server = Some(started);
*state.mcp.running_with.lock() = Some(wanted);
*state.mcp.error.lock() = None;
}
Err(e) => {
tracing::warn!("MCP server: {e}");
*state.mcp.running_with.lock() = None;
*state.mcp.error.lock() = Some(e.to_string());
}
}
}
/// Waits briefly for a stopped server's port to close, so the restart gets it again.
fn wait_until_free(port: u16) {
for _ in 0..100 {
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
/// Stops the server on exit (its discovery file goes with it).
pub fn stop(state: &AppState) {
state.host.set_mcp_executable(None);
@@ -108,7 +61,6 @@ pub fn stop(state: &AppState) {
if let Some(server) = state.mcp.server.lock().take() {
server.stop();
}
*state.mcp.running_with.lock() = None;
}
/// What Settings › Agents › MCP Server shows.
@@ -119,14 +71,11 @@ pub struct McpServerStatus {
/// Why it isn't running though turned on.
pub error: Option<String>,
pub port: Option<u16>,
/// Why the fixed port (`mcpServer.port`) isn't the one listened on: it was in use or
/// reserved, so a free port was taken for this run.
pub port_unavailable: Option<String>,
/// The window's local project, which the URL and command below are for.
pub project: Option<IpcPath>,
/// `http://127.0.0.1:<port>/mcp?project=<root>`.
pub url: Option<String>,
/// Bearer token of `url` (changes with every run unless `mcpServer.fixedToken` is on).
/// Bearer token of `url` (changes with every run).
pub token: Option<String>,
/// The stdio bridge: this executable and its arguments.
pub stdio_command: Vec<String>,
@@ -142,7 +91,6 @@ pub fn mcp_server_status(window: Window, state: State<'_, AppState>) -> IdeResul
running: server.is_some(),
error: state.mcp.error.lock().clone(),
port: server.as_ref().map(|s| s.port()),
port_unavailable: server.as_ref().and_then(|s| s.port_unavailable().map(str::to_owned)),
url: server.as_ref().zip(root.as_ref()).map(|(s, r)| s.url(r)),
token: server.as_ref().map(|s| s.token().to_owned()),
stdio_command: root.as_ref().map(|r| ide_mcp::stdio_command(&exe, r)).unwrap_or_default(),
@@ -150,21 +98,6 @@ pub fn mcp_server_status(window: Window, state: State<'_, AppState>) -> IdeResul
})
}
/// Settings › Agents › MCP Server › Regenerate Token: forgets the kept token, and restarts the
/// server with a new one. Clients configured with the old token stop working.
#[tauri::command]
#[specta::specta]
pub fn mcp_regenerate_token(state: State<'_, AppState>) -> IdeResult<()> {
match std::fs::remove_file(ide_mcp::token_path(&state.data_dir)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
*state.mcp.running_with.lock() = None;
apply(&state);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn mcp_set_open_editors(window: Window, state: State<'_, AppState>, paths: Vec<IpcPath>) -> IdeResult<()> {
-1
View File
@@ -75,7 +75,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
mcp::mcp_ui_ready,
mcp::mcp_respond,
mcp::mcp_agent_run_closed,
mcp::mcp_regenerate_token,
mcp::mcp_server_status,
mcp::mcp_set_open_editors,
mcp::mcp_set_focused_editor,
+1 -3
View File
@@ -63,9 +63,7 @@ impl EventSink for TauriSink {
) {
flash_taskbar_unless_focused(&self.app);
}
// A confirmation waits for the user: bring the window up. Other requests (an agent's
// run, opening a file) need nothing from the user and leave the focus where it is.
if matches!(&event, IdeEvent::McpRequest { request: ide_core::event::McpUiRequest::Confirm { .. }, .. })
if matches!(&event, IdeEvent::McpRequest { .. })
&& let Some(label) = self.window.clone()
{
let app = self.app.clone();
-23
View File
@@ -180,29 +180,6 @@ unsafe extern "C-unwind" fn should_terminate(_this: *mut AnyObject, _selector: S
static APP: std::sync::OnceLock<AppHandle> = std::sync::OnceLock::new();
/// Escape the page leaves unhandled reaches the window as `cancelOperation:`, on which NSWindow
/// leaves full screen, so pressing Escape once more after closing a popup left full screen. The
/// IDE's windows answer it with nothing; View › Appearance › Toggle Full Screen (⌃⌘F) leaves it.
pub fn keep_full_screen_on_escape() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| unsafe {
let Some(class) = AnyClass::get(c"TaoWindow") else {
tracing::warn!("no TaoWindow class: Escape leaves full screen");
return;
};
let imp: Imp = std::mem::transmute(ignore_cancel_operation as CancelOperation);
let class = class as *const AnyClass as *mut AnyClass;
let added = objc2::ffi::class_addMethod(class, objc2::sel!(cancelOperation:), imp, c"v@:@".as_ptr());
if !added.as_bool() {
tracing::warn!("could not handle cancelOperation:");
}
});
}
type CancelOperation = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject);
unsafe extern "C-unwind" fn ignore_cancel_operation(_this: *mut AnyObject, _selector: Sel, _sender: *mut AnyObject) {}
/// Whether AppKit is handling a key press right now. A menu item performed at this point
/// was chosen by its key equivalent: a click, arrow keys plus Return and accessibility
/// presses all run with a non-key current event.
-2
View File
@@ -660,8 +660,6 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
decorate_csd(&window);
#[cfg(target_os = "macos")]
crate::page_swipe::install(&window);
#[cfg(target_os = "macos")]
crate::macos::keep_full_screen_on_escape();
apply_zoom(app, &window);
#[cfg(target_os = "macos")]
align_titlebar(app, &window, app.try_state::<WindowsState>().is_some_and(|w| w.opens_project(window.label())));
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenIDEA",
"version": "0.1.44",
"version": "0.1.43",
"identifier": "ai.l0ng.openidea",
"build": {
"beforeDevCommand": "pnpm dev",
+2 -29
View File
@@ -19,11 +19,6 @@ export const commands = {
mcpRespond: (id: number, allow: boolean, error: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_respond", { id, allow, error })),
/** The user closed the Run tool window tab of an agent command (`run_command`). */
mcpAgentRunClosed: (name: string) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_agent_run_closed", { name })),
/**
* Settings › Agents › MCP Server › Regenerate Token: forgets the kept token, and restarts the
* server with a new one. Clients configured with the old token stop working.
*/
mcpRegenerateToken: () => typedError<null, IdeError>(__TAURI_INVOKE("mcp_regenerate_token")),
mcpServerStatus: () => typedError<McpServerStatus, IdeError>(__TAURI_INVOKE("mcp_server_status")),
mcpSetOpenEditors: (paths: IpcPath[]) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_set_open_editors", { paths })),
mcpSetFocusedEditor: (editor: {
@@ -5864,12 +5859,7 @@ workspaceEdit?: boolean } |
/** A document's dirty flag changed (saved / modified externally). */
{ type: "docDirty"; doc: DocId; dirty: boolean } |
/** A complete workspace edit can be undone as one operation. */
{ type: "workspaceEditApplied"; id: number; paths: IpcPath[];
/**
* Made by an agent through the MCP server's tools: undoable like a refactoring, but
* not announced as one.
*/
byAgent: boolean } |
{ type: "workspaceEditApplied"; id: number; paths: IpcPath[] } |
/**
* An open document's language changed (Settings › File Types, Override File
* Type), or its UI mode (a framework's root marker appeared or went).
@@ -7207,18 +7197,6 @@ export type McpServerSettings = {
agentTabLimit?: number,
/** Archived agent tabs kept; older ones are closed. */
agentArchiveLimit?: number,
/**
* Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
* port forwards keep working after a restart.
*/
fixedPort?: boolean,
/** The port of [`Self::fixed_port`]. */
port?: number,
/**
* Keep the bearer token across runs (in the app data, readable by this user only) rather than
* make a new one each run.
*/
fixedToken?: boolean,
};
/** What Settings › Agents › MCP Server shows. */
@@ -7227,16 +7205,11 @@ export type McpServerStatus = {
/** Why it isn't running though turned on. */
error: string | null,
port: number | null,
/**
* Why the fixed port (`mcpServer.port`) isn't the one listened on: it was in use or
* reserved, so a free port was taken for this run.
*/
portUnavailable: string | null,
/** The window's local project, which the URL and command below are for. */
project: IpcPath | null,
/** `http://127.0.0.1:<port>/mcp?project=<root>`. */
url: string | null,
/** Bearer token of `url` (changes with every run unless `mcpServer.fixedToken` is on). */
/** Bearer token of `url` (changes with every run). */
token: string | null,
/** The stdio bridge: this executable and its arguments. */
stdioCommand: string[],
+1 -40
View File
@@ -2,8 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { undo, history } from "@codemirror/commands";
import { java } from "@codemirror/lang-java";
import { enterInBlockComment, enterInDocComment, phpDocTags } from "./docComment";
import { enterInDocComment, phpDocTags } from "./docComment";
const views: EditorView[] = [];
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
@@ -76,41 +75,3 @@ describe("Enter after /**", () => {
expect(enter("/**|", "python", hash).handled).toBe(false);
});
});
/** Enter through `enterInBlockComment`, in a parsed Java document. */
function enterBlock(text: string) {
const at = text.indexOf("|");
const view = new EditorView({
state: EditorState.create({
doc: text.replace("|", ""),
selection: EditorSelection.cursor(at),
extensions: [java()],
}),
});
views.push(view);
const handled = enterInBlockComment(view);
const s = view.state;
const caret = s.selection.main.head;
return { handled, text: s.doc.sliceString(0, caret) + "|" + s.doc.sliceString(caret) };
}
describe("Enter in a /* */ comment", () => {
it("starts the new line with a leading asterisk aligned under the comment's", () => {
expect(enterBlock("/**\n * xxxx|\n **/\nclass A {}").text).toBe("/**\n * xxxx\n * |\n **/\nclass A {}");
expect(enterBlock("class A {\n /**\n * a|b\n */\n}").text).toBe(
"class A {\n /**\n * a\n * |b\n */\n}",
);
expect(enterBlock("class A {\n /* note|\n */\n}").text).toBe("class A {\n /* note\n * |\n */\n}");
});
it("keeps the closing */ on its own line", () => {
expect(enterBlock("/** summary |*/\nclass A {}").text).toBe("/** summary\n * |\n */\nclass A {}");
});
it("leaves Enter alone outside a comment, before its asterisk, and in a string", () => {
expect(enterBlock("/* a */ int x;|").handled).toBe(false);
expect(enterBlock("/**\n| * a\n */").handled).toBe(false);
expect(enterBlock('class A { String s = "/* x|"; }').handled).toBe(false);
expect(enterBlock("// a /* b|").handled).toBe(false);
});
});
+1 -59
View File
@@ -3,8 +3,7 @@
* new comment closes it, the caret on the summary line. In PHP the stub of a function lists its
* parameters (`@param`) and return type (`@return`), as PhpStorm does.
*/
import { syntaxTree } from "@codemirror/language";
import { EditorSelection, type EditorState } from "@codemirror/state";
import { EditorSelection } from "@codemirror/state";
import type { Command } from "@codemirror/view";
/** `@param` / `@return` lines for the PHP function declared in `text` (the code below the `/**`). */
@@ -87,60 +86,3 @@ export function enterInDocComment(language: string | undefined): Command {
return true;
};
}
/** How far back the opening `/*` of the comment around the caret is looked for. */
const OPENER_SCAN = 20_000;
/** Whether `pos` is inside a `/* *\/` comment opened at `open` (by the syntax tree, when it has one there). */
function inBlockComment(state: EditorState, pos: number): number | null {
const from = Math.max(0, pos - OPENER_SCAN);
const before = state.sliceDoc(from, pos);
const open = before.lastIndexOf("/*");
if (open < 0 || before.indexOf("*/", open + 2) >= 0) return null;
const node = syntaxTree(state).resolveInner(pos, -1);
// A parsed document says whether this `/*` opens the comment (not text in a string or a `//`).
if (node.parent && !(/comment/i.test(node.name) && node.from === from + open)) return null;
return from + open;
}
/**
* Enter inside a `/* *\/` comment, on its first line or on a line that starts with `*`: the new
* line starts with `* ` aligned under the comment's asterisks, as IDEA's leading asterisks do.
*/
export const enterInBlockComment: Command = (view) => {
const { state } = view;
if (state.readOnly || state.selection.ranges.length !== 1) return false;
const r = state.selection.main;
if (!r.empty) return false;
const block = state.languageDataAt<{ block?: { open: string; close: string } }>("commentTokens", r.head)[0]?.block;
if (block?.open !== "/*" || block.close !== "*/") return false;
const open = inBlockComment(state, r.head);
if (open === null) return false;
const line = state.doc.lineAt(r.head);
const before = line.text.slice(0, r.head - line.from);
const after = line.text.slice(r.head - line.from);
let prefix: string;
if (open >= line.from) {
// The opening line: the asterisks go one column right of the `/`.
prefix = line.text.slice(0, open - line.from).replace(/[^\t]/g, " ") + " ";
} else {
const star = /^([ \t]*)\*(?!\/)/.exec(line.text);
if (!star || star[0].length > before.length) return false;
prefix = star[1]!;
}
const br = state.lineBreak;
const rest = after.trimStart();
const insert = rest.startsWith("*/") ? `${br}${prefix}* ${br}${prefix}` : `${br}${prefix}* `;
// Spaces around the break go, as a plain Enter drops them.
const from = r.head - (before.length - before.trimEnd().length);
const caret = from + br.length + prefix.length + 2;
view.dispatch(
state.update({
changes: { from, to: r.head + (after.length - rest.length), insert },
selection: EditorSelection.cursor(caret),
scrollIntoView: true,
userEvent: "input",
}),
);
return true;
};
+2 -2
View File
@@ -49,7 +49,7 @@ import {
selectHumpRight,
} from "./editing";
import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments";
import { enterInBlockComment, enterInDocComment } from "./docComment";
import { enterInDocComment } from "./docComment";
import { renderDocComments } from "./renderedDocs";
import { skipClosingQuote, smartAngleBrackets, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
import { stringClipboard } from "./stringClipboard";
@@ -626,7 +626,7 @@ export function prefsExtension(o: PrefsOptions): Extension {
{ mac: "Mod-ArrowLeft", run: cursorLineStart, shift: selectLineStart, preventDefault: true },
);
if (!o.readOnly && k.insertDocStub) keys.push({ key: "Enter", run: enterInDocComment(o.language) });
if (!o.readOnly) keys.push({ key: "Enter", run: enterInBlockComment }, { key: "Enter", run: enterInLineComment });
if (!o.readOnly) keys.push({ key: "Enter", run: enterInLineComment });
if (!k.smartIndent) keys.push({ key: "Enter", run: insertNewlineKeepIndent });
if (k.backspaceUnindent === "none") keys.push({ key: "Backspace", run: deleteCharBackwardStrict });
else if (smartBackspaceApplies(k.backspaceUnindent, o.language)) keys.push({ key: "Backspace", run: smartBackspace });
@@ -57,12 +57,7 @@ async function replace(text: string) {
}
async function rename() {
await replace("new");
emitLocalIdeEvent({
type: "workspaceEditApplied",
id: 9,
paths: ["/p/A.java", "/p/B.java", "/p/closed.java"],
byAgent: false,
});
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 9, paths: ["/p/A.java", "/p/B.java", "/p/closed.java"] });
}
function historyStep(slot: EditorSlot, forward = false) {
return (forward ? redo : undo)({
@@ -188,7 +183,7 @@ it("rebuilds multiple redo markers in order when a closed file opens", async ()
const a = await open("/p/A.java");
await rename();
await replace("alt");
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 10, paths: ["/p/A.java", "/p/B.java"], byAgent: true });
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 10, paths: ["/p/A.java", "/p/B.java"] });
commands.undoWorkspaceEdit.mockImplementation(async (id: number) => {
await replace(id === 10 ? "new" : "old");
return { status: "ok", data: null };
+2 -24
View File
@@ -46,7 +46,7 @@ it("keeps mapped lenses across edits instead of clearing them (#2710)", async ()
data: [
{
range: { start: { line: 1, col: 0 }, end: { line: 1, col: 3 } },
command: { title: "Run test", command: "csharp.runTest", arguments: [] },
command: { title: "1 reference", command: "csharp.showReferences", arguments: [] },
},
],
});
@@ -67,27 +67,5 @@ it("keeps mapped lenses across edits instead of clearing them (#2710)", async ()
const after = view.state.facet(codeLenses);
expect(after).toHaveLength(1);
expect(after[0]!.pos).toBe(posBefore + 1);
expect(after[0]!.title).toBe("Run test");
});
it("leaves reference counts to Code Vision's usages", async () => {
vi.useFakeTimers();
const lens = (line: number, title: string) => ({
range: { start: { line, col: 0 }, end: { line, col: 1 } },
command: { title, command: "csharp.showReferences", arguments: [] },
});
ipc.lspCodeLens.mockResolvedValue({
status: "ok",
data: [lens(0, "3 Reference(s)"), lens(1, "1 reference"), lens(2, "12 references"), lens(3, "2 implementations")],
});
const view = new EditorView({
state: EditorState.create({
doc: "a\nb\nc\nd",
extensions: serverCodeLenses({ doc: 1, path: "/p/a.cs", language: "csharp", uiMode: null, readOnly: false }),
}),
parent: document.body,
});
views.push(view);
await vi.advanceTimersByTimeAsync(400);
expect(view.state.facet(codeLenses).map((l) => l.title)).toEqual(["2 implementations"]);
expect(after[0]!.title).toBe("1 reference");
});
+1 -7
View File
@@ -6,12 +6,6 @@ import { call, commands } from "@/platform/ipc";
import { onNavigationProvidersChanged } from "@/editor/navigationProviders";
import { posToOffset } from "./positions";
import { onLspRefresh, onServerSettled } from "./availability";
/**
* A server's reference count ("3 references", csharp-ls's "3 Reference(s)"): Code Vision's
* Usages shows the same count above the declaration, under its own setting.
*/
const REFERENCE_COUNT = /^\s*\d+\s+references?(\(s\))?\s*$/i;
const set = StateEffect.define<CodeLens[]>();
const field = StateField.define<CodeLens[]>({
create: () => [],
@@ -68,7 +62,7 @@ export function serverCodeLenses(ctx: EditorDocContext): Extension {
view.dispatch({
effects: set.of(
result.flatMap((l) =>
l.command && !REFERENCE_COUNT.test(l.command.title)
l.command
? [
{
pos: posToOffset(text, l.range.start),
+2 -44
View File
@@ -1,22 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { describe, expect, it } from "vitest";
import type { CodeAction } from "@/bindings";
const { openPopup, applyCodeAction } = vi.hoisted(() => ({
openPopup: vi.fn(),
applyCodeAction: vi.fn(async () => {}),
}));
vi.mock("@/editor/lsp/popupStore", async (importOriginal) => ({
...(await importOriginal<typeof import("@/editor/lsp/popupStore")>()),
openPopup,
}));
vi.mock("@/editor/lsp/intentions", async (importOriginal) => ({
...(await importOriginal<typeof import("@/editor/lsp/intentions")>()),
applyCodeAction,
}));
const { chooseOccurrences, introducedName, occurrenceVariants } = await import("./serverIntroduce");
import { introducedName, occurrenceVariants } from "./serverIntroduce";
const spansText = (text: string, spans: { from: number; to: number }[]) => spans.map((s) => text.slice(s.from, s.to));
@@ -71,29 +55,3 @@ describe("occurrenceVariants", () => {
expect(occurrenceVariants([one, action("Extract to field"), all])).toBeNull();
});
});
describe("chooseOccurrences", () => {
const action = (title: string) => ({ title, kind: "refactor.extract.variable", server: 1 }) as CodeAction;
const one = action("Extract to local variable");
const all = action("Extract to local variable (replace all occurrences)");
const viewOf = (doc: string, selected: string) => {
const from = doc.indexOf(selected);
return new EditorView({
state: EditorState.create({ doc, selection: { anchor: from, head: from + selected.length } }),
});
};
it("asks when the selected expression occurs more than once", () => {
openPopup.mockClear();
expect(chooseOccurrences(viewOf("f(a + b); g(a + b);", "a + b"), [one, all], "extractVariable")).toBe(true);
expect(openPopup).toHaveBeenCalledWith(expect.objectContaining({ title: "Multiple occurrences found" }));
});
it("introduces it directly when it occurs once", async () => {
openPopup.mockClear();
applyCodeAction.mockClear();
expect(chooseOccurrences(viewOf("f(a + b); g(c);", "a + b"), [one, all], "extractVariable")).toBe(true);
expect(openPopup).not.toHaveBeenCalled();
await vi.waitFor(() => expect(applyCodeAction).toHaveBeenCalledWith(expect.anything(), one));
});
});
+1 -6
View File
@@ -112,8 +112,7 @@ export function occurrenceVariants(actions: readonly CodeAction[]): { one: CodeA
}
/**
* IDEA's "Multiple occurrences found" for a server that offers both variants,
* applying the single-occurrence one directly when the selected text occurs only once;
* IDEA's "Multiple occurrences found" for a server that offers both variants;
* false when it does not (the caller applies / lists the actions as before).
*/
export function chooseOccurrences(view: EditorView, actions: readonly CodeAction[], id: RefactoringId): boolean {
@@ -124,10 +123,6 @@ export function chooseOccurrences(view: EditorView, actions: readonly CodeAction
const text = state.sliceDoc(sel.from, sel.to);
const occurrences = text.trim() ? findTextOccurrences(state.doc.toString(), 0, state.doc.length, text, 200) : [];
const n = occurrences.length;
if (n === 1) {
void applyIntroduceAction(view, variants.one, id);
return true;
}
openPopup({
kind: "list",
anchor: caretAnchor(view, sel.head),
@@ -31,11 +31,4 @@ describe("Language server status popup", () => {
expect(screen.queryByRole("button", { name: /Download JDK/ })).toBeNull();
expect(screen.getByRole("button", { name: "Restart jdtls" })).toBeInTheDocument();
});
it("checks again for a missing server installed outside the IDE", () => {
useServers.setState({ servers: new Map([[1 as never, server("missing", "brew install marksman")]]) });
render(<ServerStatus />);
fireEvent.click(screen.getByRole("button", { name: /Language servers/ }));
expect(screen.getByRole("button", { name: "Check for jdtls again" })).toBeEnabled();
});
});
+18 -22
View File
@@ -69,29 +69,25 @@ export function ServerStatus() {
>
{jdkFix.label}
</Button>
) : s.status === "missing" && canInstall ? (
<Button
size="small"
aria-label={tx`Install ${s.name}`}
onClick={() => {
setOpen(false);
void executeAction("tools.install", s.name);
}}
>
<T>Install</T>
</Button>
) : (
<>
{s.status === "missing" && canInstall && (
<Button
size="small"
aria-label={tx`Install ${s.name}`}
onClick={() => {
setOpen(false);
void executeAction("tools.install", s.name);
}}
>
<T>Install</T>
</Button>
)}
{/* A missing server may have been installed outside the IDE since; restarting looks for it again. */}
<IconButton
icon="refresh"
size="small"
title={s.status === "missing" ? tx`Check for ${s.name} again` : tx`Restart ${s.name}`}
disabled={s.status === "starting"}
onClick={() => void restartServer(s.id)}
/>
</>
<IconButton
icon="refresh"
size="small"
title={tx`Restart ${s.name}`}
disabled={s.status === "missing" || s.status === "starting"}
onClick={() => void restartServer(s.id)}
/>
)}
</div>
{(s.languages.length > 0 || s.root) && (
+1 -16
View File
@@ -1,6 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ServerId, ServerInfo } from "@/bindings";
import type { ServerEntry } from "./servers";
const pending = vi.hoisted(() => [] as ((data: ServerInfo[]) => void)[]);
@@ -15,32 +14,18 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
new Promise((resolve) => {
pending.push((data) => resolve({ status: "ok", data }));
}),
lspRestart: () => Promise.resolve({ status: "error", error: { kind: "unavailable", message: "not found" } }),
},
};
});
const { emitLocalIdeEvent } = await import("@/platform/ipc");
const { useProject } = await import("@/platform/project");
const { provideNotificationService } = await import("@/workbench/contributions");
const { BUSY_SHOW_DELAY_MS, eslintCovers, installServerEvents, restartServer, serversSummary, useServers } =
await import("./servers");
const { BUSY_SHOW_DELAY_MS, eslintCovers, installServerEvents, serversSummary, useServers } = await import("./servers");
describe("server status", () => {
let dispose = () => {};
afterEach(() => dispose());
it("says a missing server is still not installed when checking again finds nothing", async () => {
const id = 7 as ServerId;
const entry: ServerEntry = { id, name: "sqls", status: "missing", languages: ["sql"], root: null, message: null };
useServers.setState({ servers: new Map([[id, entry]]) });
const notify = vi.fn();
provideNotificationService({ notify } as never);
await restartServer(id);
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "sqls is still not installed" }));
useServers.setState({ servers: new Map() });
});
it("keeps a status that arrived after the snapshot was requested", async () => {
useProject.setState({ project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never });
dispose = installServerEvents();
-7
View File
@@ -5,7 +5,6 @@ import type { ServerId, ServerInfo, ServerStatus } from "@/bindings";
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { tx } from "@/platform/i18n";
export interface ServerEntry {
id: ServerId;
@@ -95,15 +94,9 @@ export async function refreshServers() {
}
export async function restartServer(id: ServerId) {
const missing = useServers.getState().servers.get(id)?.status === "missing";
try {
await call(commands.lspRestart(id));
} catch (e) {
const entry = useServers.getState().servers.get(id);
if (missing && entry?.status === "missing") {
notifications().notify({ group: "lsp", level: "warning", title: tx`${entry.name} is still not installed` });
return;
}
notifications().notify({
group: "lsp",
level: "error",
@@ -35,7 +35,7 @@ it("waits for pending typing before undoing the entire backend transaction", asy
release = resolve;
}),
);
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 17, paths: [], byAgent: false });
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 17, paths: [] });
const action = notices[0]!.actions![0]!;
expect(action.label).toBe("Undo");
const pending = action.run();
@@ -47,13 +47,13 @@ it("waits for pending typing before undoing the entire backend transaction", asy
it("reports a refused undo instead of discarding newer work", async () => {
mocks.undo.mockRejectedValue(new Error("a.txt changed"));
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 18, paths: [], byAgent: false });
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 18, paths: [] });
await notices[0]!.actions![0]!.run();
expect(notices[1]).toMatchObject({ level: "error", title: "Cannot undo refactoring", body: "a.txt changed" });
});
it("does not apply an old notification's id to a different project", async () => {
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 19, paths: [], byAgent: false });
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 19, paths: [] });
const previous = useProject.getState().project;
useProject.setState({ project: { ...previous, root: "/different-project" } as NonNullable<typeof previous> });
try {
@@ -64,8 +64,3 @@ it("does not apply an old notification's id to a different project", async () =>
useProject.setState({ project: previous });
}
});
it("does not announce an agent's edit as a refactoring", () => {
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 20, paths: [], byAgent: true });
expect(notices).toEqual([]);
});
+2 -6
View File
@@ -3,10 +3,7 @@ import { onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
/**
* The notification follows the same history state as editor Undo/Redo. An agent's edits (the MCP
* server's tools) stay undoable from the editor but are not announced: an agent makes many.
*/
/** The notification follows the same history state as editor Undo/Redo. */
export function installWorkspaceEditUndo(): () => void {
let current: { id: number; project: ReturnType<typeof useProject.getState>["project"]; undone: boolean } | null =
null;
@@ -41,8 +38,7 @@ export function installWorkspaceEditUndo(): () => void {
],
});
};
const stopApplied = onIdeEvent("workspaceEditApplied", ({ id, byAgent }) => {
if (byAgent) return;
const stopApplied = onIdeEvent("workspaceEditApplied", ({ id }) => {
current = { id, project: useProject.getState().project, undone: false };
show();
});
+1 -71
View File
@@ -1,11 +1,9 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { writeClipboardText } from "@/platform/clipboard";
import { describe, expect, it, vi } from "vitest";
import { commands } from "@/platform/ipc";
import { ExportDialog } from "./ExportDialog";
import { cellPreview } from "./cellPreview";
vi.mock("@/platform/clipboard", () => ({ writeClipboardText: vi.fn(async () => {}) }));
vi.mock("./store", () => ({ closeDialog: vi.fn() }));
vi.mock("@/workbench/contributions", async (importOriginal) => ({
...(await importOriginal<typeof import("@/workbench/contributions")>()),
@@ -13,8 +11,6 @@ vi.mock("@/workbench/contributions", async (importOriginal) => ({
}));
vi.mock("@tauri-apps/api/path", () => ({ homeDir: async () => "/home/test" }));
afterEach(() => vi.restoreAllMocks());
describe("large value export", () => {
it("bounds preview values while exporting every row in full", async () => {
const full = JSON.stringify({ text: "\u4e2d text ".repeat(200_000), tail: "complete" });
@@ -42,69 +38,3 @@ describe("large value export", () => {
dbExportFile.mockRestore();
});
});
describe("exact numeric export", () => {
it.each(["Copy to Clipboard", "Export to File"])("keeps preview and %s values exact", async (action) => {
const dbExportFile = vi.spyOn(commands, "dbExportFile").mockResolvedValue({ status: "ok", data: null });
vi.mocked(writeClipboardText).mockClear();
const expected =
'[\n {\n "id": 9223372036854775807,\n "amount": 1.0000000000000000002,\n "note": "9007199254740993",\n "empty": null\n }\n]';
render(
<ExportDialog
data={{
columns: [
{ name: "id", type: "BIGINT" },
{ name: "amount", type: "NUMERIC(38,19)" },
{ name: "note", type: "TEXT" },
{ name: "empty", type: "TEXT" },
],
rows: [["9223372036854775807", "1.0000000000000000002", "9007199254740993", null]],
table: "numbers",
driver: "postgres",
}}
source="public.numbers"
initialFormat="json"
/>,
);
expect(screen.getByLabelText("Export preview").textContent).toBe(expected);
await waitFor(() => expect(screen.getByLabelText("Output file")).toHaveValue("/home/test/numbers.json"));
fireEvent.click(screen.getByRole("button", { name: action }));
if (action === "Copy to Clipboard") {
await waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith(expected));
} else {
await waitFor(() => expect(dbExportFile).toHaveBeenCalledWith("/home/test/numbers.json", expected));
}
});
});
it.each(["Copy to Clipboard", "Export to File"])(
"%s includes numeric rows beyond the preview limit",
async (action) => {
const dbExportFile = vi.spyOn(commands, "dbExportFile").mockResolvedValue({ status: "ok", data: null });
vi.mocked(writeClipboardText).mockClear();
const rows = Array.from({ length: 101 }, (_, i) => [(9223372036854775807n - BigInt(i)).toString()]);
render(
<ExportDialog
data={{ columns: [{ name: "id", type: "BIGINT" }], rows, table: "numbers", driver: "postgres" }}
source="public.numbers"
initialFormat="json"
/>,
);
const preview = screen.getByLabelText("Export preview").textContent!;
expect(preview).toContain('"id": 9223372036854775807');
expect(preview).not.toContain(rows[100]![0]!);
expect(preview).toContain("… 1 more rows");
await waitFor(() => expect(screen.getByLabelText("Output file")).toHaveValue("/home/test/numbers.json"));
fireEvent.click(screen.getByRole("button", { name: action }));
let text: string;
if (action === "Copy to Clipboard") {
await waitFor(() => expect(writeClipboardText).toHaveBeenCalled());
text = vi.mocked(writeClipboardText).mock.calls[0]![0];
} else {
await waitFor(() => expect(dbExportFile).toHaveBeenCalled());
text = dbExportFile.mock.calls[0]![1];
}
expect(JSON.parse(text)).toHaveLength(101);
expect(text).toContain(`"id": ${rows[100]![0]}`);
},
);
+32 -64
View File
@@ -23,7 +23,6 @@ import {
tooltipAttrs,
} from "@/ui";
import { notifications } from "@/workbench/contributions";
import { ToolWindowContentTabs } from "@/workbench/toolwindows/ToolWindows";
import { ChartView } from "./ChartView";
import { ColumnsPopup } from "./ColumnsPopup";
import { DataView, ExtractorSelect, PanelToggle, copyFormatOf, exportMenu, type DataPanel } from "./DataView";
@@ -53,7 +52,6 @@ import {
log,
outcomeSummary,
pageable,
pinResult,
rerunResult,
targetLabel,
useDatabase,
@@ -202,9 +200,6 @@ function Pager({ tab }: { tab: ResultTab }) {
);
}
/** Unsubmitted edits by the rows they were made on, kept while another result tab is shown. */
const keptEdits = new WeakMap<DbQueryResult, PendingEdits>();
function Result({ tab }: { tab: ResultTab }) {
const [view, setView] = useState<"table" | "chart">("table");
const [panel, setPanel] = useState<DataPanel | null>(null);
@@ -280,19 +275,14 @@ function Result({ tab }: { tab: ResultTab }) {
sources.some((s) => s !== null);
// The pending edits belong to the result they were made on: fresh rows (a
// re-run, another page) start over, without an effect. They outlive the pane,
// which remounts when another result tab is selected.
const [pending, setPending] = useState<{ outcome: DbQueryResult | null; edits: PendingEdits }>(() => ({
outcome: r ?? null,
edits: (r && keptEdits.get(r)) ?? emptyEdits(),
}));
// re-run, another page) start over, without an effect.
const [pending, setPending] = useState<{ outcome: DbQueryResult | null; edits: PendingEdits }>({
outcome: null,
edits: emptyEdits(),
});
const edits = pending.outcome === r ? pending.edits : emptyEdits();
const setEdits = (f: (e: PendingEdits) => PendingEdits) =>
setPending((previous) => {
const next = f(previous.outcome === r ? previous.edits : emptyEdits());
if (r) keptEdits.set(r, next);
return { outcome: r ?? null, edits: next };
});
setPending((previous) => ({ outcome: r, edits: f(previous.outcome === r ? previous.edits : emptyEdits()) }));
const loaded = r?.rows.length ?? 0;
const rowCount = loaded + edits.inserts.length;
const value = useCallback(
@@ -434,11 +424,10 @@ function Result({ tab }: { tab: ResultTab }) {
return (
<div className={styles.view} ref={viewRef} data-db-table-editor="">
<div className={styles.toolbar}>
<IconButton icon="database" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
<IconButton icon="db-table" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
<IconButton icon="db-chart" title="Chart" aria-pressed={view === "chart"} onClick={() => setView("chart")} />
<span className={styles.toolbarSeparator} />
<IconButton icon="refresh" title="Rerun Query" disabled={busy} onClick={() => void rerunResult(tab.id)} />
<PinButton tab={tab} />
{view === "table" && (
<IconButton
icon="search"
@@ -597,7 +586,6 @@ function PlanResult({ tab }: { tab: ResultTab }) {
<div className={styles.view}>
<div className={styles.toolbar}>
<IconButton icon="refresh" title="Explain Again" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
<PinButton tab={tab} />
{tab.busy && <Spinner size={12} />}
<span className={styles.spacer} />
<span className={styles.resultSource} title={tab.outcome.statement.text}>
@@ -665,27 +653,11 @@ function PlanResult({ tab }: { tab: ResultTab }) {
);
}
/** IDEA's Pin Tab: the console's next runs leave this result alone. */
function PinButton({ tab }: { tab: ResultTab }) {
return (
<IconButton icon="pin" title="Pin Tab" aria-pressed={!!tab.pinned} onClick={() => pinResult(tab.id, !tab.pinned)} />
);
}
export function ResultsView() {
const results = useDatabase((s) => s.results);
const active = useDatabase((s) => s.activeResult);
const hasOutput = useDatabase((s) => s.output.length > 0);
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
const syncFocus = () => setContext("dbResultsFocus", !!root.current?.contains(document.activeElement));
document.addEventListener("focusin", syncFocus);
syncFocus();
return () => {
document.removeEventListener("focusin", syncFocus);
setContext("dbResultsFocus", false);
};
}, []);
useEffect(() => () => setContext("dbResultsFocus", false), []);
const tabMenu = (id: string, e: MouseEvent) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
@@ -702,8 +674,11 @@ export function ResultsView() {
const tab = results.find((r) => r.id === active);
return (
<div
ref={root}
className={styles.view}
onFocusCapture={() => setContext("dbResultsFocus", true)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setContext("dbResultsFocus", false);
}}
onKeyDown={(e) => {
if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return;
const tab = (e.target as Element).closest<HTMLElement>('[role="tab"]');
@@ -717,33 +692,26 @@ export function ResultsView() {
);
}}
>
<ToolWindowContentTabs>
<Tabs
className={styles.resultTabs}
variant="compact"
aria-label="Results"
activeId={tab ? String(tab.id) : String(OUTPUT_TAB)}
panelIdPrefix="db-results"
onSelect={(id) => useDatabase.setState({ activeResult: Number(id) })}
onClose={(id) => closeResult(Number(id))}
onTogglePin={(id) => {
const r = results.find((r) => r.id === Number(id));
if (r) pinResult(r.id, !r.pinned);
}}
onContextMenu={tabMenu}
items={[
{ id: String(OUTPUT_TAB), label: "Output", icon: "db-console", closable: false },
...results.map((r) => ({
id: String(r.id),
label: r.title,
icon: r.plan ? "db-plan" : "database",
closable: true,
pinned: r.pinned,
tooltip: r.outcome.statement.text,
})),
]}
/>
</ToolWindowContentTabs>
<Tabs
className={styles.resultTabs}
variant="compact"
aria-label="Results"
activeId={tab ? String(tab.id) : String(OUTPUT_TAB)}
panelIdPrefix="db-results"
onSelect={(id) => useDatabase.setState({ activeResult: Number(id) })}
onClose={(id) => closeResult(Number(id))}
onContextMenu={tabMenu}
items={[
{ id: String(OUTPUT_TAB), label: "Output", icon: "db-console", closable: false },
...results.map((r) => ({
id: String(r.id),
label: r.title,
icon: r.plan ? "db-plan" : "db-table",
closable: true,
tooltip: r.outcome.statement.text,
})),
]}
/>
<div className={styles.fill}>
{!tab ? <Output /> : tab.plan ? <PlanResult key={tab.id} tab={tab} /> : <Result key={tab.id} tab={tab} />}
</div>
-197
View File
@@ -1,197 +0,0 @@
import { describe, expect, it } from "vitest";
import type { DbDriver } from "@/platform/ipc";
import { exportData, type ExportData, type ExportFormat } from "./export";
const data = (value: string, type = "NUMERIC", driver: DbDriver = "postgres"): ExportData => ({
columns: [{ name: "value", type }],
rows: [[value]],
table: "numbers",
driver,
});
const jsonNumber = (value: string) => `[\n {\n "value": ${value}\n }\n]`;
describe("exact JSON number export", () => {
it.each([
["9007199254740993", "BIGINT", "postgres"],
["9223372036854775807", "BIGINT", "postgres"],
["-9223372036854775808", "BIGINT", "sqlite"],
["18446744073709551615", "BIGINT UNSIGNED", "mysql"],
["-9007199254740993", "Int64", "clickhouse"],
["12345678901234567890123456789012345678", "NUMBER(38,0)", "snowflake"],
["1.0000000000000000002", "DECIMAL(38,19)", "sqlServer"],
["-1.0000000000000000002", "NUMERIC(38,19)", "postgres"],
["1234567890.12345678901234567890", "DECIMAL(38,20)", "mysql"],
["0.0000000000000000001", "NUMERIC", "postgres"],
["1e-400", "NUMERIC", "postgres"],
["1e-325", "NUMERIC", "postgres"],
["1e-9007199254740993", "NUMERIC", "postgres"],
["-2e-999999999999999999999", "NUMERIC", "postgres"],
["99999999999999999999.99999999999999999999", "NUMERIC", "postgres"],
["0.10000000000000000000000000001", "DECIMAL(38,29)", "mysql"],
["1.797693134862315708145274237317043567981e308", "DOUBLE", "mysql"],
["-2e-400", "NUMERIC", "postgres"],
["1.0000000000000000002E+100", "NUMERIC", "postgres"],
] as const)("preserves %s (%s, %s) as a JSON number", (value, type, driver) => {
const json = exportData("json", data(value, type, driver));
expect(json).toBe(jsonNumber(value));
expect(typeof JSON.parse(json)[0].value).toBe("number");
});
it.each([
["+9007199254740993", "9007199254740993"],
["09007199254740993", "9007199254740993"],
["-09223372036854775807", "-9223372036854775807"],
[" \t+001.0000000000000000002\n", "1.0000000000000000002"],
[".0000000000000000001", "0.0000000000000000001"],
["-.0000000000000000001", "-0.0000000000000000001"],
["0001.", "1"],
["+001.25e-003", "1.25e-003"],
["1.e-400", "1e-400"],
["-000.0000000000000000001", "-0.0000000000000000001"],
["+.0000000000000000001E-400", "0.0000000000000000001E-400"],
])("normalizes %s to valid JSON without rounding", (value, expected) => {
const json = exportData("json", data(value));
expect(json).toBe(jsonNumber(expected));
expect(typeof JSON.parse(json)[0].value).toBe("number");
});
it.each(["0", "-0", "-000.000e99999", "42", "-12.5", "+1", ".5", "1.", "", " \t ", "0x10", "0b10", "0o10"])(
"keeps ordinary numeric coercion for %s",
(value) => {
expect(JSON.parse(exportData("json", data(value)))).toEqual([
{ value: JSON.parse(JSON.stringify(Number(value))) },
]);
},
);
it.each(["NaN", "Infinity", "-Infinity", "1e400", "-1e999999999999999999999", "1e", '0,"injected":true', "1\n2"])(
"keeps non-finite or invalid numeric text %s quoted",
(value) => {
expect(JSON.parse(exportData("json", data(value)))).toEqual([{ value }]);
},
);
it.each(["TEXT", "VARCHAR(100)", "jsonb", "BOOLEAN", "POINT", "INTERVAL"])(
"keeps numeric-looking %s as text",
(type) => {
expect(JSON.parse(exportData("json", data("9007199254740993", type)))).toEqual([{ value: "9007199254740993" }]);
},
);
it.each(["", "unknown", "NULL"])("preserves exact numbers inferred from %s types", (type) => {
expect(exportData("json", data("9007199254740993", type))).toBe(jsonNumber("9007199254740993"));
});
it("keeps a mixed unknown column as text", () => {
const d = { ...data("9007199254740993", "unknown"), rows: [["9007199254740993"], ["text"]] };
expect(JSON.parse(exportData("json", d))).toEqual([{ value: "9007199254740993" }, { value: "text" }]);
});
it("escapes column names and text and keeps NULL and missing cells", () => {
const d: ExportData = {
columns: [
{ name: 'a"\n', type: "TEXT" },
{ name: "empty", type: "NUMERIC" },
{ name: "missing", type: "TEXT" },
],
rows: [['中文 \\ \t "quote"\n\u0000', null]],
table: "numbers",
driver: "postgres",
};
expect(exportData("json", d)).toBe(
JSON.stringify([{ ['a"\n']: '中文 \\ \t "quote"\n\u0000', empty: null, missing: null }], null, 2),
);
});
it("keeps duplicate columns last-wins, integer key order and literal __proto__ keys", () => {
const d: ExportData = {
columns: [
{ name: "2", type: "NUMERIC" },
{ name: "10", type: "TEXT" },
{ name: "1", type: "TEXT" },
{ name: "2", type: "TEXT" },
{ name: "__proto__", type: "TEXT" },
],
rows: [["9007199254740993", "ten", "one", "9007199254740993", "literal"]],
table: "numbers",
driver: "postgres",
};
const row = Object.fromEntries([
["2", "9007199254740993"],
["10", "ten"],
["1", "one"],
["__proto__", "literal"],
]);
const json = exportData("json", d);
expect(json).toBe(JSON.stringify([row], null, 2));
expect(Object.hasOwn(JSON.parse(json)[0], "__proto__")).toBe(true);
});
it("keeps empty results, zero-column rows and ignored extra cells", () => {
expect(exportData("json", { ...data("1"), rows: [] })).toBe("[]");
expect(exportData("json", { ...data("1"), columns: [], rows: [[], []] })).toBe("[\n {},\n {}\n]");
expect(exportData("json", { ...data("1"), rows: [["1", "ignored"]] })).toBe(jsonNumber("1"));
});
it("keeps all-NULL inference samples from changing later values to numbers", () => {
const rows = [...Array.from({ length: 50 }, () => [null]), ["9007199254740993"]];
const json = exportData("json", { ...data("", "unknown"), rows });
expect(JSON.parse(json)[50]).toEqual({ value: "9007199254740993" });
});
it("keeps numeric inference when later rows include text and NULL", () => {
const rows = [...Array.from({ length: 50 }, () => ["9007199254740993"]), ["text"], [null]];
const json = exportData("json", { ...data("", "unknown"), rows });
expect(json.match(/"value": 9007199254740993/g)).toHaveLength(50);
expect(JSON.parse(json).slice(50)).toEqual([{ value: "text" }, { value: null }]);
});
it("retains the exact numeric value when the last duplicate column is numeric", () => {
const d: ExportData = {
...data(""),
columns: [
{ name: "value", type: "TEXT" },
{ name: "value", type: "BIGINT" },
],
rows: [["discarded", "9223372036854775807"]],
};
expect(exportData("json", d)).toBe(jsonNumber("9223372036854775807"));
});
it("agrees with independent integer arithmetic over 250 decimal scales and exponents", () => {
let seed = 0x20261113;
const random = () => (seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0);
const exponents = [-450, -400, -40, -1, 0, 1, 40, 200];
for (let i = 0; i < 250; i++) {
const absolute = 10n ** 36n + BigInt(random()) * 10n ** 27n + BigInt(random());
const coefficient = i % 2 ? absolute : -absolute;
const scale = (random() >>> 16) % 45;
const exponent = exponents[(random() >>> 16) % exponents.length]!;
const digits = absolute.toString().padStart(scale + 1, "0");
const mantissa = scale ? `${digits.slice(0, -scale)}.${digits.slice(-scale)}` : digits;
const sign = coefficient < 0n ? "-" : i % 3 ? "" : "+";
const input = `\t${sign}${i % 5 ? "" : "00"}${mantissa}E${exponent >= 0 ? "+" : ""}${exponent} `;
const json = exportData("json", data(input));
expect(typeof JSON.parse(json)[0].value, input).toBe("number");
const token = /"value": ([^\n]+)\n/.exec(json)![1]!;
const [whole, exp = "0"] = token.toLowerCase().split("e");
const point = whole!.indexOf(".");
const actual = BigInt(whole!.replace(".", ""));
const power = Number(exp) - (point < 0 ? 0 : whole!.length - point - 1);
const expectedPower = exponent - scale;
const common = Math.min(power, expectedPower);
expect(actual * 10n ** BigInt(power - common), input).toBe(coefficient * 10n ** BigInt(expectedPower - common));
}
});
it("writes long numbers without losing trailing digits or expanding exponents", () => {
const value = `1.${"0".repeat(20_000)}1e-400`;
expect(exportData("json", data(value))).toBe(jsonNumber(value));
});
});
it.each<ExportFormat>(["csv", "tsv", "sql", "markdown", "onerow"])("%s retains exact numeric digits", (format) => {
const value = "9223372036854775807";
expect(exportData(format, data(value))).toContain(value);
});
+7 -19
View File
@@ -49,19 +49,6 @@ function laidOut(d: ExportData, o: ExportOptions): { head: string[] | null; rows
return { head: null, rows: head.map((h, c) => [...(o.columnHeader ? [h] : []), ...rows.map((r) => r[c] ?? null)]) };
}
/** Normalize decimal text to JSON syntax without rounding its digits. */
function jsonCell(v: Cell, numeric: boolean): string {
if (v === null) return "null";
const number = numeric ? Number(v) : NaN;
if (!Number.isFinite(number)) return JSON.stringify(v);
const parts = /^([+-]?)(\d*)(?:\.(\d*))?([eE][+-]?\d+)?$/.exec(v.trim());
if (!parts) return JSON.stringify(number);
const whole = parts[2]!.replace(/^0+/, "") || "0";
const fraction = parts[3] ?? "";
if (!/[1-9]/.test(whole + fraction)) return "0";
return `${parts[1] === "-" ? "-" : ""}${whole}${fraction ? `.${fraction}` : ""}${parts[4] ?? ""}`;
}
export function exportData(
format: ExportFormat,
d: ExportData,
@@ -84,12 +71,13 @@ export function exportData(
return toTsv(t.head, t.rows);
}
case "json": {
const rows = d.rows.map((r) => {
const cells = Object.fromEntries(names.map((n, i) => [n, jsonCell(r[i] ?? null, numeric[i]!)]));
const members = Object.entries(cells).map(([name, value]) => ` ${JSON.stringify(name)}: ${value}`);
return members.length ? ` {\n${members.join(",\n")}\n }` : " {}";
});
return rows.length ? `[\n${rows.join(",\n")}\n]` : "[]";
const value = (v: Cell, i: number) =>
v === null ? null : numeric[i] && Number.isFinite(Number(v)) ? Number(v) : v;
return JSON.stringify(
d.rows.map((r) => Object.fromEntries(names.map((n, i) => [n, value(r[i] ?? null, i)]))),
null,
2,
);
}
case "sql": {
const literal = (v: Cell, i: number) =>
+12 -221
View File
@@ -1,17 +1,8 @@
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installKeyboardDispatcher, normalizeBinding, PRESETS, useKeymap } from "@/platform/keymap";
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { ContextMenuHost, hideContextMenu } from "@/ui";
import { InputDialogsHost } from "@/workbench/inputDialogs";
import { SERVICES_WINDOW, registerToolWindow } from "@/workbench/contributions";
import { installToolWindowActions } from "@/workbench/toolwindows/actions";
import {
isToolWindowVisible,
resetToolWindowLayout,
setFocusedToolWindow,
showToolWindow,
} from "@/workbench/toolwindows/store";
import { ToolWindowLayout } from "@/workbench/toolwindows/ToolWindows";
import { ResultsView } from "./ResultsView";
import { registerResultTabActions } from "./resultTabActions";
import { OUTPUT_TAB, useDatabase, type ResultTab } from "./store";
@@ -28,8 +19,6 @@ const actions = [
["db.closeResultTab", "Close"],
["db.closeOtherResultTabs", "Close Others"],
["db.closeAllResultTabs", "Close All"],
["db.closeAllButPinnedResultTabs", "Close All but Pinned"],
["db.pinResultTab", "Pin Tab"],
["db.renameResultTab", "Rename Tab"],
["db.nextResultTab", "Select Next Tab"],
["db.previousResultTab", "Select Previous Tab"],
@@ -124,7 +113,7 @@ function openMenu(name: string) {
async function choose(name: string) {
await act(async () => {
fireEvent.click(screen.getByRole("menuitem", { name: name.startsWith("Select ") ? new RegExp(`^${name}`) : name }));
fireEvent.click(screen.getByRole("menuitem", { name }));
});
}
@@ -157,20 +146,18 @@ describe("database result tab context menu", () => {
within(menu)
.getAllByRole("menuitem")
.map((item) => item.textContent),
).toEqual(actions.map(([, label]) => expect.stringMatching(new RegExp(`^${label}(?![A-Za-z ])`))));
).toEqual(["Close", "Close Others", "Close All", "Rename Tab", "Select Next Tab", "Select Previous Tab"]);
expect(Array.from(menu.children).map((item) => item.getAttribute("role"))).toEqual([
"menuitem",
"menuitem",
"menuitem",
"menuitem",
"separator",
"menuitem",
"menuitem",
"separator",
"menuitem",
"menuitem",
]);
expect(within(menu).queryByText(/layout/i)).not.toBeInTheDocument();
expect(within(menu).queryByText(/pin|layout/i)).not.toBeInTheDocument();
expectSelected("Customer count");
expect(screen.getByRole("gridcell", { name: "count-row" })).toBeInTheDocument();
});
@@ -237,11 +224,11 @@ describe("database result tab context menu", () => {
expectConsoleStateUnchanged(before);
});
it("disables Close, Pin Tab and Rename Tab on Output and does not perform any of them", async () => {
it("disables Close and Rename Tab on Output and does not perform either operation", async () => {
await show();
const before = useDatabase.getState();
const menu = openMenu("Output");
for (const name of ["Close", "Pin Tab", "Rename Tab"]) {
for (const name of ["Close", "Rename Tab"]) {
const item = within(menu).getByRole("menuitem", { name });
expect(item).toHaveAttribute("aria-disabled", "true");
fireEvent.click(item);
@@ -367,7 +354,12 @@ describe("database result tab context menu", () => {
useKeymap.setState({
overrides: {
"editor.closeTab": ["Meta+W"],
...Object.fromEntries(actions.map(([id], index) => [id, [`F${index + 1}`]])),
"db.closeResultTab": ["F1"],
"db.closeOtherResultTabs": ["F2"],
"db.closeAllResultTabs": ["F3"],
"db.renameResultTab": ["F4"],
"db.nextResultTab": ["F5"],
"db.previousResultTab": ["F6"],
},
});
await show();
@@ -394,88 +386,6 @@ describe("database result tab context menu", () => {
expectSelected("Customer count");
});
it.each(Object.entries(PRESETS))(
"navigates results using every default tab shortcut in %s",
async (preset, keymap) => {
useKeymap.setState({ preset });
await show();
const uninstall = installKeyboardDispatcher(window);
const press = async (binding: string) => {
const parts = normalizeBinding(binding).split("+");
const key = parts.at(-1)!;
const codes: Record<string, string> = {
Right: "ArrowRight",
Left: "ArrowLeft",
"[": "BracketLeft",
"]": "BracketRight",
};
await act(async () => {
fireEvent.keyDown(document.activeElement!, {
key,
code: codes[key] ?? key,
metaKey: parts.includes("Meta"),
ctrlKey: parts.includes("Ctrl"),
altKey: parts.includes("Alt"),
shiftKey: parts.includes("Shift"),
});
});
};
try {
for (const [index, next] of keymap["editor.nextTab"]!.entries()) {
const previous = keymap["editor.prevTab"]![index]!;
act(() => screen.getByRole("button", { name: "Outside results" }).focus());
await press(next);
await press(previous);
expectSelected("Customer count");
act(() => tab("Orders").focus());
await press(next);
expectOutput();
await press(next);
expectSelected("Customers");
expect(screen.getByRole("gridcell", { name: "customer-row" })).toBeInTheDocument();
await press(previous);
expectOutput();
await press(previous);
expectSelected("Customer count");
openMenu("Customers");
await press(next);
await press(previous);
expectSelected("Customer count");
fireEvent.keyDown(document.activeElement!, { key: "Escape", code: "Escape" });
}
expect(useDatabase.getState().results).toBe(seeded);
} finally {
uninstall();
}
},
);
it("releases result shortcuts after switching from a grid and moving focus outside", async () => {
await show();
const uninstall = installKeyboardDispatcher(window);
const next = async () => {
await act(async () => {
fireEvent.keyDown(document.activeElement!, {
key: "}",
code: "BracketRight",
metaKey: true,
shiftKey: true,
});
});
};
try {
act(() => screen.getByRole("grid").focus());
await next();
expectOutput();
expect(tab("Output")).toHaveFocus();
act(() => screen.getByRole("button", { name: "Outside results" }).focus());
await next();
expectOutput();
} finally {
uninstall();
}
});
it("dispatches assigned result shortcuts only while results have focus, relative to the active tab", async () => {
useKeymap.setState({
overrides: {
@@ -513,122 +423,3 @@ describe("database result tab context menu", () => {
}
});
});
describe("Close Tab in the Services tool window", () => {
let disposers: (() => void)[] = [];
beforeEach(() => {
resetToolWindowLayout();
disposers = [
installToolWindowActions(),
installKeyboardDispatcher(window),
registerToolWindow({
id: SERVICES_WINDOW,
title: "Services",
icon: "toolwindow-services",
anchor: "bottom",
requiresProject: false,
component: ResultsView,
}),
];
});
afterEach(() => {
setFocusedToolWindow(null);
disposers.forEach((dispose) => dispose());
});
async function closeTab(focus: () => HTMLElement) {
showToolWindow(SERVICES_WINDOW);
await act(async () => render(<ToolWindowLayout editor={null} />));
act(() => {
focus().focus();
setFocusedToolWindow(SERVICES_WINDOW);
});
await act(async () => {
fireEvent.keyDown(document.activeElement!, { key: "w", code: "KeyW", metaKey: true });
});
}
it.each([
["the grid", () => screen.getByRole("grid")],
["the tab strip", () => tab("Customer count")],
])("closes the active result, not the window, from %s", async (_, focus) => {
await closeTab(focus);
expect(screen.queryByRole("tab", { name: "Customer count" })).not.toBeInTheDocument();
expectSelected("Orders");
expect(isToolWindowVisible(SERVICES_WINDOW)).toBe(true);
});
it("hides the window when Output, which cannot be closed, is active", async () => {
useDatabase.setState({ activeResult: OUTPUT_TAB });
await closeTab(() => tab("Output"));
expect(useDatabase.getState().results).toBe(seeded);
expect(isToolWindowVisible(SERVICES_WINDOW)).toBe(false);
});
});
describe("pinned result tabs", () => {
const pinButton = () => screen.getByRole("button", { name: "Pin Tab" });
const pinnedIds = () => useDatabase.getState().results.flatMap((r) => (r.pinned ? [r.id] : []));
it("pins an inactive result from its menu: Unpin replaces Close, close gestures spare it, selection kept", async () => {
await show();
const slot = (name: string) => within(tab(name).parentElement!);
const typeGlyph = tab("Orders").querySelector("svg")!.innerHTML;
openMenu("Orders");
await choose("Pin Tab");
expect(pinnedIds()).toEqual([22]);
expectSelected("Customer count");
expect(pinButton()).toHaveAttribute("aria-pressed", "false");
expect(tab("Orders").querySelector("svg")!.innerHTML).toBe(typeGlyph);
expect(slot("Orders").queryByRole("button", { name: "Close Tab" })).not.toBeInTheDocument();
const unpin = slot("Orders").getByRole("button", { name: "Unpin Tab" });
expect(unpin.querySelector("svg")!.innerHTML).toBe(pinButton().querySelector("svg")!.innerHTML);
fireEvent.mouseDown(tab("Orders").parentElement!, { button: 1 });
fireEvent.keyDown(tab("Orders"), { key: "Delete" });
expect(useDatabase.getState().results.map((r) => r.id)).toEqual([11, 22, 33]);
const menu = openMenu("Orders");
expect(within(menu).queryByRole("menuitem", { name: "Pin Tab" })).not.toBeInTheDocument();
within(menu).getByRole("menuitem", { name: "Unpin Tab" });
await act(async () => fireEvent.keyDown(document.activeElement!, { key: "Escape" }));
fireEvent.click(unpin);
expect(pinnedIds()).toEqual([]);
expectSelected("Customer count");
expect(slot("Orders").queryByRole("button", { name: "Unpin Tab" })).not.toBeInTheDocument();
slot("Orders").getByRole("button", { name: "Close Tab" });
});
it("keeps the toolbar toggle and the menu in step for the active result", async () => {
await show();
fireEvent.click(pinButton());
expect(pinButton()).toHaveAttribute("aria-pressed", "true");
expect(pinnedIds()).toEqual([33]);
openMenu("Customer count");
await choose("Unpin Tab");
expect(pinButton()).toHaveAttribute("aria-pressed", "false");
expect(pinnedIds()).toEqual([]);
});
it("enables Close All but Pinned only with pinned and unpinned results, and keeps just the pinned ones", async () => {
await show();
let menu = openMenu("Orders");
expect(within(menu).getByRole("menuitem", { name: "Close All but Pinned" })).toHaveAttribute(
"aria-disabled",
"true",
);
await choose("Pin Tab");
openMenu("Customers");
await choose("Close All but Pinned");
expect(useDatabase.getState().results.map((r) => r.id)).toEqual([22]);
expectSelected("Orders");
menu = openMenu("Orders");
expect(within(menu).getByRole("menuitem", { name: "Close All but Pinned" })).toHaveAttribute(
"aria-disabled",
"true",
);
await choose("Close All");
expect(useDatabase.getState().results).toEqual([]);
expectOutput();
});
});
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more