Compare commits

...
Author SHA1 Message Date
l0ng-ai 4a667b2cd2 fix(vcs): lay out a merge commit's file side by side, each pane scrolling
Opening a file of a merge commit from the Git log stacked its three panes (parent 1, merge result, parent 2) in a column the editor could not scroll: only the first parent's text showed, without the changes, and the wheel and arrow keys did not move it. The panes now sit side by side under a label row, each one scrolls and they stay in step.

Fixes #3108
2026-11-13 18:38:28 +08:00
l0ng-ai 7e11d0729b fix(macos): keep full screen when Escape goes unhandled (#3134)
Escape the page leaves unhandled reaches the window as cancelOperation:, and NSWindow leaves full screen on it, so pressing Escape once more after closing a popup dropped the IDE out of full screen. The IDE's windows now answer cancelOperation: with nothing; View › Appearance › Toggle Full Screen (⌃⌘F) leaves full screen. A page-level preventDefault was not used because several windows close on an otherwise unhandled Escape.

Fixes #3104
2026-11-13 18:33:53 +08:00
l0ng-ai 2e78c42fac perf(java): find implementations in the sources through the index (#3132)
Go to Implementation on project code built a region type hierarchy over every source folder, which resolves every type in the region whatever the target; in a large multi-module project it took seconds. It now runs the operation IType.newTypeHierarchy uses, scoped to the source folders and the bridged Kotlin output, so the index narrows the subtypes to files that name a supertype. Results are unchanged, local and anonymous classes included (~230 ms -> ~7 ms on a 4,000-class test project).

Refs #3101
2026-11-13 18:27:46 +08:00
l0ng-ai 1d0449d430 fix(refactor): introduce a variable directly when the expression occurs once (#3128)
With a language server that offers both a single-occurrence and an all-occurrences Introduce Variable action, the "Multiple occurrences found" chooser opened even when the selected expression occurred only once. It now applies the single-occurrence action directly then, as the built-in path already did.
2026-11-13 18:05:20 +08:00
l0ng-ai 73f7fa0016 feat(editor): start a new line in a /* */ comment with a leading asterisk (#3127)
Enter on the first line of a /* */ or /** */ comment, or on a line that starts with *, starts the new line with * aligned under the comment's asterisks; before the closing */ the comment is closed on its own line. Strings and // comments are left alone.

Fixes #3099
2026-11-13 18:03:37 +08:00
l0ng-ai f3dfb384c9 fix(lsp): check again for a missing language server from the status popup (#3126)
A language server reported as not installed had its restart button disabled in the Language Servers popup, so one installed outside the IDE afterwards stayed "Not installed" until the project was reopened. The popup now offers "Check for <name> again" next to Install; restarting a missing server resolves its executable again and starts it when found, and says "<name> is still not installed" otherwise.

Refs #2925
2026-11-13 18:02:59 +08:00
Muuandmuutot b53cfe8cc2 feat(color-scheme): Git page for inline blame and project view branch colors (#3090)
Adds an Editor › Color Scheme › Git page with attributes for the editor inline blame and the Project View branch decorations (changed, HEAD tags, local, master/main with and without remote, tracking), with a live preview. The attribute panel now disables the channels an attribute doesn't use and labels the effect selector "Effect style".

Co-authored-by: muutot <[email protected]>
2026-11-13 17:43:02 +08:00
Mr0Bread 7d146ef1b7 feat(database): reuse a console's result tabs on each run, pin tabs to keep them (#3113)
Running a console added a new Result N tab per run. As in IntelliJ, a run now replaces that console's unpinned result tabs (in place of the first of them); a run without result sets keeps them and shows Output. Pin Tab (tab menu or the result toolbar) keeps a result: its close button becomes Unpin and close gestures spare it; Close All but Pinned closes the rest. Explain Plan replaces only an unpinned plan tab, and the 12-tab limit never evicts pinned or just-created tabs. The shared Tabs component gets an opt-in pinned item flag.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 17:37:59 +08:00
Mr0Bread 4bde38ef9d fix(database): use the Database tab's table icon for result tabs and the Table view (#3115)
Result tabs and the result toolbar's Table button drew the db-table glyph while the Services Database tab above them shows the dataTables icon; as in IntelliJ all three now use the same icon. Table nodes in the Database tree and Edit Data keep db-table.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 17:36:45 +08:00
Mr0Bread c6212293e1 fix(database): close the active result tab, not the Services window, on Close Tab (#3118)
Close Tab (⌘W) with focus in the database results hid the whole Services window: toolwindow.closeContent only closes tabs of strips registered with the window's content-tab registry, which was provided only in tool window header rows. ToolWindowContentTabs registers tab strips rendered in a window's content; the result tabs use it, so Close Tab closes the selected result, and with Output selected the window hides as before.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 17:34:42 +08:00
l0ng-ai b8ba1256d2 fix(terminal): keep Nerd Font icons wider than their cell above the next cell's background (#3121)
Nerd Font / Powerline icons (Private Use Area characters) are often drawn wider than one cell. xterm's DOM renderer paints each span's background in order, so the next prompt segment's background covered the icon's right part and powerline prompts showed half icons. Spans holding such characters are now marked in the DOM row hook and lifted over the following spans.

Fixes #3094
2026-11-13 17:30:43 +08:00
langxpandkradme 6fc6bf598c fix(java): use and cache the Maven runner launch classpath (#2929)
Delegated Java launches took their dependency classpath from jdtls, whose m2e import runs on the server JDK and can activate other JDK-activated Maven profiles or classifiers than the module build (a reactor switching between jdk8 and jdk21 artifacts). The launch now asks the Maven process and runner JDK that built the module for its runtime / test classpath (compile + dependency:build-classpath with -am, no package), cached by the launch fingerprint (POMs, sources, JDK, settings, profiles, environment). A failed probe falls back to the jdtls classpath with a hint in the console; cancellation still cancels. A fingerprint-verified snapshot is reused before JDT errors escalate a launch to Rebuild, and generated sources under the build directory no longer change the fingerprint after each compile.

Co-authored-by: kradme <[email protected]>
2026-11-13 17:27:38 +08:00
l0ng-ai 95235220b5 fix(lsp): escape unescaped Unicode and spaces in file URIs from any server (#3117)
lsp-types rejects a URI with raw non-ASCII characters or spaces, so one such file URI made a whole response fail to deserialize ("data did not match any variant of untagged enum DocumentChanges"). Java servers build some URIs with java.net.URI.toString(), which keeps non-ASCII characters, e.g. jdtls in Introduce Variable's edit for a project under a Chinese path. The csharp-ls normalisation now applies to file: URIs from every server, in responses, publishDiagnostics and workspace/applyEdit, including WorkspaceEdit.changes keys; opaque data and other schemes pass through unchanged.

Refs #3089
2026-11-13 17:24:17 +08:00
GuoYuandguoyucode 620fa247ef feat(mcp): fixed port and kept token, focus only for confirmations, no refactoring notice for agent edits (#3062)
The IDE MCP server took a new port and token in every run, so configured HTTP clients and port forwards broke at each restart; every MCP request brought the window to the front; and an agent's edits were announced as refactorings.

- Settings › Agents › IDE MCP Server: Use a fixed port (on by default, 26371) with a fallback to a free port when it is in use or reserved, reported in Status; Keep the token (off by default) stores the bearer token in the app data, readable by this user only, with Regenerate Token. Changing either restarts the server. Access control is otherwise unchanged (127.0.0.1, bearer token, origin and host checks).
- Only confirmations, which wait for the user, raise the window.
- Agent edits stay undoable (Local History point for multi-file edits) but are not announced as refactorings.

Co-authored-by: guoyucode <[email protected]>
2026-11-13 17:19:02 +08:00
l0ng-ai 52e5a0c4af fix(vcs): leave a new branch from a remote branch with another name untracked (#3063)
Git's autoSetupMerge made the remote start point the upstream of a branch created from it whatever its name, so a branch 'feature' created from 'origin/main' proposed and pushed to 'main'. New Branch and New Worktree now create such a branch with --no-track, so the Push dialog targets its own name and the first push sets that upstream; a remote branch checked out under the same name is still tracked.
2026-11-13 17:11:46 +08:00
l0ng-ai 4acb265b90 fix(gradle): run the unpacked wrapper distribution when gradle-wrapper.jar is missing (#3116)
A *.jar rule in .gitignore often leaves gradle/wrapper/gradle-wrapper.jar out of the repository, and gradlew then fails with only "could not find or load main class org.gradle.wrapper.GradleWrapperMain". When the jar is missing, the distribution named by gradle-wrapper.properties runs from the Gradle user home's wrapper/dists if the wrapper already unpacked it; otherwise the wrapper runs as before and the failure summary names the missing jar and the fixes.

Fixes #3086
2026-11-13 17:10:15 +08:00
Mr0Bread b284c37588 feat(database): add default result tab navigation shortcuts (#3100)
Select Next / Previous Tab for database results get default shortcuts in every keymap preset, the same keys as editor-tab navigation (IntelliJ macOS ⇧⌘] / ⇧⌘[ and ⌃→ / ⌃←, IntelliJ Windows/Linux Alt+→ / Alt+←, VS Code and Eclipse their editor-tab keys). They apply while focus is in Results (tracked with focusin so it survives a replaced grid or tab) and move focus to the destination tab; elsewhere the keys keep switching editor tabs.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 17:07:09 +08:00
95 changed files with 3119 additions and 394 deletions

No files matched your search

+2 -1
View File
@@ -12,7 +12,8 @@ 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, single_file_classpath, single_file_target,
SingleFileTarget, compile_file_plan, find_javac, javac_step, module_classpath, single_file_classpath,
single_file_target,
};
pub use up_to_date::{menu_up_to_date, record_menu_build};
+210 -3
View File
@@ -4,12 +4,21 @@
//! 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.
@@ -64,6 +73,28 @@ 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);
@@ -78,6 +109,88 @@ 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"] };
@@ -110,7 +223,14 @@ pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFil
args.extend(["-pl", target.pl.as_str(), "-am"]);
}
let output_arg = format!("-Dmdep.outputFile={probe}");
args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]);
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"]);
let top_dir = subdir(root, &target.top.dir);
let status = ProcCommand::new(&target.top.command)
.args(&args)
@@ -119,8 +239,10 @@ pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFil
.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);
let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
// 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 = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n");
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
}
@@ -305,7 +427,7 @@ mod tests {
let script = root.join("mvnw");
fs::write(
&script,
"#!/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",
"#!/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",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
@@ -327,5 +449,90 @@ 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");
}
}
+67 -12
View File
@@ -712,18 +712,27 @@ 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 {
// 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));
// 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);
}
}
}
}
for child in &el.children {
@@ -2140,6 +2149,52 @@ 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,6 +270,13 @@ 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| {
@@ -909,6 +916,8 @@ 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,6 +77,9 @@ 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).
+20 -1
View File
@@ -300,11 +300,30 @@ 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 }
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,
}
}
}
+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_workspace_edit(&call.host, call.window(), edit).await
crate::ops::lsp::apply_agent_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_workspace_edit(&host, call.window(), edit).await
crate::ops::lsp::apply_agent_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_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_agent_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_workspace_edit(&h, &w, edit).await;
let written = crate::ops::lsp::apply_agent_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,6 +985,38 @@ 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();
+97 -27
View File
@@ -96,18 +96,19 @@ 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 policy = delegated_policy(policy, prefer_maven, info.error_count);
let rebuild_on_jdt_errors = rebuild_on_jdt_errors(policy, prefer_maven, info.error_count);
let outputs = project.java_launch_snapshots.lock().build(
&project.root,
&model,
&module,
cache::BuildOptions { test, policy },
ide_sdk::apply::maven_local_repository(&project.root, &settings),
cache::BuildOptions { test, policy, rebuild_on_jdt_errors },
local_repo.clone(),
&cancel,
)?;
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
apply_external_build_result(&mut info, outputs.failure, policy == BuildPolicy::Rebuild);
apply_external_build_result(&mut info, outputs.failure, outputs.authoritative);
copies
} else if external {
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
@@ -123,10 +124,22 @@ async fn resolve_launch(
Vec::new()
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
class_paths.push(p);
}
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));
}
info.class_paths = class_paths;
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
@@ -191,21 +204,18 @@ 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 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 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 apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) {
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, authoritative: bool) {
info.build = Some(if let Some(failure) = failure {
info.hints.push(failure);
JavaBuildStatus::Failed
} 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).
} 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.
info.errors.clear();
info.error_count = 0;
JavaBuildStatus::Succeeded
@@ -286,6 +296,44 @@ 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))
}
@@ -410,14 +458,36 @@ mod tests {
#[test]
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
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
);
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());
}
#[test]
@@ -461,7 +531,7 @@ mod tests {
}
#[test]
fn clean_external_build_clears_stale_jdt_errors() {
fn authoritative_external_result_clears_stale_jdt_errors() {
let mut info = JavaLaunchInfo {
error_count: 7,
errors: vec![ide_lsp::jdtls::JavaCompileError {
+71 -4
View File
@@ -14,6 +14,10 @@ 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 {
@@ -42,6 +46,9 @@ 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.
@@ -75,7 +82,7 @@ impl Session {
cancel: &CancellationToken,
) -> IdeResult<Outputs> {
self.initialize();
let BuildOptions { test, policy } = options;
let BuildOptions { test, policy, rebuild_on_jdt_errors } = options;
let cache = &self.cache;
let record =
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
@@ -115,8 +122,14 @@ impl Session {
&& outputs.reusable(&before, &snapshots)
{
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
return Ok(previous.unwrap());
let mut outputs = previous.unwrap();
outputs.authoritative = true;
return Ok(outputs);
}
// 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())?;
@@ -156,7 +169,13 @@ impl Session {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure };
let mut outputs = Outputs {
fingerprint: None,
snapshot,
originals,
failure: built.failure,
authoritative: policy == BuildPolicy::Rebuild,
};
if outputs.failure.is_none() && before.is_some() && before == after {
outputs.fingerprint = before;
fs::create_dir_all(record.parent().unwrap())?;
@@ -334,6 +353,48 @@ 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();
@@ -483,7 +544,13 @@ 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 };
let outputs = Outputs {
fingerprint: None,
snapshot: retained.clone(),
originals: Vec::new(),
failure: None,
authoritative: false,
};
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
let mut session = Session::new(cache);
session.initialize();
+27 -12
View File
@@ -1483,23 +1483,38 @@ 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 || {
// 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");
}
record_before_multi_file_edit(&project, &edit);
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 || {
+5 -1
View File
@@ -231,7 +231,11 @@ async fn mutations_preserve_unsaved_text_and_reject_metadata_and_escapes() {
.iter()
.rev()
.find_map(|event| match event {
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
// 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)
}
_ => None,
})
.unwrap();
+71
View File
@@ -252,6 +252,77 @@ 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() {
+9 -5
View File
@@ -223,6 +223,8 @@ 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) => {
@@ -322,11 +324,13 @@ 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(|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(
|mut e| {
crate::virtual_doc::normalize_uris(&mut 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() {
+3 -7
View File
@@ -1440,13 +1440,9 @@ 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 {
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 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}")))
};
let result = match tokio::time::timeout(timeout, fut).await {
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
+58 -10
View File
@@ -18,23 +18,36 @@ const URI_ESCAPES: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
/// 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) {
/// 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) {
match value {
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_csharp_uris),
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_uris),
serde_json::Value::Object(fields) => {
for (key, value) in fields {
for (key, value) in fields.iter_mut() {
// Opaque payloads must survive resolve/execute round trips unchanged.
if matches!(key.as_str(), "data" | "arguments") {
continue;
}
if matches!(key.as_str(), "uri" | "targetUri")
&& let Some(uri) = value.as_str().filter(|s| s.starts_with("csharp:"))
if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri")
&& let Some(uri) = value.as_str().and_then(escaped_uri)
{
*value = utf8_percent_encode(uri, URI_ESCAPES).to_string().into();
*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();
} else {
normalize_csharp_uris(value);
normalize_uris(value);
}
}
}
@@ -42,6 +55,16 @@ pub(crate) fn normalize_csharp_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 {
@@ -202,7 +225,7 @@ mod tests {
{ "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } }
]);
let original = response.clone();
normalize_csharp_uris(&mut response);
normalize_uris(&mut response);
assert_eq!(response[0]["uri"], encoded);
assert_eq!(response[0]["source"], uri);
assert_eq!(response[1]["location"]["targetUri"], encoded);
@@ -210,6 +233,31 @@ 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");
+56 -9
View File
@@ -1,6 +1,7 @@
//! `<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.
//! the server stops. `<app-data>/mcp/token` keeps the token across runs when
//! the user chose a fixed token.
use serde::{Deserialize, Serialize};
use std::io;
@@ -14,6 +15,28 @@ 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,
@@ -32,15 +55,8 @@ 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)?;
let written = write_private(&tmp, &bytes).and_then(|()| std::fs::rename(&tmp, path));
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
write_atomic(path, &bytes)
}
/// Removes the file at `path` if it still describes this process's server.
@@ -51,6 +67,18 @@ 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;
@@ -91,4 +119,23 @@ 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, discovery_path};
pub use server::McpServer;
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
pub use server::{McpServer, ServerOptions};
pub use tokio_util::sync::CancellationToken;
use std::path::{Path, PathBuf};
+43 -3
View File
@@ -49,12 +49,25 @@ 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.
@@ -63,10 +76,32 @@ impl McpServer {
discovery: Option<PathBuf>,
runtime: &tokio::runtime::Handle,
) -> std::io::Result<Self> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
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),
};
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
let token = new_token()?;
let token = match options.token {
Some(token) => token,
None => new_token()?,
};
let listener = {
let _entered = runtime.enter();
tokio::net::TcpListener::from_std(listener)?
@@ -91,13 +126,18 @@ impl McpServer {
});
runtime.spawn(accept(listener, shared, stopped));
tracing::info!("MCP server on 127.0.0.1:{port}");
Ok(Self { port, token, discovery, shutdown, cancel })
Ok(Self { port, port_unavailable, 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
+28 -1
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, Tool, ToolCallError, ToolRegistry, discovery_path, stdio};
use ide_mcp::{Discovery, McpServer, ServerOptions, Tool, ToolCallError, ToolRegistry, discovery_path, stdio};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -366,3 +366,30 @@ 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,6 +323,18 @@ 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)
@@ -330,6 +342,21 @@ 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,6 +1866,37 @@ 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,6 +281,9 @@ 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]);
+27 -2
View File
@@ -800,16 +800,41 @@ 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", "--", name],
(false, true) => vec!["branch", "--force", "--", name],
(false, false) => vec!["branch"],
(false, true) => vec!["branch", "--force"],
};
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
+29 -3
View File
@@ -984,15 +984,41 @@ fn branch_overwrite_preserves_the_ref_if_checkout_fails() {
}
#[test]
fn new_branch_from_remote_branch_tracks_it() {
let (fx, _bare) = Fixture::with_origin();
fn new_branch_from_remote_branch_tracks_it_only_under_the_same_name() {
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.as_deref(), Some("origin/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);
}
#[test]
@@ -389,6 +389,12 @@ 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(()))
@@ -399,6 +405,16 @@ 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() {
@@ -485,7 +501,7 @@ impl Documents {
});
}
history.prune(SystemTime::now(), MAX_HISTORY_BYTES, MAX_HISTORY_AGE);
self.sink.emit(IdeEvent::WorkspaceEditApplied { id, paths: affected });
self.sink.emit(IdeEvent::WorkspaceEditApplied { id, paths: affected, by_agent });
}
Ok(())
}
+13 -2
View File
@@ -118,9 +118,20 @@ 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).
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).
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.
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**.
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.
+5 -2
View File
@@ -18,14 +18,17 @@ Agent tasks started inside OpenIDEA (see [Agents](/en/guide/agents)) get the sam
</Step>
</Steps>
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.
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.
## 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 stops working when OpenIDEA restarts, because the port and token change.
- **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.
The stdio configuration looks like this (the path is the one on your machine):
+12 -2
View File
@@ -118,9 +118,19 @@ SQL 补全基于已连接的架构:`FROM`/`JOIN` 后补全表(并根据外
## 结果与数据编辑
结果显示在 **服务** 工具窗口的 **数据库** 标签页中:**输出** 标签页记录每条语句,每个结果集一个 **Result N** 标签页(最多 12 个)。
结果显示在 **服务** 工具窗口的 **数据库** 标签页中:**输出** 标签页记录每条语句,每个结果集一个 **Result N** 标签页(最多 12 个,已固定的标签页除外)。
右键点击结果标签页(或聚焦后按 `Shift+F10`),可使用 **关闭**、**关闭其他**、**全部关闭**、**重命名标签页**、**选择下一个标签页** 和 **选择上一个标签页**。这些操作只作用于结果标签页,**输出** 始终保留。重命名的标题在结果关闭前一直有效。
再次运行控制台会替换它之前的结果:它未固定的结果标签页会让位给新的运行,新运行每个结果集一个标签页(**Result N**;选中的多条语句都返回行时为 **Result N**、**Result N-2**……)。没有结果集的运行(如 `UPDATE`)会保留之前的结果。其他控制台的结果不受影响。
要保留某个结果,可在结果标签页的右键菜单中选择 **固定标签页**,或点击结果工具栏上的固定按钮;标签页的关闭按钮会变成固定按钮,点击即可取消固定,误点或中键点击都不会关闭它;之后的运行会改为打开新标签页。重新运行或翻页已固定的结果时,会在原标签页中更新。
<Warning>
替换结果会丢弃其中尚未提交的更改。再次运行控制台前,请先 **提交** 或 **还原** 这些更改,或固定该标签页。
</Warning>
右键点击结果标签页(或聚焦后按 `Shift+F10`),可使用 **关闭**、**关闭其他**、**全部关闭**、**关闭除已固定外的所有标签页**、**固定标签页** / **取消固定标签页**、**重命名标签页**、**选择下一个标签页** 和 **选择上一个标签页**。这些操作只作用于结果标签页,**输出** 始终保留。重命名的标题在结果关闭或被替换前一直有效。
焦点位于结果标签页或结果内容中时,IntelliJ macOS 快捷键方案使用 `⇧⌘]` / `⇧⌘[`(也支持 `⌃→` / `⌃←`),IntelliJ Windows/Linux 使用 `Alt+→` / `Alt+←`。VS Code 和 Eclipse 方案分别使用各自默认的编辑器标签页导航快捷键。切换后焦点移到目标结果标签页。结果导航快捷键仅在结果区域中生效,菜单或对话框打开时不会切换结果。可在 **设置 › 快捷键** 中单独修改数据库结果快捷键,不影响编辑器快捷键。
关闭当前选中的结果标签页时,优先选中左侧相邻的结果;没有左侧结果时,选中右侧相邻结果。没有剩余结果时,选中 **输出**。关闭未选中的结果不会改变当前选择。
+5 -2
View File
@@ -17,14 +17,17 @@ OpenIDEA 内置了一个 [Model Context Protocol](https://modelcontextprotocol.i
</Step>
</Steps>
服务器只监听 `127.0.0.1` 上的一个随机端口,每个请求都必须带上令牌,令牌在每次启动 OpenIDEA 时都会变化。来自其他 Origin 或 Host 的请求会被拒绝。
服务器只监听 `127.0.0.1`,每个请求都必须带上访问令牌。来自其他 Origin 或 Host 的请求会被拒绝。
- **端口。** 默认每次运行都监听同一个端口 26371(**使用固定端口** 和 **端口**),这样 OpenIDEA 重启后 HTTP 客户端和端口转发(例如到另一台机器的 SSH 隧道)仍然有效。端口被占用或被系统保留时,本次运行改用一个空闲端口,**状态** 中会说明原因。关闭 **使用固定端口** 则每次运行使用一个空闲端口。
- **令牌。** 默认每次启动 OpenIDEA 都会换一个新令牌。打开 **保留令牌** 后会在多次运行间保留同一个令牌,保存在 OpenIDEA 的应用数据目录中,只有你的账户可读;**重新生成令牌** 会替换它,使用旧令牌配置的客户端随之失效。
## 连接客户端
设置页面的 **客户端配置** 下有两个按钮,都会复制一段大多数客户端都能识别的 `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 配置大致如下(路径以你机器上的为准):
+162 -6
View File
@@ -1,5 +1,5 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./fixtures";
import { expect, openFromTree, 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,7 +64,9 @@ 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, exact: true }).click();
await menu(page)
.getByRole("menuitem", { name: command.startsWith("Select ") ? new RegExp(`^${command}`) : command, exact: true })
.click();
await expect(menu(page)).toHaveCount(0);
}
@@ -90,9 +92,11 @@ 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");
@@ -140,7 +144,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", exact: true })).toBeFocused();
await expect(popup.getByRole("menuitem", { name: /^Select Previous Tab/ })).toBeFocused();
await page.keyboard.press("Enter");
await expect(popup).toHaveCount(0);
await selected(page, "Result 1", "alpha rows");
@@ -207,7 +211,9 @@ 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", exact: true }).click();
await menu(page)
.getByRole("menuitem", { name: /^Select Previous Tab/ })
.click();
await expect(menu(page)).toHaveCount(0);
await page.mouse.move(0, 0);
await expect(otherSurface).toHaveCSS("background-color", idle);
@@ -266,6 +272,59 @@ 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 () => {
@@ -356,3 +415,100 @@ test("closes clicked results, other results and all results without removing Out
await expect(page.getByRole("treeitem", { name: "Alpha database", exact: true })).toBeVisible();
await expect(page.getByRole("treeitem", { name: "Beta database", exact: true })).toBeVisible();
});
/** Runs `text` in console `path` through the production store, with the backend's answers stubbed. */
async function runConsole(page: Page, path: string, text: string) {
await page.evaluate(
async ({ path, text }) => {
const bindingsPath = "/src/bindings.ts";
const storePath = "/src/features/database/store.ts";
const { commands } = await import(bindingsPath);
const { executeConsole } = await import(storePath);
commands.dbStatements = async (_id: string, sql: string) => {
let from = 0;
const data = sql.split(";").flatMap((part) => {
const start = from + part.length - part.trimStart().length;
from += part.length + 1;
const st = part.trim();
return st ? [{ from: start, to: start + st.length, text: st }] : [];
});
return { status: "ok", data };
};
commands.dbExecute = async (_r: unknown, _id: string, statements: { text: string }[]) => ({
status: "ok",
data: statements.map((statement) => ({
statement,
error: null,
result: {
statement: statement.text,
columns: [{ name: "payload", typeName: "text" }],
rows: [[/'(.*)'/.exec(statement.text)![1]]],
affected: null,
hasMore: false,
elapsedMs: 1,
executionMs: 1,
},
})),
});
await executeConsole(path, text, 0, text.length);
},
{ path, text },
);
}
test("console runs replace their unpinned results; pinned results stay until Close All but Pinned", async ({
workbench: page,
}) => {
await seedResults(page);
const strip = page.getByRole("tablist", { name: "Results", exact: true }).getByRole("tab");
await choose(page, "Result 1", "Close All");
// The seeded Output line's id would collide with the ids of the lines the runs log.
await page.evaluate(async () => {
const storePath = "/src/features/database/store.ts";
const { useDatabase } = await import(storePath);
useDatabase.setState({ output: [] });
});
await runConsole(page, "alpha-console.sql", "SELECT 'one'");
const n = Number(/\d+/.exec((await strip.nth(1).textContent())!)![0]);
const result = (run: number, index = 1) => `Result ${n + run}${index > 1 ? `-${index}` : ""}`;
await runConsole(page, "alpha-console.sql", "SELECT 'two'");
await expect(strip).toHaveText(["Output", result(1)]);
await selected(page, result(1), "two");
const pin = page.getByRole("button", { name: "Pin Tab", exact: true });
await pin.click();
await expect(pin).toHaveAttribute("aria-pressed", "true");
await runConsole(page, "alpha-console.sql", "SELECT 'three'; SELECT 'four'");
await runConsole(page, "beta-console.sql", "SELECT 'beta'");
await expect(strip).toHaveText(["Output", result(1), result(2), result(2, 2), result(3)]);
await selected(page, result(3), "beta");
await tab(page, result(2)).click({ button: "right" });
await expect(menu(page).getByRole("menuitem", { name: "Close All but Pinned", exact: true })).toBeEnabled();
await menu(page).getByRole("menuitem", { name: "Pin Tab", exact: true }).click();
await expect(menu(page)).toHaveCount(0);
await runConsole(page, "alpha-console.sql", "SELECT 'five'");
await expect(strip).toHaveText(["Output", result(1), result(2), result(4), result(3)]);
await selected(page, result(4), "five");
await expect(pin).toHaveAttribute("aria-pressed", "false");
await tab(page, result(2)).click();
await expect(pin).toHaveAttribute("aria-pressed", "true");
await choose(page, result(3), "Close All but Pinned");
await expect(strip).toHaveText(["Output", result(1), result(2)]);
await selected(page, result(2), "three");
await tab(page, result(1)).click({ button: "right" });
await expect(menu(page).getByRole("menuitem", { name: "Unpin Tab", exact: true })).toBeVisible();
await expect(menu(page).getByRole("menuitem", { name: "Close All but Pinned", exact: true })).toBeDisabled();
await page.keyboard.press("Escape");
// A pinned tab's close button is its Unpin button; middle-click leaves it open.
const slot = tab(page, result(1)).locator("..");
await expect(slot.getByRole("button", { name: "Close Tab", exact: true })).toHaveCount(0);
await tab(page, result(1)).click({ button: "middle" });
await expect(strip).toHaveText(["Output", result(1), result(2)]);
await slot.getByRole("button", { name: "Unpin Tab", exact: true }).click();
await selected(page, result(2), "three");
await slot.getByRole("button", { name: "Close Tab", exact: true }).click();
await expect(strip).toHaveText(["Output", result(2)]);
});
Binary file not shown.
@@ -8,6 +8,11 @@ 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;
@@ -56,7 +61,7 @@ public final class ImplementationsCommand implements IDelegateCommandHandler {
return false;
}
if (type == null || type.getJavaProject() == null) return false;
IRegion sources = JavaCore.newRegion();
List<IJavaElement> sources = new ArrayList<>();
for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) {
for (IPackageFragmentRoot fragmentRoot : project.getPackageFragmentRoots()) {
if (fragmentRoot.getKind() == IPackageFragmentRoot.K_SOURCE || isKotlinOutput(fragmentRoot)) {
@@ -64,7 +69,13 @@ public final class ImplementationsCommand implements IDelegateCommandHandler {
}
}
}
ITypeHierarchy hierarchy = type.getJavaProject().newTypeHierarchy(type, sources, monitor);
// 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();
for (IType subtype : hierarchy.getAllSubtypes(type)) {
if (monitor.isCanceled()) throw new OperationCanceledException();
IMember found;
+19 -4
View File
@@ -72,6 +72,21 @@ 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;
@@ -174,11 +189,11 @@ try:
request('java/buildWorkspace', True)
# (implementations in the sources, whether they are all there are)
cases = {
# 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),
# 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),
# Through the type arguments; not the abstract re-declaration.
('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', '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', '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),
+73 -6
View File
@@ -15,45 +15,92 @@ 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>>,
}
/// Starts or stops the server to match the global `mcpServer.enabled`.
/// 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.
pub fn apply(state: &AppState) {
let enabled = state.global_settings.effective().mcp_server.enabled;
let settings = state.global_settings.effective().mcp_server;
let mut server = state.mcp.server.lock();
if !enabled {
if !settings.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;
}
if server.is_some() {
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) {
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(
match ide_mcp::McpServer::start_with(
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);
@@ -61,6 +108,7 @@ 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.
@@ -71,11 +119,14 @@ 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).
/// Bearer token of `url` (changes with every run unless `mcpServer.fixedToken` is on).
pub token: Option<String>,
/// The stdio bridge: this executable and its arguments.
pub stdio_command: Vec<String>,
@@ -91,6 +142,7 @@ 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(),
@@ -98,6 +150,21 @@ 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,6 +75,7 @@ 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,
+3 -1
View File
@@ -63,7 +63,9 @@ impl EventSink for TauriSink {
) {
flash_taskbar_unless_focused(&self.app);
}
if matches!(&event, IdeEvent::McpRequest { .. })
// 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 { .. }, .. })
&& let Some(label) = self.window.clone()
{
let app = self.app.clone();
+23
View File
@@ -180,6 +180,29 @@ 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,6 +660,8 @@ 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())));
+29 -2
View File
@@ -19,6 +19,11 @@ 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: {
@@ -5859,7 +5864,12 @@ 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[] } |
{ 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 } |
/**
* An open document's language changed (Settings › File Types, Override File
* Type), or its UI mode (a framework's root marker appeared or went).
@@ -7197,6 +7207,18 @@ 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. */
@@ -7205,11 +7227,16 @@ 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). */
/** Bearer token of `url` (changes with every run unless `mcpServer.fixedToken` is on). */
token: string | null,
/** The stdio bridge: this executable and its arguments. */
stdioCommand: string[],
+40 -1
View File
@@ -2,7 +2,8 @@ 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 { enterInDocComment, phpDocTags } from "./docComment";
import { java } from "@codemirror/lang-java";
import { enterInBlockComment, enterInDocComment, phpDocTags } from "./docComment";
const views: EditorView[] = [];
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
@@ -75,3 +76,41 @@ 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);
});
});
+59 -1
View File
@@ -3,7 +3,8 @@
* 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 { EditorSelection } from "@codemirror/state";
import { syntaxTree } from "@codemirror/language";
import { EditorSelection, type EditorState } from "@codemirror/state";
import type { Command } from "@codemirror/view";
/** `@param` / `@return` lines for the PHP function declared in `text` (the code below the `/**`). */
@@ -86,3 +87,60 @@ 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 { enterInDocComment } from "./docComment";
import { enterInBlockComment, 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: enterInLineComment });
if (!o.readOnly) keys.push({ key: "Enter", run: enterInBlockComment }, { 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,7 +57,12 @@ 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"] });
emitLocalIdeEvent({
type: "workspaceEditApplied",
id: 9,
paths: ["/p/A.java", "/p/B.java", "/p/closed.java"],
byAgent: false,
});
}
function historyStep(slot: EditorSlot, forward = false) {
return (forward ? redo : undo)({
@@ -183,7 +188,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"] });
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 10, paths: ["/p/A.java", "/p/B.java"], byAgent: true });
commands.undoWorkspaceEdit.mockImplementation(async (id: number) => {
await replace(id === 10 ? "new" : "old");
return { status: "ok", data: null };
+44 -2
View File
@@ -1,6 +1,22 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { CodeAction } from "@/bindings";
import { introducedName, occurrenceVariants } from "./serverIntroduce";
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");
const spansText = (text: string, spans: { from: number; to: number }[]) => spans.map((s) => text.slice(s.from, s.to));
@@ -55,3 +71,29 @@ 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));
});
});
+6 -1
View File
@@ -112,7 +112,8 @@ export function occurrenceVariants(actions: readonly CodeAction[]): { one: CodeA
}
/**
* IDEA's "Multiple occurrences found" for a server that offers both variants;
* 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;
* false when it does not (the caller applies / lists the actions as before).
*/
export function chooseOccurrences(view: EditorView, actions: readonly CodeAction[], id: RefactoringId): boolean {
@@ -123,6 +124,10 @@ 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,4 +31,11 @@ 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();
});
});
+22 -18
View File
@@ -69,25 +69,29 @@ 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>
) : (
<IconButton
icon="refresh"
size="small"
title={tx`Restart ${s.name}`}
disabled={s.status === "missing" || s.status === "starting"}
onClick={() => void restartServer(s.id)}
/>
<>
{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)}
/>
</>
)}
</div>
{(s.languages.length > 0 || s.root) && (
+16 -1
View File
@@ -1,5 +1,6 @@
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)[]);
@@ -14,18 +15,32 @@ 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 { BUSY_SHOW_DELAY_MS, eslintCovers, installServerEvents, serversSummary, useServers } = await import("./servers");
const { provideNotificationService } = await import("@/workbench/contributions");
const { BUSY_SHOW_DELAY_MS, eslintCovers, installServerEvents, restartServer, 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,6 +5,7 @@ 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;
@@ -94,9 +95,15 @@ 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: [] });
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 17, paths: [], byAgent: false });
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: [] });
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 18, paths: [], byAgent: false });
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: [] });
emitLocalIdeEvent({ type: "workspaceEditApplied", id: 19, paths: [], byAgent: false });
const previous = useProject.getState().project;
useProject.setState({ project: { ...previous, root: "/different-project" } as NonNullable<typeof previous> });
try {
@@ -64,3 +64,8 @@ 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([]);
});
+6 -2
View File
@@ -3,7 +3,10 @@ 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. */
/**
* 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.
*/
export function installWorkspaceEditUndo(): () => void {
let current: { id: number; project: ReturnType<typeof useProject.getState>["project"]; undone: boolean } | null =
null;
@@ -38,7 +41,8 @@ export function installWorkspaceEditUndo(): () => void {
],
});
};
const stopApplied = onIdeEvent("workspaceEditApplied", ({ id }) => {
const stopApplied = onIdeEvent("workspaceEditApplied", ({ id, byAgent }) => {
if (byAgent) return;
current = { id, project: useProject.getState().project, undone: false };
show();
});
+50 -26
View File
@@ -23,6 +23,7 @@ import {
tooltipAttrs,
} from "@/ui";
import { notifications } from "@/workbench/contributions";
import { ToolWindowContentTabs } from "@/workbench/toolwindows/ToolWindows";
import { ChartView } from "./ChartView";
import { ColumnsPopup } from "./ColumnsPopup";
import { DataView, ExtractorSelect, PanelToggle, copyFormatOf, exportMenu, type DataPanel } from "./DataView";
@@ -52,6 +53,7 @@ import {
log,
outcomeSummary,
pageable,
pinResult,
rerunResult,
targetLabel,
useDatabase,
@@ -432,10 +434,11 @@ function Result({ tab }: { tab: ResultTab }) {
return (
<div className={styles.view} ref={viewRef} data-db-table-editor="">
<div className={styles.toolbar}>
<IconButton icon="db-table" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
<IconButton icon="database" 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"
@@ -594,6 +597,7 @@ function PlanResult({ tab }: { tab: ResultTab }) {
<div className={styles.view}>
<div className={styles.toolbar}>
<IconButton icon="refresh" title="Explain Again" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
<PinButton tab={tab} />
{tab.busy && <Spinner size={12} />}
<span className={styles.spacer} />
<span className={styles.resultSource} title={tab.outcome.statement.text}>
@@ -661,11 +665,27 @@ 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);
useEffect(() => () => setContext("dbResultsFocus", false), []);
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
const syncFocus = () => setContext("dbResultsFocus", !!root.current?.contains(document.activeElement));
document.addEventListener("focusin", syncFocus);
syncFocus();
return () => {
document.removeEventListener("focusin", syncFocus);
setContext("dbResultsFocus", false);
};
}, []);
const tabMenu = (id: string, e: MouseEvent) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
@@ -682,11 +702,8 @@ export function ResultsView() {
const tab = results.find((r) => r.id === active);
return (
<div
ref={root}
className={styles.view}
onFocusCapture={() => setContext("dbResultsFocus", true)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setContext("dbResultsFocus", false);
}}
onKeyDown={(e) => {
if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return;
const tab = (e.target as Element).closest<HTMLElement>('[role="tab"]');
@@ -700,26 +717,33 @@ export function ResultsView() {
);
}}
>
<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,
})),
]}
/>
<ToolWindowContentTabs>
<Tabs
className={styles.resultTabs}
variant="compact"
aria-label="Results"
activeId={tab ? String(tab.id) : String(OUTPUT_TAB)}
panelIdPrefix="db-results"
onSelect={(id) => useDatabase.setState({ activeResult: Number(id) })}
onClose={(id) => closeResult(Number(id))}
onTogglePin={(id) => {
const r = results.find((r) => r.id === Number(id));
if (r) pinResult(r.id, !r.pinned);
}}
onContextMenu={tabMenu}
items={[
{ id: String(OUTPUT_TAB), label: "Output", icon: "db-console", closable: false },
...results.map((r) => ({
id: String(r.id),
label: r.title,
icon: r.plan ? "db-plan" : "database",
closable: true,
pinned: r.pinned,
tooltip: r.outcome.statement.text,
})),
]}
/>
</ToolWindowContentTabs>
<div className={styles.fill}>
{!tab ? <Output /> : tab.plan ? <PlanResult key={tab.id} tab={tab} /> : <Result key={tab.id} tab={tab} />}
</div>
+221 -12
View File
@@ -1,8 +1,17 @@
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { installKeyboardDispatcher, normalizeBinding, PRESETS, 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";
@@ -19,6 +28,8 @@ 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"],
@@ -113,7 +124,7 @@ function openMenu(name: string) {
async function choose(name: string) {
await act(async () => {
fireEvent.click(screen.getByRole("menuitem", { name }));
fireEvent.click(screen.getByRole("menuitem", { name: name.startsWith("Select ") ? new RegExp(`^${name}`) : name }));
});
}
@@ -146,18 +157,20 @@ describe("database result tab context menu", () => {
within(menu)
.getAllByRole("menuitem")
.map((item) => item.textContent),
).toEqual(["Close", "Close Others", "Close All", "Rename Tab", "Select Next Tab", "Select Previous Tab"]);
).toEqual(actions.map(([, label]) => expect.stringMatching(new RegExp(`^${label}(?![A-Za-z ])`))));
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(/pin|layout/i)).not.toBeInTheDocument();
expect(within(menu).queryByText(/layout/i)).not.toBeInTheDocument();
expectSelected("Customer count");
expect(screen.getByRole("gridcell", { name: "count-row" })).toBeInTheDocument();
});
@@ -224,11 +237,11 @@ describe("database result tab context menu", () => {
expectConsoleStateUnchanged(before);
});
it("disables Close and Rename Tab on Output and does not perform either operation", async () => {
it("disables Close, Pin Tab and Rename Tab on Output and does not perform any of them", async () => {
await show();
const before = useDatabase.getState();
const menu = openMenu("Output");
for (const name of ["Close", "Rename Tab"]) {
for (const name of ["Close", "Pin Tab", "Rename Tab"]) {
const item = within(menu).getByRole("menuitem", { name });
expect(item).toHaveAttribute("aria-disabled", "true");
fireEvent.click(item);
@@ -354,12 +367,7 @@ describe("database result tab context menu", () => {
useKeymap.setState({
overrides: {
"editor.closeTab": ["Meta+W"],
"db.closeResultTab": ["F1"],
"db.closeOtherResultTabs": ["F2"],
"db.closeAllResultTabs": ["F3"],
"db.renameResultTab": ["F4"],
"db.nextResultTab": ["F5"],
"db.previousResultTab": ["F6"],
...Object.fromEntries(actions.map(([id], index) => [id, [`F${index + 1}`]])),
},
});
await show();
@@ -386,6 +394,88 @@ 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: {
@@ -423,3 +513,122 @@ 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();
});
});
+43 -4
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, useDatabase } from "./store";
import { OUTPUT_TAB, closeResult, pinResult, useDatabase } from "./store";
function target({ arg, ctx }: ActionContext): number | null {
const id = typeof arg === "number" ? arg : ctx.dbResultsFocus ? useDatabase.getState().activeResult : null;
@@ -17,11 +17,23 @@ function closeOthers(id: number) {
for (const tab of useDatabase.getState().results) if (tab.id !== id) closeResult(tab.id);
}
function cycle(direction: 1 | -1) {
function pinned(id: number | null): boolean {
return !!useDatabase.getState().results.find((r) => r.id === id)?.pinned;
}
function cycle(direction: 1 | -1, focus: boolean) {
const { results, activeResult } = useDatabase.getState();
const ids = [OUTPUT_TAB, ...results.map((r) => r.id)];
const index = ids.indexOf(activeResult ?? OUTPUT_TAB);
if (index >= 0) useDatabase.setState({ activeResult: ids[(index + direction + ids.length) % ids.length]! });
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 });
}
export function registerResultTabActions(): () => void {
@@ -51,6 +63,31 @@ 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",
@@ -73,7 +110,7 @@ export function registerResultTabActions(): () => void {
category: "Database",
keyWhen: "dbResultsFocus && !popupOpen",
enabled: (c: ActionContext) => target(c) !== null && useDatabase.getState().results.length > 0,
run: () => cycle(direction),
run: ({ ctx }: ActionContext) => cycle(direction, !!ctx.dbResultsFocus),
})),
]);
}
@@ -83,7 +120,9 @@ 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 },
+192 -1
View File
@@ -5,7 +5,8 @@ import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { installCellActions } from "./cellActions";
import { getContext } from "@/platform/context";
import { ResultsView } from "./ResultsView";
import { executeConsole, useDatabase, type ResultTab } from "./store";
import { executeConsole, explainConsole, pinResult, rerunResult, useDatabase, type ResultTab } from "./store";
import { registerResultTabActions } from "./resultTabActions";
import { activeTableEditor, installTableEditorFocus } from "./tableEditorFocus";
const backend = vi.hoisted(() => ({
@@ -15,6 +16,7 @@ const backend = vi.hoisted(() => ({
applyEdits: vi.fn(),
execute: vi.fn(),
statements: vi.fn(),
explain: vi.fn(),
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
@@ -27,6 +29,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
dbApplyEdits: backend.applyEdits,
dbExecute: backend.execute,
dbStatements: backend.statements,
dbExplain: backend.explain,
},
};
});
@@ -157,6 +160,48 @@ 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 } });
@@ -411,3 +456,149 @@ 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);
});
});
+45 -13
View File
@@ -63,7 +63,7 @@ const MAX_TABS = 12;
export interface ResultTab {
id: number;
/** `Result N` (numbered per window, like IDEA). */
/** `Result N`, `Result N-2`… (N numbers a console execution, like IDEA). */
title: string;
/** Console file the statement came from. */
path: string;
@@ -80,6 +80,8 @@ 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). */
@@ -734,6 +736,7 @@ 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) {
@@ -882,8 +885,9 @@ 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, everything is logged to its Output tab; returns the
* outcomes (errors included) for the editor's marks.
* 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.
*/
/** Asks for the values of the statements' query parameters and binds them; `[]` when cancelled. */
async function withParams(statements: SqlStatement[], driver: DbDriver): Promise<SqlStatement[]> {
@@ -931,15 +935,16 @@ 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 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 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 failed = outcomes.some((o) => o.error);
useDatabase.setState((s) => ({
results: [...s.results, ...tabs].slice(-MAX_TABS),
results: replaceResults(s.results, path, tabs),
activeResult: failed || !tabs.length ? OUTPUT_TAB : tabs.at(-1)!.id,
}));
showServiceView(RESULTS_VIEW, false);
@@ -967,6 +972,27 @@ 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));
@@ -979,7 +1005,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);
const old = s.results.find((r) => r.plan && !r.pinned);
const tab: ResultTab = {
id: old?.id ?? nextTab++,
title: old?.title ?? PLAN_TITLE,
@@ -988,7 +1014,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)) : [...s.results, tab].slice(-MAX_TABS);
const results = old ? s.results.map((r) => (r === old ? tab : r)) : capResults([...s.results, tab], [tab]);
return { results, activeResult: tab.id };
});
showServiceView(RESULTS_VIEW, false);
@@ -1114,7 +1140,9 @@ export async function loadResultPage(tabId: number, page: number, pageSize: numb
...(rerun || sizeChanged ? { total: null } : {}),
});
} else patchResult(tabId, { busy: false });
if (outcome?.error) useDatabase.setState({ activeResult: OUTPUT_TAB });
// 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 });
} catch (e) {
patchResult(tabId, { busy: false });
if (!isCancelled(e)) log("error", message(e));
@@ -1239,3 +1267,7 @@ export function closeResult(id: number) {
};
});
}
export function pinResult(id: number, pinned: boolean) {
patchResult(id, { pinned });
}
+11
View File
@@ -12,6 +12,7 @@ import {
useRef,
useState,
type ClipboardEvent as ReactClipboardEvent,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
@@ -365,6 +366,15 @@ 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}
@@ -372,6 +382,7 @@ 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} />}
@@ -283,10 +283,9 @@ 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.queryByRole("textbox", { name: "Background hex" })).not.toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Effects" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "Effect style" })).toBeChecked();
expect(screen.getByRole("button", { name: "Effect" })).toHaveTextContent("Underwaved");
expect(screen.getByRole("textbox", { name: "Effect color hex" })).toHaveValue("#abcdef");
expect(screen.getByRole("textbox", { name: "Effects hex" })).toHaveValue("#abcdef");
});
it("selects an empty background and clears it back to the scheme state", () => {
@@ -382,3 +381,27 @@ 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,6 +7,7 @@ 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",
@@ -20,6 +21,13 @@ 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({
+21 -1
View File
@@ -40,6 +40,10 @@ 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";
@@ -112,7 +116,17 @@ 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 };
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;
};
}
/** One `languageServers.<name>` entry; every field optional, like in settings.toml. */
@@ -208,6 +222,9 @@ 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,
};
@@ -383,6 +400,9 @@ 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),
};
@@ -294,6 +294,110 @@
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;
+282 -102
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, Input, Tree, type SelectOption, type TreeHandle, T } from "@/ui";
import { Button, Checkbox, Icon, Input, Tree, type SelectOption, type TreeHandle, T } from "@/ui";
import { fontWeightVars, type CustomColorSchemes, type TextAttributesOverride } from "@/platform/editorSettings";
import {
ATTRIBUTES,
@@ -122,6 +122,7 @@ function previewVars(
k.startsWith("--bg-") ||
k.startsWith("--border-") ||
k.startsWith("--ansi-") ||
k.startsWith("--git-") ||
k === "--caret"
)
vars[k] = v;
@@ -140,6 +141,15 @@ 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"})` };
@@ -160,6 +170,17 @@ 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[][];
@@ -975,8 +996,27 @@ 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
@@ -986,50 +1026,29 @@ function AttributeEditor({
/>
</div>
)}
{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="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 && (
<>
<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({
@@ -1043,36 +1062,27 @@ function AttributeEditor({
effectColor: b ? v.effectColor : undefined,
})
}
label="Effects"
label="Effect style"
/>
{!!(v.effect ?? inherited.effect) && (v.effect ?? inherited.effect) !== "none" && (
<ColorInput
value={v.effectColor ?? inherited.effectColor ?? "#808080"}
onChange={(c) => set({ effectColor: c ?? "#808080" })}
label="Effect color"
<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>
{!!(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"
/>
)}
</>
)}
{!attr.italicOnly && (
<div className={styles.inherited}>
<T>The controls show the scheme's actual appearance; changes override it.</T>
</div>
)}
<div className={styles.inherited}>
<T>The controls show the scheme's actual appearance; changes override it.</T>
</div>
</div>
);
}
@@ -1122,7 +1132,8 @@ function ColorRow({
label: string;
value: string | undefined;
inherited: string | undefined;
onChange: (c: string | undefined) => void;
/** Undefined disables the row for attributes that cannot carry this channel. */
onChange: ((c: string | undefined) => void) | undefined;
}) {
const cleared = value === CLEAR_COLOR;
const shown = cleared ? undefined : (value ?? inherited);
@@ -1130,8 +1141,9 @@ function ColorRow({
<div className={styles.attrRow}>
<Checkbox
checked={shown !== undefined}
disabled={!onChange}
onChange={(on) =>
onChange(
onChange?.(
on
? cleared
? (inherited ?? "#808080")
@@ -1143,7 +1155,162 @@ function ColorRow({
}
label={label}
/>
{shown !== undefined && <ColorInput value={shown} onChange={onChange} 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>
</div>
);
}
@@ -1435,39 +1602,43 @@ function ColorSchemePage({ scopes, landing }: { scopes: readonly string[]; landi
<div />
)}
</div>
<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));
{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));
}
}
}
: undefined
}
>
{text}
</span>
);
})}
</code>
</div>
))}
</pre>
: undefined
}
>
{text}
</span>
);
})}
</code>
</div>
))}
</pre>
)}
</>
)}
</div>
@@ -1475,7 +1646,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"].includes(s),
(s) => !["General", "Debugger", "Language Defaults", "Console Colors", "Git"].includes(s),
);
/** A sub-page id: `C#` → `csharp`, `Console Colors` → `console-colors` (`#` would end a settings link). */
const languagePageId = (language: string) =>
@@ -1487,6 +1658,7 @@ 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 })),
];
@@ -1587,11 +1759,19 @@ 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: 5 + i,
order: 6 + i,
projectScope: false,
keywords: ATTRIBUTES.filter((a) => a.path[0] === language).map((a) => a.name),
sections: [colorSchemeSection([language])],
+43 -8
View File
@@ -6,6 +6,8 @@ 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";
@@ -23,9 +25,10 @@ export function stdioConfig(status: McpServerStatus): string | null {
return JSON.stringify({ mcpServers: { openidea: { type: "stdio", command, args } } }, null, 2);
}
function useStatus(): McpServerStatus | null {
function useStatus(): [McpServerStatus | null, () => void] {
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;
@@ -43,13 +46,14 @@ function useStatus(): McpServerStatus | null {
live = false;
off();
};
}, [root]);
return status;
}, [root, loads]);
return [status, () => setLoads((n) => n + 1)];
}
function McpServerEndpoint() {
const tx = useTx();
const status = useStatus();
const [status, reload] = useStatus();
const [fixedToken] = useSetting<boolean>("mcpServer.fixedToken");
const [copied, setCopied] = useState<string | null>(null);
useEffect(() => {
if (!copied) return;
@@ -61,9 +65,13 @@ 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
? tx`Running on port ${port}`
? busy
? tx`Running on port ${port}: the fixed port is unavailable (${busy})`
: tx`Running on port ${port}`
: error
? tx`Failed to start: ${error}`
: tx("Not running");
@@ -72,7 +80,7 @@ function McpServerEndpoint() {
return (
<>
<Row label="Status">
<span className={error ? styles.error : styles.status}>{state}</span>
<span className={error || busy ? styles.error : styles.status}>{state}</span>
</Row>
<Row
label="Endpoint"
@@ -94,7 +102,7 @@ function McpServerEndpoint() {
<Row
label="Client configuration"
stacked
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."
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."
>
<div className={styles.buttons}>
<Button size="small" disabled={!http} onClick={() => copy("http", http)}>
@@ -110,6 +118,13 @@ 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>
)}
</>
);
}
@@ -119,7 +134,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"],
keywords: ["mcp", "model context protocol", "ai", "agent", "claude", "codex", "tools", "stdio", "port", "token"],
sections: [
{
options: [
@@ -135,6 +150,26 @@ 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,6 +110,11 @@
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;
}
+11 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import type { Terminal } from "@xterm/xterm";
import { installDomRowCache } from "./domRowCache";
import { ICON_SPAN_CLASS, installDomRowCache } from "./domRowCache";
/** Mirrors the shape of xterm 6's private DomRenderer closely enough for the cache. */
function fakeRendererClass() {
@@ -76,6 +76,16 @@ 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"]);
+14 -2
View File
@@ -7,12 +7,21 @@
* 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.
*
* Remove once xterm's DomRenderer does this itself (xterm 6.0.0 does not).
* 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).
*/
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;
@@ -92,7 +101,10 @@ function renderRows(this: DomRenderer, start: number, end: number) {
-1,
);
let markup = "";
for (const span of spans) markup += span.outerHTML;
for (const span of spans) {
if (ICON.test(span.textContent ?? "")) span.classList.add(ICON_SPAN_CLASS);
markup += span.outerHTML;
}
if (element[MARKUP] === markup) continue;
element[MARKUP] = markup;
element.replaceChildren(...spans);
+48
View File
@@ -444,6 +444,54 @@
display: flex;
flex-direction: column;
}
/* Go to a merge commit's file: parent 1 | merge result | parent 2, each pane scrolling itself. */
.mergeDiff {
flex: 1;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.mergeDiffLabels {
flex: none;
height: var(--h-row);
display: grid;
grid-template-columns: 1fr 1fr 1fr;
border-bottom: 1px solid var(--border);
color: var(--fg-secondary);
font-size: var(--fs-small);
}
.mergeDiffLabels > span {
display: flex;
align-items: center;
min-width: 0;
padding: 0 12px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.mergeDiffLabels > span + span {
border-left: 1px solid var(--border);
}
.mergeDiffPanes {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
}
.mergeDiffPane {
min-width: 0;
min-height: 0;
display: flex;
overflow: hidden;
}
.mergeDiffPane + .mergeDiffPane {
border-left: 1px solid var(--border);
}
.mergeDiffPane > :global(.cm-editor) {
flex: 1;
min-width: 0;
}
.subjectLine {
color: var(--fg-strong);
font-weight: 600;
+11 -8
View File
@@ -51,10 +51,7 @@ export function GitMergeDiff({ input }: { input: EditorInput }) {
}
for (const [i, text] of texts.entries()) {
const pane = document.createElement("div");
pane.style.cssText = "flex:1;min-width:0;display:flex;flex-direction:column";
const label = document.createElement("div");
label.textContent = `${sides[i]!.rev.slice(0, 8)} · ${sides[i]!.path}`;
pane.append(label);
pane.className = styles.mergeDiffPane!;
host.current.append(pane);
const view = new EditorView({
parent: pane,
@@ -86,10 +83,16 @@ export function GitMergeDiff({ input }: { input: EditorInput }) {
element?.replaceChildren();
};
}, [data]);
const labels = [data.parents[0]!, { rev: data.revision, path: data.path }, data.parents[1]!];
return (
<>
<div role={error ? "alert" : undefined}>{error}</div>
<div className={styles.revisionSource} style={{ display: "flex" }} ref={host} />
</>
<div className={styles.mergeDiff}>
{error && <div role="alert">{error}</div>}
<div className={styles.mergeDiffLabels}>
{labels.map((side, i) => (
<span key={i}>{`${side.rev.slice(0, 8)} · ${side.path}`}</span>
))}
</div>
<div className={styles.mergeDiffPanes} ref={host} />
</div>
);
}
+27 -1
View File
@@ -12,7 +12,7 @@ import type { BlameLine, Settings } from "@/bindings";
import { HEAVY_LIMIT, _emitEditorEvent } from "@/editor/api";
import { useProject } from "@/platform/project";
import { useI18n } from "@/platform/i18n";
import { formatInlineBlame, inlineBlame, relativeBlameDate } from "../inlineBlame";
import { formatInlineBlame, inlineBlame, inlineBlameSegments, relativeBlameDate } from "../inlineBlame";
import { useVcs } from "../store";
const mocks = vi.hoisted(() => ({ blame: vi.fn(), file: vi.fn(), log: vi.fn(), action: vi.fn() }));
@@ -83,6 +83,27 @@ describe("inline blame formatting", () => {
expect(relativeBlameDate(now - 120_000, now)).toBe("2 minutes ago");
expect(relativeBlameDate(now - 400 * 86400_000, now)).toBe("1 year ago");
});
it("splits into colourable segments that join back into the original text", () => {
const joined = (line: Parameters<typeof inlineBlameSegments>[0]) =>
inlineBlameSegments(line, now)
.map((s) => s.text)
.join("");
expect(joined(alice)).toBe(formatInlineBlame(alice, now));
expect(joined({ ...bob, timeMs: null, summary: "Long\nmessage" })).toBe("Bob Jones · Long message");
expect(joined(uncommitted)).toBe("You, now · Uncommitted changes");
expect(inlineBlameSegments(alice, now)).toEqual([
{ text: "Alice Smith, ", key: "author" },
{ text: "1 day ago · ", key: "date" },
{ text: "First line", key: "subject" },
]);
expect(inlineBlameSegments({ ...bob, summary: "Change subject (#feature-12)" }, now)).toEqual([
{ text: "Bob Jones, ", key: "author" },
{ text: "1 hour ago · ", key: "date" },
{ text: "Change subject", key: "subject" },
{ text: " (#feature-12)", key: "issue" },
]);
});
});
describe("current line decoration", () => {
@@ -102,6 +123,11 @@ describe("current line decoration", () => {
expect(mocks.blame).toHaveBeenCalledTimes(1);
expect(mocks.file).toHaveBeenCalledTimes(1);
expect(hint()?.contentEditable).toBe("false");
expect([...hint()!.querySelectorAll("span")].map((s) => s.className)).toEqual([
"cm-vcs-inline-blame-author",
"cm-vcs-inline-blame-date",
"cm-vcs-inline-blame-subject",
]);
hint()?.click();
expect(mocks.action).toHaveBeenCalledWith("vcs.showCommit", alice.commit);
});
+72 -15
View File
@@ -22,31 +22,78 @@ const info = (head: string | null, upstream: string | null, ahead = 0, behind =
operation: null,
});
const COLORS = {
withRemote: "var(--git-master-with-remote, var(--accent))",
withoutRemote: "var(--git-master-without-remote, var(--accent))",
tracking: "var(--git-tracking-branch, var(--accent))",
local: "var(--git-local-branch, var(--accent))",
changed: "var(--git-branch-changed, var(--accent))",
headTags: "var(--git-head-tags, var(--accent))",
};
/** The font style / effect channels every suffix segment now carries. */
const styleOf = (attr: string) => ({
fontWeight: `var(--git-${attr}-bold, inherit)`,
fontStyle: `var(--git-${attr}-italic, inherit)`,
textDecoration: `var(--git-${attr}-effect, none)`,
});
describe("rootSyncSuffix", () => {
it("strikes out zero when the branch matches its upstream", () => {
expect(rootSyncSuffix(info("main", "origin/main"))).toEqual([
{ text: "main" },
{ text: "/" },
{ text: "0", strike: true, label: "Up to date with origin/main" },
{ text: "main", color: COLORS.withRemote, ...styleOf("master-with-remote") },
{ text: "/", color: COLORS.headTags, ...styleOf("head-tags") },
{
text: "0",
strike: true,
label: "Up to date with origin/main",
color: COLORS.headTags,
...styleOf("head-tags"),
},
]);
});
it("shows outgoing then incoming commits with their icons", () => {
expect(rootSyncSuffix(info("main", "origin/main", 1, 3))).toEqual([
{ text: "main" },
{ text: "/" },
{ text: "1", icon: "vcs-outgoing", label: "1 outgoing commits" },
{ text: "3", icon: "vcs-incoming", label: "3 incoming commits" },
{ text: "main", color: COLORS.withRemote, ...styleOf("master-with-remote") },
{ text: "/", color: COLORS.headTags, ...styleOf("head-tags") },
{
text: "1",
icon: "vcs-outgoing",
label: "1 outgoing commits",
color: COLORS.changed,
...styleOf("branch-changed"),
},
{
text: "3",
icon: "vcs-incoming",
label: "3 incoming commits",
color: COLORS.changed,
...styleOf("branch-changed"),
},
]);
expect(rootSyncSuffix(info("main", "origin/main", 0, 2))).toEqual([
{ text: "main" },
{ text: "/" },
{ text: "2", icon: "vcs-incoming", label: "2 incoming commits" },
{ text: "main", color: COLORS.withRemote, ...styleOf("master-with-remote") },
{ text: "/", color: COLORS.headTags, ...styleOf("head-tags") },
{
text: "2",
icon: "vcs-incoming",
label: "2 incoming commits",
color: COLORS.changed,
...styleOf("branch-changed"),
},
]);
});
it("shows only the branch without an upstream", () => {
expect(rootSyncSuffix(info("feature", null))).toEqual([{ text: "feature" }]);
expect(rootSyncSuffix(info("feature", null))).toEqual([
{ text: "feature", color: COLORS.local, ...styleOf("local-branch") },
]);
});
it("uses the tracking and master-without-remote colours", () => {
expect(rootSyncSuffix(info("dev", "origin/dev"))![0]?.color).toBe(COLORS.tracking);
expect(rootSyncSuffix(info("master", null))![0]?.color).toBe(COLORS.withoutRemote);
});
it("shows nothing for a detached HEAD", () => {
@@ -78,8 +125,10 @@ describe("installRootSync", () => {
enable(true);
dispose = installRootSync();
await vi.waitFor(() => expect(getFileDecoration("/p")?.suffix?.[2]?.text).toBe("1"));
expect(getFileDecoration("/p/lib")?.suffix).toEqual([{ text: "dev" }]);
expect(getFileDecoration("/p")?.suffixColor).toBe("var(--accent)");
expect(getFileDecoration("/p/lib")?.suffix).toEqual([
{ text: "dev", color: COLORS.local, ...styleOf("local-branch") },
]);
expect(getFileDecoration("/p")?.suffixColor).toBe("var(--git-local-branch, var(--accent))");
expect(ipc.branchInfo).toHaveBeenCalledWith("/p");
expect(ipc.branchInfo).toHaveBeenCalledWith("/p/lib");
});
@@ -137,7 +186,11 @@ describe("installRootSync", () => {
);
enable(true);
dispose = installRootSync();
await vi.waitFor(() => expect(getFileDecoration("/p/lib")?.suffix).toEqual([{ text: "dev" }]));
await vi.waitFor(() =>
expect(getFileDecoration("/p/lib")?.suffix).toEqual([
{ text: "dev", color: COLORS.local, ...styleOf("local-branch") },
]),
);
expect(getFileDecoration("/p")?.suffix).toBeUndefined();
});
@@ -172,7 +225,11 @@ describe("installRootSync", () => {
pending.splice(0).forEach((done) => done());
await vi.waitFor(() => expect(ipc.branchInfo).toHaveBeenCalledTimes(4));
pending.splice(0).forEach((done) => done());
await vi.waitFor(() => expect(getFileDecoration("/p")?.suffix).toEqual([{ text: "main" }]));
await vi.waitFor(() =>
expect(getFileDecoration("/p")?.suffix).toEqual([
{ text: "main", color: COLORS.withoutRemote, ...styleOf("master-without-remote") },
]),
);
await settle();
expect(ipc.branchInfo).toHaveBeenCalledTimes(4);
});
+57 -2
View File
@@ -42,6 +42,29 @@ export function formatInlineBlame(line: BlameLine, now = Date.now()): string {
return `${line.author}${date ? `, ${date}` : ""} · ${line.summary.replace(/\s+/g, " ")}`;
}
/** The four segments Color Scheme › Git › Editor inline blame styles. */
interface BlameSegment {
text: string;
key: "author" | "date" | "subject" | "issue";
}
/** Splits the blame text so each part can use its own Git scheme colour. */
export function inlineBlameSegments(line: BlameLine, now = Date.now()): BlameSegment[] {
if (isUncommitted(line)) return [{ text: formatInlineBlame(line, now), key: "subject" }];
const date = relativeBlameDate(line.timeMs, now);
const summary = line.summary.replace(/\s+/g, " ");
const author = line.author;
// A trailing (#123) / #ISSUE-1 reference is the issue link, coloured separately.
const issue = /\s(\(#[^)]+\)|#[\w-]+)\s*$/.exec(summary);
const subject = issue ? summary.slice(0, issue.index) : summary;
return [
{ text: date ? `${author}, ` : `${author} · `, key: "author" },
...(date ? [{ text: `${date} · `, key: "date" } as BlameSegment] : []),
{ text: subject, key: "subject" },
...(issue ? [{ text: summary.slice(issue.index), key: "issue" } as BlameSegment] : []),
];
}
/** Only the current document version is retained, including its in-flight request. */
class BlameCache {
private doc: Text | undefined;
@@ -90,7 +113,12 @@ class BlameWidget extends WidgetType {
toDOM() {
const el = document.createElement("span");
el.className = "cm-vcs-inline-blame";
el.textContent = formatInlineBlame(this.line);
for (const segment of inlineBlameSegments(this.line)) {
const part = document.createElement("span");
part.className = `cm-vcs-inline-blame-${segment.key}`;
part.textContent = segment.text;
el.append(part);
}
el.contentEditable = "false";
// The element is only measurable once CodeMirror has inserted it: defer to the next task, not a
// nested frame, so a widget that goes away first simply skips.
@@ -291,7 +319,6 @@ export function inlineBlame(ctx: EditorDocContext): Extension {
),
EditorView.baseTheme({
".cm-vcs-inline-blame": {
color: "var(--fg-inlay)",
marginLeft: "3ch",
display: "inline-block",
maxWidth: "min(60ch, 50vw)",
@@ -301,6 +328,34 @@ export function inlineBlame(ctx: EditorDocContext): Extension {
verticalAlign: "bottom",
userSelect: "none",
},
".cm-vcs-inline-blame-author": {
color: "var(--git-blame-author, var(--fg-inlay))",
backgroundColor: "var(--git-blame-author-bg, transparent)",
fontWeight: "var(--git-blame-author-bold, inherit)",
fontStyle: "var(--git-blame-author-italic, inherit)",
textDecoration: "var(--git-blame-author-effect, none)",
},
".cm-vcs-inline-blame-date": {
color: "var(--git-blame-date, var(--fg-inlay))",
backgroundColor: "var(--git-blame-date-bg, transparent)",
fontWeight: "var(--git-blame-date-bold, inherit)",
fontStyle: "var(--git-blame-date-italic, inherit)",
textDecoration: "var(--git-blame-date-effect, none)",
},
".cm-vcs-inline-blame-subject": {
color: "var(--git-blame-subject, var(--fg-inlay))",
backgroundColor: "var(--git-blame-subject-bg, transparent)",
fontWeight: "var(--git-blame-subject-bold, inherit)",
fontStyle: "var(--git-blame-subject-italic, inherit)",
textDecoration: "var(--git-blame-subject-effect, none)",
},
".cm-vcs-inline-blame-issue": {
color: "var(--git-blame-issue, var(--fg-inlay))",
backgroundColor: "var(--git-blame-issue-bg, transparent)",
fontWeight: "var(--git-blame-issue-bold, inherit)",
fontStyle: "var(--git-blame-issue-italic, inherit)",
textDecoration: "var(--git-blame-issue-effect, none)",
},
'.cm-vcs-inline-blame[role="button"]': { cursor: "pointer" },
".cm-vcs-inline-blame-tooltip": {
padding: "8px 12px",
+50 -6
View File
@@ -7,17 +7,61 @@ import { useVcs } from "./store";
const PROVIDER = "vcs-root-sync";
const GIT_CHANGE_DELAY_MS = 250;
const SUFFIX_COLOR = "var(--accent)";
const SUFFIX_COLOR = "var(--git-local-branch, var(--accent))";
/** Whether a branch name is a default branch (master/main get their own colours). */
const defaultBranch = (name: string) => name === "master" || name === "main";
/** Font style / effect a scheme override puts on one Git attribute, e.g. bold tracking branches. */
const styled = (attr: string, extra: Pick<DecorationSegment, "text"> & DecorationSegment): DecorationSegment => ({
fontWeight: `var(--git-${attr}-bold, inherit)`,
fontStyle: `var(--git-${attr}-italic, inherit)`,
textDecoration: `var(--git-${attr}-effect, none)`,
...extra,
});
export function rootSyncSuffix(info: BranchInfo): DecorationSegment[] | null {
const { head, upstream, ahead, behind } = info;
if (!head) return null;
if (!upstream) return [{ text: head }];
const branchAttr = upstream
? defaultBranch(head)
? "master-with-remote"
: "tracking-branch"
: defaultBranch(head)
? "master-without-remote"
: "local-branch";
const branchColor = `var(--git-${branchAttr}, var(--accent))`;
const branch: DecorationSegment = styled(branchAttr, { text: head, color: branchColor });
if (!upstream) return [branch];
const counts: DecorationSegment[] = [];
if (ahead > 0) counts.push({ text: String(ahead), icon: "vcs-outgoing", label: tx`${ahead} outgoing commits` });
if (behind > 0) counts.push({ text: String(behind), icon: "vcs-incoming", label: tx`${behind} incoming commits` });
if (!counts.length) counts.push({ text: "0", strike: true, label: tx`Up to date with ${upstream}` });
return [{ text: head }, { text: "/" }, ...counts];
if (ahead > 0)
counts.push(
styled("branch-changed", {
text: String(ahead),
icon: "vcs-outgoing",
label: tx`${ahead} outgoing commits`,
color: "var(--git-branch-changed, var(--accent))",
}),
);
if (behind > 0)
counts.push(
styled("branch-changed", {
text: String(behind),
icon: "vcs-incoming",
label: tx`${behind} incoming commits`,
color: "var(--git-branch-changed, var(--accent))",
}),
);
if (!counts.length)
counts.push(
styled("head-tags", {
text: "0",
strike: true,
label: tx`Up to date with ${upstream}`,
color: "var(--git-head-tags, var(--accent))",
}),
);
return [branch, styled("head-tags", { text: "/", color: "var(--git-head-tags, var(--accent))" }), ...counts];
}
async function branchInfoOf(root: string): Promise<[string, BranchInfo] | null> {
+97 -3
View File
@@ -31,6 +31,8 @@ export interface AttributeDef {
token?: string;
/** A semantic modifier whose only configurable attribute is italic. */
italicOnly?: boolean;
/** The attribute's renderer applies font style and effects (tokens, the Git surfaces). */
fontStyle?: true;
defaultBold?: boolean;
defaultItalic?: boolean;
defaultEffect?: TextAttributesOverride["effect"];
@@ -49,6 +51,7 @@ const syn = (
name,
path,
token,
fontStyle: true,
fg: `--syn-${token}`,
...defaults,
});
@@ -68,7 +71,12 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
{ id: "text", name: "Default text", path: ["General", "Text"], fg: "--fg-editor", bg: "--bg-editor" },
{ id: "caret", name: "Caret", path: ["General", "Editor"], fg: "--caret" },
{ id: "caretRow", name: "Caret row", path: ["General", "Editor"], bg: "--bg-current-line" },
{ id: "selection", name: "Selection background", path: ["General", "Editor"], bg: "--bg-editor-selection" },
{
id: "selection",
name: "Selection background",
path: ["General", "Editor"],
bg: "--bg-editor-selection",
},
{
id: "selectionForeground",
name: "Selection foreground",
@@ -119,7 +127,12 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
fg: "--fg-fold-placeholder",
bg: "--bg-fold-placeholder",
},
{ id: "searchResult", name: "Search result", path: ["General", "Search Results"], bg: "--bg-search-match" },
{
id: "searchResult",
name: "Search result",
path: ["General", "Search Results"],
bg: "--bg-search-match",
},
{
id: "searchResultCurrent",
name: "Current search result",
@@ -137,7 +150,12 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
// Console / terminal: IDEA's Console Colors (background + 16 ANSI). `--ansi-*` is mirrored
// to `--terminal-ansi-*` in overrideDeclarations so the terminal picks up edits.
{ id: "consoleBackground", name: "Console background", path: ["Console Colors"], bg: "--bg-console" },
{
id: "consoleBackground",
name: "Console background",
path: ["Console Colors"],
bg: "--bg-console",
},
{ id: "ansiBlack", name: "Black", path: ["Console Colors"], fg: "--ansi-black" },
{ id: "ansiRed", name: "Red", path: ["Console Colors"], fg: "--ansi-red" },
{ id: "ansiGreen", name: "Green", path: ["Console Colors"], fg: "--ansi-green" },
@@ -155,6 +173,63 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
{ id: "ansiBrightCyan", name: "Bright cyan", path: ["Console Colors"], fg: "--ansi-bright-cyan" },
{ id: "ansiBrightWhite", name: "Bright white", path: ["Console Colors"], fg: "--ansi-bright-white" },
// Color Scheme › Git: inline blame segments and project view branch decorations.
// The inline blame widget and the project root suffix read these variables directly;
// only the blame segments render font style, so the branch decorations opt out.
{
id: "gitBlameAuthor",
name: "Editor inline blame › Author",
path: ["Git"],
fg: "--git-blame-author",
bg: "--git-blame-author-bg",
fontStyle: true,
},
{
id: "gitBlameDate",
name: "Editor inline blame › Date time",
path: ["Git"],
fg: "--git-blame-date",
bg: "--git-blame-date-bg",
fontStyle: true,
},
{
id: "gitBlameIssue",
name: "Editor inline blame › Issue link",
path: ["Git"],
fg: "--git-blame-issue",
bg: "--git-blame-issue-bg",
fontStyle: true,
},
{
id: "gitBlameSubject",
name: "Editor inline blame › Subject",
path: ["Git"],
fg: "--git-blame-subject",
bg: "--git-blame-subject-bg",
fontStyle: true,
},
{ id: "gitBranchChanged", name: "Project View › Changed decoration", path: ["Git"], fg: "--git-branch-changed" },
{ id: "gitHeadTags", name: "Project View › HEAD tags decoration", path: ["Git"], fg: "--git-head-tags" },
{ id: "gitLocalBranch", name: "Project View › Local branch decoration", path: ["Git"], fg: "--git-local-branch" },
{
id: "gitMasterWithRemote",
name: "Project View › master/main with remote",
path: ["Git"],
fg: "--git-master-with-remote",
},
{
id: "gitMasterWithoutRemote",
name: "Project View › master/main without remote",
path: ["Git"],
fg: "--git-master-without-remote",
},
{
id: "gitTrackingBranch",
name: "Project View › Tracking branch decoration",
path: ["Git"],
fg: "--git-tracking-branch",
},
syn("keyword", "Keyword", [LD], "keyword"),
syn("identifier", "Identifiers › Local variable", [LD], "variable"),
syn("parameter", "Identifiers › Parameter", [LD], "param"),
@@ -449,6 +524,19 @@ export function overrideDeclarations(
`--syn-${t}-effect`,
EFFECT_CSS[o.effect]?.(setOrCleared(o.effectColor) ? o.effectColor! : "currentColor") ?? "none",
]);
} else if (attr.fontStyle) {
// Token-less attributes whose renderer applies font style (the Git blame segments)
// carry it on `--<fg>-*`, mirroring the token scheme's `--syn-<token>-*` family.
const base = attr.fg ?? attr.effect;
if (base) {
if (o.bold !== undefined) out.push([`${base}-bold`, boldWeight(o.bold)]);
if (o.italic !== undefined) out.push([`${base}-italic`, o.italic ? "italic" : "normal"]);
if (o.effect)
out.push([
`${attr.effect ?? base}-effect`,
EFFECT_CSS[o.effect]?.(setOrCleared(o.effectColor) ? o.effectColor! : "currentColor") ?? "none",
]);
}
}
}
return out;
@@ -558,6 +646,12 @@ export function resolvedAttribute(
const italicCss =
vars.get(`--syn-${token}-italic`) ?? (token === "comment" ? vars.get("--syn-comment-style") : undefined);
out.italic = own?.italic ?? italicFromCss(italicCss) ?? attr.defaultItalic ?? false;
} else if (attr.fontStyle) {
const base = attr.fg ?? attr.effect;
const bold = base ? vars.get(`${base}-bold`) : undefined;
const italic = base ? vars.get(`${base}-italic`) : undefined;
if (bold !== undefined) out.bold = boldFromCss(bold) ?? false;
if (italic !== undefined) out.italic = italicFromCss(italic) ?? false;
} else if (effect) out.effect = effect;
if (token && effect) out.effect = effect;
return out;
+2
View File
@@ -95,6 +95,7 @@ export const eclipseMacos: Record<string, string[]> = {
"diff.nextFile": [],
"navigate.nextOccurrence": [],
"editor.nextTab": ["Meta+F6", "Meta+Alt+Right"],
"db.nextResultTab": ["Meta+F6", "Meta+Alt+Right"],
"code.optimizeImports": ["Shift+Meta+O"],
"project.optimizeImports": ["Shift+Meta+O"],
"code.overrideMethods": [],
@@ -102,6 +103,7 @@ export const eclipseMacos: Record<string, string[]> = {
"code.expressionTypeInfo": [],
"navigate.prevOccurrence": [],
"editor.prevTab": ["Shift+Meta+F6", "Meta+Alt+Left"],
"db.previousResultTab": ["Shift+Meta+F6", "Meta+Alt+Left"],
"code.quickDocumentation": ["F2"],
"db.quickDoc": ["F2"],
"localHistory.recentChanges": [],
+2
View File
@@ -99,6 +99,7 @@ export const eclipseWindows: Record<string, string[]> = {
"diff.applyRightSide": [],
"navigate.nextOccurrence": [],
"editor.nextTab": ["Ctrl+F6", "Ctrl+Alt+Left", "Ctrl+PageDown"],
"db.nextResultTab": ["Ctrl+F6", "Ctrl+Alt+Left", "Ctrl+PageDown"],
"code.optimizeImports": ["Ctrl+Alt+O", "Shift+Ctrl+O"],
"project.optimizeImports": ["Ctrl+Alt+O", "Shift+Ctrl+O"],
"code.overrideMethods": [],
@@ -106,6 +107,7 @@ export const eclipseWindows: Record<string, string[]> = {
"code.expressionTypeInfo": [],
"navigate.prevOccurrence": [],
"editor.prevTab": ["Shift+Ctrl+F6", "Ctrl+Alt+Right", "Ctrl+PageUp"],
"db.previousResultTab": ["Shift+Ctrl+F6", "Ctrl+Alt+Right", "Ctrl+PageUp"],
"code.quickDocumentation": ["Shift+Ctrl+Space", "F2"],
"db.quickDoc": ["Shift+Ctrl+Space", "F2"],
"localHistory.recentChanges": [],
+2
View File
@@ -57,6 +57,8 @@ const shared = [
["refactor.changeSignature", "db.modifyTable"],
["refactor.move", "project.move"],
["editor.closeTab", "toolwindow.closeContent"],
["editor.nextTab", "db.nextResultTab"],
["editor.prevTab", "db.previousResultTab"],
["run.rerun", "debug.rerun", "find.rerun", "usages.rerun"],
[
"debug.smartStepInto",
+2
View File
@@ -363,6 +363,8 @@ export const intellijMacos: Record<string, string[]> = {
"db.openValueWindow": [],
"db.findInValue": ["Meta+F"],
"db.execute": ["Meta+Enter"],
"db.nextResultTab": ["Meta+Shift+]", "Ctrl+Right"],
"db.previousResultTab": ["Meta+Shift+[", "Ctrl+Left"],
// Table editor with pending changes (after `vcs.rollback`, so ⌥⌘Z reverts the table's edits there).
"db.goToColumn": ["Meta+F12"],
"db.submitChanges": ["Meta+Enter"],
+2
View File
@@ -361,6 +361,8 @@ export const intellijWindows: Record<string, string[]> = {
"db.openValueWindow": [],
"db.findInValue": ["Ctrl+F"],
"db.execute": ["Ctrl+Enter"],
"db.nextResultTab": ["Alt+Right"],
"db.previousResultTab": ["Alt+Left"],
// Table editor with pending changes (after `vcs.rollback`, so Ctrl+Alt+Z reverts the table's edits there).
"db.goToColumn": ["Ctrl+F12"],
"db.submitChanges": ["Ctrl+Enter"],
+2
View File
@@ -359,6 +359,8 @@ export const vscodeMacos: Record<string, string[]> = {
"db.openValueWindow": [],
"db.findInValue": ["Meta+F"],
"db.execute": ["Meta+Enter"],
"db.nextResultTab": ["Meta+Alt+Right", "Meta+Shift+]"],
"db.previousResultTab": ["Meta+Alt+Left", "Meta+Shift+["],
"db.showToolWindow": [],
// Database tool window (when the tree has focus).
"db.openConsole": [],
+2
View File
@@ -37,6 +37,8 @@ export const vscodeWindows: Record<string, string[]> = {
"toolwindow.closeContent": ["Ctrl+W", "Ctrl+F4"],
"editor.nextTab": ["Ctrl+PageDown"],
"editor.prevTab": ["Ctrl+PageUp"],
"db.nextResultTab": ["Ctrl+PageDown"],
"db.previousResultTab": ["Ctrl+PageUp"],
"window.toggleFullScreen": ["F11"],
"project.rename": ["F2"],
"project.delete": ["Delete"],
+35 -2
View File
@@ -19,6 +19,7 @@
"Tab name:": "Tab name:",
"Select Next Tab": "Select Next Tab",
"Select Previous Tab": "Select Previous Tab",
"Result {n}-{index}": "Result {n}-{index}",
"Absolute": "Absolute",
"Relative": "Relative",
"Hybrid": "Hybrid",
@@ -44,6 +45,28 @@
"Cyan": "Cyan",
"Dark gray": "Dark gray",
"Magenta": "Magenta",
"Editor inline blame": "Editor inline blame",
"Author": "Author",
"Date time": "Date time",
"Issue link": "Issue link",
"Subject": "Subject",
"Project View": "Project View",
"Changed decoration": "Changed decoration",
"HEAD tags decoration": "HEAD tags decoration",
"Local branch decoration": "Local branch decoration",
"master/main with remote": "master/main with remote",
"master/main without remote": "master/main without remote",
"Tracking branch decoration": "Tracking branch decoration",
"Editor inline blame › Author": "Editor inline blame › Author",
"Editor inline blame › Date time": "Editor inline blame › Date time",
"Editor inline blame › Issue link": "Editor inline blame › Issue link",
"Editor inline blame › Subject": "Editor inline blame › Subject",
"Project View › Changed decoration": "Project View › Changed decoration",
"Project View › HEAD tags decoration": "Project View › HEAD tags decoration",
"Project View › Local branch decoration": "Project View › Local branch decoration",
"Project View › master/main with remote": "Project View › master/main with remote",
"Project View › master/main without remote": "Project View › master/main without remote",
"Project View › Tracking branch decoration": "Project View › Tracking branch decoration",
"Dock Pinned": "Dock Pinned",
"Dock Unpinned": "Dock Unpinned",
"Undock": "Undock",
@@ -760,7 +783,7 @@
"Open a project to get its endpoint.": "Open a project to get its endpoint.",
"No project open": "No project open",
"Client configuration": "Client configuration",
"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.": "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.",
"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.": "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.",
"Copy HTTP Config": "Copy HTTP Config",
"Copy Stdio Config": "Copy Stdio Config",
"Copied to the clipboard": "Copied to the clipboard",
@@ -1472,5 +1495,15 @@
"Archived ({count})": "Archived ({count})",
"no usages": "no usages",
"1 usage": "1 usage",
"{n} usages": "{n} usages"
"{n} usages": "{n} usages",
"Running on port {port}: the fixed port is unavailable ({busy})": "Running on port {port}: the fixed port is unavailable ({busy})",
"Token": "Token",
"Clients configured with the current token stop working.": "Clients configured with the current token stop working.",
"Regenerate Token": "Regenerate Token",
"Use a fixed port": "Use a fixed port",
"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.": "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.",
"Port": "Port",
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "The fixed port on 127.0.0.1, used while Use a fixed port is on.",
"Keep the token": "Keep the token",
"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.": "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."
}
+1 -1
View File
@@ -753,7 +753,7 @@
"Editor › Font": "Editor › Fuente",
"Editor › General": "Editor › General",
"Effect": "Efecto",
"Effect color": "Color del efecto",
"Effect style": "Estilo de efecto",
"Effects": "Efectos",
"Element": "Elemento",
"Empty": "Vacío",
+1 -1
View File
@@ -691,7 +691,7 @@
"Editor › Font": "Éditeur › Police",
"Editor › General": "Éditeur › Général",
"Effect": "Effet",
"Effect color": "Couleur de l'effet",
"Effect style": "Style d'effet",
"Effects": "Effets",
"Element": "Élément",
"Empty": "Vide",
+1 -1
View File
@@ -789,7 +789,7 @@
"Editor › Font": "편집기 › 글꼴",
"Editor › General": "편집기 › 일반",
"Effect": "효과",
"Effect color": "효과 색상",
"Effect style": "효과 스타일",
"Effects": "효과",
"Element": "요소",
"Empty": "비어 있음",
+1 -1
View File
@@ -745,7 +745,7 @@
"Editor › Font": "Edytor › Czcionka",
"Editor › General": "Edytor › Ogólne",
"Effect": "Efekt",
"Effect color": "Kolor efektu",
"Effect style": "Styl efektu",
"Effects": "Efekty",
"Element": "Element",
"Empty": "Pusty",
+1 -1
View File
@@ -745,7 +745,7 @@
"Editor › Font": "Editor › Fonte",
"Editor › General": "Editor › Geral",
"Effect": "Efeito",
"Effect color": "Cor do efeito",
"Effect style": "Estilo do efeito",
"Effects": "Efeitos",
"Element": "Elemento",
"Empty": "Vazio",
+35 -3
View File
@@ -41,6 +41,26 @@
"Cyan": "青色",
"Dark gray": "深灰色",
"Magenta": "品红色",
"Editor inline blame": "编辑器内联 Blame",
"Date time": "日期时间",
"Issue link": "Issue 链接",
"Subject": "主题",
"Changed decoration": "已变更装饰",
"HEAD tags decoration": "HEAD 标签装饰",
"Local branch decoration": "本地分支装饰",
"master/main with remote": "有远程的 master/main",
"master/main without remote": "无远程的 master/main",
"Tracking branch decoration": "跟踪分支装饰",
"Editor inline blame › Author": "编辑器内联 Blame › 作者",
"Editor inline blame › Date time": "编辑器内联 Blame › 日期时间",
"Editor inline blame › Issue link": "编辑器内联 Blame › Issue 链接",
"Editor inline blame › Subject": "编辑器内联 Blame › 主题",
"Project View › Changed decoration": "项目视图 › 已变更装饰",
"Project View › HEAD tags decoration": "项目视图 › HEAD 标签装饰",
"Project View › Local branch decoration": "项目视图 › 本地分支装饰",
"Project View › master/main with remote": "项目视图 › 有远程的 master/main",
"Project View › master/main without remote": "项目视图 › 无远程的 master/main",
"Project View › Tracking branch decoration": "项目视图 › 跟踪分支装饰",
"Dock Pinned": "停靠固定",
"Dock Unpinned": "停靠取消固定",
"Undock": "取消停靠",
@@ -1166,7 +1186,7 @@
"Editor › Font": "编辑器 › 字体",
"Editor › General": "编辑器 › 常规",
"Effect": "效果",
"Effect color": "效果颜色",
"Effect style": "效果样式",
"Effects": "效果",
"Element": "元素",
"Empty": "空",
@@ -4611,6 +4631,8 @@
"Language servers: {text}": "语言服务器:{text}",
"Install {name}": "安装 {name}",
"Restart {name}": "重启 {name}",
"Check for {name} again": "重新检测 {name}",
"{name} is still not installed": "{name} 仍未安装",
"{count} error": "{count} 个错误",
"{count} errors": "{count} 个错误",
"{count} warning": "{count} 个警告",
@@ -4641,6 +4663,7 @@
"{count} rows": "{count} 行",
"Remove data source '{name}'? Its query console file stays on disk.": "是否移除数据源 '{name}'?其查询控制台文件将保留在磁盘上。",
"Result {n}": "结果 {n}",
"Result {n}-{index}": "结果 {n}-{index}",
"{name} has uncommitted changes. Commit or roll them back before switching to auto-commit?": "{name} 有未提交的更改。是否在切换到自动提交前提交或回滚这些更改?",
"Cannot copy the name": "无法复制名称",
"Cannot copy the reference": "无法复制引用",
@@ -7149,7 +7172,7 @@
"Open a project to get its endpoint.": "打开一个项目以获取其端点。",
"No project open": "未打开项目",
"Client configuration": "客户端配置",
"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.": "HTTP 配置包含本次运行的令牌,OpenIDEA 重启后即失效。stdio 配置会启动 OpenIDEA 的可执行文件,由它转发到正在运行的 IDE。",
"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.": "HTTP 配置包含令牌:只有同时使用固定端口并保留令牌时,它在 OpenIDEA 重启后才继续有效。stdio 配置会启动 OpenIDEA 的可执行文件,由它转发到正在运行的 IDE。",
"Copy HTTP Config": "复制 HTTP 配置",
"Copy Stdio Config": "复制 Stdio 配置",
"Copied to the clipboard": "已复制到剪贴板",
@@ -7870,5 +7893,14 @@
"Archived ({count})": "归档({count})",
"no usages": "无用法",
"1 usage": "1 个用法",
"{n} usages": "{n} 个用法"
"{n} usages": "{n} 个用法",
"Running on port {port}: the fixed port is unavailable ({busy})": "正在端口 {port} 上运行:固定端口不可用({busy})",
"Token": "令牌",
"Clients configured with the current token stop working.": "使用当前令牌配置的客户端将失效。",
"Regenerate Token": "重新生成令牌",
"Use a fixed port": "使用固定端口",
"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.": "每次运行都监听同一个端口,这样 OpenIDEA 重启后 HTTP 客户端和端口转发仍然有效。端口被占用时,本次运行改用一个空闲端口,并在状态中说明。关闭时:每次运行使用一个空闲端口。",
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "127.0.0.1 上的固定端口,在开启“使用固定端口”时使用。",
"Keep the token": "保留令牌",
"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.": "每次运行都使用同一个访问令牌,只为你的账户保存。关闭时:每次运行生成新令牌。任何拿到令牌的人都能在 OpenIDEA 运行时使用这些工具,令牌泄露时请重新生成。"
}
+12
View File
@@ -223,6 +223,18 @@ html {
/* console / terminal surfaces (Color Scheme › Console Colors) */
--bg-console: var(--bg-island);
/* Git colors (Color Scheme › Git): editor inline blame and project view branch decorations */
--git-blame-author: var(--fg-inlay);
--git-blame-date: var(--fg-inlay);
--git-blame-issue: var(--fg-inlay);
--git-blame-subject: var(--fg-inlay);
--git-branch-changed: var(--accent);
--git-head-tags: var(--accent);
--git-local-branch: var(--accent);
--git-master-with-remote: var(--accent);
--git-master-without-remote: var(--accent);
--git-tracking-branch: var(--accent);
/* terminal / console ANSI palette */
--ansi-black: #000000;
--ansi-red: #f0524f;
+3
View File
@@ -105,6 +105,9 @@
opacity: 0;
transition: opacity var(--dur-fast) var(--ease);
}
.unpin {
color: var(--fg-muted);
}
.tab:hover .close,
.tab:where([data-context-menu-open]) .close,
.selected .close,
+27 -5
View File
@@ -33,6 +33,11 @@ export interface TabItem {
text?: string;
icon?: IconName | (string & {});
closable?: boolean;
/**
* Pinned (IDEA's content tabs): with `onTogglePin`, an Unpin button takes the close
* button's place, and the strip's close gestures leave the tab alone.
*/
pinned?: boolean;
/** Unsaved changes marker. */
modified?: boolean;
/** CSS colour for the label (VCS status etc.), e.g. `var(--vcs-modified)`. */
@@ -176,9 +181,9 @@ export function Tabs({
() =>
register?.({
closeSelected: () => {
const { items, activeId, onClose } = latest.current;
const { items, activeId, onClose, onTogglePin } = latest.current;
const item = items.find((it) => it.id === activeId);
if (!item || !onClose || item.closable === false) return false;
if (!item || !onClose || item.closable === false || (item.pinned && onTogglePin)) return false;
onClose(item.id);
return true;
},
@@ -310,7 +315,7 @@ export function Tabs({
break;
case "Delete":
case "Backspace":
if (onClose && item.closable !== false) onClose(item.id);
if (onClose && item.closable !== false && !(item.pinned && onTogglePin)) onClose(item.id);
else return;
break;
default:
@@ -364,14 +369,15 @@ export function Tabs({
>
{items.map((item, index) => {
const selected = item.id === activeId;
const closable = !!onClose && item.closable !== false;
const pinned = !!onTogglePin && !!item.pinned;
const closable = !!onClose && item.closable !== false && !pinned;
return (
<div
key={item.id}
className={clsx(
styles.tab,
selected && styles.selected,
closable && styles.closable,
(closable || pinned) && styles.closable,
dragging === item.id && styles.dragging,
marker === index && styles.dropBefore,
marker === items.length && index === items.length - 1 && styles.dropAfter,
@@ -458,6 +464,22 @@ export function Tabs({
{item.trailing}
{item.modified && !closable && <span className={styles.modified} aria-label={tx("Modified")} />}
</button>
{pinned && (
<span className={styles.closeSlot} data-tab-close>
<IconButton
icon="pin"
title="Unpin Tab"
size="small"
iconSize={14}
tabIndex={-1}
className={styles.unpin}
onClick={(e) => {
e.stopPropagation();
onTogglePin!(item.id);
}}
/>
</span>
)}
{closable && (
<span className={styles.closeSlot} data-tab-close>
{item.modified && <span className={styles.modified} aria-label={tx("Modified")} />}
+6
View File
@@ -894,6 +894,12 @@ export interface DecorationSegment {
icon?: string;
strike?: boolean;
label?: string;
/** CSS colour of this segment; falls back to the suffix colour. */
color?: string;
/** CSS values for the segment's font style / effect, e.g. scheme-driven bold or wavy underline. */
fontWeight?: string;
fontStyle?: string;
textDecoration?: string;
}
export interface FileDecoration {
@@ -666,6 +666,14 @@ export function ToolWindowHeader({
);
}
/** Makes the tab strips in `children`, rendered in a tool window's content, the ones Close Tab (⌘W) acts on. */
export function ToolWindowContentTabs({ children }: { children: ReactNode }) {
const tw = useContext(ToolWindowFrame)?.tw;
return (
<TabStripRegistry.Provider value={tw ? contentTabRegistry(tw.id) : null}>{children}</TabStripRegistry.Provider>
);
}
/**
* The Options (`⋮` Move To / Maximize / Hide) and Hide (`—`) buttons of a tool
* window that uses `useOwnToolWindowHeader` without a full `ToolWindowHeader`.
+1 -1
View File
@@ -1,6 +1,6 @@
import type { TabStripHandle } from "@/ui/Tabs";
/** Content tab strips rendered in each tool window's header (Run sessions, Find tabs, terminals…). */
/** Content tab strips of each tool window, in its header or content (Run sessions, Find tabs, terminals, query results…). */
const strips = new Map<string, Set<TabStripHandle>>();
const registries = new Map<string, (handle: TabStripHandle) => () => void>();