mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87be8cfcf2 |
No files matched your search
@@ -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};
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -77,8 +77,6 @@ pub enum AutoUpload {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentSettings {
|
||||
pub servers: Vec<DeploymentServer>,
|
||||
/// Server groups: Upload to… on a group uploads to each of its servers.
|
||||
pub groups: Vec<DeploymentGroup>,
|
||||
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
|
||||
pub default_server: String,
|
||||
pub auto_upload: AutoUpload,
|
||||
@@ -94,7 +92,6 @@ impl Default for DeploymentSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
servers: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
default_server: String::new(),
|
||||
auto_upload: AutoUpload::Never,
|
||||
delete_remote: false,
|
||||
@@ -106,16 +103,6 @@ impl Default for DeploymentSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Named set of deployment servers that files are uploaded to together.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`DeploymentServer::id`]s, in upload order.
|
||||
pub servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DeploymentType {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 || {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) => {
|
||||
@@ -324,13 +322,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() {
|
||||
|
||||
@@ -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(¶ms);
|
||||
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)),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,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()
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -204,8 +204,6 @@ argument; themes that ship no `.icls` keep the built-in syntax colours.
|
||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
|
||||
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
|
||||
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
|
||||
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||
@@ -270,7 +268,6 @@ by hand:
|
||||
| `--link` | `textLink.foreground` |
|
||||
| `--bg-input`, `--bg-field` | `input.background` |
|
||||
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
|
||||
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
|
||||
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
|
||||
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
|
||||
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
|
||||
|
||||
@@ -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`),可使用 **关闭**、**关闭其他**、**全部关闭**、**重命名标签页**、**选择下一个标签页** 和 **选择上一个标签页**。这些操作只作用于结果标签页,**输出** 始终保留。重命名的标题在结果关闭前一直有效。
|
||||
|
||||
关闭当前选中的结果标签页时,优先选中左侧相邻的结果;没有左侧结果时,选中右侧相邻结果。没有剩余结果时,选中 **输出**。关闭未选中的结果不会改变当前选择。
|
||||
|
||||
|
||||
@@ -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 配置大致如下(路径以你机器上的为准):
|
||||
|
||||
|
||||
@@ -177,8 +177,6 @@ pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out
|
||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor`(Islands), `EditorTabs.underlineColor` |
|
||||
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
|
||||
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
|
||||
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||
@@ -241,7 +239,6 @@ VS Code 主题(扩展中的 `themes/*.json`)包含 `type`(`dark` / `light`
|
||||
| `--link` | `textLink.foreground` |
|
||||
| `--bg-input`, `--bg-field` | `input.background` |
|
||||
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
|
||||
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
|
||||
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
|
||||
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
|
||||
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
|
||||
|
||||
@@ -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");
|
||||
@@ -174,8 +170,6 @@ for (const [theme, label] of [
|
||||
const surface = target.locator("..");
|
||||
const other = tab(page, "Result 3");
|
||||
const otherSurface = other.locator("..");
|
||||
// The results hold focus, as they do at the end when the menu is opened from a result tab.
|
||||
await tab(page, "Result 2").focus();
|
||||
await page.mouse.move(0, 0);
|
||||
const idle = await surface.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
const selectedColor = await tab(page, "Result 2")
|
||||
@@ -213,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);
|
||||
@@ -229,43 +221,6 @@ for (const [theme, label] of [
|
||||
});
|
||||
}
|
||||
|
||||
test("accents the selected tabs only in the part holding focus, as IDEA does", async ({ workbench: page }) => {
|
||||
await openFromTree(page, "README.md");
|
||||
await seedResults(page);
|
||||
const token = (name: string) =>
|
||||
page.evaluate((name) => {
|
||||
const probe = document.body.appendChild(document.createElement("div"));
|
||||
probe.style.background = `var(${name})`;
|
||||
const color = getComputedStyle(probe).backgroundColor;
|
||||
probe.remove();
|
||||
return color;
|
||||
}, name);
|
||||
const accent = await token("--bg-tab-selected");
|
||||
const inactive = await token("--bg-tab-selected-inactive");
|
||||
expect(inactive).not.toBe(accent);
|
||||
const surfaces = [
|
||||
page.getByRole("tablist", { name: "Editor tabs" }).getByRole("tab", { name: "README.md" }),
|
||||
page.getByRole("tab", { name: "Database", exact: true }),
|
||||
tab(page, "Result 2"),
|
||||
].map((tab) => tab.locator(".."));
|
||||
const colors = () =>
|
||||
Promise.all(surfaces.map((surface) => surface.evaluate((element) => getComputedStyle(element).backgroundColor)));
|
||||
|
||||
await page.getByRole("gridcell", { name: "beta rows", exact: true }).click();
|
||||
await expect.poll(colors).toEqual([inactive, accent, accent]);
|
||||
// A menu opened from a result tab keeps the results active.
|
||||
await tab(page, "Result 2").click({ button: "right" });
|
||||
await expect(menu(page)).toBeVisible();
|
||||
await expect.poll(colors).toEqual([inactive, accent, accent]);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu(page)).toHaveCount(0);
|
||||
|
||||
await page.locator(".cm-content").first().click();
|
||||
await expect.poll(colors).toEqual([accent, inactive, inactive]);
|
||||
await page.getByRole("tree", { name: "Project", exact: true }).click({ position: { x: 10, y: 10 } });
|
||||
await expect.poll(colors).toEqual([inactive, inactive, inactive]);
|
||||
});
|
||||
|
||||
test("renames the clicked inactive result without changing its rows and navigates through Output with wraparound", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
@@ -311,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 () => {
|
||||
@@ -454,148 +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)]);
|
||||
});
|
||||
|
||||
test("Tab goes from a result tab to its first cell, and the result toolbar is one stop back", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await openFromTree(page, "README.md");
|
||||
await seedResults(page);
|
||||
const grid = page.getByRole("grid");
|
||||
const cell = page.getByRole("gridcell", { name: "beta rows", exact: true });
|
||||
await expect(cell).toHaveAttribute("aria-selected", "false");
|
||||
|
||||
await tab(page, "Result 2").focus();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(grid).toBeFocused();
|
||||
await expect(cell).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
const toolbar = page.getByRole("toolbar", { name: "Result actions", exact: true });
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(toolbar.getByRole("button", { name: "Table", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(toolbar.getByRole("button", { name: "Chart", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(tab(page, "Result 2")).toBeFocused();
|
||||
// The toolbar is entered at the control used last.
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(grid).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(toolbar.getByRole("button", { name: "Chart", exact: true })).toBeFocused();
|
||||
|
||||
// Past the last cell, Tab leaves the grid instead of landing on its header's filter button.
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(grid).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(grid).not.toBeFocused();
|
||||
expect(await page.evaluate(() => !!document.activeElement?.closest("[role=grid]"))).toBe(false);
|
||||
|
||||
// The keyboard path to the header's local filter is the Local Filter action.
|
||||
await grid.focus();
|
||||
await page.evaluate(async () => {
|
||||
const actionsModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
|
||||
await executeAction("db.localFilter");
|
||||
});
|
||||
const filter = page.getByRole("dialog", { name: "Local filter of payload", exact: true });
|
||||
await expect(filter.getByRole("textbox", { name: "Search values", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(filter).toBeHidden();
|
||||
await expect(grid).toBeFocused();
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { expect, stubDesktop, test } from "./fixtures";
|
||||
|
||||
test.use({ video: "on" });
|
||||
|
||||
test("opens the working file with Command+Down after a revision diff loads its source", async ({ workbench: page }) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async () => {
|
||||
const path = "/dev/acme-api/README.md";
|
||||
const probe = new Promise((resolve) => {
|
||||
window.__ipc.handlers.complete_probe = () => resolve({ size: 48, binary: false, tooLarge: false });
|
||||
});
|
||||
window.__ipc.handlers.fs_probe = ({ path: candidate }) => (candidate === path ? probe : null);
|
||||
window.__ipc.handlers.fs_read_text = () => "# Acme API\n\nWorking copy\n";
|
||||
const keymap = "/src/platform/keymap.ts";
|
||||
(await import(/* @vite-ignore */ keymap)).applyKeymapSettings("intellij-macos", {});
|
||||
const actions = "/src/platform/actions.ts";
|
||||
await (
|
||||
await import(/* @vite-ignore */ actions)
|
||||
).executeAction("diff.compareTexts", {
|
||||
path,
|
||||
left: { text: "# Acme API\n\nPrevious commit\n", label: "Parent" },
|
||||
right: { text: "# Acme API\n\nNew commit\n", label: "Commit" },
|
||||
});
|
||||
});
|
||||
|
||||
const pane = page.locator(".cm-merge-b .cm-content");
|
||||
await pane.click();
|
||||
await expect(pane).toBeFocused();
|
||||
const jump = page.getByRole("button", { name: "Jump to Source", exact: true });
|
||||
await expect(jump).toHaveCount(0);
|
||||
await page.evaluate(() => window.__ipc.handlers.complete_probe!({}));
|
||||
await expect(jump).toBeVisible();
|
||||
await expect(pane).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Meta+ArrowDown");
|
||||
await page.screenshot({ path: test.info().outputPath("jump-to-source.png"), animations: "disabled" });
|
||||
await expect(page.getByRole("tab", { name: "README.md", exact: true, selected: true })).toBeVisible();
|
||||
await expect(pane).toBeHidden();
|
||||
});
|
||||
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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -199,8 +199,6 @@ ship no `.icls` at all keep the built-in syntax colours.
|
||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
|
||||
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
|
||||
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
|
||||
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||
@@ -264,7 +262,6 @@ from `type`, then map:
|
||||
| `--link` | `textLink.foreground` |
|
||||
| `--bg-input`, `--bg-field` | `input.background` |
|
||||
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
|
||||
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
|
||||
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
|
||||
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
|
||||
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())));
|
||||
|
||||
+2
-39
@@ -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: {
|
||||
@@ -4090,14 +4085,6 @@ export type DeploymentCoverage = {
|
||||
skipped: TransferError[],
|
||||
};
|
||||
|
||||
/** Named set of deployment servers that files are uploaded to together. */
|
||||
export type DeploymentGroup = {
|
||||
id?: string,
|
||||
name?: string,
|
||||
/** [`DeploymentServer::id`]s, in upload order. */
|
||||
servers?: string[],
|
||||
};
|
||||
|
||||
/** Local files to compare with a deployment server (Sync with Deployed). */
|
||||
export type DeploymentLaunch = {
|
||||
/** Deployment server id. */
|
||||
@@ -4141,8 +4128,6 @@ export type DeploymentServer = {
|
||||
/** Build, Execution, Deployment › Deployment (and its Options page). */
|
||||
export type DeploymentSettings = {
|
||||
servers?: DeploymentServer[],
|
||||
/** Server groups: Upload to… on a group uploads to each of its servers. */
|
||||
groups?: DeploymentGroup[],
|
||||
/** Server used by Upload / Download / Sync and automatic upload; empty = none. */
|
||||
defaultServer?: string,
|
||||
autoUpload?: AutoUpload,
|
||||
@@ -5874,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).
|
||||
@@ -7217,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. */
|
||||
@@ -7237,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[],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -367,7 +367,6 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
tabPrefs.placement === "right" && styles.tabsRight,
|
||||
)}
|
||||
data-pane-id={paneId}
|
||||
data-focus-scope=""
|
||||
aria-label={tx("Editor")}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -107,29 +107,3 @@ describe("routine grouping", () => {
|
||||
expect(tree.queryByText("functions")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Expand All", () => {
|
||||
it("opens every loaded node and leaves unloaded ones closed", async () => {
|
||||
useDatabase.setState({
|
||||
sources: [
|
||||
{ id: "pg", name: "PostgreSQL", driver: "postgres", options: { singleDatabase: true } },
|
||||
{ id: "lite", name: "SQLite", driver: "sqlite" },
|
||||
],
|
||||
tables: { [tablesKey("pg", "public")]: { status: "ok", value: [{ name: "users", kind: "table" }] } },
|
||||
treeExpanded: new Set(),
|
||||
});
|
||||
render(<DatabaseView />);
|
||||
await act(() => executeAction("db.expandAll"));
|
||||
expect([...useDatabase.getState().treeExpanded].sort()).toEqual([
|
||||
"pg",
|
||||
"pg/public",
|
||||
"pg/public/routines",
|
||||
"pg/public/tables",
|
||||
]);
|
||||
const tree = within(screen.getByRole("tree", { name: "Data sources" }));
|
||||
expect(tree.getByText("refresh_data")).toBeInTheDocument();
|
||||
// Its columns aren't loaded, and neither is the SQLite source: Expand All loads nothing.
|
||||
expect(tree.getByText("users").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
|
||||
expect(tree.getByText("SQLite").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
useDatabase,
|
||||
type Loaded,
|
||||
} from "./store";
|
||||
import { DB_VIEW_FOCUS, databaseTree, openTableData } from "./treeActions";
|
||||
import { DB_VIEW_FOCUS, openTableData } from "./treeActions";
|
||||
import { newMenu, nodeMenu } from "./treeMenu";
|
||||
import styles from "./Database.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -366,34 +366,6 @@ export function DatabaseView() {
|
||||
else if (n.kind === "table") await loadDetails(n.id, n.schema, n.table.name);
|
||||
};
|
||||
|
||||
const expandable = (n: Node) =>
|
||||
n.kind === "source" ||
|
||||
n.kind === "database" ||
|
||||
n.kind === "schema" ||
|
||||
n.kind === "folder" ||
|
||||
n.kind === "table" ||
|
||||
n.kind === "group";
|
||||
|
||||
// Expand All opens the nodes already loaded: it connects to nothing and runs no query.
|
||||
useEffect(() => {
|
||||
databaseTree.expandAll = () => {
|
||||
const next = new Set(useDatabase.getState().treeExpanded);
|
||||
const walk = (nodes: readonly Node[]) => {
|
||||
for (const n of nodes) {
|
||||
const kids = expandable(n) ? children(n) : undefined;
|
||||
if (!kids) continue;
|
||||
next.add(n.key);
|
||||
walk(kids);
|
||||
}
|
||||
};
|
||||
walk(roots);
|
||||
useDatabase.setState({ treeExpanded: next });
|
||||
};
|
||||
return () => {
|
||||
databaseTree.expandAll = null;
|
||||
};
|
||||
});
|
||||
|
||||
const label = (n: Node): string => {
|
||||
switch (n.kind) {
|
||||
case "source":
|
||||
@@ -537,7 +509,14 @@ export function DatabaseView() {
|
||||
getKey={(n) => n.key}
|
||||
getLabel={label}
|
||||
getChildren={children}
|
||||
isExpandable={expandable}
|
||||
isExpandable={(n) =>
|
||||
n.kind === "source" ||
|
||||
n.kind === "database" ||
|
||||
n.kind === "schema" ||
|
||||
n.kind === "folder" ||
|
||||
n.kind === "table" ||
|
||||
n.kind === "group"
|
||||
}
|
||||
loadChildren={load}
|
||||
getIcon={(n) => icon(n)}
|
||||
getDetail={detail}
|
||||
@@ -683,12 +662,7 @@ function DatabaseToolbar() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Header controls (shown on hover, like IDEA's): Expand All, Collapse All. */
|
||||
/** Header controls (shown on hover, like IDEA's): Collapse All. */
|
||||
export function DatabaseHeaderActions() {
|
||||
return (
|
||||
<>
|
||||
<IconButton icon="expand-all" action="db.expandAll" size="small" iconSize={16} />
|
||||
<IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />
|
||||
</>
|
||||
);
|
||||
return <IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />;
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
@@ -393,7 +392,6 @@ export function Grid(props: GridProps) {
|
||||
canToggle: () => canEdit() && !!bools[sel!.c1] && props.rowState?.(sel!.r1) !== "deleted",
|
||||
canSetNull: () => canEdit() && nullOk(norm(sel!)),
|
||||
canSetDefault: () => canEdit() && defaultable(norm(sel!)),
|
||||
canFilter: () => !!props.onFilter && !!sel && !editing,
|
||||
toggle: () => {
|
||||
if (actions.canToggle()) toggleBoolean(sel!.r1, sel!.c1, booleanLiterals(props.driver));
|
||||
},
|
||||
@@ -403,12 +401,6 @@ export function Grid(props: GridProps) {
|
||||
setDefault: () => {
|
||||
if (actions.canSetDefault()) setDefaults(norm(sel!));
|
||||
},
|
||||
filter: () => {
|
||||
if (!actions.canFilter()) return;
|
||||
scrollColumnIntoView(sel!.c1);
|
||||
const anchor = scrollRef.current?.querySelectorAll<HTMLElement>("[data-column-filter]")[sel!.c1];
|
||||
if (anchor) props.onFilter!(sel!.c1, anchor);
|
||||
},
|
||||
};
|
||||
Object.assign((cellActions.current ??= {} as CellActions), actions);
|
||||
});
|
||||
@@ -438,14 +430,6 @@ export function Grid(props: GridProps) {
|
||||
startEdit(sel.r1, sel.c1, e.key === "Enter" && e.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
// IDEA's tables: Tab moves between cells, leaving the grid after the last one.
|
||||
const next = mod || e.altKey ? null : nextCell(sel.r1, sel.c1, e.shiftKey ? -1 : 1);
|
||||
if (!next) return;
|
||||
e.preventDefault();
|
||||
moveTo(next[0], next[1], false);
|
||||
return;
|
||||
}
|
||||
const moves: Record<string, [number, number]> = {
|
||||
ArrowUp: [-1, 0],
|
||||
ArrowDown: [1, 0],
|
||||
@@ -455,32 +439,13 @@ export function Grid(props: GridProps) {
|
||||
const m = moves[e.key];
|
||||
if (!m) return;
|
||||
e.preventDefault();
|
||||
moveTo(
|
||||
Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0])),
|
||||
Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1])),
|
||||
e.shiftKey,
|
||||
);
|
||||
};
|
||||
|
||||
/** The cell Tab (`step` 1) or Shift+Tab (-1) goes to, row by row; null past either end. */
|
||||
const nextCell = (r: number, c: number, step: 1 | -1): [number, number] | null => {
|
||||
const i = Math.min(r, rowCount - 1) * columns.length + c + step;
|
||||
return i < 0 || i >= rowCount * columns.length ? null : [Math.floor(i / columns.length), i % columns.length];
|
||||
};
|
||||
|
||||
const moveTo = (r: number, c: number, extend: boolean) => {
|
||||
setSel(extend && sel ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
|
||||
const r = Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0]));
|
||||
const c = Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1]));
|
||||
setSel(e.shiftKey ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
|
||||
virtualizer.scrollToIndex(r, { align: "auto" });
|
||||
scrollColumnIntoView(c);
|
||||
};
|
||||
|
||||
/** Focus from a click lands on the clicked cell; from the keyboard, on the first cell. */
|
||||
const pointerFocus = useRef(false);
|
||||
const onFocus = (e: FocusEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget || pointerFocus.current || sel || editing) return;
|
||||
if (rowCount && columns.length) moveTo(0, 0, false);
|
||||
};
|
||||
|
||||
const onCellMouseDown = (e: MouseEvent, r: number, c: number) => {
|
||||
if (e.button !== 0) {
|
||||
if (!selected(r, c)) setSel({ r0: r, c0: c, r1: r, c1: c });
|
||||
@@ -547,9 +512,6 @@ export function Grid(props: GridProps) {
|
||||
{ kind: "action", action: "db.setCellDefault", arg: cellActions.current },
|
||||
);
|
||||
}
|
||||
if (props.onFilter) {
|
||||
items.push({ kind: "separator" }, { kind: "action", action: "db.localFilter", arg: cellActions.current });
|
||||
}
|
||||
const extra = rowMenu?.(selectedRows()) ?? [];
|
||||
if (extra.length) items.push({ kind: "separator" }, ...extra);
|
||||
showContextMenu(e, items);
|
||||
@@ -584,11 +546,6 @@ export function Grid(props: GridProps) {
|
||||
aria-label={props["aria-label"] ?? "Results"}
|
||||
aria-rowcount={rowCount}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
onMouseDownCapture={() => {
|
||||
pointerFocus.current = true;
|
||||
setTimeout(() => (pointerFocus.current = false));
|
||||
}}
|
||||
onPaste={onPaste}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
@@ -658,10 +615,6 @@ export function Grid(props: GridProps) {
|
||||
{props.onFilter && (
|
||||
<button
|
||||
type="button"
|
||||
data-column-filter=""
|
||||
// Not a Tab stop, like IDEA's table header: Tab past the last cell leaves the grid.
|
||||
// The keyboard opens it with the Local Filter action.
|
||||
tabIndex={-1}
|
||||
className={clsx(styles.headerFilter, props.filtered?.(ci) && styles.headerFilterOn)}
|
||||
aria-label={`Filter ${c.name}`}
|
||||
title={tx("Local filter")}
|
||||
@@ -756,13 +709,6 @@ export function Grid(props: GridProps) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") commit();
|
||||
if (e.key === "Tab") {
|
||||
// Like IDEA's table editor: Tab commits and selects the next cell.
|
||||
e.preventDefault();
|
||||
const next = nextCell(editing.row, editing.col, e.shiftKey ? -1 : 1);
|
||||
commit();
|
||||
if (next) moveTo(next[0], next[1], false);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
skipBlurCommit.current = true;
|
||||
setEditing(null);
|
||||
@@ -780,7 +726,6 @@ export function Grid(props: GridProps) {
|
||||
<div
|
||||
key={c}
|
||||
role="gridcell"
|
||||
aria-selected={selected(r, c)}
|
||||
className={clsx(
|
||||
styles.cell,
|
||||
numeric[c] && styles.cellNumber,
|
||||
@@ -806,7 +751,6 @@ export function Grid(props: GridProps) {
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
tabIndex={-1}
|
||||
className={styles.cellCheckbox}
|
||||
aria-label={tx`Toggle ${columns[c]!.name}`}
|
||||
checked={val === lit.t}
|
||||
|
||||
@@ -21,10 +21,8 @@ import {
|
||||
showContextMenu,
|
||||
T,
|
||||
tooltipAttrs,
|
||||
Toolbar,
|
||||
} 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";
|
||||
@@ -54,7 +52,6 @@ import {
|
||||
log,
|
||||
outcomeSummary,
|
||||
pageable,
|
||||
pinResult,
|
||||
rerunResult,
|
||||
targetLabel,
|
||||
useDatabase,
|
||||
@@ -81,10 +78,10 @@ function Output() {
|
||||
}, [lines.length]);
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<Toolbar className={styles.toolbar} aria-label="Output actions">
|
||||
<div className={styles.toolbar}>
|
||||
<span className={styles.spacer} />
|
||||
<IconButton icon="trash" title="Clear All" disabled={!lines.length} onClick={clearOutput} />
|
||||
</Toolbar>
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.output}
|
||||
@@ -149,7 +146,12 @@ function Pager({ tab }: { tab: ResultTab }) {
|
||||
const total = w.total === null ? `${w.last + 1}+` : String(w.total);
|
||||
const sizes = PAGE_SIZES.includes(pageSize) ? PAGE_SIZES : [...PAGE_SIZES, pageSize].sort((a, b) => a - b);
|
||||
return (
|
||||
<Toolbar className={styles.resultPager} aria-label="Result pages" title={outcomeSummary(tab.outcome)}>
|
||||
<div
|
||||
className={styles.resultPager}
|
||||
role="toolbar"
|
||||
aria-label={tx("Result pages")}
|
||||
title={outcomeSummary(tab.outcome)}
|
||||
>
|
||||
<IconButton
|
||||
icon="page-first"
|
||||
title="First Page"
|
||||
@@ -194,7 +196,7 @@ function Pager({ tab }: { tab: ResultTab }) {
|
||||
onChange={(v) => go(0, Number(v))}
|
||||
disabled={!canPage}
|
||||
/>
|
||||
</Toolbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -429,12 +431,11 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
}
|
||||
return (
|
||||
<div className={styles.view} ref={viewRef} data-db-table-editor="">
|
||||
<Toolbar className={styles.toolbar} aria-label="Result actions">
|
||||
<IconButton icon="database" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
|
||||
<div className={styles.toolbar}>
|
||||
<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"
|
||||
@@ -483,7 +484,7 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
onClick={(e) => showContextMenu(e, exportMenu(data(), queryTable(text, { driver }), extractor))}
|
||||
/>
|
||||
{view === "table" && <PanelToggle panel={panel} onChange={setPanel} />}
|
||||
</Toolbar>
|
||||
</div>
|
||||
{view === "chart" ? (
|
||||
<div className={styles.fill}>
|
||||
<ChartView columns={columns} rows={r.rows} />
|
||||
@@ -591,15 +592,14 @@ function PlanResult({ tab }: { tab: ResultTab }) {
|
||||
const selectedKey = selected < 0 ? statement.key : String(selected);
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<Toolbar className={styles.toolbar} aria-label="Result actions">
|
||||
<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}>
|
||||
{targetLabel(tab.sourceId)}
|
||||
</span>
|
||||
</Toolbar>
|
||||
</div>
|
||||
<div className={clsx(styles.fill, styles.planView)}>
|
||||
<div className={styles.planTree}>
|
||||
<div className={styles.planHeader}>
|
||||
@@ -661,34 +661,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);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
/** Tab from a result tab: its table, plan tree or output log (IDEA focuses the tab's content). */
|
||||
const focusContent = () => {
|
||||
const target = panel.current?.querySelector<HTMLElement>('[role="grid"], [role="tree"], [role="log"]');
|
||||
target?.focus();
|
||||
return !!target && document.activeElement === target;
|
||||
};
|
||||
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();
|
||||
@@ -705,9 +682,11 @@ export function ResultsView() {
|
||||
const tab = results.find((r) => r.id === active);
|
||||
return (
|
||||
<div
|
||||
ref={root}
|
||||
className={styles.view}
|
||||
data-focus-scope=""
|
||||
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"]');
|
||||
@@ -721,41 +700,27 @@ export function ResultsView() {
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ToolWindowContentTabs>
|
||||
<Tabs
|
||||
className={styles.resultTabs}
|
||||
variant="compact"
|
||||
aria-label="Results"
|
||||
activeId={tab ? String(tab.id) : String(OUTPUT_TAB)}
|
||||
panelIdPrefix="db-results"
|
||||
focusContent={focusContent}
|
||||
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>
|
||||
<div
|
||||
ref={panel}
|
||||
className={styles.fill}
|
||||
role="tabpanel"
|
||||
id={`db-results-panel-${tab ? tab.id : OUTPUT_TAB}`}
|
||||
aria-labelledby={`db-results-tab-${tab ? tab.id : OUTPUT_TAB}`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,9 @@ export interface CellActions {
|
||||
canToggle(): boolean;
|
||||
canSetNull(): boolean;
|
||||
canSetDefault(): boolean;
|
||||
canFilter(): boolean;
|
||||
toggle(): void;
|
||||
setNull(): void;
|
||||
setDefault(): void;
|
||||
/** Opens the local filter of the selected cell's column. */
|
||||
filter(): void;
|
||||
}
|
||||
|
||||
const grids = new WeakMap<Element, CellActions>();
|
||||
@@ -62,14 +59,6 @@ export function installCellActions(): () => void {
|
||||
enabled: (c) => handle(c)?.canSetDefault() ?? false,
|
||||
run: (c) => handle(c)?.setDefault(),
|
||||
},
|
||||
{
|
||||
id: "db.localFilter",
|
||||
title: "Local Filter",
|
||||
category: "Database",
|
||||
keyWhen: "dbCellGridFocus",
|
||||
enabled: (c) => handle(c)?.canFilter() ?? false,
|
||||
run: (c) => handle(c)?.filter(),
|
||||
},
|
||||
]);
|
||||
return () => {
|
||||
dispose();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { registerActions, type ActionContext } from "@/platform/actions";
|
||||
import { promptInput } from "@/workbench/inputDialogs";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { OUTPUT_TAB, closeResult, pinResult, useDatabase } from "./store";
|
||||
import { OUTPUT_TAB, closeResult, useDatabase } from "./store";
|
||||
|
||||
function target({ arg, ctx }: ActionContext): number | null {
|
||||
const id = typeof arg === "number" ? arg : ctx.dbResultsFocus ? useDatabase.getState().activeResult : null;
|
||||
@@ -17,23 +17,11 @@ function closeOthers(id: number) {
|
||||
for (const tab of useDatabase.getState().results) if (tab.id !== id) closeResult(tab.id);
|
||||
}
|
||||
|
||||
function pinned(id: number | null): boolean {
|
||||
return !!useDatabase.getState().results.find((r) => r.id === id)?.pinned;
|
||||
}
|
||||
|
||||
function cycle(direction: 1 | -1, focus: boolean) {
|
||||
function cycle(direction: 1 | -1) {
|
||||
const { results, activeResult } = useDatabase.getState();
|
||||
const ids = [OUTPUT_TAB, ...results.map((r) => r.id)];
|
||||
const index = ids.indexOf(activeResult ?? OUTPUT_TAB);
|
||||
if (index < 0) return;
|
||||
const next = ids[(index + direction + ids.length) % ids.length]!;
|
||||
if (focus) {
|
||||
const button = document.getElementById(`db-results-tab-${next}`);
|
||||
button?.focus();
|
||||
// An inline editor can hand focus back to its grid when it blurs.
|
||||
if (document.activeElement !== button) button?.focus();
|
||||
}
|
||||
useDatabase.setState({ activeResult: next });
|
||||
if (index >= 0) useDatabase.setState({ activeResult: ids[(index + direction + ids.length) % ids.length]! });
|
||||
}
|
||||
|
||||
export function registerResultTabActions(): () => void {
|
||||
@@ -63,31 +51,6 @@ export function registerResultTabActions(): () => void {
|
||||
enabled: (c) => target(c) !== null && useDatabase.getState().results.length > 0,
|
||||
run: () => closeOthers(OUTPUT_TAB),
|
||||
},
|
||||
{
|
||||
id: "db.closeAllButPinnedResultTabs",
|
||||
title: "Close All but Pinned",
|
||||
category: "Database",
|
||||
keyWhen: "dbResultsFocus && !popupOpen",
|
||||
enabled: (c) => {
|
||||
const { results } = useDatabase.getState();
|
||||
return target(c) !== null && results.some((r) => r.pinned) && results.some((r) => !r.pinned);
|
||||
},
|
||||
run: () => {
|
||||
for (const tab of useDatabase.getState().results) if (!tab.pinned) closeResult(tab.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "db.pinResultTab",
|
||||
title: "Pin / Unpin Tab",
|
||||
category: "Database",
|
||||
icon: "pin",
|
||||
keyWhen: "dbResultsFocus && !popupOpen",
|
||||
enabled: closable,
|
||||
run: (c) => {
|
||||
const id = target(c)!;
|
||||
pinResult(id, !pinned(id));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "db.renameResultTab",
|
||||
title: "Rename Tab",
|
||||
@@ -110,7 +73,7 @@ export function registerResultTabActions(): () => void {
|
||||
category: "Database",
|
||||
keyWhen: "dbResultsFocus && !popupOpen",
|
||||
enabled: (c: ActionContext) => target(c) !== null && useDatabase.getState().results.length > 0,
|
||||
run: ({ ctx }: ActionContext) => cycle(direction, !!ctx.dbResultsFocus),
|
||||
run: () => cycle(direction),
|
||||
})),
|
||||
]);
|
||||
}
|
||||
@@ -120,9 +83,7 @@ export function resultTabMenu(id: number): MenuItem[] {
|
||||
{ kind: "action", action: "db.closeResultTab", arg: id },
|
||||
{ kind: "action", action: "db.closeOtherResultTabs", arg: id },
|
||||
{ kind: "action", action: "db.closeAllResultTabs", arg: id },
|
||||
{ kind: "action", action: "db.closeAllButPinnedResultTabs", arg: id },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "db.pinResultTab", arg: id, label: pinned(id) ? "Unpin Tab" : "Pin Tab" },
|
||||
{ kind: "action", action: "db.renameResultTab", arg: id },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "db.nextResultTab", arg: id },
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DbDriver, DbQueryResult, DbTableDetails } from "@/platform/ipc";
|
||||
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
|
||||
import { installCellActions } from "./cellActions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { ResultsView } from "./ResultsView";
|
||||
import { executeConsole, explainConsole, pinResult, rerunResult, useDatabase, type ResultTab } from "./store";
|
||||
import { registerResultTabActions } from "./resultTabActions";
|
||||
import { executeConsole, useDatabase, type ResultTab } from "./store";
|
||||
import { activeTableEditor, installTableEditorFocus } from "./tableEditorFocus";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
@@ -17,7 +15,6 @@ const backend = vi.hoisted(() => ({
|
||||
applyEdits: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
statements: vi.fn(),
|
||||
explain: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
@@ -30,7 +27,6 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
dbApplyEdits: backend.applyEdits,
|
||||
dbExecute: backend.execute,
|
||||
dbStatements: backend.statements,
|
||||
dbExplain: backend.explain,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -161,48 +157,6 @@ describe("editable result grid", () => {
|
||||
expect(useDatabase.getState().results[0]!.id).toBe(tab.id);
|
||||
});
|
||||
|
||||
it("keeps navigation focus after leaving an inline cell editor and releases it outside Results", async () => {
|
||||
const first = show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
useDatabase.setState({ results: [first, { ...first, id: nextId++, title: "Result 2" }] });
|
||||
const keymap = useKeymap.getState();
|
||||
useKeymap.setState({ preset: "intellij-macos", overrides: {}, extensions: {} });
|
||||
const dispose = registerResultTabActions();
|
||||
const uninstall = installKeyboardDispatcher(window);
|
||||
try {
|
||||
await act(async () =>
|
||||
render(
|
||||
<>
|
||||
<ResultsView />
|
||||
<button>Outside results</button>
|
||||
</>,
|
||||
),
|
||||
);
|
||||
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
|
||||
const input = screen.getByRole("textbox", { name: "Edit name" });
|
||||
fireEvent.change(input, { target: { value: "Bob" } });
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "}", code: "BracketRight", metaKey: true, shiftKey: true });
|
||||
});
|
||||
expect(screen.getByRole("tab", { name: "Result 2" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tab", { name: "Result 2" })).toHaveFocus();
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document.activeElement!, { key: "{", code: "BracketLeft", metaKey: true, shiftKey: true });
|
||||
});
|
||||
expect(screen.getByRole("tab", { name: "Result 1" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tab", { name: "Result 1" })).toHaveFocus();
|
||||
act(() => screen.getByRole("button", { name: "Outside results" }).focus());
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document.activeElement!, { key: "}", code: "BracketRight", metaKey: true, shiftKey: true });
|
||||
});
|
||||
expect(screen.getByRole("tab", { name: "Result 1" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(backend.applyEdits).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
uninstall();
|
||||
dispose();
|
||||
useKeymap.setState(keymap, true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["s2", ""])("records the selected schema or connection default: %s", async (schema) => {
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
useDatabase.setState({ consoles: { "console.sql": "pg" }, consoleSchemas: { "console.sql": schema } });
|
||||
@@ -433,75 +387,6 @@ describe("result grid keyboard Submit / Revert (#2332)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tab in the results (IDEA: Tab enters the table and moves between its cells)", () => {
|
||||
const selectedCells = () =>
|
||||
screen
|
||||
.getAllByRole("gridcell")
|
||||
.filter((cell) => cell.getAttribute("aria-selected") === "true")
|
||||
.map((cell) => cell.textContent);
|
||||
const rows = [
|
||||
["1", "Ann"],
|
||||
["2", "Bob"],
|
||||
];
|
||||
|
||||
it("goes from the result tab to the first cell, cell by cell, and back through the toolbar's one stop", async () => {
|
||||
const user = userEvent.setup();
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
|
||||
await act(async () => render(<ResultsView />));
|
||||
await screen.findByRole("button", { name: "Add Row" });
|
||||
act(() => screen.getByRole("tab", { name: "Result 1" }).focus());
|
||||
await user.tab();
|
||||
expect(screen.getByRole("grid")).toHaveFocus();
|
||||
expect(selectedCells()).toEqual(["1"]);
|
||||
await user.tab();
|
||||
expect(selectedCells()).toEqual(["Ann"]);
|
||||
await user.tab();
|
||||
expect(selectedCells()).toEqual(["2"]);
|
||||
await user.tab({ shift: true });
|
||||
await user.tab({ shift: true });
|
||||
expect(selectedCells()).toEqual(["1"]);
|
||||
await user.tab({ shift: true });
|
||||
expect(screen.getByRole("toolbar", { name: "Result actions" })).toContainElement(
|
||||
document.activeElement as HTMLElement,
|
||||
);
|
||||
await user.tab({ shift: true });
|
||||
expect(screen.getByRole("tab", { name: "Result 1" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("leaves the grid after the last cell", async () => {
|
||||
const user = userEvent.setup();
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
|
||||
await act(async () => render(<ResultsView />));
|
||||
fireEvent.mouseDown(screen.getByRole("gridcell", { name: "Bob" }), { button: 0 });
|
||||
await user.tab();
|
||||
expect(screen.getByRole("grid")).not.toHaveFocus();
|
||||
expect(selectedCells()).toEqual(["Bob"]);
|
||||
});
|
||||
|
||||
it("commits the cell editor with Tab and selects the next cell", async () => {
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
|
||||
await act(async () => render(<ResultsView />));
|
||||
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
|
||||
const input = screen.getByRole("textbox", { name: "Edit name" });
|
||||
fireEvent.change(input, { target: { value: "Cara" } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(screen.getByRole("gridcell", { name: "Cara" })).toBeTruthy();
|
||||
expect(selectedCells()).toEqual(["2"]);
|
||||
expect(screen.getByRole("grid")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("goes from the Output tab to the log, past its toolbar", async () => {
|
||||
const user = userEvent.setup();
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
|
||||
useDatabase.setState({ output: [{ id: 1, time: 0, kind: "statement", text: "SELECT * FROM users" }] });
|
||||
await act(async () => render(<ResultsView />));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Output" }));
|
||||
act(() => screen.getByRole("tab", { name: "Output" }).focus());
|
||||
await user.tab();
|
||||
expect(screen.getByRole("log", { name: "Database output" })).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it("omits a reset inserted cell from Submit instead of writing SQL NULL", async () => {
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
|
||||
@@ -526,149 +411,3 @@ it("omits a reset inserted cell from Submit instead of writing SQL NULL", async
|
||||
disposeActions();
|
||||
}
|
||||
});
|
||||
|
||||
describe("result tab reuse (IDEA: new results replace the console's unpinned tabs)", () => {
|
||||
const runIn = (path: string, text: string) => executeConsole(path, text, 0, text.length);
|
||||
const strip = () => useDatabase.getState().results.map((r) => `${r.path}:${r.outcome.statement.text}`);
|
||||
const titles = () => useDatabase.getState().results.map((r) => r.title);
|
||||
const ids = () => useDatabase.getState().results.map((r) => r.id);
|
||||
const runNumber = (title: string) => Number(/^Result (\d+)/.exec(title)![1]);
|
||||
|
||||
beforeEach(() => {
|
||||
useDatabase.setState({
|
||||
sources: [{ id: "pg", name: "Local", driver: "postgres", readOnly: false }],
|
||||
completion: { pg: { defaultSchema: "public", tables: [], foreignKeys: [] } },
|
||||
consoles: { "a.sql": "pg", "b.sql": "pg" },
|
||||
consoleSchemas: {},
|
||||
results: [],
|
||||
activeResult: null,
|
||||
output: [],
|
||||
details: {},
|
||||
running: {},
|
||||
});
|
||||
backend.statements.mockImplementation((_id: unknown, text: string) => {
|
||||
let from = 0;
|
||||
const statements = text.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 Promise.resolve({ status: "ok", data: statements });
|
||||
});
|
||||
backend.execute.mockImplementation((_r: unknown, _id: unknown, statements: Array<{ text: string }>) =>
|
||||
Promise.resolve({
|
||||
status: "ok",
|
||||
data: statements.map((st) =>
|
||||
/^bad/i.test(st.text)
|
||||
? { statement: st, result: null, error: "syntax error" }
|
||||
: /^update/i.test(st.text)
|
||||
? { statement: st, result: { ...result([], []), affected: 1, statement: st.text }, error: null }
|
||||
: { statement: st, result: { ...result([{ name: "v" }], [[st.text]]), statement: st.text }, error: null },
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces the console's result in place with a fresh tab, leaving other consoles' results", async () => {
|
||||
await runIn("a.sql", "SELECT 1");
|
||||
await runIn("b.sql", "SELECT b");
|
||||
const [first, other] = ids();
|
||||
await runIn("a.sql", "SELECT 2");
|
||||
expect(strip()).toEqual(["a.sql:SELECT 2", "b.sql:SELECT b"]);
|
||||
expect(ids()[0]).not.toBe(first);
|
||||
expect(ids()[1]).toBe(other);
|
||||
expect(useDatabase.getState().activeResult).toBe(ids()[0]);
|
||||
expect(runNumber(titles()[0]!)).toBe(runNumber(titles()[1]!) + 1);
|
||||
});
|
||||
|
||||
it("opens one tab per result set of a batch, numbered per execution, and a single run replaces them all", async () => {
|
||||
await runIn("a.sql", "SELECT 1; SELECT 2; SELECT 3");
|
||||
const n = runNumber(titles()[0]!);
|
||||
expect(titles()).toEqual([`Result ${n}`, `Result ${n}-2`, `Result ${n}-3`]);
|
||||
expect(useDatabase.getState().activeResult).toBe(ids()[2]);
|
||||
await runIn("a.sql", "SELECT 4");
|
||||
expect(strip()).toEqual(["a.sql:SELECT 4"]);
|
||||
expect(titles()).toEqual([`Result ${n + 1}`]);
|
||||
await runIn("a.sql", "SELECT 5; SELECT 6");
|
||||
expect(titles()).toEqual([`Result ${n + 2}`, `Result ${n + 2}-2`]);
|
||||
});
|
||||
|
||||
it("leaves a pinned result alone and replaces only the newer unpinned one", async () => {
|
||||
await runIn("a.sql", "SELECT 1");
|
||||
const pinned = ids()[0]!;
|
||||
pinResult(pinned, true);
|
||||
await runIn("a.sql", "SELECT 2");
|
||||
await runIn("a.sql", "SELECT 3");
|
||||
expect(strip()).toEqual(["a.sql:SELECT 1", "a.sql:SELECT 3"]);
|
||||
expect(ids()[0]).toBe(pinned);
|
||||
pinResult(pinned, false);
|
||||
await runIn("a.sql", "SELECT 4");
|
||||
expect(strip()).toEqual(["a.sql:SELECT 4"]);
|
||||
});
|
||||
|
||||
it("keeps the previous results when a run returns no result set or fails", async () => {
|
||||
await runIn("a.sql", "SELECT 1");
|
||||
const before = useDatabase.getState().results;
|
||||
await runIn("a.sql", "UPDATE t SET v = 1");
|
||||
expect(useDatabase.getState().results).toBe(before);
|
||||
expect(useDatabase.getState().activeResult).toBe(0);
|
||||
await runIn("a.sql", "bad sql");
|
||||
expect(useDatabase.getState().results).toBe(before);
|
||||
});
|
||||
|
||||
it("never replaces the Query Plan from a run, and appends a new plan beside a pinned one", async () => {
|
||||
backend.explain.mockResolvedValue({ status: "ok", data: [] });
|
||||
await explainConsole("a.sql", "SELECT 1", 0, 0);
|
||||
await runIn("a.sql", "SELECT 2");
|
||||
const plan = ids()[0]!;
|
||||
expect(useDatabase.getState().results.map((r) => !!r.plan)).toEqual([true, false]);
|
||||
await runIn("a.sql", "SELECT 3");
|
||||
expect(ids()[0]).toBe(plan);
|
||||
pinResult(plan, true);
|
||||
await explainConsole("a.sql", "SELECT 4", 0, 0);
|
||||
expect(
|
||||
useDatabase
|
||||
.getState()
|
||||
.results.filter((r) => r.plan)
|
||||
.map((r) => r.outcome.statement.text),
|
||||
).toEqual(["SELECT 1", "SELECT 4"]);
|
||||
});
|
||||
|
||||
it("drops the oldest unpinned results beyond 12 but never pinned or new ones", async () => {
|
||||
const paths = Array.from({ length: 12 }, (_, i) => `c${i}.sql`);
|
||||
useDatabase.setState((s) => ({ consoles: { ...s.consoles, ...Object.fromEntries(paths.map((p) => [p, "pg"])) } }));
|
||||
for (const path of paths) await runIn(path, "SELECT 0");
|
||||
pinResult(ids()[0]!, true);
|
||||
await runIn("a.sql", "SELECT 1; SELECT 2");
|
||||
// c1 and c2 go (oldest unpinned); pinned c0 and both new results stay.
|
||||
expect(strip()).toEqual([
|
||||
"c0.sql:SELECT 0",
|
||||
...paths.slice(3).map((p) => `${p}:SELECT 0`),
|
||||
"a.sql:SELECT 1",
|
||||
"a.sql:SELECT 2",
|
||||
]);
|
||||
for (const r of useDatabase.getState().results) pinResult(r.id, true);
|
||||
await runIn("b.sql", "SELECT b");
|
||||
expect(strip()).toHaveLength(13);
|
||||
expect(strip().at(-1)).toBe("b.sql:SELECT b");
|
||||
});
|
||||
|
||||
it("stays on the new result when a replaced tab's rerun fails afterwards", async () => {
|
||||
await runIn("a.sql", "SELECT 1");
|
||||
const old = ids()[0]!;
|
||||
let fail!: () => void;
|
||||
backend.execute.mockImplementationOnce(
|
||||
(_r: unknown, _id: unknown, statements: Array<{ text: string }>) =>
|
||||
new Promise((resolve) => {
|
||||
fail = () => resolve({ status: "ok", data: [{ statement: statements[0], result: null, error: "gone" }] });
|
||||
}),
|
||||
);
|
||||
const rerun = rerunResult(old);
|
||||
await runIn("a.sql", "SELECT 2");
|
||||
const fresh = ids()[0]!;
|
||||
fail();
|
||||
await rerun;
|
||||
expect(useDatabase.getState().activeResult).toBe(fresh);
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,7 @@ const MAX_TABS = 12;
|
||||
|
||||
export interface ResultTab {
|
||||
id: number;
|
||||
/** `Result N`, `Result N-2`… (N numbers a console execution, like IDEA). */
|
||||
/** `Result N` (numbered per window, like IDEA). */
|
||||
title: string;
|
||||
/** Console file the statement came from. */
|
||||
path: string;
|
||||
@@ -80,8 +80,6 @@ export interface ResultTab {
|
||||
pageSize?: number;
|
||||
/** Rows of the whole query, once counted. */
|
||||
total?: number | null;
|
||||
/** Kept when the console runs again (Pin Tab); results otherwise replace the console's last ones. */
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
/** One line of the consoles' Output log (IDEA's Database Console output). */
|
||||
@@ -736,7 +734,6 @@ export async function openConsole(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
let nextTab = 1;
|
||||
let nextRun = 1;
|
||||
let nextLine = 1;
|
||||
|
||||
export function log(kind: OutputLine["kind"], text: string, prompt?: string) {
|
||||
@@ -885,9 +882,8 @@ export async function openDdl(id: string, schema: string, name: string): Promise
|
||||
/**
|
||||
* Runs the statement at `from` (or the selection `from..to`, UTF-16 offsets)
|
||||
* of console `path` on its data source. Result sets open as `Result N` tabs
|
||||
* of the Services view, replacing the console's unpinned ones; everything is
|
||||
* logged to its Output tab; returns the outcomes (errors included) for the
|
||||
* editor's marks.
|
||||
* of the Services view, everything is logged to its Output tab; returns the
|
||||
* outcomes (errors included) for the editor's marks.
|
||||
*/
|
||||
/** Asks for the values of the statements' query parameters and binds them; `[]` when cancelled. */
|
||||
async function withParams(statements: SqlStatement[], driver: DbDriver): Promise<SqlStatement[]> {
|
||||
@@ -935,16 +931,15 @@ export async function executeConsole(
|
||||
const pageSize = useDatabase.getState().maxRows;
|
||||
const schema = consoleSchema(path) || (await loadCompletion(id))?.defaultSchema || "";
|
||||
const outcomes = await run(id, statements, request, pageSize, 0, schema);
|
||||
const sets = outcomes.filter((o) => o.result?.columns.length);
|
||||
const n = sets.length ? nextRun++ : 0;
|
||||
const tabs: ResultTab[] = sets.map((outcome, i) => {
|
||||
const index = i + 1;
|
||||
const title = index === 1 ? tx`Result ${n}` : tx`Result ${n}-${index}`;
|
||||
return { id: nextTab++, title, path, sourceId: id, schema, outcome, page: 0, pageSize };
|
||||
});
|
||||
const tabs: ResultTab[] = outcomes
|
||||
.filter((o) => o.result?.columns.length)
|
||||
.map((outcome) => {
|
||||
const n = nextTab++;
|
||||
return { id: n, title: tx`Result ${n}`, path, sourceId: id, schema, outcome, page: 0, pageSize };
|
||||
});
|
||||
const failed = outcomes.some((o) => o.error);
|
||||
useDatabase.setState((s) => ({
|
||||
results: replaceResults(s.results, path, tabs),
|
||||
results: [...s.results, ...tabs].slice(-MAX_TABS),
|
||||
activeResult: failed || !tabs.length ? OUTPUT_TAB : tabs.at(-1)!.id,
|
||||
}));
|
||||
showServiceView(RESULTS_VIEW, false);
|
||||
@@ -972,27 +967,6 @@ export async function executeConsole(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IDEA reuses result tabs: a console's new result sets take the place of its
|
||||
* unpinned ones (where the first of them was), others stay. Nothing is
|
||||
* replaced when the run returned no result set (an UPDATE keeps the SELECT).
|
||||
*/
|
||||
function replaceResults(results: ResultTab[], path: string, tabs: ResultTab[]): ResultTab[] {
|
||||
if (!tabs.length) return results;
|
||||
const replaced = (r: ResultTab) => r.path === path && !r.plan && !r.pinned;
|
||||
const at = results.findIndex(replaced);
|
||||
const kept = results.filter((r) => !replaced(r));
|
||||
const index = at < 0 ? kept.length : at;
|
||||
return capResults([...kept.slice(0, index), ...tabs, ...kept.slice(index)], tabs);
|
||||
}
|
||||
|
||||
/** Drops the oldest tabs beyond `MAX_TABS`, never pinned or `fresh` ones (exceeding the limit instead). */
|
||||
function capResults(results: ResultTab[], fresh: ResultTab[]): ResultTab[] {
|
||||
let extra = results.length - MAX_TABS;
|
||||
if (extra <= 0) return results;
|
||||
return results.filter((r) => r.pinned || fresh.includes(r) || extra-- <= 0);
|
||||
}
|
||||
|
||||
/** The statements of console `path` to run or explain: the selection's, else the one at `from`, parameters bound. */
|
||||
async function consoleStatements(id: string, text: string, from: number, to: number): Promise<SqlStatement[]> {
|
||||
const statements: SqlStatement[] = await call(commands.dbStatements(id, text, from, to));
|
||||
@@ -1005,7 +979,7 @@ const PLAN_TITLE = "Query Plan";
|
||||
/** Shows `plan` of `statement` in the window's Query Plan tab (one per window, like IDEA's). */
|
||||
function showPlan(path: string, id: string, statement: SqlStatement, plan: DbPlanNode[]) {
|
||||
useDatabase.setState((s) => {
|
||||
const old = s.results.find((r) => r.plan && !r.pinned);
|
||||
const old = s.results.find((r) => r.plan);
|
||||
const tab: ResultTab = {
|
||||
id: old?.id ?? nextTab++,
|
||||
title: old?.title ?? PLAN_TITLE,
|
||||
@@ -1014,7 +988,7 @@ function showPlan(path: string, id: string, statement: SqlStatement, plan: DbPla
|
||||
outcome: { statement, result: null, error: null },
|
||||
plan,
|
||||
};
|
||||
const results = old ? s.results.map((r) => (r === old ? tab : r)) : capResults([...s.results, tab], [tab]);
|
||||
const results = old ? s.results.map((r) => (r === old ? tab : r)) : [...s.results, tab].slice(-MAX_TABS);
|
||||
return { results, activeResult: tab.id };
|
||||
});
|
||||
showServiceView(RESULTS_VIEW, false);
|
||||
@@ -1140,9 +1114,7 @@ export async function loadResultPage(tabId: number, page: number, pageSize: numb
|
||||
...(rerun || sizeChanged ? { total: null } : {}),
|
||||
});
|
||||
} else patchResult(tabId, { busy: false });
|
||||
// A run of its console may have replaced the tab meanwhile.
|
||||
if (outcome?.error && useDatabase.getState().results.some((r) => r.id === tabId))
|
||||
useDatabase.setState({ activeResult: OUTPUT_TAB });
|
||||
if (outcome?.error) useDatabase.setState({ activeResult: OUTPUT_TAB });
|
||||
} catch (e) {
|
||||
patchResult(tabId, { busy: false });
|
||||
if (!isCancelled(e)) log("error", message(e));
|
||||
@@ -1267,7 +1239,3 @@ export function closeResult(id: number) {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function pinResult(id: number, pinned: boolean) {
|
||||
patchResult(id, { pinned });
|
||||
}
|
||||
@@ -44,10 +44,6 @@ import { tx } from "@/platform/i18n";
|
||||
|
||||
/** Context key: the Database tree has focus. */
|
||||
export const DB_VIEW_FOCUS = "databaseViewFocus";
|
||||
|
||||
/** Set while the Database view is mounted: Expand All walks its tree's loaded nodes. */
|
||||
export const databaseTree: { expandAll: (() => void) | null } = { expandAll: null };
|
||||
|
||||
const OPEN = "projectOpen";
|
||||
|
||||
const isNode = (v: unknown): v is DbNode => !!v && typeof v === "object" && "kind" in v && "key" in v;
|
||||
@@ -485,12 +481,6 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
title: "Separate Procedures and Functions",
|
||||
run: toggleSeparateProceduresAndFunctions,
|
||||
}),
|
||||
tree({
|
||||
id: "db.expandAll",
|
||||
title: "Expand All",
|
||||
icon: "expand-all",
|
||||
run: () => databaseTree.expandAll?.(),
|
||||
}),
|
||||
tree({
|
||||
id: "db.collapseAll",
|
||||
title: "Collapse All",
|
||||
|
||||
@@ -307,7 +307,7 @@ describe("variables the adapter computes after the stop", () => {
|
||||
expect(await screen.findByText("Collecting data…")).toBeTruthy();
|
||||
adapterData.library = "com.example.library.Library@1";
|
||||
act(() => handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] }));
|
||||
const value = await screen.findByText("{Library@1}");
|
||||
const value = await screen.findByText("com.example.library.Library@1");
|
||||
expect(screen.queryByText("Collecting data…")).toBeNull();
|
||||
expect(value.closest("[role=treeitem]")?.querySelector("[class*=changed]")).toBeNull();
|
||||
});
|
||||
@@ -348,7 +348,7 @@ describe("variables the adapter computes after the stop", () => {
|
||||
await screen.findByText("Collecting data…");
|
||||
adapterData.library = "Library@2";
|
||||
await act(() => setVariableValue({ container: 1, name: "count", evaluateName: "count" }, "4", session));
|
||||
expect(await screen.findByText("{Library@2}")).toBeTruthy();
|
||||
expect(await screen.findByText("Library@2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does nothing for an invalidation while the program runs", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { selectionExpression } from "../editor/valueTooltip";
|
||||
import { classifyValue, identifiersIn, inlineValue, shownValue, sortVariables } from "../values";
|
||||
import { classifyValue, identifiersIn, inlineValue, sortVariables } from "../values";
|
||||
import { inlineValuesFor } from "../editor/execution";
|
||||
import { frameLabel, groupFrames, isLibraryFrame } from "../frames";
|
||||
import type { StackFrame } from "@/platform/ipc";
|
||||
@@ -150,25 +150,3 @@ describe("selectionExpression", () => {
|
||||
expect(selectionExpression(multi)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shown values", () => {
|
||||
it.each([
|
||||
["String[0]@9", "String[]", { label: "{String[0]@9}", text: "[]" }],
|
||||
["int[3]@12", "int[]", { label: "{int[3]@12}", text: "" }],
|
||||
["HashMap@10 size=1", "HashMap", { label: "{HashMap@10}", text: "size = 1" }],
|
||||
["ArrayList@15 size=0", "java.util.ArrayList", { label: "{ArrayList@15}", text: "size = 0" }],
|
||||
["Db@21", "Db", { label: "{Db@21}", text: "" }],
|
||||
["com.example.Library@1", "Library", { label: "{Library@1}", text: "" }],
|
||||
['HashMap$Node@31 "a":"b"', "HashMap$Node", { label: null, text: '"a" -> "b"', entry: true }],
|
||||
['HashMap$Node@32 "a:b":"c"', "HashMap$Node", { label: null, text: '"a:b" -> "c"', entry: true }],
|
||||
['TreeMap$Entry@33 1:"x"', "TreeMap$Entry", { label: null, text: '1 -> "x"', entry: true }],
|
||||
['"hello"', "String", { label: null, text: '"hello"' }],
|
||||
["42", "int", { label: "{int}", text: "42" }],
|
||||
// Other debuggers' values keep their text and type.
|
||||
["Foo@1", "Bar", { label: "{Bar}", text: "Foo@1" }],
|
||||
["{'a': 1}", "dict", { label: "{dict}", text: "{'a': 1}" }],
|
||||
["0x1000", null, { label: null, text: "0x1000" }],
|
||||
])("%s {%s}", (value, type, shown) => {
|
||||
expect(shownValue(value, type)).toEqual(shown);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
|
||||
import { activeSession, useExecutionPoint } from "../state";
|
||||
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { identifiersIn, inlineValue, shownValue } from "../values";
|
||||
import { identifiersIn, inlineValue } from "../values";
|
||||
import { valueHint } from "./valueTooltip";
|
||||
import styles from "./editor.module.css";
|
||||
|
||||
@@ -113,13 +113,11 @@ export function inlineValuesFor(
|
||||
evaluateName: id,
|
||||
})
|
||||
: raw;
|
||||
const shown = shownValue(variable.value, variable.typeName);
|
||||
const value = shown.text || shown.label || variable.value;
|
||||
parts.push({
|
||||
name: id,
|
||||
text: `${id}: ${inlineValue(value, 40)}`,
|
||||
text: `${id}: ${inlineValue(variable.value, 40)}`,
|
||||
variable,
|
||||
truncated: value.length > 40,
|
||||
truncated: variable.value.length > 40,
|
||||
});
|
||||
if (parts.length >= MAX_PER_LINE) break;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { navigationModifierHeld } from "@/platform/navigationModifier";
|
||||
import { activeSession, evaluate } from "../state";
|
||||
import { inlineValue, shownValue } from "../values";
|
||||
import { inlineValue } from "../values";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { iconMarkup, selectableText } from "@/ui";
|
||||
import { mountValueTree } from "../ui/ValueHintTree";
|
||||
@@ -70,19 +70,13 @@ export function valueHint(
|
||||
const name = document.createElement("span");
|
||||
name.className = styles.valueName!;
|
||||
name.textContent = `${expression} = `;
|
||||
const shown = shownValue(value.value, value.typeName);
|
||||
// A JVM object's `{Type@id}` leads its value; other types follow it.
|
||||
const leading = shown.label?.includes("@") ?? false;
|
||||
const type = (text: string) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = styles.valueType!;
|
||||
span.textContent = text;
|
||||
return span;
|
||||
};
|
||||
dom.append(name);
|
||||
if (leading) dom.append(type(`${shown.label} `));
|
||||
dom.append(max === null ? shown.text : inlineValue(shown.text, max));
|
||||
if (shown.label && !leading) dom.append(type(` ${shown.label}`));
|
||||
dom.append(name, max === null ? value.value : inlineValue(value.value, max));
|
||||
if (value.typeName) {
|
||||
const type = document.createElement("span");
|
||||
type.className = styles.valueType!;
|
||||
type.textContent = ` {${value.typeName}}`;
|
||||
dom.append(type);
|
||||
}
|
||||
return { dom, destroy: () => tree?.unmount() };
|
||||
}
|
||||
|
||||
|
||||
@@ -48,11 +48,6 @@ import { forgetMemory } from "./memory/memoryState";
|
||||
const useAnyOverlay = () =>
|
||||
useOverlays((s) => !!(s.popover || s.evaluate || s.breakpoints || s.chooser || s.attach || s.smartStep));
|
||||
|
||||
/** The Debug stripe button's badge, like Run's: how many debug sessions (child sessions aside) are live. */
|
||||
function useLiveSessionCount(): number | null {
|
||||
return useDebug((s) => Object.values(s.sessions).filter((x) => x.parent === null && isLive(x)).length || null);
|
||||
}
|
||||
|
||||
export function activate(): () => void {
|
||||
useDebugRunSessions.setState({
|
||||
Console: lazyComponent(() => import("./ui/DebugConsole").then((m) => m.DebugServiceConsole)),
|
||||
@@ -76,7 +71,6 @@ export function activate(): () => void {
|
||||
order: 20,
|
||||
component: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugToolWindow)),
|
||||
headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)),
|
||||
useBadge: useLiveSessionCount,
|
||||
}),
|
||||
registerEditorKind(heapEditorKind),
|
||||
installHeapContext(),
|
||||
|
||||
@@ -176,7 +176,30 @@ export function DebugToolWindow() {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.toolbar} role="toolbar" aria-label={tx("Debugger actions")}>
|
||||
{/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group), then the views. */}
|
||||
<div role="tablist" aria-label={tx("Debug views")} className={styles.tabs}>
|
||||
{(
|
||||
[
|
||||
["variables", "Threads & Variables"],
|
||||
["console", "Console"],
|
||||
...(hasMemory ? [["memory", "Memory"] as const] : []),
|
||||
] as const
|
||||
).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`debug-tab-${id}`}
|
||||
aria-selected={tab === id}
|
||||
aria-controls={`debug-panel-${id}`}
|
||||
className={clsx(styles.tab, tab === id && styles.tabActive)}
|
||||
onClick={() => setView({ session: active.startedAt, status: active.status, tab: id })}
|
||||
>
|
||||
{tx(label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group). */}
|
||||
<span className={styles.sep} aria-hidden />
|
||||
<ToolButton action="debug.rerun" icon="rerun" />
|
||||
<ToolButton action="debug.stop" icon="stop" tone="danger" />
|
||||
<span className={styles.sep} aria-hidden />
|
||||
@@ -210,29 +233,6 @@ export function DebugToolWindow() {
|
||||
onClick={() => setMuted(!muted)}
|
||||
/>
|
||||
<MoreActions />
|
||||
<span className={styles.sep} aria-hidden />
|
||||
<div role="tablist" aria-label={tx("Debug views")} className={styles.tabs}>
|
||||
{(
|
||||
[
|
||||
["variables", "Threads & Variables"],
|
||||
["console", "Console"],
|
||||
...(hasMemory ? [["memory", "Memory"] as const] : []),
|
||||
] as const
|
||||
).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`debug-tab-${id}`}
|
||||
aria-selected={tab === id}
|
||||
aria-controls={`debug-panel-${id}`}
|
||||
className={clsx(styles.tab, tab === id && styles.tabActive)}
|
||||
onClick={() => setView({ session: active.startedAt, status: active.status, tab: id })}
|
||||
>
|
||||
{tx(label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" id={`debug-panel-${tab}`} aria-labelledby={`debug-tab-${tab}`} className={styles.panel}>
|
||||
{tab === "variables" ? (
|
||||
@@ -251,12 +251,7 @@ export function DebugToolWindow() {
|
||||
|
||||
function ThreadsAndVariables({ session }: { session: DebugSession }) {
|
||||
return (
|
||||
<SplitGroup
|
||||
orientation="horizontal"
|
||||
id="debug-threads-variables"
|
||||
storageKey="openidea.debug.threadsVariables"
|
||||
className={styles.split}
|
||||
>
|
||||
<SplitGroup orientation="horizontal" id="debug-threads-variables" className={styles.split}>
|
||||
<SplitPanel id="frames" defaultSize="34%" minSize="160px">
|
||||
<FramesPane session={session} />
|
||||
</SplitPanel>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { addFieldWatchpoint } from "../breakpoints/special";
|
||||
import { methodAt } from "../breakpoints/symbols";
|
||||
import { activeSession, addWatch, removeWatch, revealFrame, sessionSupports, setVariableValue } from "../state";
|
||||
import { blurDebugView, focusDebugView } from "./focusedView";
|
||||
import { classifyValue, oneLine, shownValue, sortVariables } from "../values";
|
||||
import { classifyValue, oneLine, sortVariables } from "../values";
|
||||
import { valueText } from "../valueText";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import styles from "./VariablesView.module.css";
|
||||
@@ -486,25 +486,20 @@ function VarRow({
|
||||
</span>
|
||||
);
|
||||
const kind = node.error ? null : classifyValue(node.value, node.typeName);
|
||||
const shown = shownValue(node.value, node.typeName);
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<Icon name={nodeIcon(node)} size={16} className={styles.nodeIcon} />
|
||||
{!shown.entry && (
|
||||
<>
|
||||
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
|
||||
{node.name}
|
||||
</span>
|
||||
<span className={styles.eq}>=</span>
|
||||
</>
|
||||
)}
|
||||
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
|
||||
{node.name}
|
||||
</span>
|
||||
<span className={styles.eq}>=</span>
|
||||
{node.error ? (
|
||||
<span className={styles.error}>{oneLine(node.error)}</span>
|
||||
) : (
|
||||
<>
|
||||
{shown.label && <span className={styles.type}>{shown.label}</span>}
|
||||
{node.typeName && <span className={styles.type}>{`{${node.typeName}}`}</span>}
|
||||
<span ref={valueRef} className={clsx(styles.value, kind && styles[kind])} title={node.value}>
|
||||
{oneLine(shown.text)}
|
||||
{oneLine(node.value)}
|
||||
</span>
|
||||
{overflow && (
|
||||
<button
|
||||
|
||||
@@ -69,35 +69,3 @@ export function sortVariables<T extends { name: string }>(vars: readonly T[]): T
|
||||
if (vars.every((v) => INDEX.test(v.name))) return [...vars];
|
||||
return [...vars].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
||||
}
|
||||
|
||||
/** How a variable's type and value read in the Variables view: the braced label before the value, then the value. */
|
||||
export interface ShownValue {
|
||||
label: string | null;
|
||||
text: string;
|
||||
/** A map entry: the row shows `key -> value` alone, without its index. */
|
||||
entry?: true;
|
||||
}
|
||||
|
||||
// `HashMap@10 size=1`, `String[0]@9`: a JVM object as the Java debugger writes it (type, then the object id).
|
||||
const JVM_OBJECT = /^([\w$.]+(?:\[\d*\])*)@(\d+)(?:\s+(.*))?$/s;
|
||||
// A map entry's key, at the start of `"a":"b"` or `1:"b"`.
|
||||
const ENTRY_KEY = /^("(?:[^"\\]|\\.)*"|[^:"]+?):(.*)$/s;
|
||||
|
||||
/**
|
||||
* JVM objects read as `{HashMap@10} size = 1` and a map entry as `"a" -> "b"`; strings show without
|
||||
* a type. Values in other forms keep the adapter's text under `{type}`.
|
||||
*/
|
||||
export function shownValue(value: string, typeName: string | null | undefined): ShownValue {
|
||||
const plain = { label: typeName ? `{${typeName}}` : null, text: value };
|
||||
if (!typeName) return plain;
|
||||
if (typeName === "String" && /^".*"$/s.test(value)) return { label: null, text: value };
|
||||
const object = JVM_OBJECT.exec(value);
|
||||
if (!object) return plain;
|
||||
const [, head, id, rest = ""] = object;
|
||||
const simple = (t: string) => t.replace(/\[\d*\]/g, "[]").replace(/^.*\./, "");
|
||||
if (simple(head!) !== simple(typeName)) return plain;
|
||||
const entry = /(?:^|\$)\w*(?:Entry|Node)$/.test(typeName) ? ENTRY_KEY.exec(rest) : null;
|
||||
if (entry) return { label: null, text: `${entry[1]!.trim()} -> ${entry[2]!.trim()}`, entry: true };
|
||||
const text = rest.replace(/\b(size|length)=(\d+)/g, "$1 = $2") || (/\[0\]/.test(head!) ? "[]" : "");
|
||||
return { label: `{${head!.replace(/^.*\./, "")}@${id}}`, text };
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import type { SyncEntry } from "@/platform/ipc";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
actions: [] as { id: string; run: (context: { arg: string[] }) => unknown }[],
|
||||
servers: [] as DeploymentServerValid[],
|
||||
groups: [] as { id: string; name: string; servers: string[] }[],
|
||||
coverage: [] as { server: string; paths: string[]; skipped: { path: string; message: string }[] }[],
|
||||
choose: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
@@ -38,7 +37,7 @@ vi.mock("@/platform/project", () => ({
|
||||
useProject: { getState: () => ({ project: { root: "/project" } }) },
|
||||
}));
|
||||
vi.mock("@/platform/workflowSettings", () => ({
|
||||
workflowSettings: () => ({ deployment: { servers: mocks.servers, groups: mocks.groups, defaultServer: "one" } }),
|
||||
workflowSettings: () => ({ deployment: { servers: mocks.servers, defaultServer: "one" } }),
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", () => ({
|
||||
notifications: () => ({ notify: mocks.notify }),
|
||||
@@ -73,7 +72,6 @@ async function run(id: string, paths = ["/project/a", "/project/b"]) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.servers = ["one", "two"].map((id) => ({ id, name: id, kind: "local" }) as DeploymentServerValid);
|
||||
mocks.groups = [];
|
||||
mocks.coverage = [
|
||||
{
|
||||
server: "one",
|
||||
@@ -93,34 +91,6 @@ beforeEach(() => {
|
||||
useDeployment.setState({ sync: null });
|
||||
registerDeploymentActions();
|
||||
});
|
||||
describe("server groups", () => {
|
||||
it("offers a group with a covering server and uploads to each of its servers, past a failure", async () => {
|
||||
mocks.groups = [
|
||||
{ id: "g", name: "Production", servers: ["one", "two"] },
|
||||
{ id: "none", name: "Elsewhere", servers: ["two"] },
|
||||
];
|
||||
mocks.coverage[1]!.paths = [];
|
||||
mocks.choose.mockResolvedValue("Production (group)");
|
||||
mocks.upload.mockRejectedValueOnce(new Error("connection refused"));
|
||||
await run("deployment.uploadTo");
|
||||
expect(mocks.choose).toHaveBeenCalledWith({ title: "Upload to", items: ["one", "Production (group)"] });
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "No selected paths can be deployed to two" }),
|
||||
),
|
||||
);
|
||||
expect(mocks.upload).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Cannot upload to one" }));
|
||||
});
|
||||
it("uploads to every covered server of the group in order", async () => {
|
||||
mocks.groups = [{ id: "g", name: "Production", servers: ["two", "one"] }];
|
||||
mocks.coverage[1]!.paths = ["/project/b"];
|
||||
mocks.choose.mockResolvedValue("Production (group)");
|
||||
await run("deployment.uploadTo");
|
||||
await vi.waitFor(() => expect(mocks.upload).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.upload.mock.calls.map((c) => c[1])).toEqual(["two", "one"]);
|
||||
});
|
||||
});
|
||||
describe("deployment eligibility", () => {
|
||||
it("keeps upstream's server-specific explanation when only one server is configured", async () => {
|
||||
mocks.servers = [mocks.servers[0]!];
|
||||
|
||||
@@ -89,13 +89,13 @@ function notifyNoCoverage(server?: DeploymentServerValid, skipped?: TransferRepo
|
||||
: "No deployment server covers the selected paths",
|
||||
body: skipped?.length ? <ReportBody report={{ files: 0, skipped: skipped.length, errors: skipped }} /> : undefined,
|
||||
group: GROUP,
|
||||
key: server ? `deployment.noCoverage.${server.id}` : "deployment.noCoverage",
|
||||
key: "deployment.noCoverage",
|
||||
actions: [{ label: "Configure…", run: () => void executeAction("deployment.configure") }],
|
||||
});
|
||||
}
|
||||
|
||||
/** The servers covering `paths`, or null (after saying why) when none does. */
|
||||
async function coveringServers(paths: string[]): Promise<DeploymentServerValid[] | null> {
|
||||
/** Keep the chooser visible even when only one server covers the selection. */
|
||||
async function pickServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
||||
const configured = deployment().servers;
|
||||
if (!configured.length) {
|
||||
notifyNoServers();
|
||||
@@ -119,36 +119,11 @@ async function coveringServers(paths: string[]): Promise<DeploymentServerValid[]
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
|
||||
/** Keep the chooser visible even when only one server covers the selection. */
|
||||
async function pickServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
||||
const servers = await coveringServers(paths);
|
||||
if (!servers) return null;
|
||||
const labels = servers.map(serverLabel);
|
||||
const picked = await chooseItem({ title, items: labels });
|
||||
return picked === null ? null : (servers[labels.indexOf(picked)] ?? null);
|
||||
}
|
||||
|
||||
/** Upload to…: a server covering the selection, or a group with such a server (all its servers). */
|
||||
async function pickUploadTargets(paths: string[]): Promise<DeploymentServerValid[] | null> {
|
||||
const servers = await coveringServers(paths);
|
||||
if (!servers) return null;
|
||||
const { servers: configured, groups } = deployment();
|
||||
const choices = new Map<string, DeploymentServerValid[]>();
|
||||
for (const server of servers) if (!choices.has(serverLabel(server))) choices.set(serverLabel(server), [server]);
|
||||
for (const group of groups) {
|
||||
const members = group.servers.flatMap((id) => configured.find((s) => s.id === id) ?? []);
|
||||
if (!members.some((m) => servers.includes(m))) continue;
|
||||
const name = group.name.trim() || group.id;
|
||||
const label = tx`${name} (group)`;
|
||||
if (!choices.has(label)) choices.set(label, members);
|
||||
}
|
||||
const picked = await chooseItem({ title: "Upload to", items: [...choices.keys()] });
|
||||
return picked === null ? null : (choices.get(picked) ?? null);
|
||||
}
|
||||
|
||||
async function defaultServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
||||
return resolveServer(deployment(), null) ?? pickServer(title, paths);
|
||||
}
|
||||
@@ -185,8 +160,7 @@ export function showReport(
|
||||
title: s.title.replace("{n}", String(s.params.n)).replace("{server}", s.params.server),
|
||||
body: report.errors.length || report.skipped ? <ReportBody report={report} /> : undefined,
|
||||
group: GROUP,
|
||||
// One per server, so each server of a group reports on its own.
|
||||
key: `deployment.${verb}.${server.id}`,
|
||||
key: `deployment.${verb}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -197,7 +171,7 @@ function showFailure(title: string, server: DeploymentServerValid, e: unknown) {
|
||||
title: title.replace("{server}", serverLabel(server)),
|
||||
body: errorText(e),
|
||||
group: GROUP,
|
||||
key: `deployment.failure.${server.id}`,
|
||||
key: "deployment.failure",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -477,10 +451,7 @@ export function registerDeploymentActions(): () => void {
|
||||
category: CATEGORY,
|
||||
when,
|
||||
keywords: ["deploy", "sftp", "upload", "remote"],
|
||||
run: withTargets(async (paths) => {
|
||||
// A group uploads to each server in turn; each reports on its own, and a failure doesn't stop the rest.
|
||||
for (const server of (await pickUploadTargets(paths)) ?? []) await transfer("upload", server, paths);
|
||||
}),
|
||||
run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to", paths), paths)),
|
||||
},
|
||||
{
|
||||
id: "deployment.downloadRemoteInto",
|
||||
|
||||
@@ -55,12 +55,6 @@ export const UI_KEYS: Record<string, string[]> = {
|
||||
"--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"],
|
||||
// Islands paints the selected tab's border with `underlinedBorderColor`; the classic tabs underline with `underlineColor`.
|
||||
"--border-tab-selected": ["EditorTabs.underlinedBorderColor", "EditorTabs.underlineColor"],
|
||||
// The selected tab of a part without focus (src/ui/focusScope.ts).
|
||||
"--bg-tab-selected-inactive": ["EditorTabs.inactiveUnderlinedTabBackground"],
|
||||
"--border-tab-selected-inactive": [
|
||||
"EditorTabs.inactiveUnderlinedTabBorderColor",
|
||||
"EditorTabs.inactiveUnderlineColor",
|
||||
],
|
||||
"--bg-tab-hover": ["EditorTabs.hoverBackground"],
|
||||
"--border-editor-tabs": ["EditorTabs.borderColor"],
|
||||
"--bg-toolbar-hover": ["ActionButton.hoverBackground"],
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
@@ -366,15 +365,6 @@ function DecorationSuffix({ segments, color }: DecorationSuffixProps) {
|
||||
<span className={styles.suffix} style={color ? { color } : undefined}>
|
||||
{segments.map((s, i) => {
|
||||
const text = s.strike ? <s>{s.text}</s> : s.text;
|
||||
const style: CSSProperties | undefined =
|
||||
s.color || s.fontWeight || s.fontStyle || s.textDecoration
|
||||
? {
|
||||
...(s.color ? { color: s.color } : null),
|
||||
...(s.fontWeight ? { fontWeight: s.fontWeight } : null),
|
||||
...(s.fontStyle ? { fontStyle: s.fontStyle } : null),
|
||||
...(s.textDecoration ? { textDecoration: s.textDecoration } : null),
|
||||
}
|
||||
: undefined;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
@@ -382,7 +372,6 @@ function DecorationSuffix({ segments, color }: DecorationSuffixProps) {
|
||||
role={s.label ? "img" : undefined}
|
||||
aria-label={s.label}
|
||||
title={s.label}
|
||||
style={style}
|
||||
>
|
||||
{text}
|
||||
{s.icon && <Icon name={s.icon} size={12} />}
|
||||
|
||||
@@ -301,32 +301,6 @@ it("offers workspace removal for every member root", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Git and Find Usages in workspace members outside the workspace folder", () => {
|
||||
useProject.setState({
|
||||
project: {
|
||||
root: "/work/workspaces/demo",
|
||||
name: "demo",
|
||||
settings: {},
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
workspace: { version: 1, name: "demo", projects: ["/work/repos/a", "/work/repos/b"] },
|
||||
},
|
||||
});
|
||||
setContext("vcsAvailable", true);
|
||||
try {
|
||||
for (const target of [node("/work/repos/a", true, true), node("/work/repos/a/src", true)])
|
||||
expect(sub(contextMenuItems([target]), "Git")).toContain("Commit…");
|
||||
expect(sub(contextMenuItems([node("/work/repos/a/x.py")]), "Git")).toContain("Annotate with Git Blame");
|
||||
expect(labels(contextMenuItems([node("/work/repos/a/x.py")]))).toContain("Find Usages");
|
||||
// Project structure is the workspace folder's: its roots can't name a folder outside it.
|
||||
expect(labels(contextMenuItems([node("/work/repos/a/src", true)]))).not.toContain("Mark Directory as");
|
||||
// Scratches stay outside.
|
||||
expect(sub(contextMenuItems([node("/config/scratches/s.py")]), "Git")).toBeUndefined();
|
||||
} finally {
|
||||
clearContext("vcsAvailable");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not offer removal on the attached primary", () => {
|
||||
useProject.setState({
|
||||
project: {
|
||||
|
||||
@@ -151,9 +151,6 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
||||
const project = useProject.getState().project;
|
||||
// Scratches and other contributed roots live outside the project: no VCS, roots or LSP usages.
|
||||
const inProject = !!project && nodes.every((n) => isSameOrInside(n.path, project.root));
|
||||
// Workspace members may live outside the workspace folder; they have VCS and usages too.
|
||||
const memberRoots = project ? [project.root, ...(project.workspace?.projects ?? [])] : [];
|
||||
const inWorkspace = nodes.every((n) => memberRoots.some((root) => isSameOrInside(n.path, root)));
|
||||
const items: MenuItem[] = [];
|
||||
const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i));
|
||||
|
||||
@@ -231,7 +228,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
||||
shortcut: keyLabel("project.open"),
|
||||
run: () => files.forEach((f) => void openFile(f.path)),
|
||||
},
|
||||
inWorkspace &&
|
||||
inProject &&
|
||||
singleFile && {
|
||||
id: "findUsages",
|
||||
label: "Find Usages",
|
||||
@@ -341,7 +338,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
||||
);
|
||||
|
||||
// Git › (`vcsAvailable` also covers repositories nested below a non-Git project root)
|
||||
if (inWorkspace && (project?.isGit || getContext().vcsAvailable === true)) {
|
||||
if (inProject && (project?.isGit || getContext().vcsAvailable === true)) {
|
||||
const arg = vcsArg(nodes);
|
||||
const dirs = nodes.some((n) => n.dir);
|
||||
push(
|
||||
|
||||
@@ -283,9 +283,10 @@ describe("scheme attribute state", () => {
|
||||
expect(screen.getByRole("checkbox", { name: "Bold" })).toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: "Italic" })).not.toBeChecked();
|
||||
expect(screen.getByRole("textbox", { name: "Foreground hex" })).toHaveValue("#139839");
|
||||
expect(screen.getByRole("checkbox", { name: "Effect style" })).toBeChecked();
|
||||
expect(screen.queryByRole("textbox", { name: "Background hex" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: "Effects" })).toBeChecked();
|
||||
expect(screen.getByRole("button", { name: "Effect" })).toHaveTextContent("Underwaved");
|
||||
expect(screen.getByRole("textbox", { name: "Effects hex" })).toHaveValue("#abcdef");
|
||||
expect(screen.getByRole("textbox", { name: "Effect color hex" })).toHaveValue("#abcdef");
|
||||
});
|
||||
|
||||
it("selects an empty background and clears it back to the scheme state", () => {
|
||||
@@ -381,27 +382,3 @@ describe("Console Colors (#2698)", () => {
|
||||
expect(screen.getByRole("treeitem", { name: "Red", selected: true })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Git colors", () => {
|
||||
it("groups inline blame and project view attributes and overrides the preview", () => {
|
||||
act(() => stage("editor.colorScheme", "global", "editor.colorScheme", "dark"));
|
||||
schemePage("editor.colorScheme.git");
|
||||
const tree = screen.getByRole("tree", { name: "Color scheme attributes" });
|
||||
expect(within(tree).getByRole("treeitem", { name: "Editor inline blame" })).toBeInTheDocument();
|
||||
expect(within(tree).getByRole("treeitem", { name: "Project View" })).toBeInTheDocument();
|
||||
const preview = screen.getByLabelText("Color scheme preview");
|
||||
act(() => fireEvent.click(within(preview).getByText(/^Kitov/)));
|
||||
expect(screen.getByRole("treeitem", { name: "Author", selected: true })).toBeInTheDocument();
|
||||
act(() => fireEvent.click(screen.getByRole("checkbox", { name: "Foreground" })));
|
||||
expect(value("colorOverrides")).toEqual({ dark: { gitBlameAuthor: { foreground: "#808080" } } });
|
||||
expect(preview.style.getPropertyValue("--git-blame-author")).toBe("#808080");
|
||||
});
|
||||
|
||||
it("selects a project view branch colour from the preview suffix", () => {
|
||||
act(() => stage("editor.colorScheme", "global", "editor.colorScheme", "dark"));
|
||||
schemePage("editor.colorScheme.git");
|
||||
const preview = screen.getByLabelText("Color scheme preview");
|
||||
act(() => fireEvent.click(within(preview).getByText("feature/login")));
|
||||
expect(screen.getByRole("treeitem", { name: "Local branch decoration", selected: true })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ const status: McpServerStatus = {
|
||||
running: true,
|
||||
error: null,
|
||||
port: 1234,
|
||||
portUnavailable: null,
|
||||
project: "/p",
|
||||
url: "http://127.0.0.1:1234/mcp?project=/p",
|
||||
token: "secret",
|
||||
@@ -21,13 +20,6 @@ describe("MCP server settings", () => {
|
||||
expect(validateSettings({ mcpServer: { enabled: true } }).mcpServer.enabled).toBe(true);
|
||||
expect(MCP_SERVER_PAGE.projectScope).not.toBe(true);
|
||||
});
|
||||
it("keeps a fixed port by default and a new token in every run", () => {
|
||||
expect(DEFAULT_SETTINGS.mcpServer).toMatchObject({ fixedPort: true, port: 26371, fixedToken: false });
|
||||
const custom = validateSettings({ mcpServer: { fixedPort: false, port: 7000, fixedToken: true } }).mcpServer;
|
||||
expect(custom).toMatchObject({ fixedPort: false, port: 7000, fixedToken: true });
|
||||
expect(validateSettings({ mcpServer: { port: 70000 } }).mcpServer.port).toBe(65535);
|
||||
expect(validateSettings({ mcpServer: { port: "x" } }).mcpServer.port).toBe(26371);
|
||||
});
|
||||
it("copies a reusable stdio configuration without the token", () => {
|
||||
const config = stdioConfig(status)!;
|
||||
expect(JSON.parse(config).mcpServers.openidea).toEqual({
|
||||
|
||||
@@ -43,23 +43,13 @@ export function AddInterpreterDialog({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ids = { base: useId(), location: useId(), existing: useId() };
|
||||
|
||||
const baseValue = base ?? bases[0]?.path ?? null;
|
||||
const toOption = (s: { path: string; name: string }): SelectOption => ({
|
||||
value: s.path,
|
||||
label: s.name,
|
||||
detail: tildify(s.path),
|
||||
});
|
||||
// conda installs a fresh Python of the base's version: offer versions, not environments to copy.
|
||||
const versionOptions = (() => {
|
||||
const seen = new Map<string, SelectOption>();
|
||||
for (const s of bases) {
|
||||
const short = s.version?.split(".").slice(0, 2).join(".");
|
||||
if (short && !seen.has(short)) seen.set(short, { value: s.path, label: tx`Python ${short}` });
|
||||
}
|
||||
return [...seen.values()].sort((a, b) => b.label.localeCompare(a.label, undefined, { numeric: true }));
|
||||
})();
|
||||
const baseOptions = kind === "conda" ? versionOptions : bases.map(toOption);
|
||||
const baseValue =
|
||||
base ?? (kind === "conda" ? (versionOptions[0]?.value as string | undefined) : bases[0]?.path) ?? null;
|
||||
const baseOptions = bases.map(toOption);
|
||||
const existingOptions = [...browsed, ...list.filter((s) => !browsed.some((b) => b.path === s.path))].map((s) => ({
|
||||
value: s.path,
|
||||
label: `${s.name} — ${tildify(s.path)}`,
|
||||
@@ -118,9 +108,7 @@ export function AddInterpreterDialog({
|
||||
const canOk = !busy && (mode === "new" ? !!baseValue && !!location.trim() : !!existing?.trim());
|
||||
const note = {
|
||||
venv: tx("Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter."),
|
||||
conda: tx(
|
||||
"Runs “conda create -p <location> python=<version>”: a new environment with only Python in it; the new interpreter becomes the project interpreter.",
|
||||
),
|
||||
conda: tx("Runs “conda create -p <location>”; the new interpreter becomes the project interpreter."),
|
||||
uv: tx("Runs “uv venv <location> --python <base>”; the new interpreter becomes the project interpreter."),
|
||||
poetry: tx(
|
||||
"Runs “poetry env use <base>” in the project; poetry keeps the environment under its own cache directory.",
|
||||
@@ -155,7 +143,6 @@ export function AddInterpreterDialog({
|
||||
aria-current={kind === k.tool || undefined}
|
||||
onClick={() => {
|
||||
setKind(k.tool);
|
||||
setBase(null);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
@@ -212,7 +199,9 @@ export function AddInterpreterDialog({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label htmlFor={ids.base}>{kind === "conda" ? <T>Python version:</T> : <T>Base interpreter:</T>}</label>
|
||||
<label htmlFor={ids.base}>
|
||||
<T>Base interpreter:</T>
|
||||
</label>
|
||||
<div className={styles.pathRow}>
|
||||
<Select
|
||||
className={styles.pathInput}
|
||||
@@ -220,19 +209,17 @@ export function AddInterpreterDialog({
|
||||
options={baseOptions}
|
||||
onChange={setBase}
|
||||
placeholder={loading ? "Detecting…" : "No Python found"}
|
||||
aria-label={kind === "conda" ? "Python version" : "Base interpreter"}
|
||||
aria-label="Base interpreter"
|
||||
/>
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
size="small"
|
||||
title="Browse…"
|
||||
onClick={async () => {
|
||||
const p = await choosePath({ directory: false, title: "Select Base Interpreter" });
|
||||
if (p) setBase(p);
|
||||
}}
|
||||
/>
|
||||
{kind !== "conda" && (
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
size="small"
|
||||
title="Browse…"
|
||||
onClick={async () => {
|
||||
const p = await choosePath({ directory: false, title: "Select Base Interpreter" });
|
||||
if (p) setBase(p);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.note}>{note}</div>
|
||||
</>
|
||||
|
||||
@@ -68,8 +68,6 @@ export function PythonInterpreterPage() {
|
||||
const [activate, setActivate] = useSetting<boolean>("languages.python.activateVirtualenv");
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
const [auto, setAuto] = useState<string | null>(null);
|
||||
// Bumped when an environment is created: it may reuse the configured path, whose state changed.
|
||||
const [created, setCreated] = useState(0);
|
||||
// Detected interpreters plus the ones added to (or hidden from) the IDE-wide list.
|
||||
const { detected, rows, hidden } = usePythonInterpreters({ withProjects: false });
|
||||
const extras = useMemo(
|
||||
@@ -93,7 +91,7 @@ export function PythonInterpreterPage() {
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [root, created]);
|
||||
}, [root]);
|
||||
|
||||
// The interpreter runs resolve to: the configured one while it exists, else the fallback.
|
||||
const [resolved, setResolved] = useState<{ interpreter: string; path: string | null } | null>(null);
|
||||
@@ -106,7 +104,7 @@ export function PythonInterpreterPage() {
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [root, interpreter, created]);
|
||||
}, [root, interpreter]);
|
||||
// A configured interpreter whose executable is gone (PyCharm's "Invalid"): runs fall back.
|
||||
const invalid =
|
||||
!!interpreter && resolved?.interpreter === interpreter && !sameInterpreter(resolved.path, interpreter, root);
|
||||
@@ -364,8 +362,6 @@ export function PythonInterpreterPage() {
|
||||
onClose={() => setAdding(false)}
|
||||
onAdded={(path) => {
|
||||
setInterpreter(path);
|
||||
setCreated((n) => n + 1);
|
||||
reload();
|
||||
setAdding(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -40,10 +40,6 @@ import {
|
||||
} from "./behaviorSchema";
|
||||
import { isThemeChoiceId, type ThemeChoiceId } from "@/platform/themes";
|
||||
|
||||
/** Agents › IDE MCP Server › Port: its default (`ide-core::settings::DEFAULT_MCP_PORT`) and range. */
|
||||
export const DEFAULT_MCP_PORT = 26371;
|
||||
export const MCP_PORT_LIMITS = { min: 1, max: 65535 };
|
||||
|
||||
/** A built-in theme, a plugin's (`plugin:<id>/<theme>`) or the legacy `"system"`. */
|
||||
export type ThemeId = ThemeChoiceId | "system";
|
||||
|
||||
@@ -116,17 +112,7 @@ export interface ValidSettings extends WorkflowSettings {
|
||||
/** Tools › Agents › Chat: the meters in an agent task's header. */
|
||||
agents: Required<AgentSettings>;
|
||||
/** Agents › IDE MCP Server (global). */
|
||||
mcpServer: {
|
||||
enabled: boolean;
|
||||
runWithoutConfirmation: boolean;
|
||||
agentTabLimit: number;
|
||||
agentArchiveLimit: number;
|
||||
/** Listen on `port` in every run rather than a free port each time. */
|
||||
fixedPort: boolean;
|
||||
port: number;
|
||||
/** Keep the bearer token across runs rather than make a new one each run. */
|
||||
fixedToken: boolean;
|
||||
};
|
||||
mcpServer: { enabled: boolean; runWithoutConfirmation: boolean; agentTabLimit: number; agentArchiveLimit: number };
|
||||
}
|
||||
|
||||
/** One `languageServers.<name>` entry; every field optional, like in settings.toml. */
|
||||
@@ -222,9 +208,6 @@ export const DEFAULT_SETTINGS: ValidSettings = {
|
||||
runWithoutConfirmation: false,
|
||||
agentTabLimit: DEFAULT_AGENT_RUNS.tabLimit,
|
||||
agentArchiveLimit: DEFAULT_AGENT_RUNS.archiveLimit,
|
||||
fixedPort: true,
|
||||
port: DEFAULT_MCP_PORT,
|
||||
fixedToken: false,
|
||||
},
|
||||
...DEFAULT_WORKFLOW,
|
||||
};
|
||||
@@ -400,9 +383,6 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
|
||||
runWithoutConfirmation: bool(obj(root.mcpServer).runWithoutConfirmation, d.mcpServer.runWithoutConfirmation),
|
||||
agentTabLimit: validateAgentRuns(root.mcpServer).tabLimit,
|
||||
agentArchiveLimit: validateAgentRuns(root.mcpServer).archiveLimit,
|
||||
fixedPort: bool(obj(root.mcpServer).fixedPort, d.mcpServer.fixedPort),
|
||||
port: num(obj(root.mcpServer).port, d.mcpServer.port, MCP_PORT_LIMITS, true),
|
||||
fixedToken: bool(obj(root.mcpServer).fixedToken, d.mcpServer.fixedToken),
|
||||
},
|
||||
...validateWorkflow(root),
|
||||
};
|
||||
|
||||
@@ -194,13 +194,3 @@
|
||||
padding: 16px;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.groupMembers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.groupTitle {
|
||||
color: var(--fg-subtle);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
@@ -13,24 +13,10 @@ import {
|
||||
deploymentConnectable,
|
||||
deploymentConnection,
|
||||
deploymentSecretKey,
|
||||
type DeploymentGroupValid,
|
||||
type DeploymentServerValid,
|
||||
type FtpConnectionValid,
|
||||
} from "@/platform/remoteSettings";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
showContextMenu,
|
||||
Spinner,
|
||||
T,
|
||||
Tabs,
|
||||
} from "@/ui";
|
||||
import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Select, Spinner, T, Tabs } from "@/ui";
|
||||
import { GlobListEditor, Row } from "../components/Form";
|
||||
import { SshConfigSelect, useSshConfigs } from "../components/SshConfigSelect";
|
||||
import { useSetting } from "../components/context";
|
||||
@@ -39,13 +25,10 @@ import {
|
||||
fixDefaultServer,
|
||||
folderWithin,
|
||||
formatExcludes,
|
||||
newGroup,
|
||||
newServer,
|
||||
parseExcludes,
|
||||
pruneGroups,
|
||||
remoteParent,
|
||||
removeMapping,
|
||||
toggleGroupServer,
|
||||
updateMapping,
|
||||
} from "./deploymentModel";
|
||||
import { choosePath } from "../languages/api";
|
||||
@@ -628,118 +611,44 @@ function ExcludedPathsTab({
|
||||
|
||||
type ServerTab = "connection" | "mappings" | "excluded";
|
||||
|
||||
function GroupPanel({
|
||||
group,
|
||||
servers,
|
||||
update,
|
||||
}: {
|
||||
group: DeploymentGroupValid;
|
||||
servers: readonly DeploymentServerValid[];
|
||||
update: (patch: Partial<DeploymentGroupValid>) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<TextField label="Name" value={group.name} onChange={(name) => update({ name })} />
|
||||
<div className={styles.groupMembers} role="group" aria-label={tx("Servers in group")}>
|
||||
<div className={styles.groupTitle}>
|
||||
<T>Servers in group</T>
|
||||
</div>
|
||||
{servers.length === 0 && (
|
||||
<div className={styles.note}>
|
||||
<T>No servers. Click + to add one.</T>
|
||||
</div>
|
||||
)}
|
||||
{servers.map((s) => (
|
||||
<Checkbox
|
||||
key={s.id}
|
||||
checked={group.servers.includes(s.id)}
|
||||
onChange={() => update(toggleGroupServer(group, s.id, servers))}
|
||||
label={<span>{s.name || s.id}</span>}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.note}>
|
||||
<T>Upload to… on this group uploads the files to each of its servers in turn.</T>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Selection = { kind: "server" | "group"; id: string } | null;
|
||||
|
||||
function DeploymentServersEditor() {
|
||||
const tx = useTx();
|
||||
const [servers, setServers] = useSetting<DeploymentServerValid[]>("deployment.servers");
|
||||
const [groups, setGroups] = useSetting<DeploymentGroupValid[]>("deployment.groups");
|
||||
const [defaultServer, setDefaultServer] = useSetting<string>("deployment.defaultServer");
|
||||
const list = servers ?? [];
|
||||
const groupList = groups ?? [];
|
||||
const [selected, setSelected] = useState<Selection>(list[0] ? { kind: "server", id: list[0].id } : null);
|
||||
const [selected, setSelected] = useState<string | null>(list[0]?.id ?? null);
|
||||
const [tab, setTab] = useState<ServerTab>("connection");
|
||||
const current = selected?.kind === "server" ? (list.find((s) => s.id === selected.id) ?? null) : null;
|
||||
const currentGroup = selected?.kind === "group" ? (groupList.find((g) => g.id === selected.id) ?? null) : null;
|
||||
const current = list.find((s) => s.id === selected) ?? null;
|
||||
const effectiveDefault = fixDefaultServer(list, defaultServer ?? "");
|
||||
|
||||
const commit = (next: DeploymentServerValid[]) => {
|
||||
setServers(next);
|
||||
const d = fixDefaultServer(next, defaultServer ?? "");
|
||||
if (d !== (defaultServer ?? "")) setDefaultServer(d);
|
||||
const pruned = pruneGroups(groupList, next);
|
||||
if (pruned.some((g, i) => g !== groupList[i])) setGroups(pruned);
|
||||
};
|
||||
const update = (patch: Partial<DeploymentServerValid>) => {
|
||||
if (current) commit(list.map((s) => (s.id === current.id ? { ...s, ...patch } : s)));
|
||||
};
|
||||
const updateGroup = (patch: Partial<DeploymentGroupValid>) => {
|
||||
if (currentGroup) setGroups(groupList.map((g) => (g.id === currentGroup.id ? { ...g, ...patch } : g)));
|
||||
};
|
||||
const add = (base?: DeploymentServerValid) => {
|
||||
const server = newServer(list, base);
|
||||
commit([...list, server]);
|
||||
setSelected({ kind: "server", id: server.id });
|
||||
setSelected(server.id);
|
||||
setTab("connection");
|
||||
};
|
||||
const addGroup = () => {
|
||||
const group = newGroup(groupList);
|
||||
setGroups([...groupList, group]);
|
||||
setSelected({ kind: "group", id: group.id });
|
||||
};
|
||||
const remove = () => {
|
||||
if (currentGroup) {
|
||||
const index = groupList.indexOf(currentGroup);
|
||||
const rest = groupList.filter((g) => g.id !== currentGroup.id);
|
||||
setGroups(rest);
|
||||
const next = rest[Math.min(index, rest.length - 1)] ?? null;
|
||||
setSelected(next ? { kind: "group", id: next.id } : list[0] ? { kind: "server", id: list[0].id } : null);
|
||||
return;
|
||||
}
|
||||
if (!current) return;
|
||||
const index = list.indexOf(current);
|
||||
const rest = list.filter((s) => s.id !== current.id);
|
||||
commit(rest);
|
||||
const next = rest[Math.min(index, rest.length - 1)] ?? null;
|
||||
setSelected(next ? { kind: "server", id: next.id } : null);
|
||||
setSelected(rest[Math.min(index, rest.length - 1)]?.id ?? null);
|
||||
};
|
||||
const isSelected = (kind: "server" | "group", id: string) => selected?.kind === kind && selected.id === id;
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<div className={workflow.tableBox}>
|
||||
<div className={workflow.toolbar} role="toolbar" aria-label={tx("Deployment servers actions")}>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
title="Add"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
showContextMenu({ x: r.left, y: r.bottom }, [
|
||||
{ label: "Server", icon: "web", run: () => add() },
|
||||
{ label: "Server Group", icon: "folder", run: addGroup },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
<IconButton icon="minus" title="Remove" size="small" disabled={!current && !currentGroup} onClick={remove} />
|
||||
<IconButton icon="plus" title="Add" size="small" onClick={() => add()} />
|
||||
<IconButton icon="minus" title="Remove" size="small" disabled={!current} onClick={remove} />
|
||||
<IconButton
|
||||
icon="copy"
|
||||
title="Copy"
|
||||
@@ -756,31 +665,18 @@ function DeploymentServersEditor() {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.serverList} role="listbox" aria-label={tx("Deployment servers")}>
|
||||
{list.length === 0 && groupList.length === 0 && (
|
||||
{list.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
<T>No servers. Click + to add one.</T>
|
||||
</div>
|
||||
)}
|
||||
{groupList.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
role="option"
|
||||
aria-selected={isSelected("group", g.id)}
|
||||
className={clsx(styles.serverRow, isSelected("group", g.id) && styles.serverSelected)}
|
||||
onMouseDown={() => setSelected({ kind: "group", id: g.id })}
|
||||
>
|
||||
<Icon name="folder" />
|
||||
<span className={styles.serverName}>{g.name || g.id}</span>
|
||||
<Badge>{g.servers.length}</Badge>
|
||||
</div>
|
||||
))}
|
||||
{list.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
role="option"
|
||||
aria-selected={isSelected("server", s.id)}
|
||||
className={clsx(styles.serverRow, isSelected("server", s.id) && styles.serverSelected)}
|
||||
onMouseDown={() => setSelected({ kind: "server", id: s.id })}
|
||||
aria-selected={s.id === selected}
|
||||
className={clsx(styles.serverRow, s.id === selected && styles.serverSelected)}
|
||||
onMouseDown={() => setSelected(s.id)}
|
||||
>
|
||||
<Icon name="web" />
|
||||
<span className={clsx(styles.serverName, s.id === effectiveDefault && styles.default)}>
|
||||
@@ -791,11 +687,7 @@ function DeploymentServersEditor() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{currentGroup ? (
|
||||
<div className={styles.detail}>
|
||||
<GroupPanel key={currentGroup.id} group={currentGroup} servers={list} update={updateGroup} />
|
||||
</div>
|
||||
) : current ? (
|
||||
{current ? (
|
||||
<div className={styles.detail}>
|
||||
<Tabs
|
||||
variant="compact"
|
||||
@@ -888,7 +780,6 @@ export const DEPLOYMENT_PAGES: PageSpec[] = [
|
||||
"Passive mode",
|
||||
"Test Connection",
|
||||
"Use as Default",
|
||||
"Server Group",
|
||||
"Local path",
|
||||
"Deployment path",
|
||||
"Web path",
|
||||
|
||||
@@ -5,40 +5,14 @@ import {
|
||||
fixDefaultServer,
|
||||
folderWithin,
|
||||
formatExcludes,
|
||||
newGroup,
|
||||
newServer,
|
||||
nextServerName,
|
||||
parseExcludes,
|
||||
pruneGroups,
|
||||
remoteParent,
|
||||
removeMapping,
|
||||
toggleGroupServer,
|
||||
updateMapping,
|
||||
} from "./deploymentModel";
|
||||
|
||||
describe("server groups", () => {
|
||||
const servers = ["a", "b", "c"].map((id) => ({ ...DEFAULT_DEPLOYMENT_SERVER, id, name: id }));
|
||||
it("names a new group after the first free number", () => {
|
||||
const g = newGroup([{ id: "x", name: "Group 2", servers: [] }]);
|
||||
expect(g.name).toBe("Group 3");
|
||||
expect(g.servers).toEqual([]);
|
||||
expect(g.id).toMatch(/^group-/);
|
||||
});
|
||||
it("toggles members, keeping the server list's order", () => {
|
||||
let g = { id: "g", name: "G", servers: [] as string[] };
|
||||
g = toggleGroupServer(g, "c", servers);
|
||||
g = toggleGroupServer(g, "a", servers);
|
||||
expect(g.servers).toEqual(["a", "c"]);
|
||||
expect(toggleGroupServer(g, "a", servers).servers).toEqual(["c"]);
|
||||
});
|
||||
it("drops removed servers from groups and keeps untouched groups as they are", () => {
|
||||
const keep = { id: "k", name: "K", servers: ["a"] };
|
||||
const pruned = pruneGroups([keep, { id: "g", name: "G", servers: ["a", "b"] }], servers.slice(0, 1));
|
||||
expect(pruned[0]).toBe(keep);
|
||||
expect(pruned[1]!.servers).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exclude names", () => {
|
||||
it("parses a ;-separated list", () => {
|
||||
expect(parseExcludes(".git; .idea;;*.pyc ;")).toEqual([".git", ".idea", "*.pyc"]);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import {
|
||||
DEFAULT_DEPLOYMENT_SERVER,
|
||||
newRemoteId,
|
||||
type DeploymentGroupValid,
|
||||
type DeploymentMappingValid,
|
||||
type DeploymentServerValid,
|
||||
} from "@/platform/remoteSettings";
|
||||
@@ -68,37 +67,6 @@ export function newServer(
|
||||
};
|
||||
}
|
||||
|
||||
/** A new, empty group named "Group <n>" (the first free number). */
|
||||
export function newGroup(groups: readonly DeploymentGroupValid[]): DeploymentGroupValid {
|
||||
const names = new Set(groups.map((g) => g.name));
|
||||
let n = groups.length + 1;
|
||||
while (names.has(`Group ${n}`)) n++;
|
||||
return { id: newRemoteId("group"), name: `Group ${n}`, servers: [] };
|
||||
}
|
||||
|
||||
/** Adds or removes `server` in `group`, keeping the members in the server list's order. */
|
||||
export function toggleGroupServer(
|
||||
group: DeploymentGroupValid,
|
||||
server: string,
|
||||
servers: readonly DeploymentServerValid[],
|
||||
): DeploymentGroupValid {
|
||||
const members = new Set(group.servers);
|
||||
if (members.has(server)) members.delete(server);
|
||||
else members.add(server);
|
||||
return { ...group, servers: servers.map((s) => s.id).filter((id) => members.has(id)) };
|
||||
}
|
||||
|
||||
/** `groups` without members that are no longer in `servers`. */
|
||||
export function pruneGroups(
|
||||
groups: readonly DeploymentGroupValid[],
|
||||
servers: readonly DeploymentServerValid[],
|
||||
): DeploymentGroupValid[] {
|
||||
const ids = new Set(servers.map((s) => s.id));
|
||||
return groups.map((g) =>
|
||||
g.servers.every((id) => ids.has(id)) ? g : { ...g, servers: g.servers.filter((id) => ids.has(id)) },
|
||||
);
|
||||
}
|
||||
|
||||
/** The default server after `servers` changed: kept when still present, else the first one (or none). */
|
||||
export function fixDefaultServer(servers: readonly DeploymentServerValid[], defaultServer: string): string {
|
||||
if (servers.some((s) => s.id === defaultServer)) return defaultServer;
|
||||
|
||||
@@ -294,110 +294,6 @@
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Git preview mirrors the two real surfaces: the editor inline blame widget and
|
||||
the project tree root branch suffix. Only the Git page uses these. */
|
||||
.gitPreview {
|
||||
margin: 14px 0 0;
|
||||
border-radius: var(--radius-small);
|
||||
box-shadow: inset 0 0 0 1px var(--border-strong);
|
||||
background: var(--bg-editor, var(--bg-island));
|
||||
overflow: auto;
|
||||
max-height: 260px;
|
||||
}
|
||||
.gitSection {
|
||||
padding: 8px 0;
|
||||
}
|
||||
.gitSection + .gitSection {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.gitSectionTitle {
|
||||
padding: 0 12px 6px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.gitEditor {
|
||||
color: var(--fg-editor, var(--fg));
|
||||
font-family: var(--openidea-editor-font, var(--font-mono));
|
||||
font-weight: var(--fw-code);
|
||||
font-size: var(--fs-code);
|
||||
line-height: var(--lh-code);
|
||||
}
|
||||
.gitBlame {
|
||||
display: inline-block;
|
||||
max-width: min(60ch, 50vw);
|
||||
margin-left: 3ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.gitTree {
|
||||
padding: 0 6px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--fs-ui);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.gitRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
padding-left: 4px;
|
||||
padding-right: 8px;
|
||||
border-radius: var(--radius-selection, 5px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gitChevron {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.gitContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: var(--radius-row);
|
||||
}
|
||||
.gitIconStack {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.gitFolderIcon {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.gitRootLabel {
|
||||
flex: none;
|
||||
max-width: 60%;
|
||||
color: var(--fg-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.gitSuffix {
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
margin-left: 6px;
|
||||
overflow: hidden;
|
||||
color: var(--git-local-branch, var(--accent));
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gitSuffixSegment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* Code Style tables */
|
||||
.guides {
|
||||
width: 160px;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* `editor.colorOverrides[scheme][attribute]`; `editor/core` injects them.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { Button, Checkbox, Icon, Input, Tree, type SelectOption, type TreeHandle, T } from "@/ui";
|
||||
import { Button, Checkbox, Input, Tree, type SelectOption, type TreeHandle, T } from "@/ui";
|
||||
import { fontWeightVars, type CustomColorSchemes, type TextAttributesOverride } from "@/platform/editorSettings";
|
||||
import {
|
||||
ATTRIBUTES,
|
||||
@@ -122,7 +122,6 @@ function previewVars(
|
||||
k.startsWith("--bg-") ||
|
||||
k.startsWith("--border-") ||
|
||||
k.startsWith("--ansi-") ||
|
||||
k.startsWith("--git-") ||
|
||||
k === "--caret"
|
||||
)
|
||||
vars[k] = v;
|
||||
@@ -141,15 +140,6 @@ function tokenStyle(attr: AttributeDef | undefined): CSSProperties {
|
||||
return {
|
||||
...(attr?.fg ? { color: `var(${attr.fg})` } : null),
|
||||
...(attr?.bg ? { backgroundColor: `var(${attr.bg})` } : null),
|
||||
// Token-less attributes with a font-style renderer carry it on `--<fg>-*`, the same
|
||||
// declarations `overrideDeclarations` emits for their overrides.
|
||||
...(attr?.fontStyle && (attr.fg ?? attr.effect)
|
||||
? {
|
||||
fontWeight: `var(${attr.fg ?? attr.effect}-bold, inherit)`,
|
||||
fontStyle: `var(${attr.fg ?? attr.effect}-italic, inherit)`,
|
||||
textDecoration: `var(${attr.effect ?? attr.fg}-effect, none)`,
|
||||
}
|
||||
: null),
|
||||
};
|
||||
if (attr.italicOnly)
|
||||
return { fontStyle: `var(--syn-${attr.token}-italic, ${attr.defaultItalic === false ? "normal" : "italic"})` };
|
||||
@@ -170,17 +160,6 @@ function tokenStyle(attr: AttributeDef | undefined): CSSProperties {
|
||||
|
||||
const A = (id: string) => ATTRIBUTES.find((a) => a.id === id);
|
||||
|
||||
/** Preview click: reveal in this page, or switch to the attribute's own page. */
|
||||
function revealPreviewAttr(attrId: string, ids: Set<string>, reveal: (id: string) => void): void {
|
||||
const attr = A(attrId);
|
||||
if (!attr) return;
|
||||
if (ids.has(attrId)) reveal(attrId);
|
||||
else {
|
||||
crossPageReveal = attrId;
|
||||
selectPage(attrPageId(attr));
|
||||
}
|
||||
}
|
||||
|
||||
type Seg = [string, string?];
|
||||
interface CodeSample {
|
||||
code: Seg[][];
|
||||
@@ -996,27 +975,8 @@ function AttributeEditor({
|
||||
if (next[k] === undefined || next[k] === inherited[k]) delete next[k];
|
||||
onChange(Object.keys(next).length ? next : undefined);
|
||||
};
|
||||
// IDEA's panel is fixed: every channel renders for every attribute, disabled when the
|
||||
// attribute cannot carry it (ColorAndFontDescriptionPanel.kt). Capability is declared,
|
||||
// not inferred: `fontStyle` marks attributes whose renderer applies font style/effects.
|
||||
return (
|
||||
<div className={styles.attrEditor} aria-label={`${attr.name} attributes`}>
|
||||
{!attr.italicOnly && (
|
||||
<div className={styles.attrRow}>
|
||||
<Checkbox
|
||||
checked={v.bold ?? inherited.bold ?? false}
|
||||
disabled={!attr.fontStyle}
|
||||
onChange={(b) => set({ bold: b === (inherited.bold ?? false) ? undefined : b })}
|
||||
label="Bold"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={v.italic ?? inherited.italic ?? false}
|
||||
disabled={!attr.fontStyle}
|
||||
onChange={(b) => set({ italic: b === (inherited.italic ?? false) ? undefined : b })}
|
||||
label="Italic"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{attr.italicOnly && (
|
||||
<div className={styles.attrRow}>
|
||||
<Checkbox
|
||||
@@ -1026,29 +986,50 @@ function AttributeEditor({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ColorRow
|
||||
label="Foreground"
|
||||
value={v.foreground}
|
||||
inherited={inherited.foreground}
|
||||
onChange={attr.fg ? (c) => set({ foreground: c }) : undefined}
|
||||
/>
|
||||
<ColorRow
|
||||
label="Background"
|
||||
value={v.background}
|
||||
inherited={inherited.background}
|
||||
onChange={attr.bg || (attr.token && !attr.italicOnly) ? (c) => set({ background: c }) : undefined}
|
||||
/>
|
||||
{!attr.italicOnly && (
|
||||
{attr.token && !attr.italicOnly && (
|
||||
<>
|
||||
<div className={styles.attrRow}>
|
||||
<Checkbox
|
||||
checked={v.bold ?? inherited.bold ?? false}
|
||||
onChange={(b) => set({ bold: b === (inherited.bold ?? false) ? undefined : b })}
|
||||
label="Bold"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={v.italic ?? inherited.italic ?? false}
|
||||
onChange={(b) => set({ italic: b === (inherited.italic ?? false) ? undefined : b })}
|
||||
label="Italic"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{attr.fg && (
|
||||
<ColorRow
|
||||
label="Foreground"
|
||||
value={v.foreground}
|
||||
inherited={inherited.foreground}
|
||||
onChange={(c) => set({ foreground: c })}
|
||||
/>
|
||||
)}
|
||||
{(attr.bg || (attr.token && !attr.italicOnly)) && (
|
||||
<ColorRow
|
||||
label="Background"
|
||||
value={v.background}
|
||||
inherited={inherited.background}
|
||||
onChange={(c) => set({ background: c })}
|
||||
/>
|
||||
)}
|
||||
{attr.effect && (
|
||||
<ColorRow
|
||||
label="Effects"
|
||||
value={v.effectColor}
|
||||
inherited={inherited.effectColor}
|
||||
onChange={(c) => set({ effectColor: c })}
|
||||
/>
|
||||
)}
|
||||
{attr.token && !attr.italicOnly && (
|
||||
<>
|
||||
<ColorRow
|
||||
label="Effects"
|
||||
value={v.effectColor}
|
||||
inherited={inherited.effectColor}
|
||||
onChange={attr.effect || attr.fontStyle ? (c) => set({ effectColor: c }) : undefined}
|
||||
/>
|
||||
<div className={styles.attrRow}>
|
||||
<Checkbox
|
||||
disabled={!attr.effect && !attr.fontStyle}
|
||||
checked={!!(v.effect ?? inherited.effect) && (v.effect ?? inherited.effect) !== "none"}
|
||||
onChange={(b) =>
|
||||
set({
|
||||
@@ -1062,27 +1043,36 @@ function AttributeEditor({
|
||||
effectColor: b ? v.effectColor : undefined,
|
||||
})
|
||||
}
|
||||
label="Effect style"
|
||||
label="Effects"
|
||||
/>
|
||||
{!!(v.effect ?? inherited.effect) && (v.effect ?? inherited.effect) !== "none" && (
|
||||
<Select
|
||||
value={(v.effect ?? inherited.effect)!}
|
||||
options={[
|
||||
{ value: "underline", label: "Underscored" },
|
||||
{ value: "wave", label: "Underwaved" },
|
||||
{ value: "strikeout", label: "Strikeout" },
|
||||
]}
|
||||
onChange={(e) => set({ effect: e as TextAttributesOverride["effect"] })}
|
||||
className={styles.effectSelect}
|
||||
aria-label="Effect"
|
||||
<ColorInput
|
||||
value={v.effectColor ?? inherited.effectColor ?? "#808080"}
|
||||
onChange={(c) => set({ effectColor: c ?? "#808080" })}
|
||||
label="Effect color"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!(v.effect ?? inherited.effect) && (v.effect ?? inherited.effect) !== "none" && (
|
||||
<Select
|
||||
value={(v.effect ?? inherited.effect)!}
|
||||
options={[
|
||||
{ value: "underline", label: "Underscored" },
|
||||
{ value: "wave", label: "Underwaved" },
|
||||
{ value: "strikeout", label: "Strikeout" },
|
||||
]}
|
||||
onChange={(e) => set({ effect: e as TextAttributesOverride["effect"] })}
|
||||
className={styles.effectSelect}
|
||||
aria-label="Effect"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className={styles.inherited}>
|
||||
<T>The controls show the scheme's actual appearance; changes override it.</T>
|
||||
</div>
|
||||
{!attr.italicOnly && (
|
||||
<div className={styles.inherited}>
|
||||
<T>The controls show the scheme's actual appearance; changes override it.</T>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1132,8 +1122,7 @@ function ColorRow({
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
inherited: string | undefined;
|
||||
/** Undefined disables the row for attributes that cannot carry this channel. */
|
||||
onChange: ((c: string | undefined) => void) | undefined;
|
||||
onChange: (c: string | undefined) => void;
|
||||
}) {
|
||||
const cleared = value === CLEAR_COLOR;
|
||||
const shown = cleared ? undefined : (value ?? inherited);
|
||||
@@ -1141,9 +1130,8 @@ function ColorRow({
|
||||
<div className={styles.attrRow}>
|
||||
<Checkbox
|
||||
checked={shown !== undefined}
|
||||
disabled={!onChange}
|
||||
onChange={(on) =>
|
||||
onChange?.(
|
||||
onChange(
|
||||
on
|
||||
? cleared
|
||||
? (inherited ?? "#808080")
|
||||
@@ -1155,162 +1143,7 @@ function ColorRow({
|
||||
}
|
||||
label={label}
|
||||
/>
|
||||
{shown !== undefined && <ColorInput value={shown} onChange={onChange ?? (() => undefined)} label={label} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Git preview sample: an editor caret line carrying the inline blame widget. */
|
||||
const GIT_EDITOR_LINES: { segs: Seg[]; blame?: boolean }[] = [
|
||||
{ segs: [["function", "keyword"], [" "], ["syncRoot", "functionCall"], ["("], ["info", "parameter"], [") {"]] },
|
||||
{
|
||||
segs: [
|
||||
[" "],
|
||||
["const", "keyword"],
|
||||
[" "],
|
||||
["branch", "identifier"],
|
||||
[" = "],
|
||||
["info", "parameter"],
|
||||
["."],
|
||||
["head", "identifier"],
|
||||
[";"],
|
||||
],
|
||||
blame: true,
|
||||
},
|
||||
{ segs: [["}"]] },
|
||||
];
|
||||
|
||||
/** A project tree root in the Git preview; its suffix mirrors rootSyncSuffix. */
|
||||
interface GitPreviewRoot {
|
||||
name: string;
|
||||
branch: string;
|
||||
branchAttr: string;
|
||||
upstream?: string;
|
||||
ahead?: number;
|
||||
behind?: number;
|
||||
}
|
||||
const GIT_PREVIEW_ROOTS: GitPreviewRoot[] = [
|
||||
{ name: "openidea", branch: "main", branchAttr: "gitMasterWithRemote", upstream: "origin/main", ahead: 2, behind: 1 },
|
||||
{ name: "legacy-app", branch: "master", branchAttr: "gitMasterWithoutRemote" },
|
||||
{ name: "web-client", branch: "feature/login", branchAttr: "gitLocalBranch" },
|
||||
{ name: "api-server", branch: "release/2.0", branchAttr: "gitTrackingBranch", upstream: "origin/release/2.0" },
|
||||
];
|
||||
interface GitPreviewSuffixSeg {
|
||||
text: string;
|
||||
attr: string;
|
||||
icon?: "vcs-outgoing" | "vcs-incoming";
|
||||
label?: string;
|
||||
strike?: boolean;
|
||||
}
|
||||
|
||||
/** The Git page shows the two real surfaces instead of a code sample: the editor
|
||||
* inline blame widget and the project tree root branch suffix. Each coloured
|
||||
* segment selects its attribute, as the shared preview does. */
|
||||
function GitPreview({
|
||||
vars,
|
||||
ids,
|
||||
reveal,
|
||||
}: {
|
||||
vars: CSSProperties;
|
||||
ids: Set<string>;
|
||||
reveal: (attrId: string) => void;
|
||||
}) {
|
||||
const click = (attrId: string) => revealPreviewAttr(attrId, ids, reveal);
|
||||
const blameSeg = (text: string, attrId: string, key: string) => (
|
||||
<span key={key} style={tokenStyle(A(attrId))} className={styles.clickable} onClick={() => click(attrId)}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
// Mirrors rootSyncSuffix: branch, "/" separator, then ahead/behind counts or a struck zero.
|
||||
const suffixOf = (root: GitPreviewRoot): GitPreviewSuffixSeg[] => {
|
||||
const { branch, branchAttr, upstream, ahead, behind } = root;
|
||||
const head: GitPreviewSuffixSeg = { text: branch, attr: branchAttr };
|
||||
if (!upstream) return [head];
|
||||
const counts: GitPreviewSuffixSeg[] = [];
|
||||
if (ahead)
|
||||
counts.push({
|
||||
text: String(ahead),
|
||||
attr: "gitBranchChanged",
|
||||
icon: "vcs-outgoing",
|
||||
label: tx`${ahead} outgoing commits`,
|
||||
});
|
||||
if (behind)
|
||||
counts.push({
|
||||
text: String(behind),
|
||||
attr: "gitBranchChanged",
|
||||
icon: "vcs-incoming",
|
||||
label: tx`${behind} incoming commits`,
|
||||
});
|
||||
if (!counts.length)
|
||||
counts.push({ text: "0", attr: "gitHeadTags", strike: true, label: tx`Up to date with ${upstream}` });
|
||||
return [head, { text: "/", attr: "gitHeadTags" }, ...counts];
|
||||
};
|
||||
return (
|
||||
<div className={styles.gitPreview} style={vars} aria-label={tx("Color scheme preview")}>
|
||||
<div className={styles.gitSection}>
|
||||
<div className={styles.gitSectionTitle}>{tx("Editor inline blame")}</div>
|
||||
<div className={styles.gitEditor}>
|
||||
{GIT_EDITOR_LINES.map((line, i) => (
|
||||
<div key={i} className={`${styles.previewLine} ${line.blame ? styles.previewCaretLine : ""}`}>
|
||||
<span className={styles.previewGutter}>{i + 1}</span>
|
||||
<code>
|
||||
{line.segs.map(([text, attrId], j) => (
|
||||
<span
|
||||
key={j}
|
||||
style={tokenStyle(attrId ? A(attrId) : undefined)}
|
||||
className={attrId ? styles.clickable : undefined}
|
||||
onClick={attrId ? () => click(attrId) : undefined}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
{line.blame && (
|
||||
<span className={styles.gitBlame}>
|
||||
{blameSeg("Kitov, ", "gitBlameAuthor", "author")}
|
||||
{blameSeg("3 days ago · ", "gitBlameDate", "date")}
|
||||
{blameSeg("Add branch colours", "gitBlameSubject", "subject")}
|
||||
{blameSeg(" (#123)", "gitBlameIssue", "issue")}
|
||||
</span>
|
||||
)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.gitSection}>
|
||||
<div className={styles.gitSectionTitle}>{tx("Project View")}</div>
|
||||
<div className={styles.gitTree}>
|
||||
{GIT_PREVIEW_ROOTS.map((root) => (
|
||||
<div key={root.name} className={styles.gitRow}>
|
||||
<span className={styles.gitChevron}>
|
||||
<Icon name="chevron-down" size={16} />
|
||||
</span>
|
||||
<span className={styles.gitContent}>
|
||||
<span className={styles.gitIconStack}>
|
||||
<Icon name="folder" size={16} className={styles.gitFolderIcon} />
|
||||
</span>
|
||||
<span className={styles.gitRootLabel}>{root.name}</span>
|
||||
<span className={styles.gitSuffix}>
|
||||
{suffixOf(root).map((s, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className={`${styles.gitSuffixSegment} ${styles.clickable}`}
|
||||
style={tokenStyle(A(s.attr))}
|
||||
role={s.label ? "img" : undefined}
|
||||
aria-label={s.label}
|
||||
title={s.label}
|
||||
onClick={() => click(s.attr)}
|
||||
>
|
||||
{s.strike ? <s>{s.text}</s> : s.text}
|
||||
{s.icon && <Icon name={s.icon} size={12} />}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{shown !== undefined && <ColorInput value={shown} onChange={onChange} label={label} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1602,43 +1435,39 @@ function ColorSchemePage({ scopes, landing }: { scopes: readonly string[]; landi
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
{scopes[0] === "Git" ? (
|
||||
<GitPreview vars={vars} ids={ids} reveal={reveal} />
|
||||
) : (
|
||||
<pre className={styles.schemePreview} style={vars} aria-label={tx("Color scheme preview")}>
|
||||
{sample.code.map((line, i) => (
|
||||
<div key={i} className={`${styles.previewLine} ${i === sample.caret ? styles.previewCaretLine : ""}`}>
|
||||
<span className={styles.previewGutter}>{i + 1}</span>
|
||||
<code>
|
||||
{line.map(([text, attrId], j) => {
|
||||
const attr = attrId !== undefined ? A(attrId) : undefined;
|
||||
const inPage = attr !== undefined && ids.has(attrId!);
|
||||
return (
|
||||
<span
|
||||
key={j}
|
||||
style={tokenStyle(attr)}
|
||||
className={attr ? styles.clickable : undefined}
|
||||
onClick={
|
||||
attr
|
||||
? () => {
|
||||
if (inPage) reveal(attrId!);
|
||||
else {
|
||||
crossPageReveal = attrId!;
|
||||
selectPage(attrPageId(attr));
|
||||
}
|
||||
<pre className={styles.schemePreview} style={vars} aria-label={tx("Color scheme preview")}>
|
||||
{sample.code.map((line, i) => (
|
||||
<div key={i} className={`${styles.previewLine} ${i === sample.caret ? styles.previewCaretLine : ""}`}>
|
||||
<span className={styles.previewGutter}>{i + 1}</span>
|
||||
<code>
|
||||
{line.map(([text, attrId], j) => {
|
||||
const attr = attrId !== undefined ? A(attrId) : undefined;
|
||||
const inPage = attr !== undefined && ids.has(attrId!);
|
||||
return (
|
||||
<span
|
||||
key={j}
|
||||
style={tokenStyle(attr)}
|
||||
className={attr ? styles.clickable : undefined}
|
||||
onClick={
|
||||
attr
|
||||
? () => {
|
||||
if (inPage) reveal(attrId!);
|
||||
else {
|
||||
crossPageReveal = attrId!;
|
||||
selectPage(attrPageId(attr));
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1646,7 +1475,7 @@ function ColorSchemePage({ scopes, landing }: { scopes: readonly string[]; landi
|
||||
}
|
||||
|
||||
const LANGUAGES = [...new Set(ATTRIBUTES.map((a) => a.path[0]!))].filter(
|
||||
(s) => !["General", "Debugger", "Language Defaults", "Console Colors", "Git"].includes(s),
|
||||
(s) => !["General", "Debugger", "Language Defaults", "Console Colors"].includes(s),
|
||||
);
|
||||
/** A sub-page id: `C#` → `csharp`, `Console Colors` → `console-colors` (`#` would end a settings link). */
|
||||
const languagePageId = (language: string) =>
|
||||
@@ -1658,7 +1487,6 @@ const SCHEME_SUBPAGES = [
|
||||
{ id: "editor.colorScheme.language-defaults", title: "Language Defaults" },
|
||||
{ id: "editor.colorScheme.debugger", title: "Debugger" },
|
||||
{ id: "editor.colorScheme.console-colors", title: "Console Colors" },
|
||||
{ id: "editor.colorScheme.git", title: "Git" },
|
||||
...LANGUAGES.map((l) => ({ id: languagePageId(l), title: l })),
|
||||
];
|
||||
|
||||
@@ -1759,19 +1587,11 @@ export const COLOR_SCHEME_PAGES: PageSpec[] = [
|
||||
keywords: ["terminal", "ansi", "console", "background"],
|
||||
sections: [colorSchemeSection(["Console Colors"])],
|
||||
}),
|
||||
editorPage({
|
||||
id: "editor.colorScheme.git",
|
||||
title: "Git",
|
||||
order: 5,
|
||||
projectScope: false,
|
||||
keywords: ["git", "blame", "branch", "inline blame", "project view", "decoration"],
|
||||
sections: [colorSchemeSection(["Git"])],
|
||||
}),
|
||||
...LANGUAGES.map((language, i) =>
|
||||
editorPage({
|
||||
id: languagePageId(language),
|
||||
title: language,
|
||||
order: 6 + i,
|
||||
order: 5 + i,
|
||||
projectScope: false,
|
||||
keywords: ATTRIBUTES.filter((a) => a.path[0] === language).map((a) => a.name),
|
||||
sections: [colorSchemeSection([language])],
|
||||
|
||||
@@ -6,8 +6,6 @@ import { WORKFLOW_LIMITS } from "@/platform/workflowSettings";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { Button, Input, T, copyText } from "@/ui";
|
||||
import { Row } from "../components/Form";
|
||||
import { useSetting } from "../components/context";
|
||||
import { MCP_PORT_LIMITS } from "../model/schema";
|
||||
import type { PageSpec } from "./spec";
|
||||
import styles from "./McpServer.module.css";
|
||||
|
||||
@@ -25,10 +23,9 @@ export function stdioConfig(status: McpServerStatus): string | null {
|
||||
return JSON.stringify({ mcpServers: { openidea: { type: "stdio", command, args } } }, null, 2);
|
||||
}
|
||||
|
||||
function useStatus(): [McpServerStatus | null, () => void] {
|
||||
function useStatus(): McpServerStatus | null {
|
||||
const root = useProject((s) => s.project?.root);
|
||||
const [status, setStatus] = useState<McpServerStatus | null>(null);
|
||||
const [loads, setLoads] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
let live = true;
|
||||
@@ -46,14 +43,13 @@ function useStatus(): [McpServerStatus | null, () => void] {
|
||||
live = false;
|
||||
off();
|
||||
};
|
||||
}, [root, loads]);
|
||||
return [status, () => setLoads((n) => n + 1)];
|
||||
}, [root]);
|
||||
return status;
|
||||
}
|
||||
|
||||
function McpServerEndpoint() {
|
||||
const tx = useTx();
|
||||
const [status, reload] = useStatus();
|
||||
const [fixedToken] = useSetting<boolean>("mcpServer.fixedToken");
|
||||
const status = useStatus();
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
@@ -65,13 +61,9 @@ function McpServerEndpoint() {
|
||||
if (!text) return;
|
||||
void copyText(text).then(() => setCopied(which));
|
||||
};
|
||||
const regenerate = () => void call(commands.mcpRegenerateToken()).then(reload, reload);
|
||||
const { port, error } = status;
|
||||
const busy = status.portUnavailable;
|
||||
const state = status.running
|
||||
? busy
|
||||
? tx`Running on port ${port}: the fixed port is unavailable (${busy})`
|
||||
: tx`Running on port ${port}`
|
||||
? tx`Running on port ${port}`
|
||||
: error
|
||||
? tx`Failed to start: ${error}`
|
||||
: tx("Not running");
|
||||
@@ -80,7 +72,7 @@ function McpServerEndpoint() {
|
||||
return (
|
||||
<>
|
||||
<Row label="Status">
|
||||
<span className={error || busy ? styles.error : styles.status}>{state}</span>
|
||||
<span className={error ? styles.error : styles.status}>{state}</span>
|
||||
</Row>
|
||||
<Row
|
||||
label="Endpoint"
|
||||
@@ -102,7 +94,7 @@ function McpServerEndpoint() {
|
||||
<Row
|
||||
label="Client configuration"
|
||||
stacked
|
||||
hint="The HTTP configuration holds the token: it keeps working after OpenIDEA restarts only with a fixed port and a kept token. The stdio one starts OpenIDEA's executable, which relays to the running IDE."
|
||||
hint="The HTTP configuration holds this run's token and stops working when OpenIDEA restarts. The stdio one starts OpenIDEA's executable, which relays to the running IDE."
|
||||
>
|
||||
<div className={styles.buttons}>
|
||||
<Button size="small" disabled={!http} onClick={() => copy("http", http)}>
|
||||
@@ -118,13 +110,6 @@ function McpServerEndpoint() {
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
{fixedToken && (
|
||||
<Row label="Token" hint="Clients configured with the current token stop working.">
|
||||
<Button size="small" disabled={!status.running} onClick={regenerate}>
|
||||
<T>Regenerate Token</T>
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -134,7 +119,7 @@ export const MCP_SERVER_PAGE: PageSpec = {
|
||||
title: "IDE MCP Server",
|
||||
group: "Agents",
|
||||
order: 470,
|
||||
keywords: ["mcp", "model context protocol", "ai", "agent", "claude", "codex", "tools", "stdio", "port", "token"],
|
||||
keywords: ["mcp", "model context protocol", "ai", "agent", "claude", "codex", "tools", "stdio"],
|
||||
sections: [
|
||||
{
|
||||
options: [
|
||||
@@ -150,26 +135,6 @@ export const MCP_SERVER_PAGE: PageSpec = {
|
||||
label: "Run tools without confirmation (brave mode)",
|
||||
hint: "Run configurations, terminal commands and other tools that change a project start without asking, for unattended agents. Only the IDE's own settings can turn this on, not a project's.",
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
key: "mcpServer.fixedPort",
|
||||
label: "Use a fixed port",
|
||||
hint: "Listen on the same port in every run, so HTTP clients and port forwards keep working after OpenIDEA restarts. When the port is in use, this run takes a free one and Status says so. Off: a free port in every run.",
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "mcpServer.port",
|
||||
label: "Port",
|
||||
...MCP_PORT_LIMITS,
|
||||
integer: true,
|
||||
hint: "The fixed port on 127.0.0.1, used while Use a fixed port is on.",
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
key: "mcpServer.fixedToken",
|
||||
label: "Keep the token",
|
||||
hint: "Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.",
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
key: "mcpServer.agentTabLimit",
|
||||
|
||||
@@ -110,11 +110,6 @@
|
||||
line-height: normal;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
/* An icon wider than its cell stays over the next cell's background (domRowCache). */
|
||||
.pane :global(.xterm-rows .openidea-terminal-icon) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.pane :global(.xterm-viewport) {
|
||||
background-color: var(--bg-island, var(--bg-app)) !important;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { ICON_SPAN_CLASS, installDomRowCache } from "./domRowCache";
|
||||
import { installDomRowCache } from "./domRowCache";
|
||||
|
||||
/** Mirrors the shape of xterm 6's private DomRenderer closely enough for the cache. */
|
||||
function fakeRendererClass() {
|
||||
@@ -76,16 +76,6 @@ describe("installDomRowCache", () => {
|
||||
expect(rowAt(renderer, 1).textContent).toBe("ce");
|
||||
});
|
||||
|
||||
it("lifts spans with Nerd Font icons over the next cell's background", () => {
|
||||
const Renderer = fakeRendererClass();
|
||||
// U+E606 (Python logo) and U+F0035 (a Material Design icon) are drawn wider than one cell.
|
||||
const renderer = new Renderer(["\uE606 py \u{F0035} ~/src"]);
|
||||
installDomRowCache(terminalWith(renderer));
|
||||
renderer.renderRows(0, 0);
|
||||
const lifted = [...rowAt(renderer, 0).children].map((span) => span.classList.contains(ICON_SPAN_CLASS));
|
||||
expect(lifted).toEqual([true, false, true, false]);
|
||||
});
|
||||
|
||||
it("re-renders rows after clear() and link underlines rebuilt them", () => {
|
||||
const Renderer = fakeRendererClass();
|
||||
const renderer = new Renderer(["a b"]);
|
||||
|
||||
@@ -7,21 +7,12 @@
|
||||
* several times a second while only one or two actually changed; the window then falls
|
||||
* behind on keystrokes. Rows are still built (cheap); only identical ones are left in place.
|
||||
*
|
||||
* Rows are also where wide icons get lifted over the next cell ([`ICON_SPAN_CLASS`]): a Nerd
|
||||
* Font icon is often drawn wider than one cell, and the DOM renderer paints the next span's
|
||||
* background over its right part (powerline prompts showed half icons).
|
||||
*
|
||||
* Remove the cache once xterm's DomRenderer does this itself (xterm 6.0.0 does not).
|
||||
* Remove once xterm's DomRenderer does this itself (xterm 6.0.0 does not).
|
||||
*/
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
|
||||
const MARKUP = Symbol("rowMarkup");
|
||||
|
||||
/** Private Use Area characters: Nerd Font / Powerline icons, often drawn wider than their cell. */
|
||||
const ICON = /[\uE000-\uF8FF]|[\u{F0000}-\u{10FFFD}]/u;
|
||||
/** Lifts a span holding such an icon over the next one, so the next cell's background doesn't cover it. */
|
||||
export const ICON_SPAN_CLASS = "openidea-terminal-icon";
|
||||
|
||||
type RowElement = HTMLElement & { [MARKUP]?: string };
|
||||
|
||||
type BufferLine = unknown;
|
||||
@@ -101,10 +92,7 @@ function renderRows(this: DomRenderer, start: number, end: number) {
|
||||
-1,
|
||||
);
|
||||
let markup = "";
|
||||
for (const span of spans) {
|
||||
if (ICON.test(span.textContent ?? "")) span.classList.add(ICON_SPAN_CLASS);
|
||||
markup += span.outerHTML;
|
||||
}
|
||||
for (const span of spans) markup += span.outerHTML;
|
||||
if (element[MARKUP] === markup) continue;
|
||||
element[MARKUP] = markup;
|
||||
element.replaceChildren(...spans);
|
||||
|
||||
@@ -1107,8 +1107,7 @@ export function DiffEditor({
|
||||
viewer: viewerOf,
|
||||
});
|
||||
useEffect(() => {
|
||||
// Keep the registered navigator's identity while refreshing its handlers.
|
||||
Object.assign(nav.current, {
|
||||
nav.current = {
|
||||
next: () => step(1),
|
||||
prev: () => step(-1),
|
||||
jumpToSource,
|
||||
@@ -1116,7 +1115,7 @@ export function DiffEditor({
|
||||
file,
|
||||
showSettings,
|
||||
viewer: viewerOf,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(
|
||||
|
||||
@@ -444,54 +444,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Go to a merge commit's file: parent 1 | merge result | parent 2, each pane scrolling itself. */
|
||||
.mergeDiff {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mergeDiffLabels {
|
||||
flex: none;
|
||||
height: var(--h-row);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.mergeDiffLabels > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mergeDiffLabels > span + span {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.mergeDiffPanes {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
.mergeDiffPane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mergeDiffPane + .mergeDiffPane {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.mergeDiffPane > :global(.cm-editor) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.subjectLine {
|
||||
color: var(--fg-strong);
|
||||
font-weight: 600;
|
||||
|
||||
@@ -51,7 +51,10 @@ export function GitMergeDiff({ input }: { input: EditorInput }) {
|
||||
}
|
||||
for (const [i, text] of texts.entries()) {
|
||||
const pane = document.createElement("div");
|
||||
pane.className = styles.mergeDiffPane!;
|
||||
pane.style.cssText = "flex:1;min-width:0;display:flex;flex-direction:column";
|
||||
const label = document.createElement("div");
|
||||
label.textContent = `${sides[i]!.rev.slice(0, 8)} · ${sides[i]!.path}`;
|
||||
pane.append(label);
|
||||
host.current.append(pane);
|
||||
const view = new EditorView({
|
||||
parent: pane,
|
||||
@@ -83,16 +86,10 @@ export function GitMergeDiff({ input }: { input: EditorInput }) {
|
||||
element?.replaceChildren();
|
||||
};
|
||||
}, [data]);
|
||||
const labels = [data.parents[0]!, { rev: data.revision, path: data.path }, data.parents[1]!];
|
||||
return (
|
||||
<div className={styles.mergeDiff}>
|
||||
{error && <div role="alert">{error}</div>}
|
||||
<div className={styles.mergeDiffLabels}>
|
||||
{labels.map((side, i) => (
|
||||
<span key={i}>{`${side.rev.slice(0, 8)} · ${side.path}`}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.mergeDiffPanes} ref={host} />
|
||||
</div>
|
||||
<>
|
||||
<div role={error ? "alert" : undefined}>{error}</div>
|
||||
<div className={styles.revisionSource} style={{ display: "flex" }} ref={host} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user