Compare commits

...
Author SHA1 Message Date
l0ng-ai 6b3519c7f8 fix(python): refresh the interpreter page after recreating an environment, pick a conda env's Python version
Creating an environment at the path the project interpreter already pointed to (a deleted .venv recreated) left the Python Interpreter page saying "<path> does not exist": the package list and the validity check only reloaded when the path changed. Both now reload after an environment is created.

A new conda environment offered a "Base interpreter" chosen among existing environments, which read as copying one; only its Python version was used. It now asks for a Python version (conda create -p <location> python=<version>), and the note says the environment starts with only Python.

Refs #3109
2026-11-13 18:55:24 +08:00
l0ng-ai fdc8bc2d4c fix(editor): leave reference counts to Code Vision's usages (#3139)
csharp-ls adds a "3 Reference(s)" lens above each declaration, next to Code Vision's own "3 usages". It duplicated the count, ignored the Code Vision settings, and was a second set of lens rows replaced after every edit. Language-server lenses that are only a reference count are now left out; Settings › Editor › Inlay Hints › Code Vision › Usages governs the count.

Refs #3107
2026-11-13 18:41:34 +08:00
l0ng-ai 497d4766d7 fix(vcs): lay out a merge commit's file side by side, each pane scrolling (#3138)
Opening a file of a merge commit from the Git log stacked its three panes in a column the editor could not scroll: only the first parent's text showed, without the changes, and neither the wheel nor the arrow keys moved it. The panes now sit side by side under a label row, each scrolls and they stay in step.

Fixes #3108
2026-11-13 18:38:56 +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
63 changed files with 2416 additions and 326 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();
+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();
+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");
+11 -2
View File
@@ -118,9 +118,18 @@ 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**.
+10 -2
View File
@@ -118,9 +118,17 @@ 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 方案分别使用各自默认的编辑器标签页导航快捷键。切换后焦点移到目标结果标签页。结果导航快捷键仅在结果区域中生效,菜单或对话框打开时不会切换结果。可在 **设置 › 快捷键** 中单独修改数据库结果快捷键,不影响编辑器快捷键。
+99
View File
@@ -92,6 +92,8 @@ 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⇧⌘[",
@@ -413,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),
+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())));
+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 });
+24 -2
View File
@@ -46,7 +46,7 @@ it("keeps mapped lenses across edits instead of clearing them (#2710)", async ()
data: [
{
range: { start: { line: 1, col: 0 }, end: { line: 1, col: 3 } },
command: { title: "1 reference", command: "csharp.showReferences", arguments: [] },
command: { title: "Run test", command: "csharp.runTest", arguments: [] },
},
],
});
@@ -67,5 +67,27 @@ it("keeps mapped lenses across edits instead of clearing them (#2710)", async ()
const after = view.state.facet(codeLenses);
expect(after).toHaveLength(1);
expect(after[0]!.pos).toBe(posBefore + 1);
expect(after[0]!.title).toBe("1 reference");
expect(after[0]!.title).toBe("Run test");
});
it("leaves reference counts to Code Vision's usages", async () => {
vi.useFakeTimers();
const lens = (line: number, title: string) => ({
range: { start: { line, col: 0 }, end: { line, col: 1 } },
command: { title, command: "csharp.showReferences", arguments: [] },
});
ipc.lspCodeLens.mockResolvedValue({
status: "ok",
data: [lens(0, "3 Reference(s)"), lens(1, "1 reference"), lens(2, "12 references"), lens(3, "2 implementations")],
});
const view = new EditorView({
state: EditorState.create({
doc: "a\nb\nc\nd",
extensions: serverCodeLenses({ doc: 1, path: "/p/a.cs", language: "csharp", uiMode: null, readOnly: false }),
}),
parent: document.body,
});
views.push(view);
await vi.advanceTimersByTimeAsync(400);
expect(view.state.facet(codeLenses).map((l) => l.title)).toEqual(["2 implementations"]);
});
+7 -1
View File
@@ -6,6 +6,12 @@ import { call, commands } from "@/platform/ipc";
import { onNavigationProvidersChanged } from "@/editor/navigationProviders";
import { posToOffset } from "./positions";
import { onLspRefresh, onServerSettled } from "./availability";
/**
* A server's reference count ("3 references", csharp-ls's "3 Reference(s)"): Code Vision's
* Usages shows the same count above the declaration, under its own setting.
*/
const REFERENCE_COUNT = /^\s*\d+\s+references?(\(s\))?\s*$/i;
const set = StateEffect.define<CodeLens[]>();
const field = StateField.define<CodeLens[]>({
create: () => [],
@@ -62,7 +68,7 @@ export function serverCodeLenses(ctx: EditorDocContext): Extension {
view.dispatch({
effects: set.of(
result.flatMap((l) =>
l.command
l.command && !REFERENCE_COUNT.test(l.command.title)
? [
{
pos: posToOffset(text, l.range.start),
+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",
+39 -21
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,6 +665,13 @@ 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);
@@ -706,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>
+158 -8
View File
@@ -3,6 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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"],
@@ -139,6 +150,31 @@ function expectConsoleStateUnchanged(before: typeof initialDatabase) {
}
describe("database result tab context menu", () => {
it("offers only result actions, in order with the two group separators", async () => {
await show();
const menu = openMenu("Orders");
expect(
within(menu)
.getAllByRole("menuitem")
.map((item) => item.textContent),
).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(/layout/i)).not.toBeInTheDocument();
expectSelected("Customer count");
expect(screen.getByRole("gridcell", { name: "count-row" })).toBeInTheDocument();
});
it("closes the clicked inactive result without changing the selected result", async () => {
await show();
openMenu("Orders");
@@ -201,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);
@@ -331,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();
@@ -482,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();
});
});
+32 -1
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,6 +17,10 @@ function closeOthers(id: number) {
for (const tab of useDatabase.getState().results) if (tab.id !== id) closeResult(tab.id);
}
function pinned(id: number | null): boolean {
return !!useDatabase.getState().results.find((r) => r.id === id)?.pinned;
}
function cycle(direction: 1 | -1, focus: boolean) {
const { results, activeResult } = useDatabase.getState();
const ids = [OUTPUT_TAB, ...results.map((r) => r.id)];
@@ -59,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",
@@ -91,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 },
+149 -1
View File
@@ -5,7 +5,7 @@ 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";
@@ -16,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) => {
@@ -28,6 +29,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
dbApplyEdits: backend.applyEdits,
dbExecute: backend.execute,
dbStatements: backend.statements,
dbExplain: backend.explain,
},
};
});
@@ -454,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();
});
});
@@ -43,13 +43,23 @@ export function AddInterpreterDialog({
const [error, setError] = useState<string | null>(null);
const ids = { base: useId(), location: useId(), existing: useId() };
const baseValue = base ?? bases[0]?.path ?? null;
const toOption = (s: { path: string; name: string }): SelectOption => ({
value: s.path,
label: s.name,
detail: tildify(s.path),
});
const baseOptions = bases.map(toOption);
// conda installs a fresh Python of the base's version: offer versions, not environments to copy.
const versionOptions = (() => {
const seen = new Map<string, SelectOption>();
for (const s of bases) {
const short = s.version?.split(".").slice(0, 2).join(".");
if (short && !seen.has(short)) seen.set(short, { value: s.path, label: tx`Python ${short}` });
}
return [...seen.values()].sort((a, b) => b.label.localeCompare(a.label, undefined, { numeric: true }));
})();
const baseOptions = kind === "conda" ? versionOptions : bases.map(toOption);
const baseValue =
base ?? (kind === "conda" ? (versionOptions[0]?.value as string | undefined) : bases[0]?.path) ?? null;
const existingOptions = [...browsed, ...list.filter((s) => !browsed.some((b) => b.path === s.path))].map((s) => ({
value: s.path,
label: `${s.name} — ${tildify(s.path)}`,
@@ -108,7 +118,9 @@ export function AddInterpreterDialog({
const canOk = !busy && (mode === "new" ? !!baseValue && !!location.trim() : !!existing?.trim());
const note = {
venv: tx("Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter."),
conda: tx("Runs “conda create -p <location>”; the new interpreter becomes the project interpreter."),
conda: tx(
"Runs “conda create -p <location> python=<version>”: a new environment with only Python in it; the new interpreter becomes the project interpreter.",
),
uv: tx("Runs “uv venv <location> --python <base>”; the new interpreter becomes the project interpreter."),
poetry: tx(
"Runs “poetry env use <base>” in the project; poetry keeps the environment under its own cache directory.",
@@ -143,6 +155,7 @@ export function AddInterpreterDialog({
aria-current={kind === k.tool || undefined}
onClick={() => {
setKind(k.tool);
setBase(null);
setError(null);
}}
>
@@ -199,9 +212,7 @@ export function AddInterpreterDialog({
}}
/>
</div>
<label htmlFor={ids.base}>
<T>Base interpreter:</T>
</label>
<label htmlFor={ids.base}>{kind === "conda" ? <T>Python version:</T> : <T>Base interpreter:</T>}</label>
<div className={styles.pathRow}>
<Select
className={styles.pathInput}
@@ -209,17 +220,19 @@ export function AddInterpreterDialog({
options={baseOptions}
onChange={setBase}
placeholder={loading ? "Detecting…" : "No Python found"}
aria-label="Base interpreter"
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
onClick={async () => {
const p = await choosePath({ directory: false, title: "Select Base Interpreter" });
if (p) setBase(p);
}}
aria-label={kind === "conda" ? "Python version" : "Base interpreter"}
/>
{kind !== "conda" && (
<IconButton
icon="folder-open"
size="small"
title="Browse…"
onClick={async () => {
const p = await choosePath({ directory: false, title: "Select Base Interpreter" });
if (p) setBase(p);
}}
/>
)}
</div>
<div className={styles.note}>{note}</div>
</>
@@ -68,6 +68,8 @@ export function PythonInterpreterPage() {
const [activate, setActivate] = useSetting<boolean>("languages.python.activateVirtualenv");
const root = useProject((s) => s.project?.root ?? null);
const [auto, setAuto] = useState<string | null>(null);
// Bumped when an environment is created: it may reuse the configured path, whose state changed.
const [created, setCreated] = useState(0);
// Detected interpreters plus the ones added to (or hidden from) the IDE-wide list.
const { detected, rows, hidden } = usePythonInterpreters({ withProjects: false });
const extras = useMemo(
@@ -91,7 +93,7 @@ export function PythonInterpreterPage() {
return () => {
live = false;
};
}, [root]);
}, [root, created]);
// The interpreter runs resolve to: the configured one while it exists, else the fallback.
const [resolved, setResolved] = useState<{ interpreter: string; path: string | null } | null>(null);
@@ -104,7 +106,7 @@ export function PythonInterpreterPage() {
return () => {
live = false;
};
}, [root, interpreter]);
}, [root, interpreter, created]);
// A configured interpreter whose executable is gone (PyCharm's "Invalid"): runs fall back.
const invalid =
!!interpreter && resolved?.interpreter === interpreter && !sameInterpreter(resolved.path, interpreter, root);
@@ -362,6 +364,8 @@ export function PythonInterpreterPage() {
onClose={() => setAdding(false)}
onAdded={(path) => {
setInterpreter(path);
setCreated((n) => n + 1);
reload();
setAdding(false);
}}
/>
@@ -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])],
@@ -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;
+23
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",
+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",
+27 -2
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": "无法复制引用",
@@ -5194,7 +5217,9 @@
"{count} modified action": "已修改 {count} 个操作",
"{count} modified actions": "已修改 {count} 个操作",
"Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter.": "运行“<base> -m venv <location>”;新的解释器将成为项目解释器。",
"Runs “conda create -p <location>”; the new interpreter becomes the project interpreter.": "运行“conda create -p <location>”;新的解释器将成为项目解释器。",
"Runs “conda create -p <location> python=<version>”: a new environment with only Python in it; the new interpreter becomes the project interpreter.": "运行“conda create -p <location> python=<version>”:新建一个只包含 Python 的环境;新的解释器将成为项目解释器。",
"Python version": "Python 版本",
"Python {short}": "Python {short}",
"Runs “poetry env use <base>” in the project; poetry keeps the environment under its own cache directory.": "在项目中运行“poetry env use <base>”;Poetry 会将环境保存在其自己的缓存目录下。",
"Runs “uv venv <location> --python <base>”; the new interpreter becomes the project interpreter.": "运行“uv venv <location> --python <base>”;新的解释器将成为项目解释器。",
"{vendor} {version} is unpacked into a folder of its own in {location}, where JDK detection finds it.": "{vendor} {version} 将解压到 {location} 下它自己的文件夹中,JDK 检测会在那里找到它。",
+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>();