Compare commits

..
Author SHA1 Message Date
l0ng-ai 2bb14c9704 fix(database): keep unsubmitted result edits while another result tab is shown
The result pane remounts when another result tab is selected, so its pending
cell edits, added and deleted rows were dropped. They are now kept by the rows
they were made on, so switching back restores them; a re-run or another page
still starts over.
2026-11-13 17:00:14 +08:00
140 changed files with 522 additions and 4451 deletions

No files matched your search

+1 -2
View File
@@ -12,8 +12,7 @@ mod up_to_date;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
pub use launch_resources::process_launch_resources;
pub use single::{
SingleFileTarget, compile_file_plan, find_javac, javac_step, module_classpath, single_file_classpath,
single_file_target,
SingleFileTarget, compile_file_plan, find_javac, javac_step, single_file_classpath, single_file_target,
};
pub use up_to_date::{menu_up_to_date, record_menu_build};
+3 -210
View File
@@ -4,21 +4,12 @@
//! walks the whole reactor; compiling one file only needs that file's module
//! outputs plus its dependency classpath, which is much faster to assemble.
use super::*;
use serde::{Deserialize, Serialize};
use std::fs;
use std::process::Command as ProcCommand;
/// Output file of the `dependency:build-classpath` probe, relative to each module.
const CP_FILE: &str = "target/ide-compile-cp.txt";
/// The resolved dependency classpath for one module and scope, tied to the same
/// inputs as its launch build so POM, JDK, settings and profile changes invalidate it.
#[derive(Debug, Deserialize, Serialize)]
struct CachedClasspath {
fingerprint: LaunchFingerprint,
classpath: String,
}
/// A `.java` source and the module build that compiles it.
pub struct SingleFileTarget {
/// Absolute module directory.
@@ -73,28 +64,6 @@ pub fn single_file_target(root: &Path, model: &BuildModel, file: &Path) -> IdeRe
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
}
/// The Maven reactor target for a launch module whose source file is not known yet.
fn module_target(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<SingleFileTarget> {
let Some(m) = &model.maven else {
return Err(IdeError::not_found("no linked Maven project found for module"));
};
let module = if module.is_absolute() { module.to_path_buf() } else { root.join(module) };
let project = m
.projects
.iter()
.find(|p| root.join(&p.dir) == module)
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
if project.packaging == "pom" {
return Err(IdeError::not_found("the launch module is a Maven aggregator without sources"));
}
let top = maven_reactor(m, &project.dir)
.ok_or_else(|| IdeError::not_found("no linked Maven reactor found for module"))?;
let top_dir = subdir(root, &top.dir);
let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
let out_dir = module.join("target").join(if test { "test-classes" } else { "classes" });
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
}
/// `javac` for the module: its `JAVA_HOME`, else `PATH`.
pub fn find_javac(env: &BTreeMap<String, String>) -> String {
let home = env.get("JAVA_HOME").map(Path::new);
@@ -109,88 +78,6 @@ pub fn find_javac(env: &BTreeMap<String, String>) -> String {
/// `mvn dependency:build-classpath` (which resolves without compiling anything,
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> {
classpath(root, model, target, "test", false)
}
/// Runtime (or test) dependency classpath for a launch module.
///
/// The Java language server runs on its own JDK, so its Maven model can select a
/// different JDK profile and dependency classifier than the project launch. Use the
/// same Maven process that builds the module instead.
pub fn module_classpath(
root: &Path,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cache: &Path,
cancel: &ide_core::CancellationToken,
) -> IdeResult<String> {
let target = module_target(root, model, module, test)?;
let fingerprint = launch_fingerprint(root, model, &target.module, test, local_repo, cancel)?;
let cache_path = classpath_cache_path(root, cache, &target.module, test);
if let (Some(fingerprint), Some(path)) = (&fingerprint, &cache_path)
&& let Ok(bytes) = fs::read(path)
&& let Ok(cached) = serde_json::from_slice::<CachedClasspath>(&bytes)
&& cached.fingerprint == *fingerprint
&& classpath_entries_exist(root, &cached.classpath)
{
return Ok(cached.classpath);
}
let classpath = classpath(root, model, &target, if test { "test" } else { "runtime" }, true)?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if let (Some(fingerprint), Some(path)) = (fingerprint, cache_path) {
let cached = CachedClasspath { fingerprint, classpath: classpath.clone() };
if let Err(error) = cache_classpath(&path, &cached) {
tracing::debug!(path = %path.display(), %error, "Cannot cache the Java launch classpath");
}
}
Ok(classpath)
}
/// One cache file per module and scope. Mirroring the module path keeps nested
/// modules and their different dependency scopes from overwriting each other.
fn classpath_cache_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
let relative = module.strip_prefix(root).ok()?;
let mut path = cache.join("java-launch/classpaths");
path.push(if relative.as_os_str().is_empty() { Path::new("_root") } else { relative });
path.push(if test { "test.json" } else { "runtime.json" });
Some(path)
}
/// Best-effort cache persistence. A failed write only costs one later Maven
/// probe, so the launch still succeeds even when the cache directory is unusable.
fn cache_classpath(path: &Path, cached: &CachedClasspath) -> IdeResult<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_vec(cached).map_err(|e| IdeError::internal(e.to_string()))?)?;
Ok(())
}
/// A cache entry stays valid while every file exists. Maven also emits reactor
/// output directories for modules with no classes, which may legitimately never be
/// created; the launch snapshot already supplies the module's classes in that case.
fn classpath_entries_exist(root: &Path, classpath: &str) -> bool {
// Temp directories on macOS are commonly reached through `/var`, while Maven
// reports the canonical `/private/var` path. Compare against both spellings.
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
std::env::split_paths(classpath).all(|entry| {
entry.exists()
|| ((entry.starts_with(root) || entry.starts_with(&canonical_root))
&& (entry.ends_with(Path::new("target/classes")) || entry.ends_with(Path::new("target/test-classes"))))
})
}
fn classpath(
root: &Path,
model: &BuildModel,
target: &SingleFileTarget,
scope: &str,
compile_first: bool,
) -> IdeResult<String> {
let mut entries: Vec<String> = Vec::new();
// The module's own classes first: the file almost always uses classes next to it.
let own: &[&str] = if target.test { &["target/test-classes", "target/classes"] } else { &["target/classes"] };
@@ -223,14 +110,7 @@ fn classpath(
args.extend(["-pl", target.pl.as_str(), "-am"]);
}
let output_arg = format!("-Dmdep.outputFile={probe}");
let scope_arg = format!("-Dmdep.includeScope={scope}");
// A launch build may only have reached `compile`. Running `compile` in this
// same Maven session lets the dependency plugin resolve Reactor siblings from
// their target/classes without paying for package-phase plugins.
if compile_first {
args.push("compile");
}
args.extend(["dependency:build-classpath", output_arg.as_str(), scope_arg.as_str(), "-q"]);
args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]);
let top_dir = subdir(root, &target.top.dir);
let status = ProcCommand::new(&target.top.command)
.args(&args)
@@ -239,10 +119,8 @@ fn classpath(
.output()
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
if !status.status.success() {
let stdout = String::from_utf8_lossy(&status.stdout);
let stderr = String::from_utf8_lossy(&status.stderr);
// Maven reports reactor/dependency failures on stdout, not only stderr.
let tail: Vec<&str> = stdout.lines().chain(stderr.lines()).filter(|l| !l.trim().is_empty()).collect();
let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n");
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
}
@@ -427,7 +305,7 @@ mod tests {
let script = root.join("mvnw");
fs::write(
&script,
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
"#!/bin/sh\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
@@ -449,90 +327,5 @@ mod tests {
assert!(cp.contains("/repo/a.jar"), "{cp}");
assert!(cp.contains("/repo/b.jar"), "{cp}");
assert!(cp.split(separator).count() >= 3, "{cp}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(!args.lines().any(|a| a == "compile" || a == "package"), "{args}");
}
/// A launch uses runtime dependencies, while tests additionally see test-scope ones.
#[cfg(unix)]
#[test]
fn launch_module_classpath_uses_the_matching_dependency_scope() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fixture(root);
let script = root.join("mvnw");
fs::write(
&script,
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/runtime.jar:/repo/test.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let model = crate::load(root, &root.join("cache"), None);
let cache = root.join("cache");
let runtime =
module_classpath(root, &model, &root.join("app"), false, None, &cache, &CancellationToken::new()).unwrap();
assert!(runtime.contains("/repo/runtime.jar"), "{runtime}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(args.lines().any(|a| a == "compile"), "{args}");
assert!(!args.lines().any(|a| a == "package"), "{args}");
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=runtime"), "{args}");
let test =
module_classpath(root, &model, &root.join("app"), true, None, &cache, &CancellationToken::new()).unwrap();
assert!(test.contains("/repo/test.jar"), "{test}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=test"), "{args}");
}
/// The launch classpath follows the Maven runner JDK and is reused until that
/// build input changes; the probe does not run packaging plugins.
#[cfg(unix)]
#[test]
fn launch_classpath_cache_follows_the_runner_jdk() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fixture(root);
let script = root.join("mvnw");
// `dependency:build-classpath` reports reactor outputs as absolute paths. Keep
// the fake probe faithful so the cache's existence check sees the output.
fs::write(
&script,
"#!/bin/sh\nprintf x >> calls.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target/classes\" \"$JAVA_HOME/classpath\"\n: > \"$JAVA_HOME/classpath/jdk.jar\"\nprintf '%s/classpath/jdk.jar:%s/%s/target/classes:%s/empty/target/classes' \"$JAVA_HOME\" \"$(pwd)\" \"$pl\" \"$(pwd)\" > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let mut model = crate::load(root, &root.join("cache"), None);
for (name, version) in [("jdk21", "21"), ("jdk8", "8")] {
let home = root.join(name);
fs::create_dir_all(home.join("bin")).unwrap();
fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
fs::write(home.join("bin/java"), "").unwrap();
}
let cache = root.join("classpath-cache");
let cancel = CancellationToken::new();
let jdk21 = root.join("jdk21");
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), jdk21.to_string_lossy().into_owned());
}
let first = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert!(first.contains("jdk21/classpath/jdk.jar"), "{first}");
assert!(!first.contains("jdk8/classpath/jdk.jar"), "{first}");
let second = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert_eq!(second, first);
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "x");
let jdk8 = root.join("jdk8");
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), jdk8.to_string_lossy().into_owned());
}
let switched = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert!(switched.contains("jdk8/classpath/jdk.jar"), "{switched}");
assert!(!switched.contains("jdk21/classpath/jdk.jar"), "{switched}");
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "xx");
}
}
+12 -67
View File
@@ -712,27 +712,18 @@ pub(crate) fn launch_inputs(
// Profile activation may depend on the presence of an otherwise unrelated file.
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
if input || activation {
// A source root under Maven's build directory is generated by the build itself.
// Skip it without resolving the property: `<build><directory>` may be custom, and
// hashing its rewritten files would make every launch look changed.
if !(input && el.text.contains("${project.build.directory}")) {
// Other properties can be overridden by -D, profiles or settings. Without
// Maven's effective model, leave them unresolved to conservatively disable reuse.
let value = el
.text
.trim()
.replace("${project.basedir}", &dir.to_string_lossy())
.replace("${basedir}", &dir.to_string_lossy());
if value.contains("${") {
return None;
}
if !value.is_empty() {
let path = dir.join(value);
// Conventional generated sources under target are also build outputs.
if !(input && path.starts_with(dir.join("target"))) {
out.push(path);
}
}
// Other properties can be overridden by -D, profiles or settings.
// Without Maven's effective roots, conservatively disable reuse for them.
let value = el
.text
.trim()
.replace("${project.basedir}", &dir.to_string_lossy())
.replace("${basedir}", &dir.to_string_lossy());
if value.contains("${") {
return None;
}
if !value.is_empty() {
out.push(dir.join(value));
}
}
for child in &el.children {
@@ -2149,52 +2140,6 @@ mod tests {
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
}
#[test]
fn launch_inputs_skip_generated_sources_under_the_project_build_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>\
<packaging>pom</packaging><modules><module>app</module></modules></project>",
);
write(
root,
"app/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>app</artifactId><build><plugins><plugin>
<groupId>org.codehaus.mojo</groupId><artifactId>build-helper-maven-plugin</artifactId>
<executions><execution><configuration><sources>
<source>${project.build.directory}/generated-sources/java</source>
</sources></configuration></execution></executions>
</plugin></plugins></build></project>"#,
);
let model = load_with_repo(root, None);
let projects: Vec<&MavenProject> = model.projects.iter().collect();
let inputs = launch_inputs(root, &projects, None).expect("portable launch inputs");
assert!(!inputs.contains(&root.join("app/target/generated-sources/java")), "{inputs:?}");
assert!(inputs.contains(&root.join("app/src")), "conventional sources stay fingerprinted");
}
#[test]
fn launch_inputs_disable_reuse_for_file_activation_on_the_build_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version>
<build><directory>${project.basedir}/custom-build</directory></build>
<profiles><profile><id>generated</id><activation><file>
<exists>${project.build.directory}/enable-profile</exists>
</file></activation></profile></profiles></project>"#,
);
let model = load_with_repo(root, None);
let projects: Vec<&MavenProject> = model.projects.iter().collect();
assert!(launch_inputs(root, &projects, None).is_none());
}
fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
-9
View File
@@ -270,13 +270,6 @@ fn run(
/// The first error lines of a failed build, for the tool window.
pub(crate) fn error_summary(text: &str) -> String {
// The JVM's "could not find or load main class" (localized) when `gradlew` has no jar.
if text.contains("org.gradle.wrapper.GradleWrapperMain") {
return "gradle/wrapper/gradle-wrapper.jar is missing (often excluded by a `*.jar` rule in .gitignore). \
Restore it, run `gradle wrapper` with an installed Gradle, or choose Distribution: Local installation \
in Settings › Build, Execution, Deployment › Gradle."
.to_owned();
}
let lines: Vec<&str> = text
.lines()
.filter(|l| {
@@ -916,8 +909,6 @@ mod tests {
let text = "[INFO] x\n[ERROR] Failed to execute goal\n[ERROR] \n[ERROR] Re-run Maven using -X\n";
assert_eq!(error_summary(text), "Failed to execute goal");
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
let missing_jar = "错误: 找不到或无法加载主类 org.gradle.wrapper.GradleWrapperMain\n";
assert!(error_summary(missing_jar).starts_with("gradle/wrapper/gradle-wrapper.jar is missing"));
}
#[test]
-3
View File
@@ -77,9 +77,6 @@ pub enum IdeEvent {
WorkspaceEditApplied {
id: u32,
paths: Vec<crate::IpcPath>,
/// Made by an agent through the MCP server's tools: undoable like a refactoring, but
/// not announced as one.
by_agent: bool,
},
/// An open document's language changed (Settings › File Types, Override File
/// Type), or its UI mode (a framework's root marker appeared or went).
-13
View File
@@ -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 {
+1 -20
View File
@@ -300,30 +300,11 @@ pub struct McpServerSettings {
pub agent_tab_limit: u32,
/// Archived agent tabs kept; older ones are closed.
pub agent_archive_limit: u32,
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
/// port forwards keep working after a restart.
pub fixed_port: bool,
/// The port of [`Self::fixed_port`].
pub port: u16,
/// Keep the bearer token across runs (in the app data, readable by this user only) rather than
/// make a new one each run.
pub fixed_token: bool,
}
/// The default of [`McpServerSettings::port`].
pub const DEFAULT_MCP_PORT: u16 = 26371;
impl Default for McpServerSettings {
fn default() -> Self {
Self {
enabled: false,
run_without_confirmation: false,
agent_tab_limit: 1,
agent_archive_limit: 999,
fixed_port: true,
port: DEFAULT_MCP_PORT,
fixed_token: false,
}
Self { enabled: false, run_without_confirmation: false, agent_tab_limit: 1, agent_archive_limit: 999 }
}
}
+3 -3
View File
@@ -87,7 +87,7 @@ async fn create_or_replace(call: &Call, path: PathBuf, text: Option<String>, ove
return Ok(());
}
let edit = crate::ops::lsp::prepare_workspace_edit(&call.host, call.window(), edit).await?;
crate::ops::lsp::apply_agent_workspace_edit(&call.host, call.window(), edit).await
crate::ops::lsp::apply_workspace_edit(&call.host, call.window(), edit).await
}
/// The open document of `path`, or a transient one (`true`: close it after use).
@@ -317,7 +317,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
preconditions: Vec::new(),
};
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_agent_workspace_edit(&host, call.window(), edit).await
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await
}
FileOp::Update { path: first, move_to, hunks, or } => {
// A plain diff's other names count when its `+++` path does not exist.
@@ -400,7 +400,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
edit.files.push(FileEdit::Rename { from: path.into(), to: to.into() });
}
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_agent_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await?;
if dirty {
notes.push(format!("{shown}: patched the unsaved editor buffer"));
}
+1 -1
View File
@@ -385,7 +385,7 @@ targetIndex, or narrow the call with a contextSnippet that shows the one you mea
_ => None,
});
let (h, w) = (host.clone(), window.to_owned());
let written = crate::ops::lsp::apply_agent_workspace_edit(&h, &w, edit).await;
let written = crate::ops::lsp::apply_workspace_edit(&h, &w, edit).await;
if let Err(e) = written {
let hint = format!("The rename stopped while it wrote. {} Read the files again before you retry.", e.message);
return Ok(RenameResult::failed("write_failed", hint).with_symbol(symbol));
-32
View File
@@ -985,38 +985,6 @@ mod build_tool_settings_tests {
}
}
/// Changing Project JDK updates the next Maven build without reopening the project.
#[test]
fn changing_project_jdk_updates_the_next_maven_build() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(
root.join("pom.xml"),
r#"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>app</artifactId><version>1</version></project>"#,
)
.unwrap();
for (name, version) in [("jdk8", "1.8"), ("jdk21", "21")] {
let home = root.join(name);
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("bin").join(if cfg!(windows) { "java.exe" } else { "java" }), "").unwrap();
std::fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
}
let mut settings = Settings::default();
settings.languages.java.maven.runner_jre.clear();
for name in ["jdk8", "jdk21"] {
settings.languages.java.jdk = name.into();
let expected = root.join(name);
let model = load(&root, &root.join("cache"), &settings);
assert_eq!(model.maven.as_ref().unwrap().projects[0].env["JAVA_HOME"], expected.to_string_lossy());
let plan = compile::plan(&root, &model, BuildKind::Build, None, None).unwrap();
assert!(!plan.steps.is_empty());
for step in plan.steps {
assert_eq!(step.env["JAVA_HOME"], expected.to_string_lossy());
}
}
}
#[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
+27 -97
View File
@@ -96,19 +96,18 @@ async fn resolve_launch(
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
let local_repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
let built = if delegated {
let rebuild_on_jdt_errors = rebuild_on_jdt_errors(policy, prefer_maven, info.error_count);
let policy = delegated_policy(policy, prefer_maven, info.error_count);
let outputs = project.java_launch_snapshots.lock().build(
&project.root,
&model,
&module,
cache::BuildOptions { test, policy, rebuild_on_jdt_errors },
local_repo.clone(),
cache::BuildOptions { test, policy },
ide_sdk::apply::maven_local_repository(&project.root, &settings),
&cancel,
)?;
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
apply_external_build_result(&mut info, outputs.failure, outputs.authoritative);
apply_external_build_result(&mut info, outputs.failure, policy == BuildPolicy::Rebuild);
copies
} else if external {
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
@@ -124,22 +123,10 @@ async fn resolve_launch(
Vec::new()
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
if delegated {
// m2e runs inside jdtls on the server JDK and can resolve a different
// Maven profile/classifier than the module build. Ask the same Maven
// process that built the module for its runtime/test dependencies.
let maven_cp = compile::module_classpath(
&project.root,
&model,
&module,
test,
local_repo,
&project.build_cache_dir(),
&cancel,
);
merge_maven_launch_classpath(&mut class_paths, &mut info, maven_cp)?;
} else {
append_unique_class_paths(&mut class_paths, std::mem::take(&mut info.class_paths));
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
class_paths.push(p);
}
}
info.class_paths = class_paths;
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
@@ -204,18 +191,21 @@ async fn resolve_launch(
/// With Settings › Maven › Delegate IDE build/run actions to Maven, javac's verdict replaces
/// JDT's. Classes JDT wrote for sources with errors look up to date to Maven's incremental
/// compiler, so a launch JDT reports errors for recompiles everything.
fn rebuild_on_jdt_errors(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> bool {
prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make)
fn delegated_policy(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> BuildPolicy {
if prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make) {
BuildPolicy::Rebuild
} else {
policy
}
}
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, authoritative: bool) {
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) {
info.build = Some(if let Some(failure) = failure {
info.hints.push(failure);
JavaBuildStatus::Failed
} else if authoritative {
// A clean rebuild or a fingerprint-verified snapshot is authoritative. JDT diagnostics
// may describe an older output or its own classpath, so they must not mark this launch
// as failed after Maven has produced usable classes.
} else if clean {
// A clean external build recompiled every source; JDT's diagnostics still describe
// the previous output (and reject sources javac accepts, e.g. a package mismatch).
info.errors.clear();
info.error_count = 0;
JavaBuildStatus::Succeeded
@@ -296,44 +286,6 @@ fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows)
}
/// Merge the Maven launch classpath without turning a recoverable probe failure into a
/// failed launch. Cancellation still propagates; ordinary failures keep jdtls' classpath
/// and leave the reason in the run console.
fn merge_maven_launch_classpath(
class_paths: &mut Vec<String>,
info: &mut JavaLaunchInfo,
classpath: IdeResult<String>,
) -> IdeResult<()> {
match classpath {
Ok(classpath) => {
append_unique_class_paths(
class_paths,
std::env::split_paths(&classpath).map(|path| path.to_string_lossy().into_owned()),
);
Ok(())
}
Err(error) if error.kind == ErrorKind::Cancelled => Err(error),
Err(error) => {
info.hints.push(format!(
"Maven launch classpath resolution failed; using the Java language server classpath instead:\n{}",
error.message
));
append_unique_class_paths(class_paths, std::mem::take(&mut info.class_paths));
Ok(())
}
}
}
/// Append entries without changing their precedence; `same_entry` also catches
/// Windows case-insensitive duplicates that would otherwise lengthen the command.
fn append_unique_class_paths(class_paths: &mut Vec<String>, entries: impl IntoIterator<Item = String>) {
for entry in entries {
if !class_paths.iter().any(|existing| same_entry(existing, &entry, cfg!(windows))) {
class_paths.push(entry);
}
}
}
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
use_snapshot_on(info, built, snapshot, cfg!(windows))
}
@@ -458,36 +410,14 @@ mod tests {
#[test]
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
assert!(rebuild_on_jdt_errors(BuildPolicy::Make, true, 1));
assert!(rebuild_on_jdt_errors(BuildPolicy::Automatic, true, 2));
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, true, 0));
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, false, 1));
assert!(!rebuild_on_jdt_errors(BuildPolicy::MakeAndContinueOnErrors, true, 1));
}
#[test]
fn maven_classpath_probe_failure_falls_back_to_jdtls_classpaths() {
let maven = std::env::join_paths(["maven.jar", "snapshot.jar"]).unwrap().to_string_lossy().into_owned();
let mut class_paths = vec!["snapshot.jar".to_owned()];
let mut info =
JavaLaunchInfo { class_paths: vec!["snapshot.jar".into(), "jdtls.jar".into()], ..Default::default() };
merge_maven_launch_classpath(&mut class_paths, &mut info, Ok(maven)).unwrap();
assert_eq!(class_paths, ["snapshot.jar", "maven.jar"]);
merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::internal("offline dependency")))
.unwrap();
assert_eq!(class_paths, ["snapshot.jar", "maven.jar", "jdtls.jar"]);
assert!(info.hints[0].contains("offline dependency"), "{:?}", info.hints);
}
#[test]
fn cancelled_maven_classpath_probe_propagates_without_fallback() {
let mut class_paths = vec!["snapshot.jar".to_owned()];
let mut info = JavaLaunchInfo { class_paths: vec!["jdtls.jar".into()], ..Default::default() };
let error = merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::cancelled())).unwrap_err();
assert_eq!(error.kind, ErrorKind::Cancelled);
assert_eq!(class_paths, ["snapshot.jar"]);
assert!(info.hints.is_empty());
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Automatic, true, 2), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Make, true, 0), BuildPolicy::Make);
assert_eq!(delegated_policy(BuildPolicy::Make, false, 1), BuildPolicy::Make);
assert_eq!(
delegated_policy(BuildPolicy::MakeAndContinueOnErrors, true, 1),
BuildPolicy::MakeAndContinueOnErrors
);
}
#[test]
@@ -531,7 +461,7 @@ mod tests {
}
#[test]
fn authoritative_external_result_clears_stale_jdt_errors() {
fn clean_external_build_clears_stale_jdt_errors() {
let mut info = JavaLaunchInfo {
error_count: 7,
errors: vec![ide_lsp::jdtls::JavaCompileError {
+4 -71
View File
@@ -14,10 +14,6 @@ pub(super) struct Outputs {
pub originals: Vec<PathBuf>,
#[serde(skip)]
pub failure: Option<String>,
/// Whether this result replaces jdtls diagnostics with a clean Maven build or a
/// verified snapshot. It is runtime-only because reuse is determined per launch.
#[serde(skip)]
pub authoritative: bool,
}
impl Outputs {
@@ -46,9 +42,6 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
pub(super) struct BuildOptions {
pub test: bool,
pub policy: BuildPolicy,
/// A JDT error makes a cache miss rebuild, but it must not prevent an unchanged
/// snapshot from being reused on the next launch.
pub rebuild_on_jdt_errors: bool,
}
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
@@ -82,7 +75,7 @@ impl Session {
cancel: &CancellationToken,
) -> IdeResult<Outputs> {
self.initialize();
let BuildOptions { test, policy, rebuild_on_jdt_errors } = options;
let BuildOptions { test, policy } = options;
let cache = &self.cache;
let record =
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
@@ -122,14 +115,8 @@ impl Session {
&& outputs.reusable(&before, &snapshots)
{
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
let mut outputs = previous.unwrap();
outputs.authoritative = true;
return Ok(outputs);
return Ok(previous.unwrap());
}
// JDT can report errors from its own classpath while the last Maven snapshot is
// still valid. Reuse that snapshot first; only a cache miss needs the expensive
// clean rebuild that replaces JDT's error-stub classes.
let policy = if rebuild_on_jdt_errors { BuildPolicy::Rebuild } else { policy };
// Retain a non-reusable record even on failure, so removing the final mixed
// source still delegates to Maven to clean its old classes on the next launch.
fs::create_dir_all(record.parent().unwrap())?;
@@ -169,13 +156,7 @@ impl Session {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut outputs = Outputs {
fingerprint: None,
snapshot,
originals,
failure: built.failure,
authoritative: policy == BuildPolicy::Rebuild,
};
let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure };
if outputs.failure.is_none() && before.is_some() && before == after {
outputs.fingerprint = before;
fs::create_dir_all(record.parent().unwrap())?;
@@ -353,48 +334,6 @@ mod tests {
);
}
#[test]
fn jdt_errors_reuse_an_unchanged_snapshot_and_rebuild_only_on_a_cache_miss() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cancel = CancellationToken::new();
let mut session = Session::new(root.join("cache"));
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert!(!first.authoritative);
let reused = session
.build(
root,
&model,
root,
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_eq!(reused.snapshot, first.snapshot);
assert!(reused.authoritative);
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1);
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
let rebuilt = session
.build(
root,
&model,
root,
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot);
assert!(rebuilt.authoritative);
let calls = fs::read_to_string(root.join("calls")).unwrap();
assert_eq!(calls.lines().count(), 2);
assert!(calls.lines().last().unwrap().contains("clean"));
}
#[test]
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
let dir = tempfile::tempdir().unwrap();
@@ -544,13 +483,7 @@ mod tests {
fs::write(snapshots.join("launch-file"), "keep").unwrap();
let record = cache.join("java-launch/records/deep/module/test.json");
fs::create_dir_all(record.parent().unwrap()).unwrap();
let outputs = Outputs {
fingerprint: None,
snapshot: retained.clone(),
originals: Vec::new(),
failure: None,
authoritative: false,
};
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None };
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
let mut session = Session::new(cache);
session.initialize();
+12 -27
View File
@@ -1483,38 +1483,23 @@ pub async fn lsp_restart_named(host: &Host, name: &str) -> IdeResult<()> {
pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
record_before_multi_file_edit(&project, &edit);
// Multi-file refactorings leave a Local History point to go back to.
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
apply_edit(&project.documents, &edit)
})
.await
}
/// [`apply_workspace_edit`] for the MCP server's tools: undoable the same way, but the UI does
/// not announce it as a refactoring.
pub async fn apply_agent_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
record_before_multi_file_edit(&project, &edit);
project.documents.apply_agent_workspace_edit(&edit)
})
.await
}
/// Multi-file edits leave a Local History point to go back to.
fn record_before_multi_file_edit(project: &crate::Project, edit: &WorkspaceEdit) {
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
}
pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
let project = host.project(window)?;
blocking(move || {
+1 -5
View File
@@ -231,11 +231,7 @@ async fn mutations_preserve_unsaved_text_and_reject_metadata_and_escapes() {
.iter()
.rev()
.find_map(|event| match event {
// Undoable like a refactoring, but marked as the agent's, so it isn't announced as one.
IdeEvent::WorkspaceEditApplied { id, by_agent, .. } => {
assert!(*by_agent, "an MCP edit is the agent's");
Some(*id)
}
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
_ => None,
})
.unwrap();
-71
View File
@@ -252,77 +252,6 @@ async fn maven_sync() {
assert!(!junit.children.is_empty(), "{junit:?}");
}
/// The launch classpath must come from the runner JDK, not the JDK that happened
/// to load the language-server model: JDK-activated profiles can select a
/// different dependency classifier. Use real Maven so this covers the scenario
/// rather than only the probe arguments.
#[test]
#[ignore = "project smoke: needs Maven and a JDK"]
fn maven_launch_classpath_uses_the_runner_jdk_classifier() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let java_home = PathBuf::from(std::env::var("JAVA_HOME").expect("JAVA_HOME"));
let release = std::fs::read_to_string(java_home.join("release")).unwrap();
let version = release
.lines()
.find_map(|line| line.strip_prefix("JAVA_VERSION=\""))
.and_then(|line| line.strip_suffix('"'))
.expect("JAVA_VERSION in release");
let major = version.strip_prefix("1.").unwrap_or(version).split(['.', '_']).next().unwrap().parse::<u32>().unwrap();
let jar = java_home.join(if cfg!(windows) { "bin/jar.exe" } else { "bin/jar" });
let jars = root.join("libs");
let staging = root.join("empty");
std::fs::create_dir_all(&jars).unwrap();
std::fs::create_dir_all(&staging).unwrap();
for name in ["api-jdk8.jar", "api-jdk9.jar"] {
let status = std::process::Command::new(&jar)
.args(["cf"])
.arg(jars.join(name))
.arg(".")
.current_dir(&staging)
.status()
.unwrap();
assert!(status.success());
}
std::fs::write(
root.join("pom.xml"),
r#"<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>demo</groupId><artifactId>jdk-classifier</artifactId><version>1</version>
<profiles>
<profile><id>jdk8</id><activation><jdk>1.8</jdk></activation><dependencies>
<dependency><groupId>demo</groupId><artifactId>api</artifactId><version>1</version><classifier>jdk8</classifier>
<scope>system</scope><systemPath>${project.basedir}/libs/api-jdk8.jar</systemPath></dependency>
</dependencies></profile>
<profile><id>jdk9</id><activation><jdk>[9,)</jdk></activation><dependencies>
<dependency><groupId>demo</groupId><artifactId>api</artifactId><version>1</version><classifier>jdk9</classifier>
<scope>system</scope><systemPath>${project.basedir}/libs/api-jdk9.jar</systemPath></dependency>
</dependencies></profile>
</profiles>
</project>"#,
)
.unwrap();
let cache = root.join("cache");
let mut model = ide_build::load(root, &cache, None);
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), java_home.to_string_lossy().into_owned());
}
let classpath = ide_build::compile::module_classpath(
root,
&model,
root,
false,
None,
&root.join("classpath-cache"),
&ide_core::CancellationToken::new(),
)
.unwrap();
let expected = if major <= 8 { "api-jdk8.jar" } else { "api-jdk9.jar" };
let other = if major <= 8 { "api-jdk9.jar" } else { "api-jdk8.jar" };
assert!(classpath.contains(expected), "{classpath}");
assert!(!classpath.contains(other), "{classpath}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Gradle and a JDK"]
async fn gradle_sync() {
+5 -9
View File
@@ -223,8 +223,6 @@ impl ClientHandler {
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
return;
}
let mut params = params;
crate::virtual_doc::normalize_uris(&mut params);
let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => {
@@ -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() {
+7 -3
View File
@@ -1440,9 +1440,13 @@ impl LspManager {
let params = serde_json::to_value(params).map_err(|e| IdeError::internal(e.to_string()))?;
let stamp = self.injected_request_stamp(&params);
let fut = async {
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
virtual_doc::normalize_uris(&mut value);
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
if t.server.name == "csharp-ls" {
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
virtual_doc::normalize_csharp_uris(&mut value);
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
} else {
t.run.peer.request::<Value, R>(method, params, cancel.cloned()).await
}
};
let result = match tokio::time::timeout(timeout, fut).await {
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
+10 -58
View File
@@ -18,36 +18,23 @@ const URI_ESCAPES: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
/// Servers can leave spaces and Unicode unescaped in document URIs: csharp-ls uses .NET
/// `Uri.ToString()`, and Java servers build some URIs with `java.net.URI.toString()` (which keeps
/// non-ASCII characters), e.g. jdtls in a refactoring's edit for a project under a Chinese path.
/// Escape them in document URI fields (and `WorkspaceEdit.changes` keys) before lsp-types' strict
/// URI deserialization, which otherwise rejects the whole response.
pub(crate) fn normalize_uris(value: &mut serde_json::Value) {
/// csharp-ls uses .NET Uri.ToString(), which can leave spaces and Unicode unescaped.
/// Normalize only document URI fields, before lsp-types' strict URI deserialization.
pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) {
match value {
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_uris),
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_csharp_uris),
serde_json::Value::Object(fields) => {
for (key, value) in fields.iter_mut() {
for (key, value) in fields {
// Opaque payloads must survive resolve/execute round trips unchanged.
if matches!(key.as_str(), "data" | "arguments") {
continue;
}
if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri")
&& let Some(uri) = value.as_str().and_then(escaped_uri)
if matches!(key.as_str(), "uri" | "targetUri")
&& let Some(uri) = value.as_str().filter(|s| s.starts_with("csharp:"))
{
*value = uri.into();
} else if key == "changes"
&& let serde_json::Value::Object(changes) = value
{
*changes = std::mem::take(changes)
.into_iter()
.map(|(uri, mut edits)| {
normalize_uris(&mut edits);
(escaped_uri(&uri).unwrap_or(uri), edits)
})
.collect();
*value = utf8_percent_encode(uri, URI_ESCAPES).to_string().into();
} else {
normalize_uris(value);
normalize_csharp_uris(value);
}
}
}
@@ -55,16 +42,6 @@ pub(crate) fn normalize_uris(value: &mut serde_json::Value) {
}
}
/// `uri` with the characters a URI can't hold escaped; `None` when nothing changes or it is
/// not a document URI this applies to (other schemes are passed back to servers as given).
fn escaped_uri(uri: &str) -> Option<String> {
if !(uri.starts_with("file:") || uri.starts_with("csharp:")) {
return None;
}
let escaped = utf8_percent_encode(uri, URI_ESCAPES).to_string();
(escaped != uri).then_some(escaped)
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CSharpMetadata {
@@ -225,7 +202,7 @@ mod tests {
{ "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } }
]);
let original = response.clone();
normalize_uris(&mut response);
normalize_csharp_uris(&mut response);
assert_eq!(response[0]["uri"], encoded);
assert_eq!(response[0]["source"], uri);
assert_eq!(response[1]["location"]["targetUri"], encoded);
@@ -233,31 +210,6 @@ mod tests {
assert!(encoded.parse::<lsp_types::Uri>().is_ok());
}
/// #3089: jdtls's Introduce Variable edit for a project under a Chinese path names the file
/// with the characters unescaped, and the resolved code action failed to deserialize.
#[test]
fn file_uris_with_unicode_and_spaces_deserialize_in_workspace_edits() {
let uri = "file:///C:/项目/my app/src/A.java";
let encoded = "file:///C:/%E9%A1%B9%E7%9B%AE/my%20app/src/A.java";
let edit = serde_json::json!([{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } }, "newText": "x" }]);
let mut action = serde_json::json!({
"title": "Extract to local variable",
"edit": {
"documentChanges": [{ "textDocument": { "uri": uri, "version": 3 }, "edits": edit }],
"changes": { (uri): edit },
},
"data": { "uri": uri },
});
assert!(serde_json::from_value::<lsp_types::CodeAction>(action.clone()).is_err());
normalize_uris(&mut action);
let action: lsp_types::CodeAction = serde_json::from_value(action.clone()).unwrap();
let edit = action.edit.unwrap();
let Some(lsp_types::DocumentChanges::Edits(edits)) = edit.document_changes else { panic!() };
assert_eq!(edits[0].text_document.uri.as_str(), encoded);
assert_eq!(edit.changes.unwrap().keys().next().unwrap().as_str(), encoded);
assert_eq!(action.data.unwrap()["uri"], uri, "opaque data is passed back unchanged");
}
#[test]
fn jdt_titles() {
assert_eq!(title(JDT, true), "SpringApplication.class");
+9 -56
View File
@@ -1,7 +1,6 @@
//! `<app-data>/mcp/server.json`: where the running IDE's server listens and
//! its token, for the stdio bridge. Readable only by the user; removed when
//! the server stops. `<app-data>/mcp/token` keeps the token across runs when
//! the user chose a fixed token.
//! the server stops.
use serde::{Deserialize, Serialize};
use std::io;
@@ -15,28 +14,6 @@ pub fn discovery_path(app_data: &Path) -> PathBuf {
app_data.join(DISCOVERY_FILE)
}
/// The kept token, relative to the app data directory.
pub const TOKEN_FILE: &str = "mcp/token";
/// The kept token of the IDE whose app data directory is `app_data`.
pub fn token_path(app_data: &Path) -> PathBuf {
app_data.join(TOKEN_FILE)
}
/// The token kept at `path`; a new one is made and kept there when there is none (or the
/// file doesn't hold one). Readable only by the user.
pub fn kept_token(path: &Path) -> io::Result<String> {
if let Ok(text) = std::fs::read_to_string(path) {
let token = text.trim();
if token.len() == 64 && token.bytes().all(|b| b.is_ascii_hexdigit()) {
return Ok(token.to_owned());
}
}
let token = crate::server::new_token()?;
write_atomic(path, token.as_bytes())?;
Ok(token)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discovery {
pub port: u16,
@@ -55,8 +32,15 @@ impl Discovery {
/// Writes the file atomically, readable only by the user.
pub fn write(&self, path: &Path) -> io::Result<()> {
let dir = path.parent().ok_or_else(|| io::Error::other("no parent directory"))?;
std::fs::create_dir_all(dir)?;
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
let bytes = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
write_atomic(path, &bytes)
let written = write_private(&tmp, &bytes).and_then(|()| std::fs::rename(&tmp, path));
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
/// Removes the file at `path` if it still describes this process's server.
@@ -67,18 +51,6 @@ impl Discovery {
}
}
/// Writes `bytes` to `path` atomically, readable only by the user.
fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let dir = path.parent().ok_or_else(|| io::Error::other("no parent directory"))?;
std::fs::create_dir_all(dir)?;
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
let written = write_private(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
#[cfg(unix)]
fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
@@ -119,23 +91,4 @@ mod tests {
Discovery::remove_if_ours(&path);
assert!(!path.exists());
}
#[test]
fn keeps_one_token_until_its_file_is_removed() {
let tmp = tempfile::tempdir().unwrap();
let path = token_path(tmp.path());
let first = kept_token(&path).unwrap();
assert_eq!(first.len(), 64);
assert_eq!(kept_token(&path).unwrap(), first, "the same token on the next run");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
}
std::fs::write(&path, "not a token").unwrap();
let replaced = kept_token(&path).unwrap();
assert_ne!(replaced, first);
std::fs::remove_file(&path).unwrap();
assert_ne!(kept_token(&path).unwrap(), replaced, "a removed token is made anew");
}
}
+2 -2
View File
@@ -22,8 +22,8 @@ mod protocol;
mod server;
pub mod stdio;
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
pub use server::{McpServer, ServerOptions};
pub use discovery::{DISCOVERY_FILE, Discovery, discovery_path};
pub use server::McpServer;
pub use tokio_util::sync::CancellationToken;
use std::path::{Path, PathBuf};
+3 -43
View File
@@ -49,25 +49,12 @@ struct Shared {
/// removes its discovery file.
pub struct McpServer {
port: u16,
/// Why the port asked for in [`ServerOptions::port`] was not used.
port_unavailable: Option<String>,
token: String,
discovery: Option<PathBuf>,
shutdown: watch::Sender<bool>,
cancel: CancellationToken,
}
/// How [`McpServer::start_with`] listens.
#[derive(Clone, Debug, Default)]
pub struct ServerOptions {
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
/// `None`: a free port.
pub port: Option<u16>,
/// The bearer token; `None`: a new one.
pub token: Option<String>,
}
impl McpServer {
/// Starts listening on `127.0.0.1:<random port>` with a new token, on
/// `runtime`, and writes the discovery file at `discovery` if given.
@@ -76,32 +63,10 @@ impl McpServer {
discovery: Option<PathBuf>,
runtime: &tokio::runtime::Handle,
) -> std::io::Result<Self> {
Self::start_with(registry, discovery, runtime, ServerOptions::default())
}
/// [`Self::start`] on the port and with the token of `options`.
pub fn start_with(
registry: Arc<dyn ToolRegistry>,
discovery: Option<PathBuf>,
runtime: &tokio::runtime::Handle,
options: ServerOptions,
) -> std::io::Result<Self> {
let (listener, port_unavailable) = match options.port {
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => (listener, None),
Err(e) => {
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
}
},
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
};
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
let token = match options.token {
Some(token) => token,
None => new_token()?,
};
let token = new_token()?;
let listener = {
let _entered = runtime.enter();
tokio::net::TcpListener::from_std(listener)?
@@ -126,18 +91,13 @@ impl McpServer {
});
runtime.spawn(accept(listener, shared, stopped));
tracing::info!("MCP server on 127.0.0.1:{port}");
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
Ok(Self { port, token, discovery, shutdown, cancel })
}
pub fn port(&self) -> u16 {
self.port
}
/// Why the port of [`ServerOptions::port`] is not the one listened on.
pub fn port_unavailable(&self) -> Option<&str> {
self.port_unavailable.as_deref()
}
/// The bearer token of this run.
pub fn token(&self) -> &str {
&self.token
+1 -28
View File
@@ -1,7 +1,7 @@
//! The server over real HTTP, and the stdio bridge in front of it, with a
//! registry standing in for the IDE host.
use ide_mcp::{Discovery, McpServer, ServerOptions, Tool, ToolCallError, ToolRegistry, discovery_path, stdio};
use ide_mcp::{Discovery, McpServer, Tool, ToolCallError, ToolRegistry, discovery_path, stdio};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -366,30 +366,3 @@ async fn a_cancellation_that_arrives_before_the_call_is_remembered() {
let (_, body) = post(&s, "/mcp", &headers, &call).await;
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["isError"], true);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_one() {
let mut s = setup();
s.server.take().unwrap().stop();
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let token = "ab".repeat(32);
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
s.server =
Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, options.clone()).unwrap());
assert_eq!((s.server().port(), s.server().token()), (free, token.as_str()));
assert!(s.server().port_unavailable().is_none());
assert_eq!(Discovery::read(&s.discovery).unwrap().port, free);
let list = rpc(&s, "tools/list", json!({})).await;
assert_eq!(list["result"]["tools"][0]["name"], "echo");
// The port is taken (by the first server): a second one listens elsewhere and says why.
let second = McpServer::start_with(registry, None, &runtime, options).unwrap();
assert_ne!(second.port(), free);
assert!(
second.port_unavailable().is_some_and(|why| why.contains(&free.to_string())),
"{:?}",
second.port_unavailable()
);
}
-27
View File
@@ -323,18 +323,6 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp
}
let wrapper = build_dir.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
if use_wrapper && wrapper.is_file() {
// `gradlew` can't start without its jar, which `.gitignore`s with `*.jar` often leave
// out: run the distribution the wrapper already unpacked instead.
if build_dir.join("gradle/wrapper/gradle-wrapper.jar").is_file() {
return wrapper;
}
let home = setting_path(root, &g.user_home)
.or_else(|| std::env::var_os("GRADLE_USER_HOME").map(PathBuf::from))
.or_else(|| Some(crate::home_dir()?.join(".gradle")));
if let Some(gradle) = home.and_then(|home| unpacked_wrapper_distribution(build_dir, &home)) {
return gradle;
}
// Nothing to run instead: the wrapper's failure explains what is missing.
return wrapper;
}
pinned::pinned_install(build_dir, Tool::Gradle)
@@ -342,21 +330,6 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp
.unwrap_or_else(|| name.into())
}
/// `bin/gradle` of the distribution `gradle/wrapper/gradle-wrapper.properties` names, once the
/// wrapper has unpacked it in `<gradle_user_home>/wrapper/dists` (and marked it complete).
fn unpacked_wrapper_distribution(build_dir: &Path, gradle_user_home: &Path) -> Option<PathBuf> {
let properties = std::fs::read_to_string(build_dir.join("gradle/wrapper/gradle-wrapper.properties")).ok()?;
let url = properties.lines().find_map(|line| line.trim().strip_prefix("distributionUrl="))?;
let zip = url.trim().replace("\\:", ":").rsplit('/').next()?.to_owned();
let name = zip.strip_suffix(".zip")?;
let gradle = if cfg!(windows) { "gradle.bat" } else { "gradle" };
// One directory per distribution URL hash, each holding the unpacked `gradle-<version>`.
let hashes = std::fs::read_dir(gradle_user_home.join("wrapper/dists").join(name)).ok()?;
hashes.flatten().map(|e| e.path()).filter(|dir| dir.join(format!("{zip}.ok")).is_file()).find_map(|dir| {
std::fs::read_dir(&dir).ok()?.flatten().map(|e| e.path().join("bin").join(gradle)).find(|p| p.is_file())
})
}
fn gradle_run_jdk<'a>(
release: Option<u32>,
configured: Option<&'a crate::java::Jdk>,
-31
View File
@@ -1866,37 +1866,6 @@ fn max_java_picks_an_older_jdk() {
assert_eq!(free.env["JAVA_HOME"], j25.home.to_string_lossy());
}
/// #3086: `gradle-wrapper.jar` excluded by a `*.jar` rule in `.gitignore`: `gradlew` can't
/// start, so the distribution the wrapper already unpacked runs instead.
#[test]
fn gradle_wrapper_without_its_jar_runs_the_unpacked_distribution() {
use crate::apply::gradle_command;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let plain = if cfg!(windows) { "gradle.bat" } else { "gradle" };
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
std::fs::write(&wrapper, "").unwrap();
std::fs::create_dir_all(root.join("gradle/wrapper")).unwrap();
std::fs::write(
root.join("gradle/wrapper/gradle-wrapper.properties"),
"distributionUrl=https\\://services.gradle.org/distributions/gradle-4.10-bin.zip\n",
)
.unwrap();
let mut settings = Settings::default();
settings.languages.java.gradle.user_home = "user-home".into();
let hash = root.join("user-home/wrapper/dists/gradle-4.10-bin/7ddd1b3f");
let gradle = hash.join("gradle-4.10/bin").join(plain);
std::fs::create_dir_all(gradle.parent().unwrap()).unwrap();
std::fs::write(&gradle, "").unwrap();
// Not marked complete: possibly half unpacked.
assert_eq!(gradle_command(root, root, &settings), wrapper);
std::fs::write(hash.join("gradle-4.10-bin.zip.ok"), "").unwrap();
assert_eq!(gradle_command(root, root, &settings), gradle);
// With its jar the wrapper runs as usual.
std::fs::write(root.join("gradle/wrapper/gradle-wrapper.jar"), "").unwrap();
assert_eq!(gradle_command(root, root, &settings), wrapper);
}
#[test]
fn gradle_settings_precedence() {
use crate::apply::{gradle_command, gradle_env};
-3
View File
@@ -281,9 +281,6 @@ impl Repo {
let commitish = arg(commitish, "revision")?;
let mut args: Vec<String> = vec!["worktree".into(), "add".into()];
if let Some(b) = new_branch.map(str::trim).filter(|b| !b.is_empty()) {
if crate::write::renamed_remote_start(self, b, &commitish) {
args.push("--no-track".into());
}
args.extend(["-b".into(), arg(b, "branch name")?]);
}
args.extend(["--".into(), path, commitish]);
+2 -27
View File
@@ -800,41 +800,16 @@ pub(crate) fn create_branch(
let repo = r.gix()?;
resolve(&repo, s)?;
}
let no_track = start.is_some_and(|s| renamed_remote_start(r, name, s));
let mut args = match (checkout, overwrite) {
(true, false) => vec!["switch", "-c", name],
(true, true) => vec!["switch", "-C", name],
(false, false) => vec!["branch"],
(false, true) => vec!["branch", "--force"],
(false, false) => vec!["branch", "--", name],
(false, true) => vec!["branch", "--force", "--", name],
};
if no_track {
args.push("--no-track");
}
if !checkout {
args.extend(["--", name]);
}
args.extend(start);
run(r.root(), &args, None).map(|_| ())
}
/// Whether `start` is a remote-tracking branch with another name than the new branch
/// `name`. Git would make it the upstream, so pushing the new branch would go to the
/// start branch (`feature` from `origin/main` pushes to `main`); such a branch is left
/// without upstream and gets its own one on the first push. A remote branch under the
/// same name stays tracked.
pub(crate) fn renamed_remote_start(r: &Repo, name: &str, start: &str) -> bool {
let Ok(out) = cli::run_raw(r.root(), &["rev-parse", "--verify", "--quiet", "--symbolic-full-name", start], None)
else {
return false;
};
let full = out.stdout_str().trim().to_string();
let Some(rest) = full.strip_prefix("refs/remotes/").filter(|_| out.code == Some(0)) else { return false };
let Ok(repo) = r.gix() else { return false };
let names = repo.remote_names();
let branch = names.iter().find_map(|remote| rest.strip_prefix(&format!("{remote}/")));
branch.is_some_and(|b| b != name)
}
/// Undo Commit: moves the current branch back to the parent of `commit` (which must be
/// HEAD), keeping its changes staged. Returns the undone commit's message.
/// Edit Commit Message: writes `commit` again with `message`, and its descendants on top
+3 -29
View File
@@ -984,41 +984,15 @@ fn branch_overwrite_preserves_the_ref_if_checkout_fails() {
}
#[test]
fn new_branch_from_remote_branch_tracks_it_only_under_the_same_name() {
let (fx, bare) = Fixture::with_origin();
fn new_branch_from_remote_branch_tracks_it() {
let (fx, _bare) = Fixture::with_origin();
fx.git(&["push", "-q", "origin", "main:release"]);
fx.git(&["fetch", "-q"]);
let repo = fx.repo();
let upstream = |name: &str| {
repo.branches().unwrap().into_iter().find(|b| b.name == name && !b.remote).and_then(|b| b.upstream)
};
repo.create_branch("release", Some("origin/release"), false).unwrap();
assert_eq!(upstream("release").as_deref(), Some("origin/release"));
// Another name: no upstream, whether switched to or not, created plainly or overwritten.
repo.create_branch("other", Some("origin/release"), false).unwrap();
assert_eq!(upstream("other"), None);
repo.create_branch_with_overwrite("other", Some("origin/main"), false, true).unwrap();
assert_eq!(upstream("other"), None);
repo.create_branch("my-release", Some("origin/release"), true).unwrap();
let info = repo.branch_info().unwrap();
assert_eq!(info.head.as_deref(), Some("my-release"));
assert_eq!(info.upstream, None);
// The first push then goes to a branch of its own name and tracks it.
fx.write("a.txt", "mine\n");
let tip = fx.commit_all("mine");
let (cancel, out) = quiet();
repo.push(false, true, &cancel, &out).unwrap();
assert_eq!(git_in(&bare, &["rev-parse", "my-release"]), tip);
assert_ne!(git_in(&bare, &["rev-parse", "release"]), tip);
assert_eq!(fx.git(&["config", "branch.my-release.merge"]), "refs/heads/my-release");
// New worktree on a new branch from a remote branch: the same rule.
let wt = fx.tmp.path().join("wt");
repo.add_worktree(&wt, "origin/release", Some("wt-branch")).unwrap();
assert_eq!(upstream("wt-branch"), None);
assert_eq!(info.upstream.as_deref(), Some("origin/release"));
}
#[test]
@@ -389,12 +389,6 @@ impl Documents {
self.apply_workspace_edit_inner(edit, true, &mut |_| Ok(()))
}
/// [`Self::apply_workspace_edit`] for an agent's edit (the MCP server's tools): undoable the
/// same way, announced as an agent's (`by_agent`) rather than as a refactoring.
pub fn apply_agent_workspace_edit(&self, edit: &WorkspaceEdit) -> IdeResult<()> {
self.apply_workspace_edit_as(edit, true, true, &mut |_| Ok(()))
}
/// File moves already own a combined move/reference undo record.
pub fn apply_file_move_edits(&self, edit: &WorkspaceEdit) -> IdeResult<()> {
self.apply_workspace_edit_inner(edit, false, &mut |_| Ok(()))
@@ -405,16 +399,6 @@ impl Documents {
edit: &WorkspaceEdit,
remember: bool,
before_operation: &mut dyn FnMut(usize) -> IdeResult<()>,
) -> IdeResult<()> {
self.apply_workspace_edit_as(edit, remember, false, before_operation)
}
fn apply_workspace_edit_as(
&self,
edit: &WorkspaceEdit,
remember: bool,
by_agent: bool,
before_operation: &mut dyn FnMut(usize) -> IdeResult<()>,
) -> IdeResult<()> {
let _operation = self.operations.lock();
if edit.files.is_empty() {
@@ -501,7 +485,7 @@ impl Documents {
});
}
history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE);
self.sink.emit(IdeEvent::WorkspaceEditApplied { id, paths: affected, by_agent });
self.sink.emit(IdeEvent::WorkspaceEditApplied { id, paths: affected });
}
Ok(())
}
+2 -13
View File
@@ -118,20 +118,9 @@ SQL completion knows the connected schema: tables after `FROM`/`JOIN` (with join
## Results and data editing
Results appear in the **Services** tool window under the **Database** tab: an **Output** tab logs every statement, and each result set gets a **Result N** tab (up to 12, pinned tabs excepted).
Results appear in the **Services** tool window under the **Database** tab: an **Output** tab logs every statement, and each result set gets a **Result N** tab (up to 12).
Running a console again replaces its previous results: its unpinned result tabs make way for the new run, which gets one tab per result set (**Result N**, or **Result N**, **Result N-2**, … when several selected statements return rows). A run with no result sets, such as an `UPDATE`, keeps the previous results. Results from other consoles stay.
To keep a result, pin it with **Pin Tab** in the result tab's context menu or the pin button on the result toolbar; the tab's close button becomes a pin button that unpins it, so a stray click or middle-click can't close it, and later runs open new tabs instead. Rerunning or paging a pinned result updates it in place.
<Warning>
Replacing a result discards changes you have not submitted in it. **Submit** or **Revert** them, or pin the tab,
before running the console again.
</Warning>
Right-click a result tab (or focus it and press `Shift+F10`) for **Close**, **Close Others**, **Close All**, **Close All but Pinned**, **Pin Tab** / **Unpin Tab**, **Rename Tab**, **Select Next Tab** and **Select Previous Tab**. They act on the result tabs only; **Output** stays. Renamed titles last until the result is closed or replaced.
With focus on a result tab or inside a result, use `⇧⌘]` / `⇧⌘[` in the IntelliJ macOS keymap (`⌃→` / `⌃←` also work), or `Alt+→` / `Alt+←` in IntelliJ Windows/Linux. VS Code and Eclipse presets use their respective editor-tab navigation defaults. Switching focuses the destination result tab. Result navigation stays scoped to Results and is blocked while a menu or dialog is open. Customize database result shortcuts independently of editor shortcuts in **Settings › Keymap**.
Right-click a result tab (or focus it and press `Shift+F10`) for **Close**, **Close Others**, **Close All**, **Rename Tab**, **Select Next Tab** and **Select Previous Tab**. They act on the result tabs only; **Output** stays. Renamed titles last while the result is open.
Closing a selected result activates its left neighbor, or its right neighbor if there is no result on the left. When no results remain, **Output** is selected. Closing an inactive result keeps the current selection.
+2 -5
View File
@@ -18,17 +18,14 @@ Agent tasks started inside OpenIDEA (see [Agents](/en/guide/agents)) get the sam
</Step>
</Steps>
The server listens on `127.0.0.1` only, and every request must carry a bearer token. Requests from another origin or host are refused.
- **Port.** By default it listens on the same port, 26371, in every run (**Use a fixed port** and **Port**), so HTTP clients and port forwards (an SSH tunnel to another machine, say) keep working after a restart. When the port is in use or reserved, that run takes a free port and **Status** says why. Turn **Use a fixed port** off for a free port in every run.
- **Token.** By default the token changes each time OpenIDEA starts. Turn on **Keep the token** to keep one across runs, stored in OpenIDEA's application data for your account only; **Regenerate Token** replaces it, and clients configured with the old one stop working.
The server listens on `127.0.0.1` only, on a random port, and every request must carry a token that changes each time OpenIDEA starts. Requests from another origin or host are refused.
## Connect a client
The settings page has two buttons under **Client configuration**. Both copy an `mcpServers` entry in the JSON format most clients accept.
- **Copy Stdio Config** (recommended): the client starts OpenIDEA's executable with `--mcp-stdio`, which relays to the running IDE. It holds no token and keeps working after OpenIDEA restarts.
- **Copy HTTP Config**: the client connects to `http://127.0.0.1:<port>/mcp` with `Authorization: Bearer <token>`. It keeps working after OpenIDEA restarts only with a fixed port and a kept token; otherwise copy it again after each restart.
- **Copy HTTP Config**: the client connects to `http://127.0.0.1:<port>/mcp` with `Authorization: Bearer <token>`. It stops working when OpenIDEA restarts, because the port and token change.
The stdio configuration looks like this (the path is the one on your machine):
-3
View File
@@ -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` |
+2 -12
View File
@@ -118,19 +118,9 @@ SQL 补全基于已连接的架构:`FROM`/`JOIN` 后补全表(并根据外
## 结果与数据编辑
结果显示在 **服务** 工具窗口的 **数据库** 标签页中:**输出** 标签页记录每条语句,每个结果集一个 **Result N** 标签页(最多 12 个,已固定的标签页除外)。
结果显示在 **服务** 工具窗口的 **数据库** 标签页中:**输出** 标签页记录每条语句,每个结果集一个 **Result N** 标签页(最多 12 个)。
再次运行控制台会替换它之前的结果:它未固定的结果标签页会让位给新的运行,新运行每个结果集一个标签页(**Result N**;选中的多条语句都返回行时为 **Result N**、**Result N-2**……)。没有结果集的运行(如 `UPDATE`)会保留之前的结果。其他控制台的结果不受影响。
要保留某个结果,可在结果标签页的右键菜单中选择 **固定标签页**,或点击结果工具栏上的固定按钮;标签页的关闭按钮会变成固定按钮,点击即可取消固定,误点或中键点击都不会关闭它;之后的运行会改为打开新标签页。重新运行或翻页已固定的结果时,会在原标签页中更新。
<Warning>
替换结果会丢弃其中尚未提交的更改。再次运行控制台前,请先 **提交** 或 **还原** 这些更改,或固定该标签页。
</Warning>
右键点击结果标签页(或聚焦后按 `Shift+F10`),可使用 **关闭**、**关闭其他**、**全部关闭**、**关闭除已固定外的所有标签页**、**固定标签页** / **取消固定标签页**、**重命名标签页**、**选择下一个标签页** 和 **选择上一个标签页**。这些操作只作用于结果标签页,**输出** 始终保留。重命名的标题在结果关闭或被替换前一直有效。
焦点位于结果标签页或结果内容中时,IntelliJ macOS 快捷键方案使用 `⇧⌘]` / `⇧⌘[`(也支持 `⌃→` / `⌃←`),IntelliJ Windows/Linux 使用 `Alt+→` / `Alt+←`。VS Code 和 Eclipse 方案分别使用各自默认的编辑器标签页导航快捷键。切换后焦点移到目标结果标签页。结果导航快捷键仅在结果区域中生效,菜单或对话框打开时不会切换结果。可在 **设置 › 快捷键** 中单独修改数据库结果快捷键,不影响编辑器快捷键。
右键点击结果标签页(或聚焦后按 `Shift+F10`),可使用 **关闭**、**关闭其他**、**全部关闭**、**重命名标签页**、**选择下一个标签页** 和 **选择上一个标签页**。这些操作只作用于结果标签页,**输出** 始终保留。重命名的标题在结果关闭前一直有效。
关闭当前选中的结果标签页时,优先选中左侧相邻的结果;没有左侧结果时,选中右侧相邻结果。没有剩余结果时,选中 **输出**。关闭未选中的结果不会改变当前选择。
+2 -5
View File
@@ -17,17 +17,14 @@ OpenIDEA 内置了一个 [Model Context Protocol](https://modelcontextprotocol.i
</Step>
</Steps>
服务器只监听 `127.0.0.1`,每个请求都必须带上访问令牌。来自其他 Origin 或 Host 的请求会被拒绝。
- **端口。** 默认每次运行都监听同一个端口 26371(**使用固定端口** 和 **端口**),这样 OpenIDEA 重启后 HTTP 客户端和端口转发(例如到另一台机器的 SSH 隧道)仍然有效。端口被占用或被系统保留时,本次运行改用一个空闲端口,**状态** 中会说明原因。关闭 **使用固定端口** 则每次运行使用一个空闲端口。
- **令牌。** 默认每次启动 OpenIDEA 都会换一个新令牌。打开 **保留令牌** 后会在多次运行间保留同一个令牌,保存在 OpenIDEA 的应用数据目录中,只有你的账户可读;**重新生成令牌** 会替换它,使用旧令牌配置的客户端随之失效。
服务器只监听 `127.0.0.1` 上的一个随机端口,每个请求都必须带上令牌,令牌在每次启动 OpenIDEA 时都会变化。来自其他 Origin 或 Host 的请求会被拒绝。
## 连接客户端
设置页面的 **客户端配置** 下有两个按钮,都会复制一段大多数客户端都能识别的 `mcpServers` JSON 配置。
- **复制 Stdio 配置**(推荐):客户端以 `--mcp-stdio` 参数启动 OpenIDEA 的可执行文件,由它转发给正在运行的 IDE。配置里没有令牌,OpenIDEA 重启后依然可用。
- **复制 HTTP 配置**:客户端带着 `Authorization: Bearer <token>` 连接 `http://127.0.0.1:<port>/mcp`。只有同时使用固定端口并保留令牌时,OpenIDEA 重启后这份配置才继续有效;否则每次重启后都要重新复制。
- **复制 HTTP 配置**:客户端带着 `Authorization: Bearer <token>` 连接 `http://127.0.0.1:<port>/mcp`。由于端口和令牌会变,OpenIDEA 重启后这份配置就失效了。
stdio 配置大致如下(路径以你机器上的为准):
-3
View File
@@ -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` |
+6 -249
View File
@@ -1,5 +1,5 @@
import type { Page } from "@playwright/test";
import { expect, openFromTree, test } from "./fixtures";
import { expect, test } from "./fixtures";
const edgeTitle = "Result 3 — a long query result at the edge of the Services pane";
const bindings = { "alpha-console.sql": "alpha", "beta-console.sql": "beta" };
@@ -64,9 +64,7 @@ function menu(page: Page) {
async function choose(page: Page, title: string, command: string) {
await tab(page, title).click({ button: "right" });
await menu(page)
.getByRole("menuitem", { name: command.startsWith("Select ") ? new RegExp(`^${command}`) : command, exact: true })
.click();
await menu(page).getByRole("menuitem", { name: command, exact: true }).click();
await expect(menu(page)).toHaveCount(0);
}
@@ -92,11 +90,9 @@ test("result context menu fits the viewport, responds to hover and keyboard, and
"Close",
"Close Others",
"Close All",
"Close All but Pinned",
"Pin Tab",
"Rename Tab",
"Select Next Tab⇧⌘]",
"Select Previous Tab⇧⌘[",
"Select Next Tab",
"Select Previous Tab",
]);
await expect(popup.getByRole("separator")).toHaveCount(2);
await selected(page, "Result 2", "beta rows");
@@ -144,7 +140,7 @@ test("result context menu fits the viewport, responds to hover and keyboard, and
await page.keyboard.press("ArrowUp");
await expect(popup.getByRole("menuitem", { name: "Close", exact: true })).toBeFocused();
await page.keyboard.press("ArrowUp");
await expect(popup.getByRole("menuitem", { name: /^Select Previous Tab/ })).toBeFocused();
await expect(popup.getByRole("menuitem", { name: "Select Previous Tab", exact: true })).toBeFocused();
await page.keyboard.press("Enter");
await expect(popup).toHaveCount(0);
await selected(page, "Result 1", "alpha rows");
@@ -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();
});
-39
View File
@@ -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;
+4 -19
View File
@@ -72,21 +72,6 @@ public class Task implements Runnable {
public void run() {
}
}
''',
'Anon.java': '''package zoo;
public class Anon {
Repo<Double> make() {
class Local implements Repo<Double> {
public void save(Double item) {
}
}
return new Repo<Double>() {
public void save(Double item) {
}
};
}
}
''',
'Use.java': '''package zoo;
@@ -189,11 +174,11 @@ try:
request('java/buildWorkspace', True)
# (implementations in the sources, whether they are all there are)
cases = {
# Classes, abstract, local and anonymous ones too, and those of the bridged Kotlin output; not the sub-interface.
('Repo.java', 'Repo<T>'): (['Anon.java:4', 'Anon.java:8', 'Base.java:2', 'Longs.java:0', 'Numbers.java:2', 'Words.java:2'], True),
# Classes, abstract ones too, and those of the bridged Kotlin output; not the sub-interface.
('Repo.java', 'Repo<T>'): (['Base.java:2', 'Longs.java:0', 'Numbers.java:2', 'Words.java:2'], True),
# Through the type arguments; not the abstract re-declaration.
('Repo.java', 'save(T'): (['Anon.java:5', 'Anon.java:9', 'Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
('Use.java', 'save("x")'): (['Anon.java:5', 'Anon.java:9', 'Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
('Repo.java', 'save(T'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
('Use.java', 'save("x")'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
('Repo.java', 'name()'): (['Words.java:6'], True),
# A library type's implementations in the sources; the library has more.
('Use.java', 'Runnable task'): (['Task.java:2'], False),
-3
View File
@@ -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` |
+6 -73
View File
@@ -15,92 +15,45 @@ use tauri::{State, Window};
#[derive(Default)]
pub struct McpService {
server: Mutex<Option<ide_mcp::McpServer>>,
/// The port and token settings the running server was started with.
running_with: Mutex<Option<Listen>>,
error: Mutex<Option<String>>,
}
/// What the port and token settings ask the server for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Listen {
/// `mcpServer.port` with `mcpServer.fixedPort`; otherwise a free port.
port: Option<u16>,
fixed_token: bool,
}
/// Starts, stops or restarts the server to match the global `mcpServer` settings: `enabled`,
/// and the port and token it listens with.
/// Starts or stops the server to match the global `mcpServer.enabled`.
pub fn apply(state: &AppState) {
let settings = state.global_settings.effective().mcp_server;
let enabled = state.global_settings.effective().mcp_server.enabled;
let mut server = state.mcp.server.lock();
if !settings.enabled {
if !enabled {
if let Some(running) = server.take() {
running.stop();
}
*state.mcp.running_with.lock() = None;
ide_agent::set_mcp_executable(None);
state.host.set_mcp_executable(None);
*state.mcp.error.lock() = None;
return;
}
let wanted = Listen {
port: settings.fixed_port.then_some(settings.port).filter(|p| *p != 0),
fixed_token: settings.fixed_token,
};
if server.is_some() && *state.mcp.running_with.lock() == Some(wanted) {
if server.is_some() {
return;
}
// Another port or token: clients connect anew.
if let Some(running) = server.take() {
running.stop();
if let Some(port) = wanted.port {
wait_until_free(port);
}
}
let token = if wanted.fixed_token {
match ide_mcp::kept_token(&ide_mcp::token_path(&state.data_dir)) {
Ok(token) => Some(token),
Err(e) => {
tracing::warn!("MCP server: cannot keep the token ({e}); using a new one for this run");
None
}
}
} else {
None
};
let discovery = ide_mcp::discovery_path(&state.data_dir);
match ide_mcp::McpServer::start_with(
match ide_mcp::McpServer::start(
Arc::new(ide_host::mcp::Registry::new(state.host.clone())),
Some(discovery),
&state.host.runtime,
ide_mcp::ServerOptions { port: wanted.port, token },
) {
Ok(started) => {
let exe = std::env::current_exe().unwrap_or_default();
state.host.set_mcp_executable(Some(exe.clone()));
ide_agent::set_mcp_executable(Some(exe));
*server = Some(started);
*state.mcp.running_with.lock() = Some(wanted);
*state.mcp.error.lock() = None;
}
Err(e) => {
tracing::warn!("MCP server: {e}");
*state.mcp.running_with.lock() = None;
*state.mcp.error.lock() = Some(e.to_string());
}
}
}
/// Waits briefly for a stopped server's port to close, so the restart gets it again.
fn wait_until_free(port: u16) {
for _ in 0..100 {
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
/// Stops the server on exit (its discovery file goes with it).
pub fn stop(state: &AppState) {
state.host.set_mcp_executable(None);
@@ -108,7 +61,6 @@ pub fn stop(state: &AppState) {
if let Some(server) = state.mcp.server.lock().take() {
server.stop();
}
*state.mcp.running_with.lock() = None;
}
/// What Settings › Agents › MCP Server shows.
@@ -119,14 +71,11 @@ pub struct McpServerStatus {
/// Why it isn't running though turned on.
pub error: Option<String>,
pub port: Option<u16>,
/// Why the fixed port (`mcpServer.port`) isn't the one listened on: it was in use or
/// reserved, so a free port was taken for this run.
pub port_unavailable: Option<String>,
/// The window's local project, which the URL and command below are for.
pub project: Option<IpcPath>,
/// `http://127.0.0.1:<port>/mcp?project=<root>`.
pub url: Option<String>,
/// Bearer token of `url` (changes with every run unless `mcpServer.fixedToken` is on).
/// Bearer token of `url` (changes with every run).
pub token: Option<String>,
/// The stdio bridge: this executable and its arguments.
pub stdio_command: Vec<String>,
@@ -142,7 +91,6 @@ pub fn mcp_server_status(window: Window, state: State<'_, AppState>) -> IdeResul
running: server.is_some(),
error: state.mcp.error.lock().clone(),
port: server.as_ref().map(|s| s.port()),
port_unavailable: server.as_ref().and_then(|s| s.port_unavailable().map(str::to_owned)),
url: server.as_ref().zip(root.as_ref()).map(|(s, r)| s.url(r)),
token: server.as_ref().map(|s| s.token().to_owned()),
stdio_command: root.as_ref().map(|r| ide_mcp::stdio_command(&exe, r)).unwrap_or_default(),
@@ -150,21 +98,6 @@ pub fn mcp_server_status(window: Window, state: State<'_, AppState>) -> IdeResul
})
}
/// Settings › Agents › MCP Server › Regenerate Token: forgets the kept token, and restarts the
/// server with a new one. Clients configured with the old token stop working.
#[tauri::command]
#[specta::specta]
pub fn mcp_regenerate_token(state: State<'_, AppState>) -> IdeResult<()> {
match std::fs::remove_file(ide_mcp::token_path(&state.data_dir)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
*state.mcp.running_with.lock() = None;
apply(&state);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn mcp_set_open_editors(window: Window, state: State<'_, AppState>, paths: Vec<IpcPath>) -> IdeResult<()> {
-1
View File
@@ -75,7 +75,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
mcp::mcp_ui_ready,
mcp::mcp_respond,
mcp::mcp_agent_run_closed,
mcp::mcp_regenerate_token,
mcp::mcp_server_status,
mcp::mcp_set_open_editors,
mcp::mcp_set_focused_editor,
+1 -3
View File
@@ -63,9 +63,7 @@ impl EventSink for TauriSink {
) {
flash_taskbar_unless_focused(&self.app);
}
// A confirmation waits for the user: bring the window up. Other requests (an agent's
// run, opening a file) need nothing from the user and leave the focus where it is.
if matches!(&event, IdeEvent::McpRequest { request: ide_core::event::McpUiRequest::Confirm { .. }, .. })
if matches!(&event, IdeEvent::McpRequest { .. })
&& let Some(label) = self.window.clone()
{
let app = self.app.clone();
-23
View File
@@ -180,29 +180,6 @@ unsafe extern "C-unwind" fn should_terminate(_this: *mut AnyObject, _selector: S
static APP: std::sync::OnceLock<AppHandle> = std::sync::OnceLock::new();
/// Escape the page leaves unhandled reaches the window as `cancelOperation:`, on which NSWindow
/// leaves full screen, so pressing Escape once more after closing a popup left full screen. The
/// IDE's windows answer it with nothing; View › Appearance › Toggle Full Screen (⌃⌘F) leaves it.
pub fn keep_full_screen_on_escape() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| unsafe {
let Some(class) = AnyClass::get(c"TaoWindow") else {
tracing::warn!("no TaoWindow class: Escape leaves full screen");
return;
};
let imp: Imp = std::mem::transmute(ignore_cancel_operation as CancelOperation);
let class = class as *const AnyClass as *mut AnyClass;
let added = objc2::ffi::class_addMethod(class, objc2::sel!(cancelOperation:), imp, c"v@:@".as_ptr());
if !added.as_bool() {
tracing::warn!("could not handle cancelOperation:");
}
});
}
type CancelOperation = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject);
unsafe extern "C-unwind" fn ignore_cancel_operation(_this: *mut AnyObject, _selector: Sel, _sender: *mut AnyObject) {}
/// Whether AppKit is handling a key press right now. A menu item performed at this point
/// was chosen by its key equivalent: a click, arrow keys plus Return and accessibility
/// presses all run with a non-key current event.
-2
View File
@@ -660,8 +660,6 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
decorate_csd(&window);
#[cfg(target_os = "macos")]
crate::page_swipe::install(&window);
#[cfg(target_os = "macos")]
crate::macos::keep_full_screen_on_escape();
apply_zoom(app, &window);
#[cfg(target_os = "macos")]
align_titlebar(app, &window, app.try_state::<WindowsState>().is_some_and(|w| w.opens_project(window.label())));
+2 -39
View File
@@ -19,11 +19,6 @@ export const commands = {
mcpRespond: (id: number, allow: boolean, error: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_respond", { id, allow, error })),
/** The user closed the Run tool window tab of an agent command (`run_command`). */
mcpAgentRunClosed: (name: string) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_agent_run_closed", { name })),
/**
* Settings › Agents › MCP Server › Regenerate Token: forgets the kept token, and restarts the
* server with a new one. Clients configured with the old token stop working.
*/
mcpRegenerateToken: () => typedError<null, IdeError>(__TAURI_INVOKE("mcp_regenerate_token")),
mcpServerStatus: () => typedError<McpServerStatus, IdeError>(__TAURI_INVOKE("mcp_server_status")),
mcpSetOpenEditors: (paths: IpcPath[]) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_set_open_editors", { paths })),
mcpSetFocusedEditor: (editor: {
@@ -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[],
+1 -40
View File
@@ -2,8 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { undo, history } from "@codemirror/commands";
import { java } from "@codemirror/lang-java";
import { enterInBlockComment, enterInDocComment, phpDocTags } from "./docComment";
import { enterInDocComment, phpDocTags } from "./docComment";
const views: EditorView[] = [];
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
@@ -76,41 +75,3 @@ describe("Enter after /**", () => {
expect(enter("/**|", "python", hash).handled).toBe(false);
});
});
/** Enter through `enterInBlockComment`, in a parsed Java document. */
function enterBlock(text: string) {
const at = text.indexOf("|");
const view = new EditorView({
state: EditorState.create({
doc: text.replace("|", ""),
selection: EditorSelection.cursor(at),
extensions: [java()],
}),
});
views.push(view);
const handled = enterInBlockComment(view);
const s = view.state;
const caret = s.selection.main.head;
return { handled, text: s.doc.sliceString(0, caret) + "|" + s.doc.sliceString(caret) };
}
describe("Enter in a /* */ comment", () => {
it("starts the new line with a leading asterisk aligned under the comment's", () => {
expect(enterBlock("/**\n * xxxx|\n **/\nclass A {}").text).toBe("/**\n * xxxx\n * |\n **/\nclass A {}");
expect(enterBlock("class A {\n /**\n * a|b\n */\n}").text).toBe(
"class A {\n /**\n * a\n * |b\n */\n}",
);
expect(enterBlock("class A {\n /* note|\n */\n}").text).toBe("class A {\n /* note\n * |\n */\n}");
});
it("keeps the closing */ on its own line", () => {
expect(enterBlock("/** summary |*/\nclass A {}").text).toBe("/** summary\n * |\n */\nclass A {}");
});
it("leaves Enter alone outside a comment, before its asterisk, and in a string", () => {
expect(enterBlock("/* a */ int x;|").handled).toBe(false);
expect(enterBlock("/**\n| * a\n */").handled).toBe(false);
expect(enterBlock('class A { String s = "/* x|"; }').handled).toBe(false);
expect(enterBlock("// a /* b|").handled).toBe(false);
});
});
+1 -59
View File
@@ -3,8 +3,7 @@
* new comment closes it, the caret on the summary line. In PHP the stub of a function lists its
* parameters (`@param`) and return type (`@return`), as PhpStorm does.
*/
import { syntaxTree } from "@codemirror/language";
import { EditorSelection, type EditorState } from "@codemirror/state";
import { EditorSelection } from "@codemirror/state";
import type { Command } from "@codemirror/view";
/** `@param` / `@return` lines for the PHP function declared in `text` (the code below the `/**`). */
@@ -87,60 +86,3 @@ export function enterInDocComment(language: string | undefined): Command {
return true;
};
}
/** How far back the opening `/*` of the comment around the caret is looked for. */
const OPENER_SCAN = 20_000;
/** Whether `pos` is inside a `/* *\/` comment opened at `open` (by the syntax tree, when it has one there). */
function inBlockComment(state: EditorState, pos: number): number | null {
const from = Math.max(0, pos - OPENER_SCAN);
const before = state.sliceDoc(from, pos);
const open = before.lastIndexOf("/*");
if (open < 0 || before.indexOf("*/", open + 2) >= 0) return null;
const node = syntaxTree(state).resolveInner(pos, -1);
// A parsed document says whether this `/*` opens the comment (not text in a string or a `//`).
if (node.parent && !(/comment/i.test(node.name) && node.from === from + open)) return null;
return from + open;
}
/**
* Enter inside a `/* *\/` comment, on its first line or on a line that starts with `*`: the new
* line starts with `* ` aligned under the comment's asterisks, as IDEA's leading asterisks do.
*/
export const enterInBlockComment: Command = (view) => {
const { state } = view;
if (state.readOnly || state.selection.ranges.length !== 1) return false;
const r = state.selection.main;
if (!r.empty) return false;
const block = state.languageDataAt<{ block?: { open: string; close: string } }>("commentTokens", r.head)[0]?.block;
if (block?.open !== "/*" || block.close !== "*/") return false;
const open = inBlockComment(state, r.head);
if (open === null) return false;
const line = state.doc.lineAt(r.head);
const before = line.text.slice(0, r.head - line.from);
const after = line.text.slice(r.head - line.from);
let prefix: string;
if (open >= line.from) {
// The opening line: the asterisks go one column right of the `/`.
prefix = line.text.slice(0, open - line.from).replace(/[^\t]/g, " ") + " ";
} else {
const star = /^([ \t]*)\*(?!\/)/.exec(line.text);
if (!star || star[0].length > before.length) return false;
prefix = star[1]!;
}
const br = state.lineBreak;
const rest = after.trimStart();
const insert = rest.startsWith("*/") ? `${br}${prefix}* ${br}${prefix}` : `${br}${prefix}* `;
// Spaces around the break go, as a plain Enter drops them.
const from = r.head - (before.length - before.trimEnd().length);
const caret = from + br.length + prefix.length + 2;
view.dispatch(
state.update({
changes: { from, to: r.head + (after.length - rest.length), insert },
selection: EditorSelection.cursor(caret),
scrollIntoView: true,
userEvent: "input",
}),
);
return true;
};
+2 -2
View File
@@ -49,7 +49,7 @@ import {
selectHumpRight,
} from "./editing";
import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments";
import { enterInBlockComment, enterInDocComment } from "./docComment";
import { enterInDocComment } from "./docComment";
import { renderDocComments } from "./renderedDocs";
import { skipClosingQuote, smartAngleBrackets, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
import { stringClipboard } from "./stringClipboard";
@@ -626,7 +626,7 @@ export function prefsExtension(o: PrefsOptions): Extension {
{ mac: "Mod-ArrowLeft", run: cursorLineStart, shift: selectLineStart, preventDefault: true },
);
if (!o.readOnly && k.insertDocStub) keys.push({ key: "Enter", run: enterInDocComment(o.language) });
if (!o.readOnly) keys.push({ key: "Enter", run: enterInBlockComment }, { key: "Enter", run: enterInLineComment });
if (!o.readOnly) keys.push({ key: "Enter", run: enterInLineComment });
if (!k.smartIndent) keys.push({ key: "Enter", run: insertNewlineKeepIndent });
if (k.backspaceUnindent === "none") keys.push({ key: "Backspace", run: deleteCharBackwardStrict });
else if (smartBackspaceApplies(k.backspaceUnindent, o.language)) keys.push({ key: "Backspace", run: smartBackspace });
@@ -57,12 +57,7 @@ async function replace(text: string) {
}
async function rename() {
await replace("new");
emitLocalIdeEvent({
type: "workspaceEditApplied",
id: 9,
paths: ["/p/A.java", "/p/B.java", "/p/closed.java"],
byAgent: false,
});
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 9, paths: ["/p/A.java", "/p/B.java", "/p/closed.java"] });
}
function historyStep(slot: EditorSlot, forward = false) {
return (forward ? redo : undo)({
@@ -188,7 +183,7 @@ it("rebuilds multiple redo markers in order when a closed file opens", async ()
const a = await open("/p/A.java");
await rename();
await replace("alt");
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 10, paths: ["/p/A.java", "/p/B.java"], byAgent: true });
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 10, paths: ["/p/A.java", "/p/B.java"] });
commands.undoWorkspaceEdit.mockImplementation(async (id: number) => {
await replace(id === 10 ? "new" : "old");
return { status: "ok", data: null };
-1
View File
@@ -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}
+2 -24
View File
@@ -46,7 +46,7 @@ it("keeps mapped lenses across edits instead of clearing them (#2710)", async ()
data: [
{
range: { start: { line: 1, col: 0 }, end: { line: 1, col: 3 } },
command: { title: "Run test", command: "csharp.runTest", arguments: [] },
command: { title: "1 reference", command: "csharp.showReferences", arguments: [] },
},
],
});
@@ -67,27 +67,5 @@ it("keeps mapped lenses across edits instead of clearing them (#2710)", async ()
const after = view.state.facet(codeLenses);
expect(after).toHaveLength(1);
expect(after[0]!.pos).toBe(posBefore + 1);
expect(after[0]!.title).toBe("Run test");
});
it("leaves reference counts to Code Vision's usages", async () => {
vi.useFakeTimers();
const lens = (line: number, title: string) => ({
range: { start: { line, col: 0 }, end: { line, col: 1 } },
command: { title, command: "csharp.showReferences", arguments: [] },
});
ipc.lspCodeLens.mockResolvedValue({
status: "ok",
data: [lens(0, "3 Reference(s)"), lens(1, "1 reference"), lens(2, "12 references"), lens(3, "2 implementations")],
});
const view = new EditorView({
state: EditorState.create({
doc: "a\nb\nc\nd",
extensions: serverCodeLenses({ doc: 1, path: "/p/a.cs", language: "csharp", uiMode: null, readOnly: false }),
}),
parent: document.body,
});
views.push(view);
await vi.advanceTimersByTimeAsync(400);
expect(view.state.facet(codeLenses).map((l) => l.title)).toEqual(["2 implementations"]);
expect(after[0]!.title).toBe("1 reference");
});
+1 -7
View File
@@ -6,12 +6,6 @@ import { call, commands } from "@/platform/ipc";
import { onNavigationProvidersChanged } from "@/editor/navigationProviders";
import { posToOffset } from "./positions";
import { onLspRefresh, onServerSettled } from "./availability";
/**
* A server's reference count ("3 references", csharp-ls's "3 Reference(s)"): Code Vision's
* Usages shows the same count above the declaration, under its own setting.
*/
const REFERENCE_COUNT = /^\s*\d+\s+references?(\(s\))?\s*$/i;
const set = StateEffect.define<CodeLens[]>();
const field = StateField.define<CodeLens[]>({
create: () => [],
@@ -68,7 +62,7 @@ export function serverCodeLenses(ctx: EditorDocContext): Extension {
view.dispatch({
effects: set.of(
result.flatMap((l) =>
l.command && !REFERENCE_COUNT.test(l.command.title)
l.command
? [
{
pos: posToOffset(text, l.range.start),
+2 -44
View File
@@ -1,22 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { describe, expect, it } from "vitest";
import type { CodeAction } from "@/bindings";
const { openPopup, applyCodeAction } = vi.hoisted(() => ({
openPopup: vi.fn(),
applyCodeAction: vi.fn(async () => {}),
}));
vi.mock("@/editor/lsp/popupStore", async (importOriginal) => ({
...(await importOriginal<typeof import("@/editor/lsp/popupStore")>()),
openPopup,
}));
vi.mock("@/editor/lsp/intentions", async (importOriginal) => ({
...(await importOriginal<typeof import("@/editor/lsp/intentions")>()),
applyCodeAction,
}));
const { chooseOccurrences, introducedName, occurrenceVariants } = await import("./serverIntroduce");
import { introducedName, occurrenceVariants } from "./serverIntroduce";
const spansText = (text: string, spans: { from: number; to: number }[]) => spans.map((s) => text.slice(s.from, s.to));
@@ -71,29 +55,3 @@ describe("occurrenceVariants", () => {
expect(occurrenceVariants([one, action("Extract to field"), all])).toBeNull();
});
});
describe("chooseOccurrences", () => {
const action = (title: string) => ({ title, kind: "refactor.extract.variable", server: 1 }) as CodeAction;
const one = action("Extract to local variable");
const all = action("Extract to local variable (replace all occurrences)");
const viewOf = (doc: string, selected: string) => {
const from = doc.indexOf(selected);
return new EditorView({
state: EditorState.create({ doc, selection: { anchor: from, head: from + selected.length } }),
});
};
it("asks when the selected expression occurs more than once", () => {
openPopup.mockClear();
expect(chooseOccurrences(viewOf("f(a + b); g(a + b);", "a + b"), [one, all], "extractVariable")).toBe(true);
expect(openPopup).toHaveBeenCalledWith(expect.objectContaining({ title: "Multiple occurrences found" }));
});
it("introduces it directly when it occurs once", async () => {
openPopup.mockClear();
applyCodeAction.mockClear();
expect(chooseOccurrences(viewOf("f(a + b); g(c);", "a + b"), [one, all], "extractVariable")).toBe(true);
expect(openPopup).not.toHaveBeenCalled();
await vi.waitFor(() => expect(applyCodeAction).toHaveBeenCalledWith(expect.anything(), one));
});
});
+1 -6
View File
@@ -112,8 +112,7 @@ export function occurrenceVariants(actions: readonly CodeAction[]): { one: CodeA
}
/**
* IDEA's "Multiple occurrences found" for a server that offers both variants,
* applying the single-occurrence one directly when the selected text occurs only once;
* IDEA's "Multiple occurrences found" for a server that offers both variants;
* false when it does not (the caller applies / lists the actions as before).
*/
export function chooseOccurrences(view: EditorView, actions: readonly CodeAction[], id: RefactoringId): boolean {
@@ -124,10 +123,6 @@ export function chooseOccurrences(view: EditorView, actions: readonly CodeAction
const text = state.sliceDoc(sel.from, sel.to);
const occurrences = text.trim() ? findTextOccurrences(state.doc.toString(), 0, state.doc.length, text, 200) : [];
const n = occurrences.length;
if (n === 1) {
void applyIntroduceAction(view, variants.one, id);
return true;
}
openPopup({
kind: "list",
anchor: caretAnchor(view, sel.head),
@@ -31,11 +31,4 @@ describe("Language server status popup", () => {
expect(screen.queryByRole("button", { name: /Download JDK/ })).toBeNull();
expect(screen.getByRole("button", { name: "Restart jdtls" })).toBeInTheDocument();
});
it("checks again for a missing server installed outside the IDE", () => {
useServers.setState({ servers: new Map([[1 as never, server("missing", "brew install marksman")]]) });
render(<ServerStatus />);
fireEvent.click(screen.getByRole("button", { name: /Language servers/ }));
expect(screen.getByRole("button", { name: "Check for jdtls again" })).toBeEnabled();
});
});
+18 -22
View File
@@ -69,29 +69,25 @@ export function ServerStatus() {
>
{jdkFix.label}
</Button>
) : s.status === "missing" && canInstall ? (
<Button
size="small"
aria-label={tx`Install ${s.name}`}
onClick={() => {
setOpen(false);
void executeAction("tools.install", s.name);
}}
>
<T>Install</T>
</Button>
) : (
<>
{s.status === "missing" && canInstall && (
<Button
size="small"
aria-label={tx`Install ${s.name}`}
onClick={() => {
setOpen(false);
void executeAction("tools.install", s.name);
}}
>
<T>Install</T>
</Button>
)}
{/* A missing server may have been installed outside the IDE since; restarting looks for it again. */}
<IconButton
icon="refresh"
size="small"
title={s.status === "missing" ? tx`Check for ${s.name} again` : tx`Restart ${s.name}`}
disabled={s.status === "starting"}
onClick={() => void restartServer(s.id)}
/>
</>
<IconButton
icon="refresh"
size="small"
title={tx`Restart ${s.name}`}
disabled={s.status === "missing" || s.status === "starting"}
onClick={() => void restartServer(s.id)}
/>
)}
</div>
{(s.languages.length > 0 || s.root) && (
+1 -16
View File
@@ -1,6 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ServerId, ServerInfo } from "@/bindings";
import type { ServerEntry } from "./servers";
const pending = vi.hoisted(() => [] as ((data: ServerInfo[]) => void)[]);
@@ -15,32 +14,18 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
new Promise((resolve) => {
pending.push((data) => resolve({ status: "ok", data }));
}),
lspRestart: () => Promise.resolve({ status: "error", error: { kind: "unavailable", message: "not found" } }),
},
};
});
const { emitLocalIdeEvent } = await import("@/platform/ipc");
const { useProject } = await import("@/platform/project");
const { provideNotificationService } = await import("@/workbench/contributions");
const { BUSY_SHOW_DELAY_MS, eslintCovers, installServerEvents, restartServer, serversSummary, useServers } =
await import("./servers");
const { BUSY_SHOW_DELAY_MS, eslintCovers, installServerEvents, serversSummary, useServers } = await import("./servers");
describe("server status", () => {
let dispose = () => {};
afterEach(() => dispose());
it("says a missing server is still not installed when checking again finds nothing", async () => {
const id = 7 as ServerId;
const entry: ServerEntry = { id, name: "sqls", status: "missing", languages: ["sql"], root: null, message: null };
useServers.setState({ servers: new Map([[id, entry]]) });
const notify = vi.fn();
provideNotificationService({ notify } as never);
await restartServer(id);
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "sqls is still not installed" }));
useServers.setState({ servers: new Map() });
});
it("keeps a status that arrived after the snapshot was requested", async () => {
useProject.setState({ project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never });
dispose = installServerEvents();
-7
View File
@@ -5,7 +5,6 @@ import type { ServerId, ServerInfo, ServerStatus } from "@/bindings";
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { tx } from "@/platform/i18n";
export interface ServerEntry {
id: ServerId;
@@ -95,15 +94,9 @@ export async function refreshServers() {
}
export async function restartServer(id: ServerId) {
const missing = useServers.getState().servers.get(id)?.status === "missing";
try {
await call(commands.lspRestart(id));
} catch (e) {
const entry = useServers.getState().servers.get(id);
if (missing && entry?.status === "missing") {
notifications().notify({ group: "lsp", level: "warning", title: tx`${entry.name} is still not installed` });
return;
}
notifications().notify({
group: "lsp",
level: "error",
@@ -35,7 +35,7 @@ it("waits for pending typing before undoing the entire backend transaction", asy
release = resolve;
}),
);
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 17, paths: [], byAgent: false });
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 17, paths: [] });
const action = notices[0]!.actions![0]!;
expect(action.label).toBe("Undo");
const pending = action.run();
@@ -47,13 +47,13 @@ it("waits for pending typing before undoing the entire backend transaction", asy
it("reports a refused undo instead of discarding newer work", async () => {
mocks.undo.mockRejectedValue(new Error("a.txt changed"));
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 18, paths: [], byAgent: false });
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 18, paths: [] });
await notices[0]!.actions![0]!.run();
expect(notices[1]).toMatchObject({ level: "error", title: "Cannot undo refactoring", body: "a.txt changed" });
});
it("does not apply an old notification's id to a different project", async () => {
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 19, paths: [], byAgent: false });
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 19, paths: [] });
const previous = useProject.getState().project;
useProject.setState({ project: { ...previous, root: "/different-project" } as NonNullable<typeof previous> });
try {
@@ -64,8 +64,3 @@ it("does not apply an old notification's id to a different project", async () =>
useProject.setState({ project: previous });
}
});
it("does not announce an agent's edit as a refactoring", () => {
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 20, paths: [], byAgent: true });
expect(notices).toEqual([]);
});
+2 -6
View File
@@ -3,10 +3,7 @@ import { onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
/**
* The notification follows the same history state as editor Undo/Redo. An agent's edits (the MCP
* server's tools) stay undoable from the editor but are not announced: an agent makes many.
*/
/** The notification follows the same history state as editor Undo/Redo. */
export function installWorkspaceEditUndo(): () => void {
let current: { id: number; project: ReturnType<typeof useProject.getState>["project"]; undone: boolean } | null =
null;
@@ -41,8 +38,7 @@ export function installWorkspaceEditUndo(): () => void {
],
});
};
const stopApplied = onIdeEvent("workspaceEditApplied", ({ id, byAgent }) => {
if (byAgent) return;
const stopApplied = onIdeEvent("workspaceEditApplied", ({ id }) => {
current = { id, project: useProject.getState().project, undone: false };
show();
});
@@ -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");
});
});
+11 -37
View File
@@ -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} />;
}
+3 -59
View File
@@ -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}
+40 -75
View File
@@ -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>
-11
View File
@@ -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();
+12 -221
View File
@@ -1,17 +1,8 @@
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installKeyboardDispatcher, normalizeBinding, PRESETS, useKeymap } from "@/platform/keymap";
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { ContextMenuHost, hideContextMenu } from "@/ui";
import { InputDialogsHost } from "@/workbench/inputDialogs";
import { SERVICES_WINDOW, registerToolWindow } from "@/workbench/contributions";
import { installToolWindowActions } from "@/workbench/toolwindows/actions";
import {
isToolWindowVisible,
resetToolWindowLayout,
setFocusedToolWindow,
showToolWindow,
} from "@/workbench/toolwindows/store";
import { ToolWindowLayout } from "@/workbench/toolwindows/ToolWindows";
import { ResultsView } from "./ResultsView";
import { registerResultTabActions } from "./resultTabActions";
import { OUTPUT_TAB, useDatabase, type ResultTab } from "./store";
@@ -28,8 +19,6 @@ const actions = [
["db.closeResultTab", "Close"],
["db.closeOtherResultTabs", "Close Others"],
["db.closeAllResultTabs", "Close All"],
["db.closeAllButPinnedResultTabs", "Close All but Pinned"],
["db.pinResultTab", "Pin Tab"],
["db.renameResultTab", "Rename Tab"],
["db.nextResultTab", "Select Next Tab"],
["db.previousResultTab", "Select Previous Tab"],
@@ -124,7 +113,7 @@ function openMenu(name: string) {
async function choose(name: string) {
await act(async () => {
fireEvent.click(screen.getByRole("menuitem", { name: name.startsWith("Select ") ? new RegExp(`^${name}`) : name }));
fireEvent.click(screen.getByRole("menuitem", { name }));
});
}
@@ -157,20 +146,18 @@ describe("database result tab context menu", () => {
within(menu)
.getAllByRole("menuitem")
.map((item) => item.textContent),
).toEqual(actions.map(([, label]) => expect.stringMatching(new RegExp(`^${label}(?![A-Za-z ])`))));
).toEqual(["Close", "Close Others", "Close All", "Rename Tab", "Select Next Tab", "Select Previous Tab"]);
expect(Array.from(menu.children).map((item) => item.getAttribute("role"))).toEqual([
"menuitem",
"menuitem",
"menuitem",
"menuitem",
"separator",
"menuitem",
"menuitem",
"separator",
"menuitem",
"menuitem",
]);
expect(within(menu).queryByText(/layout/i)).not.toBeInTheDocument();
expect(within(menu).queryByText(/pin|layout/i)).not.toBeInTheDocument();
expectSelected("Customer count");
expect(screen.getByRole("gridcell", { name: "count-row" })).toBeInTheDocument();
});
@@ -237,11 +224,11 @@ describe("database result tab context menu", () => {
expectConsoleStateUnchanged(before);
});
it("disables Close, Pin Tab and Rename Tab on Output and does not perform any of them", async () => {
it("disables Close and Rename Tab on Output and does not perform either operation", async () => {
await show();
const before = useDatabase.getState();
const menu = openMenu("Output");
for (const name of ["Close", "Pin Tab", "Rename Tab"]) {
for (const name of ["Close", "Rename Tab"]) {
const item = within(menu).getByRole("menuitem", { name });
expect(item).toHaveAttribute("aria-disabled", "true");
fireEvent.click(item);
@@ -367,7 +354,12 @@ describe("database result tab context menu", () => {
useKeymap.setState({
overrides: {
"editor.closeTab": ["Meta+W"],
...Object.fromEntries(actions.map(([id], index) => [id, [`F${index + 1}`]])),
"db.closeResultTab": ["F1"],
"db.closeOtherResultTabs": ["F2"],
"db.closeAllResultTabs": ["F3"],
"db.renameResultTab": ["F4"],
"db.nextResultTab": ["F5"],
"db.previousResultTab": ["F6"],
},
});
await show();
@@ -394,88 +386,6 @@ describe("database result tab context menu", () => {
expectSelected("Customer count");
});
it.each(Object.entries(PRESETS))(
"navigates results using every default tab shortcut in %s",
async (preset, keymap) => {
useKeymap.setState({ preset });
await show();
const uninstall = installKeyboardDispatcher(window);
const press = async (binding: string) => {
const parts = normalizeBinding(binding).split("+");
const key = parts.at(-1)!;
const codes: Record<string, string> = {
Right: "ArrowRight",
Left: "ArrowLeft",
"[": "BracketLeft",
"]": "BracketRight",
};
await act(async () => {
fireEvent.keyDown(document.activeElement!, {
key,
code: codes[key] ?? key,
metaKey: parts.includes("Meta"),
ctrlKey: parts.includes("Ctrl"),
altKey: parts.includes("Alt"),
shiftKey: parts.includes("Shift"),
});
});
};
try {
for (const [index, next] of keymap["editor.nextTab"]!.entries()) {
const previous = keymap["editor.prevTab"]![index]!;
act(() => screen.getByRole("button", { name: "Outside results" }).focus());
await press(next);
await press(previous);
expectSelected("Customer count");
act(() => tab("Orders").focus());
await press(next);
expectOutput();
await press(next);
expectSelected("Customers");
expect(screen.getByRole("gridcell", { name: "customer-row" })).toBeInTheDocument();
await press(previous);
expectOutput();
await press(previous);
expectSelected("Customer count");
openMenu("Customers");
await press(next);
await press(previous);
expectSelected("Customer count");
fireEvent.keyDown(document.activeElement!, { key: "Escape", code: "Escape" });
}
expect(useDatabase.getState().results).toBe(seeded);
} finally {
uninstall();
}
},
);
it("releases result shortcuts after switching from a grid and moving focus outside", async () => {
await show();
const uninstall = installKeyboardDispatcher(window);
const next = async () => {
await act(async () => {
fireEvent.keyDown(document.activeElement!, {
key: "}",
code: "BracketRight",
metaKey: true,
shiftKey: true,
});
});
};
try {
act(() => screen.getByRole("grid").focus());
await next();
expectOutput();
expect(tab("Output")).toHaveFocus();
act(() => screen.getByRole("button", { name: "Outside results" }).focus());
await next();
expectOutput();
} finally {
uninstall();
}
});
it("dispatches assigned result shortcuts only while results have focus, relative to the active tab", async () => {
useKeymap.setState({
overrides: {
@@ -513,122 +423,3 @@ describe("database result tab context menu", () => {
}
});
});
describe("Close Tab in the Services tool window", () => {
let disposers: (() => void)[] = [];
beforeEach(() => {
resetToolWindowLayout();
disposers = [
installToolWindowActions(),
installKeyboardDispatcher(window),
registerToolWindow({
id: SERVICES_WINDOW,
title: "Services",
icon: "toolwindow-services",
anchor: "bottom",
requiresProject: false,
component: ResultsView,
}),
];
});
afterEach(() => {
setFocusedToolWindow(null);
disposers.forEach((dispose) => dispose());
});
async function closeTab(focus: () => HTMLElement) {
showToolWindow(SERVICES_WINDOW);
await act(async () => render(<ToolWindowLayout editor={null} />));
act(() => {
focus().focus();
setFocusedToolWindow(SERVICES_WINDOW);
});
await act(async () => {
fireEvent.keyDown(document.activeElement!, { key: "w", code: "KeyW", metaKey: true });
});
}
it.each([
["the grid", () => screen.getByRole("grid")],
["the tab strip", () => tab("Customer count")],
])("closes the active result, not the window, from %s", async (_, focus) => {
await closeTab(focus);
expect(screen.queryByRole("tab", { name: "Customer count" })).not.toBeInTheDocument();
expectSelected("Orders");
expect(isToolWindowVisible(SERVICES_WINDOW)).toBe(true);
});
it("hides the window when Output, which cannot be closed, is active", async () => {
useDatabase.setState({ activeResult: OUTPUT_TAB });
await closeTab(() => tab("Output"));
expect(useDatabase.getState().results).toBe(seeded);
expect(isToolWindowVisible(SERVICES_WINDOW)).toBe(false);
});
});
describe("pinned result tabs", () => {
const pinButton = () => screen.getByRole("button", { name: "Pin Tab" });
const pinnedIds = () => useDatabase.getState().results.flatMap((r) => (r.pinned ? [r.id] : []));
it("pins an inactive result from its menu: Unpin replaces Close, close gestures spare it, selection kept", async () => {
await show();
const slot = (name: string) => within(tab(name).parentElement!);
const typeGlyph = tab("Orders").querySelector("svg")!.innerHTML;
openMenu("Orders");
await choose("Pin Tab");
expect(pinnedIds()).toEqual([22]);
expectSelected("Customer count");
expect(pinButton()).toHaveAttribute("aria-pressed", "false");
expect(tab("Orders").querySelector("svg")!.innerHTML).toBe(typeGlyph);
expect(slot("Orders").queryByRole("button", { name: "Close Tab" })).not.toBeInTheDocument();
const unpin = slot("Orders").getByRole("button", { name: "Unpin Tab" });
expect(unpin.querySelector("svg")!.innerHTML).toBe(pinButton().querySelector("svg")!.innerHTML);
fireEvent.mouseDown(tab("Orders").parentElement!, { button: 1 });
fireEvent.keyDown(tab("Orders"), { key: "Delete" });
expect(useDatabase.getState().results.map((r) => r.id)).toEqual([11, 22, 33]);
const menu = openMenu("Orders");
expect(within(menu).queryByRole("menuitem", { name: "Pin Tab" })).not.toBeInTheDocument();
within(menu).getByRole("menuitem", { name: "Unpin Tab" });
await act(async () => fireEvent.keyDown(document.activeElement!, { key: "Escape" }));
fireEvent.click(unpin);
expect(pinnedIds()).toEqual([]);
expectSelected("Customer count");
expect(slot("Orders").queryByRole("button", { name: "Unpin Tab" })).not.toBeInTheDocument();
slot("Orders").getByRole("button", { name: "Close Tab" });
});
it("keeps the toolbar toggle and the menu in step for the active result", async () => {
await show();
fireEvent.click(pinButton());
expect(pinButton()).toHaveAttribute("aria-pressed", "true");
expect(pinnedIds()).toEqual([33]);
openMenu("Customer count");
await choose("Unpin Tab");
expect(pinButton()).toHaveAttribute("aria-pressed", "false");
expect(pinnedIds()).toEqual([]);
});
it("enables Close All but Pinned only with pinned and unpinned results, and keeps just the pinned ones", async () => {
await show();
let menu = openMenu("Orders");
expect(within(menu).getByRole("menuitem", { name: "Close All but Pinned" })).toHaveAttribute(
"aria-disabled",
"true",
);
await choose("Pin Tab");
openMenu("Customers");
await choose("Close All but Pinned");
expect(useDatabase.getState().results.map((r) => r.id)).toEqual([22]);
expectSelected("Orders");
menu = openMenu("Orders");
expect(within(menu).getByRole("menuitem", { name: "Close All but Pinned" })).toHaveAttribute(
"aria-disabled",
"true",
);
await choose("Close All");
expect(useDatabase.getState().results).toEqual([]);
expectOutput();
});
});
+4 -43
View File
@@ -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 -262
View File
@@ -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);
});
});
+13 -45
View File
@@ -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 });
}
-10
View File
@@ -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 -23
View File
@@ -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);
});
});
+3 -5
View File
@@ -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;
}
+8 -14
View File
@@ -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() };
}
-6
View File
@@ -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(),
+25 -30
View File
@@ -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>
+7 -12
View File
@@ -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
-32
View File
@@ -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 };
}
+1 -31
View File
@@ -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]!];
+6 -35
View File
@@ -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",
-6
View File
@@ -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"],
-11
View File
@@ -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: {
+2 -5
View File
@@ -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);
}}
/>
+1 -21
View File
@@ -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;
}
+12 -121
View File
@@ -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;
+102 -282
View File
@@ -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])],
+8 -43
View File
@@ -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 -11
View File
@@ -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"]);
+2 -14
View File
@@ -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);
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more